Skip to content

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.

ToolInputResult fields
gw2_itemsids: number[]id, name, rarity, type, flags, vendor_value
gw2_recipesids: number[]id, output_item_id, output_item_count, disciplines, min_rating, ingredients
gw2_pricesids: number[]id, buy, sell (best unit prices, in copper)
gw2_recipe_searchinput or output item id, exactly onerecipe 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:

PrefixMeansDescription opens with
gw2_pass-through to ArenaNet's API — shaped, not interpretedOfficial GW2 API:
priory_this project computed the answerPriory-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 ​

ToolKey?InputResult
priory_recipe_treenoitemIdthe priced buy-versus-craft tree — the same value GET /api/recipe-graph/:itemId returns
priory_legendariesnooptional generation (1–3), optional typecurated legendaries incl. generation
priory_accountyesnoneaccount display name
priory_account_materialsyesoptional ids (≤ MAX_IDS)per-category roll-up, or those exact rows
priory_account_walletyesnoneid, name, value per currency
priory_legendary_rankingyesnonepersonalised 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/sdk 1.30.0 — the latest published version — declares LATEST_PROTOCOL_VERSION = '2025-11-25'. 2026-07-28 is absent from SUPPORTED_PROTOCOL_VERSIONS. The 2026-07-28 release 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-25 and POSTs the classic initialize handshake. It never sends Mcp-Method or Mcp-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/discover POST carrying bothMcp-Protocol-Version: 2026-07-28 and Mcp-Method: server/discover, and only falls back to initialize at 2025-11-25 when that fails. Mcp-Method appears only on that probe — after the fallback, initialize, tools/list and tools/call carry 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 400 with -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, live tools/call on gw2_items returned 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-Version on every POST, Mcp-Method on all requests, Mcp-Name on tools/call. A mismatch — or a missing required header — MUST be refused 400 + JSON-RPC -32020 (HeaderMismatch).
  • server/discover becomes mandatory: advertise supported versions, capabilities and identity.
  • ttlMs and cacheScope are required on tools/list results.
  • Notifications answer 202 Accepted with no body.
  • Unknown method is 404 + -32601, not a 200 carrying an error.
  • Sessions and the GET endpoint are removed. The initialize / notifications/initialized handshake is gone; every request carries its protocol version and client capabilities in _meta. An Mcp-Session-Id on 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-ID resumability 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.

  1. A fresh McpServer AND 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 bodyless 500. mcp.controller.test.ts asserts two sequential requests both succeed; that test exists to catch a future hoist.
  2. handleRequest(req.raw, reply.raw, req.body) — the third argument is not optional here. The SDK reads the body from the stream only when parsedBody is 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; passing req.body is the whole fix. reply.hijack() is likewise not required: Nest's non-passthrough @Res() means Nest never calls reply.send().
  3. Never construct a Gw2Client inside src/mcp/. Everything goes through the injected Gw2Service, 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.
  4. The OpenAPI document is built in Nest preview mode. McpGuard resolves MCP_AUTH_TOKEN in 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 and verify:contract fails 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:

  1. Origin. Present and not in CORS_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 ABSENT Origin is 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 absent Origin would reject every real client while stopping no attack, since the browser is precisely the agent that always sets it.
  2. Bearer token, from MCP_AUTH_TOKEN, compared with timingSafeEqual after 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:

bash
claude mcp add --scope local --transport http gw2priory \
  https://<host>/api/mcp --header "Authorization: Bearer $GW2PRIORY_MCP_TOKEN"

JSON equivalent:

json
{
  "mcpServers": {
    "gw2priory": {
      "type": "http",
      "url": "${GW2PRIORY_MCP_URL:-http://localhost:3000/api/mcp}",
      "headers": { "Authorization": "Bearer ${GW2PRIORY_MCP_TOKEN}" }
    }
  }
}

Two traps:

  • A url entry without type is a configuration error — it is read as a stdio server, and the failure is confusing rather than explicit. streamable-http is an alias for http.
  • ${VAR} expansion inside headers is 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 a 401 that looks like a wrong token rather than an unexpanded variable. If that happens, fall back to headersHelper (a command writing a JSON object of headers to stdout; it also re-runs and retries once on 401/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:

bash
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):

Toolreduction
gw2_items72.9% (28,137 B → 7,623 B)
gw2_prices72.7% (6,857 B → 1,872 B)
gw2_recipes31.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:

json
{"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.js with MCP_AUTH_TOKEN set). claude mcp add --scope local --transport http gw2priory … --header "Authorization: Bearer …" then claude mcp list → ✔ Connected. A real tools/call on gw2_items with {"ids":[19721]} returned, through the CLI and out to api.guildwars2.com: [{"id":19721,"name":"Glob of Ectoplasm","rarity":"Exotic","type":"Trophy","flags":[],"vendor_value":256}] — the declared fields and nothing else. initialize over curl negotiated protocolVersion 2025-11-25; tools/list returned exactly gw2_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_id 50175, Armorsmith, 4 ingredients, no flags/type/chat_link); gw2_recipe_search {"output":19685} → [21]. Negative paths: no Authorization → 401 with no WWW-Authenticate response header; Origin: https://evil.example with 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 the 017-mcp-server worktree build with MCP_AUTH_TOKEN set. claude mcp list → ✔ Connected. tools/list returned 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.com data 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 carrying generation, no icon anywhere in the payload
    • priory_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 no X-GW2-Key header → 200 carrying the missing-header text, not a 401 and not a missing tool — confirming the guard is untouched by the credential path (R14/G2).

    Not yet verified: priory_account, priory_account_wallet and priory_legendary_ranking were 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 live gw2_items result reports 19721 as "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.