Governed MCP server
MCP OAuth authorizes the connection. nominee authorizes the action. Wrap the tools you already expose so a hijacked model still cannot run the dangerous call.
OAuth vs action authorization
MCP OAuth and Enterprise-Managed Authorization answer “may this client talk to this server?” They do not answer “may this tool run with these arguments for this user, right now?” A connected server with a broad mail scope can still forward the inbox if the model asks.
| MCP OAuth / EMA | nominee | |
|---|---|---|
| What it grants | A connection | One exact-input capability |
| When it is checked | At connect / consent | On every tool call, before execute |
| Human approval | Out of band | ask bound to the arguments reviewed |
| Evidence | Token issuance | Hash-chained receipts, including denials |
Use both. Keep OAuth for transport identity. Put nominee around the handlers that change data. Longer treatment: A scope is a promise made once.
The model can be hijacked. The tool still does not run.
nominee does not detect prompt injection. In
examples/prompt-injection-blocked
an email tells the agent to forward the inbox. The model follows the instruction.
The deny('email.forward') rule throws before the mailer runs, and the
receipt records the attempt. That is blast-radius containment.
Ten-minute server
npm i nominee nominee-mcp @modelcontextprotocol/sdk zod
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { Nominee, allow, ask, deny } from 'nominee'
import { mcpEndUser, registerNomineeTool } from 'nominee-mcp'
import { z } from 'zod'
const nominee = new Nominee({
policy: {
rules: [
allow('email.read'),
deny('email.forward', { reason: 'external forwarding is exfiltration' }),
ask('email.delete'),
],
fallback: 'deny',
},
})
const server = new McpServer({ name: 'inbox-tools', version: '1.0.0' })
registerNomineeTool(server, {
name: 'forward_email',
action: 'email.forward',
inputSchema: z.object({ to: z.string(), id: z.string() }),
nominee,
user: ({ extra }) => mcpEndUser(extra, 'local-stdio-user'),
execute: async ({ to, id }) => {
await forward(id, to)
return { content: [{ type: 'text', text: `Forwarded ${id}` }] }
},
})
await server.connect(new StdioServerTransport())
Full notes, including connection tokens and observe mode:
docs/integrations/mcp.md.
Durable Postgres wiring:
examples/mcp-action-server.
Approvals
MCP does not define a universal human-approval resume protocol.
registerNomineeTool returns
structuredContent: { nominee: 'pending_approval', actionId, approvalId }
instead of letting the SDK swallow ActionPendingError.
Persist the action id and the original tool input (the
durable record stores only inputHash), resolve the
approval, then resumeAction() and
executeCapability(capability, originalInput, execute).
Denied calls never reach execute.
nominee-mcp
npm i nominee-mcp — decision-bound handlers for the official MCP
TypeScript SDK. A draft MCP registry server.json for the reference
server is in the repo; the listing is not live until that package is published
and submitted.