A tool description contains readOnlyHint: true. The assistant asks to inspect a stopped automation. The operator expects the configuration to stay unchanged.
That expectation needs enforcement at the service handling the request. A label in a tool definition cannot remove write access from a credential or prevent a handler from changing a record.
The Model Context Protocol, or MCP, lets applications expose tools to AI clients. Its ToolAnnotations specification treats annotations as hints about behavior. They can help a client present a tool or decide when to ask for confirmation. They do not establish what the caller is authorized to do. The MCP maintainers explain this distinction in their discussion of annotations and enforcement.
For a developer exposing an operational tool, the useful question is: where does an unauthorized request stop before the handler touches state?
Separate the tool description from the access decision
Consider a fictional service with two tools:
| Tool | Intended behavior | Required permission |
|---|---|---|
automation.inspect |
Return one automation's configuration | automation:read |
automation.set_interval |
Change its execution interval | automation:write |
Those scope names are invented for this example. They are application policy, not standard MCP scopes or SMTasker API fields.
The service needs to identify the authenticated caller, check the required permission, and confirm that the requested record belongs to the caller's workspace. Checking only the tool name leaves the resource boundary unresolved: an editor for workspace Alpha should not be able to update workspace Beta by changing an ID.
The MCP tools specification requires input validation and access controls on servers. The following example isolates those checks so you can inspect their order.
Check permission and resource ownership before dispatch
This is an in-memory teaching model. It does not implement an MCP transport, token validation, a database or SMTasker's authorization system. The principal represents information that trusted authentication middleware would supply; it must never be accepted from the model's tool-call arguments.
The important boundary is inside execute. It looks up server-owned policy, checks the required scope, validates the argument shape, checks ownership of the stored record, and validates the requested value. Only then can the handler run.
function execute(principal, name, args) {
const tool = tools.get(name);
if (!tool) throw new Error('Unknown tool');
if (!principal || !principal.scopes.has(tool.scope)) {
throw new Error('Forbidden');
}
if (!args || typeof args !== 'object' || Array.isArray(args) ||
typeof args.automationId !== 'string' ||
Object.keys(args).some(key => !tool.fields.includes(key))) {
throw new Error('Invalid input');
}
const record = records.get(args.automationId);
if (!record || record.workspaceId !== principal.workspaceId) {
throw new Error('Not found or unauthorized');
}
tool.validate(args);
return tool.run(record, args);
}
Notice that this decision does not read annotations.readOnlyHint. The server's policy determines the required permission. An annotation still describes the tool for clients, but changing that annotation cannot turn a reader into an editor.
Ownership also comes from the stored record. A caller cannot supply a different workspaceId to make the authorization check pass. The example rejects that extra argument.
Test the requests that must be rejected
A useful demonstration needs more than a successful update. Each rejected write should leave the stored records unchanged.
The companion example includes these checks:
| Request | Expected result |
|---|---|
| Reader inspects an Alpha record | Returns a copy without changing stored state |
| Reader requests an interval change | Denied before mutation |
| Write tool incorrectly advertises a read-only hint | Reader's write remains denied |
| Reader inspects a Beta record | Denied by the resource boundary |
| Alpha editor modifies a Beta record | Denied without changing either workspace |
| Caller supplies a workspace override | Rejected as invalid input |
| Interval is missing, a string, fractional or outside the accepted range | Rejected without mutation |
| Alpha editor changes an Alpha interval | Only the interval changes; the job stays stopped |
| Tool is unknown or the principal is absent | Denied |
Save the following as permission-boundary-demo.mjs and run it with Node.js:
node permission-boundary-demo.mjs
import assert from 'node:assert/strict';
// Teaching model only: no network, credentials or real product API.
// In a service, trusted authentication middleware supplies this principal.
const reader = { workspaceId: 'alpha', scopes: new Set(['automation:read']) };
const editor = { workspaceId: 'alpha', scopes: new Set(['automation:read', 'automation:write']) };
const records = new Map([
['alpha-job', { workspaceId: 'alpha', intervalMinutes: 60, running: false }],
['beta-job', { workspaceId: 'beta', intervalMinutes: 60, running: false }],
]);
// This is server-owned policy and implementation, never tool-call input.
const tools = new Map([
['automation.inspect', {
scope: 'automation:read',
annotations: { readOnlyHint: true },
fields: ['automationId'],
validate() {},
run: record => ({ ...record }),
}],
['automation.set_interval', {
scope: 'automation:write',
annotations: { readOnlyHint: false },
fields: ['automationId', 'intervalMinutes'],
validate(args) {
if (!Number.isInteger(args.intervalMinutes) ||
args.intervalMinutes < 1 || args.intervalMinutes > 1440) {
throw new Error('Invalid interval');
}
},
run(record, args) {
record.intervalMinutes = args.intervalMinutes;
return { ...record };
},
}],
]);
function execute(principal, name, args) {
const tool = tools.get(name);
if (!tool) throw new Error('Unknown tool');
if (!principal || !principal.scopes.has(tool.scope)) {
throw new Error('Forbidden');
}
if (!args || typeof args !== 'object' || Array.isArray(args) ||
typeof args.automationId !== 'string' ||
Object.keys(args).some(key => !tool.fields.includes(key))) {
throw new Error('Invalid input');
}
const record = records.get(args.automationId);
if (!record || record.workspaceId !== principal.workspaceId) {
throw new Error('Not found or unauthorized');
}
tool.validate(args);
return tool.run(record, args);
}
let passed = 0;
function check(name, action) {
const before = structuredClone(records);
try {
action();
passed += 1;
console.log(`PASS ${name}`);
} finally {
records.clear();
for (const [id, value] of before) records.set(id, value);
}
}
check('read returns a copy and leaves stored state unchanged', () => {
const before = structuredClone(records);
const result = execute(reader, 'automation.inspect', { automationId: 'alpha-job' });
result.intervalMinutes = 5;
assert.deepEqual(records, before);
});
check('reader cannot write and denial leaves state unchanged', () => {
const before = structuredClone(records);
assert.throws(() => execute(reader, 'automation.set_interval', {
automationId: 'alpha-job', intervalMinutes: 30,
}), /Forbidden/);
assert.deepEqual(records, before);
});
check('incorrect read-only annotation cannot grant write access', () => {
const writer = tools.get('automation.set_interval');
writer.annotations.readOnlyHint = true;
const before = structuredClone(records);
try {
assert.throws(() => execute(reader, 'automation.set_interval', {
automationId: 'alpha-job', intervalMinutes: 30,
}), /Forbidden/);
assert.deepEqual(records, before);
} finally {
writer.annotations.readOnlyHint = false;
}
});
check('reader cannot inspect another workspace', () => {
assert.throws(() => execute(reader, 'automation.inspect', {
automationId: 'beta-job',
}), /Not found or unauthorized/);
});
check('editor cannot modify another workspace', () => {
const before = structuredClone(records);
assert.throws(() => execute(editor, 'automation.set_interval', {
automationId: 'beta-job', intervalMinutes: 30,
}), /Not found or unauthorized/);
assert.deepEqual(records, before);
});
check('caller cannot add a workspace override', () => {
assert.throws(() => execute(editor, 'automation.set_interval', {
automationId: 'beta-job', workspaceId: 'alpha', intervalMinutes: 30,
}), /Invalid input/);
});
check('invalid interval cannot change the record', () => {
const before = structuredClone(records);
for (const value of [0, -1, 1.5, 1441, '30', null, undefined]) {
assert.throws(() => execute(editor, 'automation.set_interval', {
automationId: 'alpha-job', intervalMinutes: value,
}), /Invalid interval/);
}
assert.deepEqual(records, before);
});
check('authorized write changes only the requested field', () => {
const other = structuredClone(records.get('beta-job'));
const result = execute(editor, 'automation.set_interval', {
automationId: 'alpha-job', intervalMinutes: 30,
});
assert.deepEqual(result, { workspaceId: 'alpha', intervalMinutes: 30, running: false });
assert.deepEqual(records.get('alpha-job'), result);
assert.deepEqual(records.get('beta-job'), other);
});
check('unknown tool and missing principal fail closed', () => {
assert.throws(() => execute(editor, 'toString', {}), /Unknown tool/);
assert.throws(() => execute(null, 'automation.inspect', {
automationId: 'alpha-job',
}), /Forbidden/);
});
console.log(`${passed} checks passed. No network requests or external changes.`);
The final line reports nine checks passed. These are checks of this local model. To evaluate a deployed service, exercise the same boundaries through its actual entry point with test credentials and disposable records. A test that calls an internal helper cannot establish that an HTTP route, MCP adapter or background job always uses that helper.
Verify the saved state separately from execution
Changing an interval and starting an automation are separate operations in this example. The authorized write test checks that running remains false and that the other workspace's record stays unchanged.
Use similarly specific postconditions in an operational integration. After a configuration edit, retrieve the affected record through an authorized read path and compare the fields that should have changed. Check fields that must stay untouched as well. If execution is a separate step, verify its status and result separately.
Read access also deserves a deliberate boundary: a tool can return private configuration without changing it. Decide which records and fields a read operation may reveal.
Applying the distinction in a product workspace
From the SMTasker team: our guide to allowing changes through MCP covers the operator side of this distinction. Read data and Make changes are separate permissions. A request to ask first does not replace the connection's permissions, and write-enabled access can permit supported edits. The guide also explains checking saved settings before running an automation.
The fictional tools and scope names above are not a description of that product's internal implementation. They give developers a small model for testing the access boundary behind their own tool handlers.
Keep the human review clear, enforce the caller's authority in the service, and inspect the resulting state. Each step answers a different question about the same operation.
Which permission check has been hardest to enforce in your MCP integration? Leave your questions or a small example in the comments.
Top comments (1)
What do you do when there is no service behind the tool to enforce anything? From the Auten team (we build a computer-use MCP server): there the "handler" is the OS itself, clicks and keystrokes, so there is no credential to scope down. The best we have is a policy layer that tells the agent to ask a human before send, delete, buy or permission dialogs, which is exactly the "label, not enforcement" gap you describe. Curious if you have seen a cleaner pattern for that case.