Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project
Prompt: least-privilege-server
You get a deployable MCP server that enforces declared per-tool scopes, refuse-to-start credential validation, an outbound allowlist, a per-principal tools/list, call-time default-deny authorization keyed off the verified bearer token, and bounded refund inputs, teaching the least privilege pattern. Wire status: the pinned stack (mcp-handler 2.1.1 on @modelcontextprotocol/server 2.0.0) serves the 2026-07-28 contract natively over Streamable HTTP and falls back to stateless 2025-11-25 Streamable HTTP for legacy clients; the SDK Client defaults to that legacy handshake unless you opt in to modern version negotiation, so the examples’ in-memory test suites exercise only the legacy path.
Copy everything in the block below into your AI coding agent as one message. See the prompt index for what makes these prompts reliable and how they were tested.
GOALBuild me a small TypeScript project called least-privilege-server that runs locally and deploys to Vercel: an MCP (Model Context Protocol) server that demonstrates least privilege for a toy billing integration. Every tool declares the scopes it needs, the server refuses to start on a credential that is too narrow OR too broad, outbound calls go through a host allowlist, tools/list is answered per verified principal, every handler re-checks the caller's scopes at call time with default deny, and the refund inputs are bounded. The principal comes from the verified bearer token (withMcpAuth plus a stub token table), never from a tool argument.
STACK (exact, non-negotiable)Next.js App Router (next ^16, react and react-dom ^19), mcp-handler 2.1.1, @modelcontextprotocol/server 2.0.0 as a dependency (mcp-handler 2.1.1 peer-requires ^2.0.0; do NOT install the old monolithic @modelcontextprotocol/sdk), @modelcontextprotocol/client 2.0.0 as a devDependency (tests only), zod ^4.2.0 (hard floor: the v2 SDK requires zod 4.2.0 or newer; zod ^3 installs cleanly and then fails typecheck and tests), devDeps typescript, vitest, @types/node. Node 22 or newer. package.json has "type": "module" and scripts dev (next dev), test (vitest run), typecheck (tsc --noEmit).
LAYOUT- app/api/mcp/route.ts is a thin shell: handler = createMcpHandler(configureServer, { serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } }) from mcp-handler, wrapped as withOriginCheck(withMcpAuth(handler, verifyToken, { required: true, requiredScopes: ROUTE_REQUIRED_SCOPES }), parseAllowedOrigins(process.env.MCP_ALLOWED_ORIGINS)) (withMcpAuth also comes from mcp-handler; the Origin check is outermost), exported as GET, POST, and DELETE. There is no [transport] directory, no three-argument createMcpHandler, and no basePath option in mcp-handler 2.x; the public endpoint is /api/mcp.- ALL protocol logic lives in src/server.ts, which exports SERVER_NAME = "least-privilege-server", SERVER_VERSION = "0.1.0", configureServer(server), plus the pure functions and constants below.- src/auth.ts is the token verification surface (framework-free). Export ROUTE_REQUIRED_SCOPES = ["billing:mcp"]; TOKEN_TABLE, a ReadonlyMap from raw bearer token to { clientId, scopes, subject } with three entries: "auditor-token" (clientId "client-auditor", scopes ["billing:mcp"], subject "user:auditor"), "treasury-token" (clientId "client-treasury", scopes ["billing:mcp"], subject "user:treasury"), and "stranger-token" (clientId "client-stranger", scopes ["billing:mcp"], subject "user:nobody"); and verifyToken(req, bearerToken) returning an AuthInfo ({ token, clientId, scopes as a copy, extra: { sub: subject } }) for a known token and undefined otherwise. Never throw: undefined is the fail-closed path and withMcpAuth answers 401. The route scope billing:mcp says "may talk to this server at all"; which tools a caller may see and call is decided by PRINCIPAL_SCOPES below. stranger-token exists so a denial for a valid, correctly scoped token is an authorization decision, not an authentication failure.- src/origin.ts is the Origin allowlist (framework-free: Fetch Request in, Response or null out), identical to the one in secure-tools-server. Export ALLOWED_ORIGINS_ENV = "MCP_ALLOWED_ORIGINS"; DEFAULT_ALLOWED_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"]; parseAllowedOrigins(raw) (comma separated, each entry normalized to new URL(entry).origin, unparseable entries dropped, duplicates removed, defaults used when raw is undefined or blank); assertAllowedOrigin(request, allowlist) (no Origin header: null, let it through; Origin present and allowlisted: null; anything else, including the literal "null" origin: a 403 text/plain Response with body "Forbidden: Origin not allowed" that does not echo the allowlist); and withOriginCheck(handler, allowlist), which wraps a Fetch-style handler, preserves extra parameters, and short-circuits with the refusal.- Tests live in tests/ (server.test.ts, origin.test.ts, vercel-config.test.ts), import only from src/, and never import Next.js. Include a vercel.json with the $schema key and functions["app/api/mcp/route.ts"].maxDuration set to 30; tests/vercel-config.test.ts reads the file and fails if that entry disappears.
BEHAVIOR- Two tools model a billing system: read_invoice needs scope invoices:read; issue_refund needs invoices:read AND refunds:write.- Export REQUIRED_SCOPES: a record mapping each tool name to a ReadonlySet of its scopes, and REQUIRED: the union of all declared scopes. Export registeredToolNames() returning ['read_invoice', 'issue_refund'] from the same table configureServer registers from.- Credential: export parseScopes(raw) that splits a comma-separated string, trims, and drops empties, and GRANTED_SCOPES = parseScopes(process.env.LP_GRANTED_SCOPES ?? 'invoices:read,refunds:write') so the default is the exact minimal set and a one-command run starts cleanly.- Export validateConfig(granted, required = REQUIRED, registered = registeredToolNames()) which throws StartupError if: (a) the credential lacks any required scope, (b) the credential exceeds the required set (an over-broad grant is a misconfiguration, not a convenience), or (c) any registered tool name has no REQUIRED_SCOPES entry (a drift guard so an undeclared tool fails closed at startup). configureServer calls validateConfig(GRANTED_SCOPES) BEFORE registering any tool.- Outbound allowlist: export OUTBOUND_ALLOWLIST = new Set(['api.payments.example']) and checkOutbound(host) which throws OutboundDenied for any host not on the list. No socket is ever opened; this is a boundary check only.- Per-principal grants: export PRINCIPAL_SCOPES with 'user:auditor' holding ['invoices:read'] and 'user:treasury' holding ['invoices:read', 'refunds:write']. The keys are the verified subjects src/auth.ts places in AuthInfo.extra.sub; user:nobody has no entry.- Export principalFromAuthInfo(authInfo): returns "" when authInfo is undefined, else authInfo.extra?.sub when that is a non-empty string, else authInfo.clientId. The empty string has no grants, so a route that dropped its withMcpAuth wrapper degrades to an empty listing and call-time denials, not to an open server.- Export visibleTools(principal): returns only the tool names whose required scopes are a subset of that principal's grants; unknown or empty principals get [] (default-deny listing). Keep it a pure function AND wire it into tools/list: at the end of configureServer, call server.server.setRequestHandler("tools/list", (_request, ctx) => ...) on the low-level Server so the listing is computed per request from visibleTools(principalFromAuthInfo(ctx.http?.authInfo)) (a later registration for the same method replaces the SDK's default, which lists everything for everyone). Build the listed entries from one tool-definition table that also drives registration (name, description, inputSchema, annotations), converting each zod schema with z.toJSONSchema(schema, { target: "draft-2020-12", io: "input" }) so the filtered listing matches the SDK's own entry for entry. Never cache the result across principals. tools/call still resolves every registered tool, which is exactly why every handler re-runs authorize.- Export authorize(principal, toolName): resolves the principal's grants (unknown means empty set) and the tool's required scopes (missing entry falls back to the full REQUIRED union so it fails closed) and throws AuthorizationError naming the missing scopes on any shortfall. Listing filtering alone is not an access control; both handlers call authorize(principalFromAuthInfo(ctx.http?.authInfo), toolName) FIRST, before any lookup or bounds check, so an unauthorized caller learns nothing.- Error classes StartupError, OutboundDenied, AuthorizationError, ValidationError all extend Error with matching name fields, and are exported.- Bounded inputs: export MAX_REFUND_CENTS = 100000, MAX_INVOICE_ID_LENGTH = 64, INVOICE_ID_PATTERN = /^inv-[0-9]+$/, invoiceIdSchema = z.string().min(1).max(MAX_INVOICE_ID_LENGTH).regex(INVOICE_ID_PATTERN), and amountCentsSchema = z.number().int().min(1).max(MAX_REFUND_CENTS). These round-trip into the advertised inputSchema (minLength, maxLength, pattern, minimum, maximum) so the model can see the limits.- Stubbed upstream: export STUB_INVOICE_AMOUNT_CENTS = 4200, an Invoice interface { invoiceId, status: 'open', amountCents }, lookupInvoice(invoiceId) returning that open invoice for any well-formed id, and assertRefundWithinInvoice(invoice, amountCents) which throws ValidationError when amountCents exceeds invoice.amountCents.- inputSchema in v2 is a FULL zod object schema, z.object({ ... }), not a raw shape. There is NO principal argument on any tool: zod strips unknown keys, so a client that sends principal anyway sees it silently dropped before the handler runs, and the handler never reads identity from arguments. Each description states that the caller's identity comes from the access token and any principal-shaped argument is ignored.- read_invoice: inputSchema z.object({ invoiceId: invoiceIdSchema }); annotations { readOnlyHint: true, destructiveHint: false, idempotentHint: true }. After authorize, looks the invoice up and returns a minimized view as one JSON text content item: exactly { invoiceId, status: 'open', amountCents: 4200 } and nothing else (no ledger IDs, PII, or processor tokens).- issue_refund: inputSchema z.object({ invoiceId: invoiceIdSchema, amountCents: amountCentsSchema }); annotations { readOnlyHint: false, destructiveHint: true, idempotentHint: false }. After authorize, calls assertRefundWithinInvoice(lookupInvoice(invoiceId), amountCents), then checkOutbound('api.payments.example'), pushes { invoiceId, amountCents } onto an exported refundLog array (with exported resetState() to clear it), and returns { invoiceId, refundedCents: amountCents, status: 'refunded' }. A denied or out-of-bounds refund leaves refundLog untouched.
TESTS (vitest, offline)Connect a real Client (from @modelcontextprotocol/client) to a real McpServer over InMemoryTransport.createLinkedPair() (both McpServer and InMemoryTransport from @modelcontextprotocol/server), call resetState() in beforeEach. Inject identity the way withMcpAuth does in production: the v2 InMemoryTransport.send accepts an { authInfo } option that the server surfaces to handlers as ctx.http.authInfo, so write a connect(authInfo?) helper that wraps clientTransport.send to attach the given AuthInfo to every message. Build AuthInfo values through the real verifier (verifyToken(new Request("https://example.test/api/mcp"), "auditor-token"), likewise treasury-token and stranger-token) so the tests and the route agree. Then cover:- Token table: the three tokens verify to user:auditor, user:treasury, and user:nobody; verifyToken returns undefined for a missing, empty, or forged token; TOKEN_TABLE has exactly those three keys. principalFromAuthInfo returns "" for undefined, the sub when present and non-empty, and the clientId otherwise.- Scope declaration, on a session connected as treasury (who holds every scope, so its listing is the full surface): listTools returns exactly issue_refund and read_invoice, every listed name has a REQUIRED_SCOPES entry, and registeredToolNames() matches the listing. REQUIRED equals ['invoices:read', 'refunds:write']. The advertised issue_refund inputSchema carries invoiceId { type string, minLength 1, maxLength 64, pattern "^inv-[0-9]+$" } and amountCents { type integer, minimum 1, maximum 100000 }, both required; no tool has a principal property; annotations are exactly the ones above.- validateConfig: rejects new Set(['invoices:read']) (lacks refunds:write), rejects the required set plus 'tenant:admin' (over-broad), accepts an exact match, and rejects an injected rogue tool name (pass registeredToolNames() plus 'rogue_tool' as the third argument, expect a StartupError whose message mentions rogue_tool). Also assert GRANTED_SCOPES equals REQUIRED when LP_GRANTED_SCOPES is unset.- checkOutbound throws OutboundDenied for 'evil.example' and allows 'api.payments.example'.- visibleTools (pure): 'user:auditor' sees only read_invoice, 'user:treasury' sees both, 'user:nobody' and '' see [].- tools/list over the wire: the auditor session lists exactly ['read_invoice']; the treasury session lists both; the stranger session lists []; a session with NO AuthInfo lists []; two sessions (auditor and treasury) listing concurrently each get their own answer, proving the filter is evaluated per request and never cached.- Happy paths over the wire: read_invoice on the auditor session returns exactly { invoiceId: 'inv-1', status: 'open', amountCents: 4200 }; issue_refund on the treasury session with { invoiceId: 'inv-1', amountCents: 100 } returns { invoiceId: 'inv-1', refundedCents: 100, status: 'refunded' } and refundLog holds exactly that one entry.- Call-time authorization from the token: issue_refund on the auditor session is isError true with 'refunds:write' in the text and refundLog stays empty; read_invoice on the stranger session is isError true with 'user:nobody' in the text; with NO AuthInfo both tools are isError true and refundLog stays empty. A principal argument can neither grant nor revoke: the no-AuthInfo session and the auditor session both passing principal: 'user:treasury' in the arguments are still denied (the auditor's error names user:auditor), and the treasury session passing principal: 'user:attacker' still succeeds. authorize called directly throws AuthorizationError for ('user:auditor', 'issue_refund'), ('', 'read_invoice'), ('user:nobody', 'read_invoice'), and ('user:auditor', 'undeclared_tool') (no declared scopes falls back to the full union), and not for ('user:treasury', 'issue_refund').- Bounds, all on the treasury session so each rejection is a bounds decision, not an authorization one: amountCents 0, MAX_REFUND_CENTS + 1, Number.MAX_SAFE_INTEGER, and 10.5 are each isError true; amountCents 4201 (under the cap, above the invoice) is isError true with 'exceeds invoice' in the text; exactly 4200 succeeds; an invoiceId of 65 characters, one of 'inv-' plus 100000 digits, and (on read_invoice) the 65-character id are isError true; an id of exactly 64 characters succeeds; '', 'INV-1', 'inv-', 'inv-1; drop table', '1', and 'inv-1\n' are each isError true. assertRefundWithinInvoice called directly throws ValidationError above the invoice amount and not at it. Every rejection asserts refundLog is unchanged: no partial state.- SDK v2 (2.0.0) reality: a handler throw and schema-invalid arguments on a KNOWN tool come back as tool RESULTS with isError true, but an UNKNOWN tool name REJECTS: await expect(client.callTool({ name: 'delete_ledger', arguments: {} })).rejects.toThrow(/not found/i), leaving refundLog empty (v1 returned isError results for unknown tools; v2 restores the spec's protocol-error semantics). In-memory wire note: these tests run over InMemoryTransport, where a bare McpServer answers server/discover with -32601 and the Client defaults to the legacy initialize handshake at protocol version 2025-11-25, so on this path results carry no resultType and list results no ttlMs/cacheScope. That is a property of the harness, not of the server: the same configureServer behind createMcpHandler serves the 2026-07-28 frames over HTTP. If you want to assert those fields, do it in an HTTP-level check against the handler, not in these in-memory tests.- Origin allowlist (tests/origin.test.ts, direct calls with plain Fetch Request objects, no server): no Origin header passes; an allowlisted origin passes, including with a different-case scheme or host; a different scheme, a different port, a lookalike host, a non-allowlisted origin, the literal "null" origin, and an unparseable origin each get a 403 whose body does not echo the allowlist; an empty allowlist refuses every Origin but still passes origin-less requests; withOriginCheck never invokes the wrapped handler on refusal and forwards extra arguments on success; parseAllowedOrigins falls back to the defaults for undefined and blank input, trims and normalizes entries (a trailing slash or path is dropped), drops unparseable entries, and yields an empty allowlist (not the default) when every entry is junk.
DEFINITION OF DONEnpm install, npm run typecheck, and npm test all green. Then npm run dev and connect MCP Inspector (npx @modelcontextprotocol/inspector) with the Streamable HTTP transport to http://localhost:3000/api/mcp with the bearer token set to auditor-token: the tool list shows only read_invoice, and calling issue_refund by name anyway is denied. Reconnect with treasury-token to see both tools listed and a refund succeed with { "invoiceId": "inv-1", "amountCents": 100 }; try amountCents 4201 (above the invoice) or 100001 (above the cap) to see the bounds reject it. Remove the token and watch the route answer 401; the Inspector's proxy sends no Origin header, so the Origin check does not apply to it. Also try LP_GRANTED_SCOPES='invoices:read' npm run dev to see refuse-to-start fail the request. Optionally vercel deploy; the endpoint is https://<deployment>/api/mcp, no environment variables are required, and MCP_ALLOWED_ORIGINS (comma separated browser origins) matters only for browser-based clients.
SOURCEShttps://modelcontextprotocol.io/specification/2026-07-28/server/tools, https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization, https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http (Origin validation is a MUST), https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices, https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel, https://github.com/vercel/mcp-handler, and the reference implementation at examples/least-privilege-server/ in this repository (a path relative to the repo root; the GitHub repository is private); compare against it if you get stuck.
GUARDRAILSTests must pass with no network access and no Vercel account; the server never opens a socket to any upstream. No dependencies beyond the stack list. Keep it small: two tools, three source files (server, auth, origin), three test files (server, origin, vercel-config), one route file. The stub token table is a teaching device; say so in a comment, since real deployments verify a JWT (signature via JWKS, issuer, audience, expiry) against their authorization server and put the verified subject in AuthInfo.extra.sub. Identity never comes from a tool argument, and the listing filter is never the only check: every handler authorizes at call time.Where to look now
- Prompt index - all thirteen prompts and the reliability notes.
examples/least-privilege-server(in the repository) - the reference implementation this prompt rebuilds.- Examples index - what each example demonstrates.
Bibliography
- localhost:3000 - http://localhost:3000/api/mcp
- <https:// /api/mcp> - Model Context Protocol Specification - https://modelcontextprotocol.io/specification/2026-07-28/server/tools
- Model Context Protocol Specification - https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
- Model Context Protocol Specification - https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http
- Model Context Protocol Specification - https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices
- Vercel Documentation - https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel
- vercel/mcp-handler, source code - https://github.com/vercel/mcp-handler
- Reference implementation, source code (this repository) -
examples/least-privilege-server(in the repository)