# Prompt: minimal-server

Canonical URL: https://vercel-mcp-reference.vercel.app/examples/prompts/minimal-server/
Markdown: https://vercel-mcp-reference.vercel.app/examples/prompts/minimal-server.md
Audience: engineer, non-technical. MCP spec version: 2026-07-28. Last reviewed: 2026-08-26. Status: stable.

This prompt builds the smallest end-to-end MCP server: one echo tool over Streamable HTTP, the 10-minute path from [getting started](https://vercel-mcp-reference.vercel.app/getting-started/) and the structural template every other example copies.

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. The prompt's TESTS section pins the behaviors the in-memory harness actually exhibits, so the project it produces is green today.

Copy everything in the block below into your AI coding agent as one message. See [the prompt index](https://vercel-mcp-reference.vercel.app/examples/prompts/) for what makes these prompts reliable and how they were tested.

```text
GOAL
Build me a small TypeScript project called minimal-server that runs locally and deploys to Vercel: an MCP (Model Context Protocol) server that exposes exactly one tool, echo, which returns the caller's message back unchanged. This is the smallest possible end-to-end MCP server, so keep everything minimal and readable.

STACK (exact, non-negotiable)
- Next.js App Router: next ^16.2.12, react and react-dom ^19.0.0.
- mcp-handler 2.1.1 plus @modelcontextprotocol/server pinned EXACTLY to 2.0.0 as regular dependencies (mcp-handler 2.1.1 peer-requires @modelcontextprotocol/server ^2.0.0). Do NOT add @modelcontextprotocol/sdk; that is the v1 package.
- 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, so never downgrade it.
- Dev dependencies: typescript ^5.9.0, vitest ^4.1.0, @types/node ^24.0.0, @types/react 19.2.18, and @modelcontextprotocol/client pinned EXACTLY to 2.0.0 (the client package is for tests only).
- Node 22 or newer ("engines": { "node": ">=22" }). In package.json set "type": "module", "private": true, an "exports" map of { ".": "./src/server.ts" } (so a sibling package such as orchestrator-host can import configureServer by package name), and scripts: dev (next dev), test (vitest run), typecheck (tsc --noEmit).

LAYOUT
- app/api/mcp/route.ts is a thin shell only. It builds the handler as withOriginCheck(createMcpHandler(configureServer, { serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } }), parseAllowedOrigins(process.env[ALLOWED_ORIGINS_ENV])) and exports that one handler as GET, POST, and DELETE. createMcpHandler comes from mcp-handler; withOriginCheck, parseAllowedOrigins, and ALLOWED_ORIGINS_ENV come from src/origin.ts. There is NO withMcpAuth here (this example has no authentication), v2 has no [transport] directory and no basePath option, and the public endpoint is /api/mcp. Nothing else lives in the route file.
- ALL protocol logic lives in src/server.ts, which exports configureServer(server) (typed against McpServer from @modelcontextprotocol/server) plus the constants SERVER_NAME = "minimal-server" and SERVER_VERSION = "0.1.0".
- src/origin.ts is a framework-free Origin allowlist (the Streamable HTTP transport spec says servers MUST validate Origin and answer 403 when it is not allowed; mcp-handler 2.x does not do this itself). It exports: ALLOWED_ORIGINS_ENV = "MCP_ALLOWED_ORIGINS"; DEFAULT_ALLOWED_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"]; parseAllowedOrigins(raw) which splits the comma separated variable, trims, normalizes each entry to its serialized origin via new URL(entry).origin, drops entries that do not parse (fail closed, never widen), de-duplicates, and falls back to DEFAULT_ALLOWED_ORIGINS only when raw is undefined or blank; assertAllowedOrigin(request, allowlist) which returns null when the request has no Origin header (non-browser clients) or an allowlisted one, and otherwise returns a 403 Response with body "Forbidden: Origin not allowed" and content-type text/plain (the literal "null" origin and unparseable values are refused; the body never echoes the allowlist); and withOriginCheck(handler, allowlist), a generic wrapper that runs assertAllowedOrigin first, returns the refusal when there is one, and otherwise forwards the request plus any extra arguments (Next.js passes a route context) to the wrapped handler.
- Tests live in tests/ as three files: tests/server.test.ts, tests/origin.test.ts, and tests/vercel-config.test.ts. They import only from src/ (and vercel.json as data). They must never import anything from Next.js.
- vercel.json contains the $schema key ("https://openapi.vercel.sh/vercel.json") plus a functions block: { "app/api/mcp/route.ts": { "maxDuration": 30 } }, so a runaway tool call is bounded by the platform.

BEHAVIOR
- Register one tool named echo with server.registerTool. Description: "Echo a message back to the caller."
- Its input schema is a full zod object schema: inputSchema: z.object({ message: z.string() }). SDK v2 takes the object schema itself, not the raw shape v1 used.
- The handler returns { content: [{ type: "text", text: message }] }, echoing the input exactly. No other tools, resources, or prompts.

TESTS (vitest, offline)
tests/server.test.ts (describe "minimal-server"): in each test, build a fresh McpServer (from @modelcontextprotocol/server) with SERVER_NAME and SERVER_VERSION, call configureServer on it, then connect a real Client from @modelcontextprotocol/client (name "test-client", version "0.0.0") over InMemoryTransport.createLinkedPair() (InMemoryTransport also comes from @modelcontextprotocol/server); connect server and client with Promise.all. Assert:
1. listTools returns exactly one tool named echo whose inputSchema is an object with properties.message of type string.
2. callTool with { message: "ping" } succeeds (isError is falsy) and content equals [{ type: "text", text: "ping" }].
3. callTool with a wrong-typed argument, message set to the number 42, comes back as a tool RESULT with isError: true. SDK v2 reality check: callTool does NOT throw for schema-invalid arguments on a known tool.
4. callTool with an unknown tool name like "nope" REJECTS with a ProtocolError whose message matches /not found/i. This CHANGED from v1, which returned isError results for unknown tools; v2 matches the spec's protocol-error semantics.
5. 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.
tests/origin.test.ts exercises src/origin.ts directly with a Fetch Request helper (POST to https://server.example/api/mcp with an optional origin header) and an allowlist of ["https://app.example"], in three describe blocks: "assertAllowedOrigin" (a disallowed Origin gets 403 whose body matches /origin/i and does not contain "app.example"; no Origin header passes; an allowlisted Origin passes; the literal "null" origin and "not a url" are refused; scheme, host, and port all count, so http://app.example, https://app.example:8443, and https://app.example.evil are refused while HTTPS://APP.example passes after normalization; an empty allowlist refuses every Origin but still passes an origin-less request), "parseAllowedOrigins" (undefined and blank fall back to DEFAULT_ALLOWED_ORIGINS; " https://app.example/ , https://admin.example:8443/path, nope, ," yields ["https://app.example", "https://admin.example:8443"]; an all-junk value such as "garbage" yields [] rather than the default), and "withOriginCheck" (a refused request returns 403 before the wrapped handler runs, counted as zero calls; allowed and origin-less requests are forwarded with their extra arguments intact).
tests/vercel-config.test.ts (describe "vercel.json") reads vercel.json from disk as data and asserts that functions["app/api/mcp/route.ts"].maxDuration is a positive integer.

DEFINITION OF DONE
- npm install, npm run typecheck, and npm test all pass with no errors.
- npm run dev starts the server; connect MCP Inspector (npx @modelcontextprotocol/inspector) using the Streamable HTTP transport to http://localhost:3000/api/mcp, list tools, and call echo by hand. The default allowlist already covers the local dev server.
- Optionally run vercel deploy; the endpoint is https://<deployment>/api/mcp and needs no environment variables. The one optional variable is MCP_ALLOWED_ORIGINS (comma separated browser origins); a deployment that serves only non-browser clients can leave it unset, since those never send Origin.

SOURCES
- https://modelcontextprotocol.io/specification/2026-07-28/server/tools
- https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning
- https://modelcontextprotocol.io/specification/2026-07-28/basic/transports
- 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/minimal-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 the project small: one tool, one source file of protocol logic (src/server.ts) plus the origin helper (src/origin.ts), and the three test files named above.
```

## Where to look now

- [Prompt index](https://vercel-mcp-reference.vercel.app/examples/prompts/) - all thirteen prompts and the reliability notes.
- `examples/minimal-server` (in the repository) - the reference implementation this prompt rebuilds.
- [Examples index](https://vercel-mcp-reference.vercel.app/examples/) - what each example demonstrates.

## Bibliography

- localhost:3000 - <http://localhost:3000/api/mcp>
- <deployment> - <https://<deployment>/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/versioning>
- Model Context Protocol Specification - <https://modelcontextprotocol.io/specification/2026-07-28/basic/transports>
- 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/minimal-server` (in the repository)
