Skip to content

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

Prompt: facade-server

Audience:engineernon-technicalMCP spec 2026-07-28

You get one MCP server fronting two backends behind a namespaced tool surface with centralized routing, exception containment, and a secret-free audit log. It teaches the facade pattern.

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. 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.

GOAL
Build me a small TypeScript project called facade-server that runs locally and deploys to Vercel: an MCP (Model Context Protocol) server that fronts two independent in-process backends (a toy weather lookup and a toy directory lookup) behind one unified, namespaced tool surface. All routing, error containment, and audit logging happen at a single choke point instead of being re-implemented per backend.
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), @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: build createMcpHandler(configureServer, { serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } }) from mcp-handler, wrap it as withOriginCheck(handler, parseAllowedOrigins(process.env[ALLOWED_ORIGINS_ENV])) (both from src/origin.ts), and export the wrapped function as GET, POST, DELETE. There is NO [transport] directory and NO basePath option; the old three-argument createMcpHandler signature is gone in mcp-handler 2.x (the name survives with a two-argument form) and the public endpoint is /api/mcp. ALL protocol logic lives in src/server.ts exporting configureServer(server), with backends in src/backends/ (error.ts, weather.ts, directory.ts) and the Origin allowlist in src/origin.ts. Tests live in tests/ (server.test.ts, origin.test.ts, vercel-config.test.ts), import only from src/ (or read vercel.json as data), and never import Next.js. Include a vercel.json with the $schema key and functions["app/api/mcp/route.ts"].maxDuration set to 30.
BEHAVIOR
- src/backends/error.ts exports class BackendError extends Error, the ONLY error type allowed to cross the facade boundary. Its constructor is (message, options?: { correlationId?: string }); it sets name = "BackendError" and exposes a readonly correlationId (string or undefined). A contained fault carries a correlationId; an expected failure (unknown key, unknown backend) does not.
- src/backends/weather.ts exports NAME = "weather", SCOPE = "weather:read", and lookup(city). Data: london gives rain, tokyo gives clear, cairo gives hot. Lookup lowercases the input so it is case-insensitive. An unknown city throws BackendError with a message containing 'unknown city'.
- src/backends/directory.ts exports NAME = "directory", SCOPE = "directory:read", and lookup(person). Data: alice gives alice@example.com, bob gives bob@example.com. The sentinel input "__boom__" throws a BARE Error ("simulated uncaught backend fault") to simulate a backend bug. An unknown person throws BackendError with 'unknown person'.
- src/server.ts builds a BACKENDS registry (a ReadonlyMap keyed by backend name) from the two modules, which share the uniform contract NAME, SCOPE, lookup. Adding a backend is one registry entry plus one thin tool shim; no policy code changes.
- dispatch(backendName, key) is the single choke point: it throws BackendError('unknown backend: "<name>"') if the name is not in the registry (BEFORE any audit entry), appends { backend, scope } to a module-level auditLog array once the route resolves, then calls the backend's lookup. An expected BackendError from the backend passes through unchanged. ANY other exception is contained: dispatch mints a correlation id with randomUUID() from node:crypto, hands the raw detail to the fault logger as a FaultLogEntry { correlationId, backend, scope, detail (the exception's message, or String(error) for non-Error throws), stack (the exception's stack or undefined) }, and throws new BackendError(containedFaultMessage(name, correlationId), { correlationId }). Nothing derived from the original exception is interpolated into that message. That is the containment boundary: a raw backend fault must never escape the handler, and its text must never enter a tool result (tool results are re-injected into the model's context, so a driver error, hostname, or query fragment in an exception message would be handed to the model).
- Export containedFaultMessage(backendName, correlationId), which returns exactly: backend "<name>" failed; see server logs for correlation id <id>. Only the backend name and the id vary.
- Export the fault-log seam from src/server.ts: interface FaultLogEntry (fields above), interface FaultLogger { logFault(entry: FaultLogEntry): void }, consoleFaultLogger (calls console.error("[facade] contained backend fault", entry); a Vercel log drain ships it off-platform), setFaultLogger(logger), and resetFaultLogger() (restores consoleFaultLogger). The fault logger is module state, separate from auditLog on purpose: the audit trail stays secret-free, while the fault record may contain anything the backend put in an exception and goes only where operators read it. Also re-export BackendError from src/server.ts, and export the Backend and AuditEntry interfaces.
- The audit log records the backend name and scope ONLY, never the key argument and never the result, so it carries no secrets. Export resetAuditLog() so tests can clear it in beforeEach.
- configureServer registers exactly two tools with server.registerTool: weather_get with inputSchema z.object({ city: z.string() }) dispatching to "weather", and directory_lookup with inputSchema z.object({ person: z.string() }) dispatching to "directory". In the v2 SDK inputSchema is a FULL zod object schema (z.object({ ... })), not the raw shape v1 accepted. Names use underscores because MCP tool names must be valid identifiers. Also export SERVER_NAME = "facade-server" and SERVER_VERSION = "0.1.0". Import the McpServer type from "@modelcontextprotocol/server".
- src/origin.ts is the Origin allowlist (framework-free: Fetch Request in, Response or null out; the MCP transport spec requires Origin validation as the DNS-rebinding defense, and mcp-handler 2.x does not do it for you). 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 only when raw is undefined or blank, so an all-junk value yields an EMPTY allowlist rather than the default); assertAllowedOrigin(request, allowlist) (no Origin header: return null and let it through; Origin present and on the allowlist after normalization: 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 (vitest)
tests/server.test.ts: connect a real Client (from "@modelcontextprotocol/client") to an McpServer over InMemoryTransport.createLinkedPair() (both from "@modelcontextprotocol/server"), then listTools and callTool. In beforeEach call resetAuditLog() and inject a recording fault logger with setFaultLogger({ logFault: (entry) => faults.push(entry) }); in afterEach call resetFaultLogger(). Define RAW_FAULT = "simulated uncaught backend fault" and a UUID regex. Assert at minimum:
- listTools advertises weather_get and directory_lookup with string schemas, and the bare registry keys "weather" and "directory" NEVER appear as tool names (loop over every BACKENDS key and assert none is exposed).
- Routing works case-insensitively: city "London" returns rain, "tokyo" returns clear, person "alice" returns alice@example.com.
- SDK v2 (2.0.0) reality checks: calling an UNKNOWN tool name (like the bare "weather") now REJECTS with a protocol error matching /not found/i, so await expect(...).rejects.toThrow(/not found/i). This CHANGED from v1, which returned isError results for unknown tools; v2 matches the spec's protocol-error semantics. It fails before any facade code runs, so the auditLog stays EMPTY. Errors thrown in a tool handler (including contained BackendErrors) and schema-invalid arguments on a KNOWN tool still come back as isError true tool RESULTS; callTool resolves rather than throwing. An unknown city like "atlantis" is isError true with 'unknown city' in the text, and weather_get with a non-string city (42) is isError true. Do NOT assert resultType on results or ttlMs/cacheScope on list results; SDK 2.0.0 does not emit them yet.
- No cross-routing: weather_get with "alice" errors, directory_lookup with "london" errors.
- Calling dispatch directly: dispatch("directory", "__boom__") throws BackendError (not a raw Error) matching the text: backend "directory" failed. Catch that error and assert its correlationId matches the UUID regex, its message equals exactly backend "directory" failed; see server logs for correlation id <that id>, the message does NOT contain RAW_FAULT, and the recording logger received exactly one entry matching { correlationId: <that id>, backend: "directory", scope: "directory:read", detail: RAW_FAULT } whose stack contains RAW_FAULT. Two consecutive __boom__ dispatches log two entries with DIFFERENT correlation ids. dispatch("weather", "atlantis") throws BackendError matching 'unknown city' and logs NO fault (expected failures are not faults). dispatch("nonexistent", "anything") throws BackendError matching 'unknown backend', logs nothing, and leaves the auditLog EMPTY.
- Session survival: over the client, directory_lookup with "__boom__" is isError true containing: backend "directory" failed, and a FOLLOW-UP weather_get "Tokyo" on the same session still returns clear.
- Opaque error over the wire (the load-bearing output-trust test): over the client, directory_lookup with "__boom__" is isError true; exactly one fault was logged, its correlationId matches the UUID regex and its detail equals RAW_FAULT; the result's text equals exactly backend "directory" failed; see server logs for correlation id <that same id>; and JSON.stringify of the whole result contains none of RAW_FAULT, "simulated", "directory.ts", or "at lookup".
- Scopes are distinct per backend: weather:read versus directory:read.
- Audit hygiene: after a successful weather_get "London", auditLog equals exactly [{ backend: "weather", scope: "weather:read" }] and its JSON serialization contains neither "london" nor "rain". After the __boom__ call, the entry { backend: "directory", scope: "directory:read" } exists but "__boom__" appears nowhere in the log.
tests/origin.test.ts (direct calls with plain Fetch Request objects, no server): no Origin header passes; an allowlisted origin passes, including "HTTPS://APP.example" against ["https://app.example"] (scheme and host case are normalized); a non-allowlisted origin, a different scheme or port, the literal "null" origin, and an unparseable origin each get a 403 whose body mentions origin but does not contain the allowlist; an empty allowlist refuses every Origin yet still passes an origin-less request; parseAllowedOrigins falls back to DEFAULT_ALLOWED_ORIGINS for undefined and blank input, splits on commas, trims, normalizes, drops junk and duplicates, and returns [] for an all-junk value; withOriginCheck never invokes the wrapped handler on refusal and forwards extra arguments on success.
tests/vercel-config.test.ts: read vercel.json as data and assert functions["app/api/mcp/route.ts"].maxDuration is a positive integer, so a refactor that drops the entry fails here rather than on the first production timeout.
DEFINITION OF DONE
npm install, npm run typecheck, and npm test are all green. Then npm run dev and connect MCP Inspector (npx @modelcontextprotocol/inspector) with the Streamable HTTP transport to http://localhost:3000/api/mcp and call weather_get and directory_lookup by hand. Optionally vercel deploy; no environment variables are required. The one optional 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/versioning, https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel, https://github.com/vercel/mcp-handler, and the reference implementation at examples/facade-server/ in this repository (a path relative to the repo root; the GitHub repository is private), compare against it if you get stuck.
GUARDRAILS
Tests must pass with no network access and no Vercel account. No dependencies beyond the stack list; the backends are deterministic in-memory maps with no I/O, so there are no timeout or non-2xx paths to harden. Keep it small: two tools, src/server.ts plus src/origin.ts plus the three backend modules, three test files (server, origin, vercel-config). Never forward an upstream exception message into a tool result; log it under the correlation id and return the fixed message plus the id. Be honest in comments that this is in-process exception containment, not process isolation: a process-fatal fault in one backend still takes down its siblings.

Where to look now

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

Bibliography