MCP server — POST /api/mcp
Durable facts about the Model Context Protocol endpoint in apps/api, graduated from specs/017-mcp-server/ (step 6). Code shape follows nestjs.md; the upstream API facts the tools wrap live in gw2-api.md. This file is about the protocol surface — what it speaks, what it refuses, and the handful of rules that look optional and are not.
What it is
One JSON-RPC endpoint, POST /api/mcp, exposing four read-only tools over the existing Gw2Service. GET and DELETE on the same path answer 405. The route is deliberately absent from openapi.json (@ApiExcludeEndpoint): JSON-RPC over a single POST is not a REST resource, and including it would make Orval generate a meaningless hook in apps/web and churn the committed contract.
It is for out-of-process agents — Claude Code during spec, research and review work — that otherwise hand-curl api.guildwars2.com, guessing paths, burning context on icon/details padding, and spending rate-limit budget outside the app's own token bucket. It is not a browser surface and apps/web does not call it.
Module: apps/api/src/mcp/ — mcp.controller.ts (per-request lifecycle only), mcp.server.ts (server + tool registration), mcp.tools.ts (the four definitions), mcp.shape.ts, mcp.errors.ts, mcp.guard.ts, mcp.config.ts.
The ten tools
Each is a thin wrapper over one Gw2Service method, with a Zod input schema published as JSON Schema and a description written for a model rather than a human. MAX_IDS = 100 bounds every id list — below the 199-id upstream batch cap, so one tool call costs at most one upstream batch — and every id-taking description states it.
| Tool | Input | Result fields |
|---|---|---|
gw2_items | ids: number[] | id, name, rarity, type, flags, vendor_value |
gw2_recipes | ids: number[] | id, output_item_id, output_item_count, disciplines, min_rating, ingredients |
gw2_prices | ids: number[] | id, buy, sell (best unit prices, in copper) |
gw2_recipe_search | input or output item id, exactly one | recipe ids |
Results are built by explicit construction in mcp.shape.ts, never by delete/omit helpers, so a new upstream field cannot leak in by default; the tests assert absence for exactly that reason. level is not an item field — Gw2ItemSchema does not declare it and z.object strips unknown keys, so exposing it would mean widening the GW2 client's schema for the lowest-value field in the set.
Both recipe descriptions carry the Mystic Forge caveat (gw2-api.md, research F3): a legendary search returns [], which means "this API cannot answer that", not "no such recipe". An agent that does not read that caveat draws the wrong conclusion from an empty array.
Provenance is in the name, and in the first clause
Three surfaces are easy to confuse, so the naming makes the distinction impossible to miss:
| Prefix | Means | Description opens with |
|---|---|---|
gw2_ | pass-through to ArenaNet's API — shaped, not interpreted | Official GW2 API: |
priory_ | this project computed the answer | Priory-computed: |
The first clause is the only thing a model reads when choosing a tool, and it is what stops our arithmetic being reported as an official ArenaNet figure. This is asserted by test over every registered tool (mcp.tools.test.ts, 017 SC14/R12), not left to authorial discipline, so a new tool cannot be added without one.
Ownership of the server was never ambiguous — a client surfaces these as mcp__gw2priory__*. What needed disambiguating is whose fact it is: upstream data carries upstream semantics (live prices, the 199-id cap, the Mystic Forge gap), while a priory_ result carries our modelling choices.
The six priory_* tools
| Tool | Key? | Input | Result |
|---|---|---|---|
priory_recipe_tree | no | itemId | the priced buy-versus-craft tree — the same value GET /api/recipe-graph/:itemId returns |
priory_legendaries | no | optional generation (1–3), optional type | curated legendaries incl. generation |
priory_account | yes | none | account display name |
priory_account_materials | yes | optional ids (≤ MAX_IDS) | per-category roll-up, or those exact rows |
priory_account_wallet | yes | none | id, name, value per currency |
priory_legendary_ranking | yes | none | personalised profit ranking — expensive, see below |
Each calls its Nest service directly. No tool issues an HTTP request to this API's own REST routes: that would be a round trip out through our own guard and back, and would make a tool's behaviour depend on the deploy's own reachability.
priory_recipe_tree returns the REST route's value unchanged — both go through RecipeGraphService.resolvePriced, one implementation with two callers. It is deliberately not reshaped: a tool that quietly returned a different tree from the endpoint would be a second answer to the same question. The cost of that decision is recorded honestly — PricedRoot keeps every node's full recipes[] and prunes no children, so a deep tree is a large payload, and its real size has never been measured.
priory_account_materials has two shapes, and the default is not the obvious one. Called with no arguments it returns a per-category roll-up of about nine rows, not the item list. The obvious default was measured and rejected: on a real account, every owned row shaped came to 468 rows, 70.6 KB, roughly 18,100 tokens — about 2.4× the entire saving of the four gw2_* tools, spent on one call. 69% of material-storage rows are owned, so filtering count: 0 removed less than a third of them. Pass ids to get exact rows, count: 0 included — "I own none of this" is the answer to "do I have enough". There is deliberately no row-level browse: that middle ground is where the cost comes back.
priory_legendary_ranking is expensive and says so in its own description. It reads material storage, the bank, shared inventory and every character's bags: 22 upstream requests and over 31 seconds measured on a 19-character account, and that timed the holdings fan-out alone — the full ranking adds recipe-graph resolution and pricing on top. Cost is 1 + characters + 2, linear in character count and not tunable. It may exceed a client's tool timeout; the description says so, and advises one retry, because the account reads are cached for five minutes and the upstream fetches are not cancelled when a client disconnects. It says a retry is likely fast, not fast: accountCache is in-process, so a Render spin-down empties it. No optimisation is designed — there is no agreed approach, and inventing one to justify keeping the tool would be worse than shipping it honestly slow.
Targeted protocol revision: 2025-11-25
The MCP specification is at 2026-07-28. This server targets 2025-11-25, because that is what both ends actually speak (research F1, verified on the wire 2026-08-16):
@modelcontextprotocol/sdk1.30.0 — the latest published version — declaresLATEST_PROTOCOL_VERSION = '2025-11-25'.2026-07-28is absent fromSUPPORTED_PROTOCOL_VERSIONS. The2026-07-28release announcement claims all four Tier 1 SDKs speak it; the npm artifact contradicts the announcement, and the artifact is the evidence.Claude Code 2.1.232 negotiates
2025-11-25and POSTs the classicinitializehandshake. It never sendsMcp-MethodorMcp-Name— no trace of the newer header contract.Corrected 2026-08-18 (research A1-b): the sentence above is no longer true of newer clients. Claude Code 2.1.234 opens every connection with a
server/discoverPOST carrying bothMcp-Protocol-Version: 2026-07-28andMcp-Method: server/discover, and only falls back toinitializeat2025-11-25when that fails.Mcp-Methodappears only on that probe — after the fallback,initialize,tools/listandtools/callcarry neither header — so the targeting decision below is unaffected. What changed is that the migration checklist now has a live trigger: the newer contract has begun arriving in shipping clients, one method at a time.Our server answers the probe
400with-32000 Unsupported protocol version, which is the SDK's response, not ours, and the client falls back cleanly — verified 2026-08-18 against a locally run instance (claude mcp list→✔ Connected, livetools/callongw2_itemsreturned the shaped Glob of Ectoplasm). No regression today; a future client may stop tolerating it.
So there is no protocol break to bridge: client and SDK are on the same revision. Targeting the published specification instead would mean hand-writing a transport the SDK does not implement, which is re-writing a specification, not saving weight.
The 2026-07-28 migration checklist
Not requirements today. This is what changes at the SDK upgrade, so the next reader does not re-derive it from the spec site:
- Mandatory headers.
MCP-Protocol-Versionon every POST,Mcp-Methodon all requests,Mcp-Nameontools/call. A mismatch — or a missing required header — MUST be refused400+ JSON-RPC-32020(HeaderMismatch). server/discoverbecomes mandatory: advertise supported versions, capabilities and identity.ttlMsandcacheScopeare required ontools/listresults.- Notifications answer
202 Acceptedwith no body. - Unknown method is
404+-32601, not a200carrying an error. - Sessions and the GET endpoint are removed. The
initialize/notifications/initializedhandshake is gone; every request carries its protocol version and client capabilities in_meta. AnMcp-Session-Idon a request is ignored, never minted or echoed. This is the one item already satisfied — the server is stateless today, for independent reasons. Last-Event-IDresumability is unsupported.- Do not advertise
subscriptions/listen. It is a long-lived POST stream, and Claude Code v2.1.233's changelog records a fix for connections endlessly reopening it against hosts that terminate long-held streams on a fixed timeout. Render's free plan is exactly such a host. This becomes live at the SDK upgrade, not before.
The trigger, and the remedy. The day the client drops the initialize handshake, a strictly-modern request fails hard against 1.30.0. Measured by hand: no initialize, with MCP-Protocol-Version: 2026-07-28 and Mcp-Method: tools/list, returns 400 {"code":-32000,"message":"Bad Request: Unsupported protocol version: 2026-07-28 …"} — a well-formed JSON-RPC error body, precisely the shape a fallback-capable client is documented not to retry on. It will not degrade gracefully; it will report a failed connection. Still sending initialize with protocolVersion: 2026-07-28 negotiates down to 2025-11-25 and succeeds.
The remedy is pnpm up @modelcontextprotocol/sdk, not a rewrite. The Nest/Fastify glue — fresh server, fresh transport, handleRequest(req.raw, reply.raw, req.body) — is protocol-independent and survives the upgrade untouched. The risk is real, deferred, and contained inside one dependency.
Four rules that look optional and are not
Each cost real debugging during 017. None of them is style.
- A fresh
McpServerAND a fresh transport per request. The stateless transport throws "Stateless transport cannot be reused across requests" on the second use. A cached server or transport — module field, closure const, memoised getter — works for exactly one request and then returns a bodyless500.mcp.controller.test.tsasserts two sequential requests both succeed; that test exists to catch a future hoist. handleRequest(req.raw, reply.raw, req.body)— the third argument is not optional here. The SDK reads the body from the stream only whenparsedBodyis omitted, and Fastify has already parsed and drained it. Omit it and every request answers-32700 Parse error. No raw-body plugin and no content-type-parser removal is needed; passingreq.bodyis the whole fix.reply.hijack()is likewise not required: Nest's non-passthrough@Res()means Nest never callsreply.send().- Never construct a
Gw2Clientinsidesrc/mcp/. Everything goes through the injectedGw2Service, so the 300/min token bucket, the 199-id batching and the bounded cache are shared with the app's own traffic. That sharing is the point of the feature, not a compromise. Guard G4 (nestjs.md,conventions/guards.ts) enforces it mechanically. - The OpenAPI document is built in Nest preview mode.
McpGuardresolvesMCP_AUTH_TOKENin its constructor and throws when unset, so instantiating the module graph to emit the contract would require the secret.buildOpenApiDocument()passes{ preview: true }, which builds the graph from decorator metadata without instantiating providers. Remove preview mode andverify:contractfails in CI with a missing-token error — generating documentation would need production configuration, or CI would need a dummy secret. Neither is acceptable; preview mode removes the need without weakening the guard.
Auth posture, and its limits
Two independent checks in mcp.guard.ts, in this order:
Origin. Present and not inCORS_ALLOWLIST(config/cors.ts— one list, not two that can drift) →403, before authentication and before any tool handler runs. The MCP specification makes this a MUST, against DNS rebinding: a page in the developer's browser reaching a locally-bound MCP server. An ABSENTOriginis allowed through to the bearer check. This is the normal case and must not be "tightened": a native MCP client is not a browser and sends none — the wire log in research F1 confirms Claude Code sends none — so refusing an absentOriginwould reject every real client while stopping no attack, since the browser is precisely the agent that always sets it.- Bearer token, from
MCP_AUTH_TOKEN, compared withtimingSafeEqualafter a length check (the leak is the token's length, which is not the secret). Missing or wrong →401.
The refusal must not look OAuth-capable. The 401 is bare: no WWW-Authenticate header, and the service exposes no /.well-known/oauth-protected-resource and no/.well-known/oauth-authorization-server. Documented client behaviour is what we want — a rejected configured Authorization header is reported as a failed connection rather than falling back to OAuth — but when a server also advertises OAuth, the client has been observed pursuing discovery and ignoring the configured header (research V4, issues #59467 and #80785). This is a requirement, not a nicety: it is what keeps a static token workable. The 401 body carries a short readable message, because the client surfaces it.
Boot fails loudly. resolveMcpToken throws when MCP_AUTH_TOKEN is unset or blank, following the VITE_APP_ENV precedent (spec 015 R4). An unguarded MCP endpoint and a silently-dead one are both worse than a refused start. The value is never logged. render.yaml declares it sync: false — set in the Render dashboard, never committed.
What this is not. A single shared static token is not per-user auth. It authenticates the token, not a person; it cannot be scoped, attributed or revoked per consumer; and it does not survive being shared — the moment it reaches a second machine, every holder is indistinguishable. It is proportionate here because the data is public GW2 information and the asset being protected is our rate-limit budget and free-tier compute, not the data. A public server wants OAuth 2.1 per the MCP authorization spec, which is a feature in its own right.
Client configuration
CLI route, which writes local scope (~/.claude.json), not the repository:
claude mcp add --scope local --transport http gw2priory \
https://<host>/api/mcp --header "Authorization: Bearer $GW2PRIORY_MCP_TOKEN"JSON equivalent:
{
"mcpServers": {
"gw2priory": {
"type": "http",
"url": "${GW2PRIORY_MCP_URL:-http://localhost:3000/api/mcp}",
"headers": { "Authorization": "Bearer ${GW2PRIORY_MCP_TOKEN}" }
}
}
}Two traps:
- A
urlentry withouttypeis a configuration error — it is read as a stdio server, and the failure is confusing rather than explicit.streamable-httpis an alias forhttp. ${VAR}expansion insideheadersis documented but has a live history of not substituting for HTTP transport (issues #6204, #51581, no changelog closing the loop). The failure mode is nasty: the config still loads and the literal${VAR}string is sent, producing a401that looks like a wrong token rather than an unexpanded variable. If that happens, fall back toheadersHelper(a command writing a JSON object of headers to stdout; it also re-runs and retries once on401/403) or to local-scope configuration holding the literal value in~/.claude.json.
Whichever route is taken, the repository contains no token.
Account tools: the X-GW2-Key header
The four priory_account* / priory_legendary_ranking tools read the caller's own GW2 account. The player's key travels as a second header, set once in client configuration:
claude mcp add --scope local --transport http gw2priory \
https://<host>/api/mcp \
--header "Authorization: Bearer $GW2PRIORY_MCP_TOKEN" \
--header "X-GW2-Key: $MY_GW2_API_KEY"Why a header rather than a tool argument. A key passed as an argument is written into the agent's conversation transcript, its tool-call log, and anything downstream that ingests either. A key in a header is never seen by the model at all. No account tool declares a key parameter, so it cannot be passed as an argument even by a model that tries — asserted over every tool by 017 SC16.
The guard does not see this header. McpGuard authenticates MCP_AUTH_TOKEN only. X-GW2-Key is a credential we forward to GW2, exactly as the REST account routes already do, so a missing or bad key is a tool error, never a 401. A caller with no key still sees all ten tools listed and gets a message naming the header to add — not a missing-tool dead end.
This rests on observed client behaviour, not a protocol guarantee. The MCP specification does not oblige a client to forward arbitrary configured headers on every request. Claude Code 2.1.234 does — verified 2026-08-18 on a real wire log, 6 of 6 requests in a connect-and-call cycle including tools/call, and 11 of 11 across two cycles (research A1). Treat it as a tested property of that client at that version.
The ${VAR} trap is worse here than for the bearer token. The same non-substitution history applies, but an unexpanded X-GW2-Key reaches GW2 as the literal string ${MY_GW2_API_KEY} and comes back rejected — which reads as "my key is wrong" rather than "my config did not expand". If an account tool reports a rejected key, check for expansion before re-creating the key.
Scopes. priory_account needs only account (mandatory on every key). Materials and the ranking need inventories; the wallet needs wallet; the ranking also needs characters. A missing scope is reported by name — except on priory_account, where Gw2Client.account() maps 401 and 403 alike, so it can only report a rejected key. That costs nothing: the account scope cannot be absent.
Measured shaping
Against minified upstream JSON — not the pretty-printed bytes the API actually sends, whose whitespace alone is 39% of the items payload and would flatter the number (research V3, 50 representative ids):
| Tool | reduction |
|---|---|
gw2_items | 72.9% (28,137 B → 7,623 B) |
gw2_prices | 72.7% (6,857 B → 1,872 B) |
gw2_recipes | 31.3% (16,617 B → 11,415 B) |
This is a context-window saving, not a bandwidth one — HTTP gzips the wire regardless. The number that matters is tokens spent per lookup.
The weight is details (32.3%), icon (17.0%) and game_types (8.7%) — not description (5.8%) or chat_link (4.6%), which an earlier draft blamed. flags is retained deliberately despite being 31% of the shaped items payload, the most expensive kept field, because buyable-versus-gated classification depends on it. gw2_recipes is the weak case and is stated as such: its shape keeps ingredients and disciplines, which are most of a recipe record.
A refinement is enforced at call time but not advertised
Discovered during implementation, and worth knowing before designing a tool schema around .refine().
gw2_recipe_search's "exactly one of input / output" rule is a Zod .refine(). The refinement is dropped from the JSON Schema the SDK publishes — tools/list returns two independent optional integers and says nothing about the constraint:
{"type":"object","properties":{
"input":{"type":"integer","exclusiveMinimum":0},
"output":{"type":"integer","exclusiveMinimum":0}}}But the SDK still runs the Zod schema at call time. Calling with both set returns -32602 Input validation error: … provide exactly one of 'input' or 'output' before the handler is reached. So the rule is enforced twice — by the SDK, and again in the handler, which re-parses its own arguments — and it is only the advertised schema that omits it. The description states the rule in prose for that reason; a model reading only the schema would never learn it.
Verification log
2026-08-16 — SC10: verified, locally. Client Claude Code 2.1.233 against
http://localhost:3000/api/mcp(node apps/api/dist/main.jswithMCP_AUTH_TOKENset).claude mcp add --scope local --transport http gw2priory … --header "Authorization: Bearer …"thenclaude mcp list→✔ Connected. A realtools/callongw2_itemswith{"ids":[19721]}returned, through the CLI and out toapi.guildwars2.com:[{"id":19721,"name":"Glob of Ectoplasm","rarity":"Exotic","type":"Trophy","flags":[],"vendor_value":256}]— the declared fields and nothing else.initializeover curl negotiatedprotocolVersion 2025-11-25;tools/listreturned exactlygw2_items,gw2_recipes,gw2_prices,gw2_recipe_search. The other three tools were exercised over the same endpoint in the same session:gw2_prices {"ids":[19721]}→[{"id":19721,"buy":2603,"sell":2797}];gw2_recipes {"ids":[8762]}→ one shaped recipe (output_item_id50175, Armorsmith, 4 ingredients, noflags/type/chat_link);gw2_recipe_search {"output":19685}→[21]. Negative paths: noAuthorization→401with noWWW-Authenticateresponse header;Origin: https://evil.examplewith a valid token →403. Registration was removed afterwards (claude mcp remove gw2priory --scope local); nothing was written into the repository. Against the deployed API: still pending — 017 had not merged or deployed at the time of writing, so the deployed URL did not yet exist. Re-run the same four steps against it after the first deploy and add an entry here.2026-08-18 — Amendment A verified, locally. Client Claude Code 2.1.234 against
http://localhost:3000/api/mcp, served from the017-mcp-serverworktree build withMCP_AUTH_TOKENset.claude mcp list→✔ Connected.tools/listreturned ten tools —gw2_items,gw2_recipes,gw2_prices,gw2_recipe_search,priory_recipe_tree,priory_legendaries,priory_account,priory_account_materials,priory_account_wallet,priory_legendary_ranking.Exercised against live
api.guildwars2.comdata over the real route:gw2_items {"ids":[19721]}→[{"id":19721,"name":"Glob of Ectoplasm","rarity":"Exotic","type":"Trophy","flags":[],"vendor_value":256}]priory_legendaries {"generation":1}→ 21 rows, each carryinggeneration, noiconanywhere in the payloadpriory_recipe_tree {"itemId":19685}→ priced tree,summary={rootId: 19685, totalCraftCost: 340, rootBuyPrice: 315, netSell: 246, profit: -94}priory_account_materials {}with a real key → the per-category roll-up, confirmed by the repository owner. Row counts and totals were not captured, so the A2 measurement (468 owned rows) remains this document's only figure for a real account.
Negative paths: wrong bearer token →
401; an account tool with noX-GW2-Keyheader →200carrying the missing-header text, not a401and not a missing tool — confirming the guard is untouched by the credential path (R14/G2).Not yet verified:
priory_account,priory_account_walletandpriory_legendary_rankingwere not exercised end to end, so R18's >31 s prediction has not been re-measured through a client and its timeout behaviour is still theory. Against the deployed API: still pending — see the entry above; 017 remains unmerged.Note on
type, 2026-08-16. The livegw2_itemsresult reports19721as"type":"Trophy", not"CraftingMaterial"as several test fixtures and spec examples assume. The fixtures are illustrative and no assertion depends on it, but do not treat those literals as facts about the game.