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 / EMAnominee
What it grantsA connectionOne exact-input capability
When it is checkedAt connect / consentOn every tool call, before execute
Human approvalOut of bandask bound to the arguments reviewed
EvidenceToken issuanceHash-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.

Security boundary. In-process wrapping only enforces actions that actually route through nominee. Keep raw tools and credentials outside model-controlled code.