Skip to content

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

Prompt: orchestrator-host

Audience:engineernon-technicalMCP spec 2026-07-28

You get the client side of MCP: a host that runs one client session per connected server, merges every server’s tools into a single namespaced list, routes calls back to the owning session, and gates destructive tools behind one fail-closed consent callback, implementing the orchestrator 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.

This prompt assumes the two servers the host drives already exist as sibling packages: build minimal-server and secure-tools-server first, or point the file: dependencies at the reference copies in examples/.

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 orchestrator-host that runs locally: an MCP (Model Context Protocol) HOST, a client-side program, not a server. It connects one MCP client session per server to two existing sibling servers (minimal-server, which exposes echo, and secure-tools-server, which exposes add_item), aggregates their tools into a single namespaced list so identically named tools never collide, routes namespaced calls back to the owning server, and gates destructive tools behind a single fail-closed consent callback. There is no MCP endpoint to serve; the deploy story is that the servers it talks to are the deployable units.
STACK (exact, non-negotiable)
Dependencies: @modelcontextprotocol/client pinned EXACTLY to 2.0.0 (this project IS the client), zod ^4.2.0 (a hard floor: the v2 SDK requires zod 4.2.0 or newer; zod ^3 installs cleanly and then fails typecheck and tests), and the two sibling servers as local path dependencies: "minimal-server": "file:../minimal-server" and "secure-tools-server": "file:../secure-tools-server" (each sibling's package.json exports "." as ./src/server.ts, so their configureServer functions resolve by package name; the siblings must be installed first with npm ci in each directory). Dev dependencies: @modelcontextprotocol/server pinned EXACTLY to 2.0.0 (used only by the tests to spin up the two sibling servers in-process), typescript ^5.9.0, vitest ^4.1.0, @types/node ^24.0.0. NO next, react, or react-dom: nothing here renders or serves. NO mcp-handler: it is a server-side route adapter and a host serves nothing. Node 22 or newer ("engines": { "node": ">=22" }). package.json has "type": "module", "private": true, an "exports" map of { ".": "./src/orchestrator.ts" }, and exactly two scripts: test (vitest run) and typecheck (tsc --noEmit). There is no dev script and no demo script.
LAYOUT
No app/ directory and no route file: this is a client, not a server, so there is no app/api/mcp/route.ts and no createMcpHandler here (those belong to the server examples). src/orchestrator.ts holds all host logic and imports ONLY from @modelcontextprotocol/client (framework-free; transports arrive by injection). Tests live in tests/ as two files: tests/orchestrator.test.ts and tests/vercel-config.test.ts. tsconfig.json includes src and tests, targets ES2022 with module ESNext, moduleResolution bundler, strict, noUncheckedIndexedAccess, noEmit, and types ["node"]. A vercel.json is present but intentionally vestigial (every example carries one for CI-matrix uniformity): it contains the $schema key ("https://openapi.vercel.sh/vercel.json") plus a functions block { "app/api/mcp/route.ts": { "maxDuration": 30 } }, even though no such route exists here.
BEHAVIOR
- src/orchestrator.ts imports Client, ProtocolError, and StreamableHTTPClientTransport from "@modelcontextprotocol/client", plus the types CallToolResult and Transport. It exports HOST_NAME "orchestrator-host" and HOST_VERSION "0.1.0"; type ToolResult = CallToolResult (in SDK 2.0.0 callTool returns a plain CallToolResult, the v1 toolResult compatibility union is gone); interface AggregatedTool { name, toolName, serverId, inputSchema }; type ConsentFn (namespacedName, args) returning unknown or a promise; and type TransportSource: a Transport or a zero-argument factory (sync or async) that builds one.
- Class Orchestrator holds readonly sessions: Map<string, Client>. connect(servers: Record<string, TransportSource>) resolves each factory, creates one new Client({ name: HOST_NAME, version: HOST_VERSION }) per server, connects it, stores it under its server id, and returns the map.
- aggregateTools() calls listTools on every session and returns entries whose name is serverId + "." + toolName, keeping the bare toolName, the owning serverId, and the tool's inputSchema on each entry for routing. The bare names are deliberately absent from the aggregated list; namespacing is the collision defense.
- call(namespacedName, args, consent) splits on the FIRST dot only, so a tool name may itself contain dots. Routing check comes first: an unknown server id throws UnknownServerError (an Error subclass carrying serverId and namespacedName) before any consent prompt or dispatch.
- Export DESTRUCTIVE, a ReadonlySet<string> containing exactly "secure.add_item", and isDestructive(name). For destructive names, await consent(namespacedName, args) BEFORE dispatch. Only a literal true approves; false, undefined, any non-true value, a rejected promise, or a thrown callback all deny and throw ConsentDenied (an Error subclass carrying namespacedName). The gate is fail-closed and is never consulted for non-destructive tools. Add a comment that a hardcoded destructive set is a teaching simplification: production should classify by server-declared tool annotations (readOnlyHint / destructiveHint) and default-deny anything not positively read-only.
- Dispatch handles the TWO server-side failure surfaces of SDK v2. First, wrap client.callTool in try/catch: if it throws a ProtocolError (an unknown tool name does this in v2; v1 returned an isError result instead), rethrow it as ToolCallError (an Error subclass carrying serverId and toolName, constructed with (serverId, toolName, message, options?: ErrorOptions)) with the ProtocolError's message and the original error preserved as cause; any other thrown error propagates untouched. Second, if the returned result has isError === true (validation and authorization failures on a KNOWN tool still arrive this way and callTool does NOT throw for them), throw ToolCallError carrying the first non-empty text block of the result, falling back to 'tool call failed (no error text returned)'. Never hand back an error-flagged result as if it succeeded.
- close() tears sessions down in reverse (LIFO) connect order and clears the map. Also export connectOverStreamableHttp(url: string | URL) returning a factory that builds a fresh StreamableHTTPClientTransport per call, for driving deployed servers; the tests never use it.
- The two servers are NOT written here. minimal-server exports configureServer, SERVER_NAME, and SERVER_VERSION and registers tool echo (inputSchema z.object({ message: z.string() }), returns the message as a text block). secure-tools-server exports configureServer, SERVER_NAME, SERVER_VERSION, AUTHORIZED_PRINCIPAL = "user:demo", and a module-level items: string[] array; it registers tool add_item with inputSchema z.object({ name: nameSchema }) (a string of 1 to 64 characters with no control characters) and NO principal argument. Its handler signature is async ({ name }, ctx): it derives the principal from the injected AuthInfo (ctx.http?.authInfo: extra.sub when that is a non-empty string, else clientId, else ""), throws "principal is not authorized to add items" unless that equals AUTHORIZED_PRINCIPAL, re-validates name, pushes it onto items, and returns text 'ok: N items' where N is the new length.
TESTS
- tests/orchestrator.test.ts imports configureServer, SERVER_NAME, and SERVER_VERSION from "minimal-server" (aliased configureMinimal, MINIMAL_NAME, MINIMAL_VERSION) and AUTHORIZED_PRINCIPAL, configureServer, items, SERVER_NAME, and SERVER_VERSION from "secure-tools-server" (aliased configureSecure, SECURE_NAME, SECURE_VERSION); ProtocolError from "@modelcontextprotocol/client"; InMemoryTransport and McpServer plus the types AuthInfo and Transport from "@modelcontextprotocol/server"; and ConsentDenied, ConsentFn, Orchestrator, ToolCallError, ToolResult, UnknownServerError from "../src/orchestrator". Because each sibling resolves @modelcontextprotocol/server to its own physical copy of the identical 2.0.0 install and McpServer carries private fields, cast both configureServer imports once to a local type Configure = (server: McpServer) => void.
- Wire everything over memory with a spinUp(name, version, configure, authInfo?) helper: build an McpServer, run its configure function, create InMemoryTransport.createLinkedPair(), connect the server end, and return the client end. For the secure server 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 when authInfo is given, wrap the client transport's send to attach it to every message. A verifiedAs(subject) helper builds the stub AuthInfo { token: "stub-token", clientId: "host-client", scopes: ["items:write"], extra: { sub: subject } }; AUTHORIZED_USER = verifiedAs(AUTHORIZED_PRINCIPAL) and OTHER_USER = verifiedAs("user:attacker"). A principal is never a tool argument: the add_item arguments are always { name: "demo" }. connectBoth(secureAuthInfo = AUTHORIZED_USER) builds both transports and calls orch.connect({ minimal: ..., secure: ... }). Reset items (items.length = 0) in beforeEach, await orch.close() in afterEach. No network, no sleeps. Note: connect() still performs the legacy initialize handshake at protocol version 2025-11-25; SDK 2.0.0 has not moved the wire protocol yet.
- describe "connection": sessions has size 2 and exactly the keys minimal and secure.
- describe "namespaced aggregation": aggregated names include minimal.echo and secure.add_item and do NOT include bare echo or add_item; the minimal.echo entry has serverId "minimal" and toolName "echo", and the secure.add_item entry has serverId "secure" and toolName "add_item".
- describe "routing": minimal.echo with { message: "hi" } returns a non-error result whose text is "hi"; nope.echo throws UnknownServerError with serverId "nope".
- describe "fail-closed consent gate", the negative assertions this example lives by: with a consent returning false, one returning undefined, and one that throws, secure.add_item with { name: "demo" } throws ConsentDenied each time AND items stays empty; in the deny and undefined cases a following approved call returns exactly 'ok: 1 items', proving the denied calls never reached the server. With approval the call succeeds with text 'ok: 1 items' and items equals ["demo"]. A counting deny callback passed to minimal.echo is never invoked (the gate is not consulted for non-destructive tools).
- describe "server-side failures surface as typed errors": with consent approving and the secure session connected as OTHER_USER (extra.sub "user:attacker"), secure.add_item with { name: "demo" } throws ToolCallError with serverId "secure", toolName "add_item", a message containing "authorized", and items stays empty. SDK v2 (2.0.0) reality checks, both verified by live runs: an UNKNOWN tool name (minimal.nope) makes client.callTool throw a ProtocolError whose message matches /not found/i, and the host surfaces it as ToolCallError with serverId "minimal", toolName "nope", and cause instanceof ProtocolError (this CHANGED from v1, which returned isError results for unknown tools; v2 matches the spec). Schema-invalid arguments on a KNOWN tool (minimal.echo with { message: 42 }) are still an isError: true tool RESULT, callTool does not throw, and the host raises ToolCallError with serverId "minimal", toolName "echo", and cause undefined. Do not assert resultType on results or ttlMs/cacheScope on list results; SDK 2.0.0 does not emit them yet.
- 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. It is copied byte for byte from the server examples and passes here only because the vestigial vercel.json keeps that block.
DEFINITION OF DONE
Install the siblings first ((cd ../minimal-server && npm ci) and (cd ../secure-tools-server && npm ci)), then npm install, npm run typecheck, npm test all green. There is no npm run dev, no demo script, no Inspector, and no deploy step: a host exposes nothing to connect to. To drive deployed servers instead of in-memory ones, pass connectOverStreamableHttp("https://<deployment>/api/mcp") factories to Orchestrator.connect; that path talks to the network and is not exercised by the tests.
SOURCES
https://modelcontextprotocol.io/specification/2026-07-28/architecture (hosts, clients, one session per server), https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning, https://modelcontextprotocol.io/specification/2026-07-28/server/tools (CallToolResult and isError), https://modelcontextprotocol.io/specification/2026-07-28/changelog (unknown tools are protocol errors, tool-level failures stay isError results), https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes, https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel, https://github.com/vercel/mcp-handler, and the reference implementation at examples/orchestrator-host/ 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: every transport is in-memory. No dependencies beyond the stack list (no tsx, no extra runners, no mcp-handler, no Next.js). Keep it small: one orchestrator file, the two test files named above, and nothing else in src/ (the servers come from the sibling packages, never from a local copy).

Where to look now

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

Bibliography