Skip to content

Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project

Prompt: secure-tools-server

Audience:engineernon-technicalMCP spec 2026-07-28

This prompt builds the house-style security showcase: a single write tool hardened with the controls from the security checklist, input validation, default-deny authorization keyed off the verified bearer token, an Origin allowlist on the route, and output minimization.

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.

GOAL
Build me a small TypeScript project called secure-tools-server that runs locally and deploys to Vercel: an MCP (Model Context Protocol) server that exposes one write tool, add_item, which appends a name to an in-memory list. The point is the security controls around it: strict input validation, default-deny authorization keyed off the verified bearer token (never a tool argument), an Origin allowlist on the route, and output that reveals only the new count.
STACK (exact, non-negotiable)
- Next.js App Router: next ^16, react and react-dom ^19.
- mcp-handler 2.1.1 with @modelcontextprotocol/server pinned EXACTLY to 2.0.0 as a dependency (mcp-handler 2.x peers on the split v2 server package; do NOT install the old monolithic @modelcontextprotocol/sdk).
- zod ^4.2.0. This is a hard floor: SDK v2 requires zod >= 4.2.0, and ^3 installs cleanly but then fails at runtime.
- Dev dependencies: typescript, vitest, @types/node, and @modelcontextprotocol/client pinned EXACTLY to 2.0.0 (tests only).
- Node 22 or newer. In package.json set "type": "module" and scripts: dev (next dev), test (vitest run), typecheck (tsc --noEmit).
LAYOUT
- app/api/mcp/route.ts is a thin shell only (no [transport] dynamic segment, that is the old v1 layout). Build handler = createMcpHandler(configureServer, { serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } }) from mcp-handler, wrap it as withOriginCheck(withMcpAuth(handler, verifyToken, { required: true, requiredScopes: REQUIRED_SCOPES }), parseAllowedOrigins(process.env.MCP_ALLOWED_ORIGINS)) (withMcpAuth also comes from mcp-handler; the Origin check is outermost), and export the wrapped function as GET, POST, and DELETE. The public endpoint is /api/mcp.
- ALL protocol logic lives in src/server.ts, exporting: configureServer(server), SERVER_NAME = "secure-tools-server", SERVER_VERSION = "0.1.0", MAX_NAME_LENGTH = 64, AUTHORIZED_PRINCIPAL = "user:demo", the mutable array items (so tests can reset it), the classes ValidationError and AuthorizationError, the zod schema nameSchema, and the functions validateName(name), principalFromAuthInfo(authInfo), and authorize(principal).
- src/auth.ts is the token verification surface (framework-free). Export REQUIRED_SCOPES = ["items:write"]; TOKEN_TABLE, a ReadonlyMap from raw bearer token to { clientId, scopes, subject } with two entries, "demo-token" (clientId "client-demo", scopes ["items:write"], subject AUTHORIZED_PRINCIPAL) and "other-token" (clientId "client-other", scopes ["items:write"], subject "user:other"); 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.
- src/origin.ts is the Origin allowlist (framework-free: Fetch Request in, Response or null out). 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: return null and let it through; Origin present and on the allowlist: 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 any extra parameters, and short-circuits with the refusal.
- Tests live in tests/, 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, plus tests/vercel-config.test.ts, which reads the file and fails if that entry disappears.
BEHAVIOR
- nameSchema is z.string().min(1).max(MAX_NAME_LENGTH) with a refine that rejects ASCII control characters (the range from NUL through 0x1F, plus DEL 0x7F). Rejection, never truncation.
- Register one tool, add_item, whose inputSchema is the FULL zod object schema z.object({ name: nameSchema }) (v2 takes a z.object, not a raw shape). There is NO principal argument: zod strips unknown keys, so a client that sends principal anyway sees it silently dropped before the handler runs. Set annotations { readOnlyHint: false, destructiveHint: false, idempotentHint: false } (it appends; it never deletes or overwrites). The description says it appends an item and returns the new count, notes it is a write tool the host should obtain explicit user consent for, and says the caller's identity comes from the verified access token and any principal-shaped argument is ignored.
- principalFromAuthInfo(authInfo) returns "" when authInfo is undefined, else authInfo.extra?.sub when that is a non-empty string, else authInfo.clientId. The empty string is never authorized, so a route that dropped its withMcpAuth wrapper degrades to denials, not to an open server.
- Handler order matters: the handler signature is async ({ name }, ctx); call authorize(principalFromAuthInfo(ctx.http?.authInfo)) FIRST (authorize throws AuthorizationError unless the principal equals AUTHORIZED_PRINCIPAL exactly), then validateName(name) as defense in depth (the SDK already checked the schema, but re-validate anyway; throw ValidationError with the first zod issue message), then push to items, then return content [{ type: "text", text: 'ok: N items' }] where N is items.length. Output minimization: never include stored names, indices, or IDs.
TESTS (vitest, offline)
Connect a real Client (from @modelcontextprotocol/client) to a McpServer over InMemoryTransport.createLinkedPair() (Client comes from @modelcontextprotocol/client; McpServer and InMemoryTransport come from @modelcontextprotocol/server). Reset items (items.length = 0) 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"), "demo-token")) so the tests and the route agree. Assert:
1. listTools shows exactly one tool, add_item, and its advertised inputSchema carries the zod constraints: name has type string with minLength 1 and maxLength 64, required contains name, there is no principal property, and the annotations are exactly the three above.
2. Happy path on a session connected with the demo-token AuthInfo: two calls return exactly "ok: 1 items" then "ok: 2 items", items holds both names, and the result text contains neither stored name.
3. Default-deny by identity alone: a session connected with the other-token AuthInfo (a valid, correctly scoped token for a different user) and a session connected with NO AuthInfo both come back isError: true with no state change. A principal argument can neither grant nor revoke: the no-AuthInfo session passing principal: "user:demo" in the arguments is still denied, and the demo-token session passing principal: "user:attacker" still succeeds. authorize called directly throws AuthorizationError for bad principals and not for "user:demo"; principalFromAuthInfo returns "" for undefined, the sub when present, and the clientId otherwise; verifyToken returns undefined for a missing or unknown token and the expected AuthInfo for demo-token.
4. Validation: empty name, a name of 65 x characters, and names containing a newline, NUL, tab, ESC, or DEL are all isError: true with items left empty; a name of exactly 64 characters succeeds; validateName called directly throws ValidationError for the bad cases.
5. SDK v2 (2.0.0) reality checks: a wrong-typed name (the number 42) on the KNOWN tool comes back as an isError: true tool RESULT (callTool does not throw), but calling an UNKNOWN tool name REJECTS: expect callTool({ name: "nope" }) to throw a protocol error matching /not found/i. This changed from v1, which returned isError results for unknown tools; v2 restores the spec's protocol-error semantics. On every rejection assert items is unchanged: no partial state.
6. 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.
7. Origin allowlist (tests/origin.test.ts, direct calls with plain Fetch Request objects, no server): no Origin header passes; an allowlisted origin passes even when the allowlist entry carried a trailing slash or different case; a non-allowlisted origin, the literal "null" origin, and an unparseable origin each get a 403 whose body does not contain the allowlist; 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 and drops unparseable and duplicate entries.
DEFINITION OF DONE
- npm install, npm run typecheck, and npm test all pass.
- npm run dev, then connect MCP Inspector (npx @modelcontextprotocol/inspector) with Streamable HTTP to http://localhost:3000/api/mcp, set the bearer token to demo-token, and call add_item with name "alpha"; then switch the token to other-token and watch it deny, and 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.
- Optionally vercel deploy; the endpoint is https://<deployment>/api/mcp. One optional environment variable, MCP_ALLOWED_ORIGINS (comma separated browser origins), matters only if a browser-based client will call the endpoint; non-browser clients send no Origin and are unaffected.
SOURCES
- https://modelcontextprotocol.io/specification/2026-07-28/server/tools
- https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http (Origin validation is a MUST)
- https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
- https://modelcontextprotocol.io/specification/2026-07-28/changelog (tool execution errors, SEP-1303)
- https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel
- https://github.com/vercel/mcp-handler
- Reference implementation, compare against it if you get stuck: examples/secure-tools-server/ in this repository (a path relative to the repo root; the GitHub repository is private)
GUARDRAILS
- Tests must pass fully offline with no network access and no Vercel account.
- No dependencies beyond the stack list above.
- Keep it small: one tool, three source files (server, auth, origin), three test files (server, origin, vercel-config). 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.

Where to look now

  • Prompt index - all thirteen prompts and the reliability notes.
  • examples/secure-tools-server (in the repository) - the reference implementation this prompt rebuilds.
  • Examples index - what each example demonstrates.

Bibliography