Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project
Prompt: db-adapter-server
You get a read-only MCP wrapper around an untouched legacy backend, with schema-expressed bounds, a scoped read-only credential, and output sanitization. It teaches the adapter 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.
GOALBuild me a small TypeScript project called db-adapter-server that runs locally and deploys to Vercel: an MCP (Model Context Protocol) server that wraps an existing read-only product database (simulated by a plain in-process module) and exposes it safely as MCP tools and a resource, without ever modifying the backend. It demonstrates three controls every adapter needs: a bound carried inside the tool schema itself, a read-only scoped credential, and sanitization of everything that leaves the adapter.
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 DEV dependency (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). Do NOT install @modelcontextprotocol/sdk; the v2 stack replaced it with the server and client packages.
LAYOUTapp/api/mcp/route.ts is a thin shell: build handler = createMcpHandler(configureServer, { serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } }) from mcp-handler, wrap it as withOriginCheck(handler, parseAllowedOrigins(process.env[ALLOWED_ORIGINS_ENV])) from src/origin.ts, and export the wrapped function as GET, POST, DELETE. There is no [transport] directory, no three-argument createMcpHandler signature, and no basePath option in mcp-handler 2.x (createMcpHandler survives with the two-argument form above); the public endpoint is /api/mcp. ALL protocol logic lives in src/server.ts exporting configureServer(server), plus src/store.ts for the backend and src/origin.ts for the Origin allowlist. Include a vercel.json with the $schema key and functions["app/api/mcp/route.ts"].maxDuration set to 30. Tests in tests/ (server.test.ts, origin.test.ts, vercel-config.test.ts) import src/ and never import Next.js.
BEHAVIOR- src/store.ts is the "legacy backend": a ReadOnlyStore class over 5 frozen seed rows with fields id, name, category, price_cents, secret_cost_cents. Rows: 1 Widget/widgets/1999, 2 Deluxe Widget/widgets/4999, 3 Gadget/gadgets/2999, 4 with name "Sprocket" plus a raw ESC control character (code 0x1b) plus "[31m" in gadgets at 999, 5 Gizmo/gizmos/3499. It records every read in a queryLog array (with a resetQueryLog method), and its insert, update, delete, and dropTable methods all throw a ReadOnlyViolation error before touching any state.- Filtering compares category as a plain value, never builds a query string, so a hostile input like "widgets'; DROP TABLE products;--" simply matches nothing.- 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.- src/server.ts imports McpServer (type) and ResourceTemplate from @modelcontextprotocol/server, re-exports ReadOnlyStore, ReadOnlyViolation, buildStore, and the ProductRow type from ./store, and exports SERVER_NAME = "db-adapter-server", SERVER_VERSION = "0.1.0", constants MIN_LIMIT = 1, MAX_LIMIT = 50, DEFAULT_LIMIT = 10, MAX_CATEGORY_LENGTH = 64, a ValidationError class, the shared store instance, and helpers escapeControlChars, sanitizeRow, queryProductsByCategory, resolveProduct.- sanitizeRow is an allowlist projection: it copies only id, name, category, price_cents (so secret_cost_cents never enters any payload) and escapes ASCII control characters (codes below 0x20 and 0x7f) in strings as backslash-x hex, for example the ESC character becomes the four characters \x1b. Apply it to EVERY row that leaves the adapter.- Tool list_categories: inputSchema z.object({}) (v2 inputSchema is a FULL zod object schema, not the v1 raw shape), returns the distinct categories sorted, JSON in a text content block.- Tool get_products_by_category: inputSchema z.object({ category: z.string().max(64), limit: z.number().int().min(1).max(50).default(10) }) so both bounds round-trip into the emitted JSON schema: minimum, maximum, and default on limit, maxLength on category. The handler calls queryProductsByCategory(category, limit), which re-checks BEFORE querying the store (defense in depth for non-validating callers) that category is at most 64 characters (reject, never truncate: a clipped filter would silently match a different category) and that limit is an integer in 1..50, throwing ValidationError otherwise. It passes the category to the store in control-character-escaped form (escapeControlChars), because the store records every filter value in its queryLog and a raw newline in that line would forge a second log entry; no legitimate category contains control characters, so a hostile value simply becomes a clean miss. Results are sanitized rows ordered by id, JSON in a text block.- Resource template product://{product_id} registered with new ResourceTemplate("product://{product_id}", { list: undefined }). resolveProduct returns { found: false, product_id } for a non-integer or unknown id (clean payload, never a throw) and { found: true, product: sanitizedRow } otherwise, served as application/json text. The echoed product_id is caller-controlled text reflected into model context, so it goes through escapeControlChars first: an id carrying a raw ESC or newline comes back as \x1b or \x0a, never verbatim.
TESTS (vitest)Connect a real Client (from @modelcontextprotocol/client) to an McpServer over InMemoryTransport.createLinkedPair() (both from @modelcontextprotocol/server), then use listTools, callTool, readResource, listResourceTemplates. Reset the store's queryLog in beforeEach. Assert at minimum:- listTools shows both tools; the limit property of get_products_by_category carries minimum 1, maximum 50, default 10 in the emitted inputSchema, and the category property carries maxLength 64.- listResourceTemplates includes product://{product_id}; product://1 resolves Widget; product://9999 and product://not-a-number return found false with the id echoed back.- Default limit returns widgets ids [1, 2] in order.- SDK v2 (2.0.0) reality checks: schema-invalid args (limit 0 or 51) on a KNOWN tool still come back as isError true tool RESULTS (callTool does not throw), and after a rejected limit the queryLog is still empty (the backend was never touched). BUT a call to an UNKNOWN tool name now REJECTS with a protocol error matching /not found/i, so use rejects.toThrow; this changed from v1, which returned isError results for unknown tools.- 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.- Direct calls to queryProductsByCategory with 0, 51, and 2.5 throw ValidationError without querying the store.- A 65-character category via callTool is an isError result with the queryLog still empty; a category of exactly 64 characters is accepted (an empty array, and exactly one queryLog line); a direct queryProductsByCategory call with 65 characters throws ValidationError without querying the store.- A hostile product_id is reflected only in escaped form: resolveProduct("9999" + ESC + "[31m") returns found false with product_id "9999\x1b[31m", resolveProduct("not-a-number" + newline + "forged") returns product_id "not-a-number\x0aforged", neither contains the raw control character, and the queryLog stays empty (the escaped form still fails the integer check). Over the wire, readResource on product://9999 plus a raw ESC plus "forged" yields a not-found payload whose serialized text contains no raw control character at all (no "[" in that URI: a bracket in the authority part is an invalid URL and the SDK rejects it before dispatch).- No raw control character is ever written into the backend queryLog: callTool with category "widgets" + newline + "selectByCategory:forged:1" returns [] and leaves exactly one log line that contains \x0a and no raw control character; a direct queryProductsByCategory("gadgets" + ESC + "[31m", 1) call likewise returns [] and logs one line containing \x1b.- insert, update, delete, dropTable each throw ReadOnlyViolation, and afterwards the data is intact (categories are exactly gadgets, gizmos, widgets).- secret_cost_cents is absent from every tool payload and from the resource payload.- Product 4's name contains the escaped \x1b text and NOT the raw ESC character, on both the tool path and the resource path.- The hostile category string returns an empty array and a normal query still works afterwards.- 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; an empty allowlist refuses every Origin; 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, drops unparseable and duplicate entries, and yields an empty allowlist (not the default) for an all-junk value.- tests/vercel-config.test.ts reads vercel.json as data and fails unless functions["app/api/mcp/route.ts"].maxDuration is a positive integer.Gotcha: ResourceTemplate requires the second argument { list: undefined } or registration fails to compile.
DEFINITION OF DONEnpm 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; call list_categories, then get_products_by_category, then read product://4 to see the escaped control character. Optionally vercel deploy; no environment variables are required. 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.
SOURCEShttps://modelcontextprotocol.io/specification/2026-07-28/server/tools, https://modelcontextprotocol.io/specification/2026-07-28/server/resources, https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel, https://github.com/vercel/mcp-handler, and the reference implementation at examples/db-adapter-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. No dependencies beyond the stack list (in particular, no database driver: the plain module IS the point). Keep it small.Where to look now
- Prompt index - all thirteen prompts and the reliability notes.
examples/db-adapter-server(in the repository) - the reference implementation this prompt rebuilds.- Examples index - what each example demonstrates.
Bibliography
- localhost:3000 - http://localhost:3000/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/server/resources
- 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/db-adapter-server(in the repository)