Short answer: implement MCP’s OAuth requirements for HTTP transport, validate the token audience and scopes, derive the user from that verified identity, and authorize each write-capable tools/call against your application data before the handler mutates anything. OAuth gets an authenticated client to a protected server or tool. Your application still decides whether this user may delete this customer with these arguments.
The current MCP authorization specification covers protected-resource metadata, authorization-server discovery, audience-bound access tokens, and scope challenges. MCP Apps also documents per-server and per-tool OAuth. Those controls are valuable. They do not encode your invoice limit, tenant membership, approval threshold, or exact-input binding.
A narrow MCP server may only need OAuth and one check
An internal server with one service account and one read-only tool can stay small. Validate the bearer token, keep the upstream credential narrow, and register the handler:
server.registerTool(
'orders_read',
{ inputSchema: z.object({ orderId: z.string() }) },
async ({ orderId }) => ({
content: [{ type: 'text', text: await orders.read(orderId) }],
}),
)
This is sufficient when the credential itself carries the full intended authority and the tool cannot change data. Keep it. Adding a policy engine to a read-only server with one caller adds ceremony without reducing much risk.
A delete, refund, deploy, or outbound message changes the calculation. A scope such as customers:write can permit the category of operation. It cannot decide whether the current support user may delete customer cus_42, whether that customer belongs to the same tenant, or whether a human approved the exact request body.
Guard the MCP handler at execution
registerNomineeTool registers on the official high-level McpServer. The MCP callback receives control only after the action policy allows the exact call:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { Nominee, allow, ask } from 'nominee'
import { registerNomineeTool } from 'nominee-mcp'
import { z } from 'zod'
const nominee = new Nominee({
policy: {
rules: [allow('customers.read'), ask('customers.delete')],
fallback: 'deny',
},
onApprovalRequest: (request) => approvalUi.show({
input: request.detail,
approve: () => request.approve(),
deny: () => request.deny(),
}),
})
const server = new McpServer({
name: 'customer-tools',
version: '1.0.0',
})
registerNomineeTool(server, {
name: 'delete_customer',
description: 'Delete one customer record',
action: 'customers.delete',
inputSchema: z.object({ customerId: z.string() }),
nominee,
user: 'user-123',
resource: ({ input }) => 'customer:' + input.customerId,
execute: async ({ customerId }) => {
await customers.remove(customerId)
return { content: [{ type: 'text', text: 'Customer deleted' }] }
},
})
await server.connect(new StdioServerTransport())
The fixed user keeps the example readable. An HTTP server should resolve user and tenant from verified auth context, not from tool arguments. Add connection and scopes when the handler also needs a fresh downstream credential.
The current MCP draft defines an input-required multi-round-trip for tools/call: a server can return InputRequiredResult, elicit an accept or decline, and correlate the retry with requestState and inputResponses. registerNomineeTool does not yet translate a pending Nominee approval into that wire flow. With this adapter, an ask either resolves through an inline handler or surfaces ActionPendingError. If approval can cross a restart, configure a durable action store and persist the action id before resuming it after the human decides. Do not catch that error and call the raw handler.
The two authorization layers answer different questions
- MCP authorization: may this client access this protected MCP resource with these scopes?
- Application action authorization: may this user perform this action on this tenant resource with this exact input now?
Many servers need both. A server with no consequential writes may need only the first. For the broader argument, read a scope is granted before the call; action authorization evaluates the call itself.
Run the policy proof
Watch a denied action stop before its tool callback.