Plan 017 — MCP server over the GW2 official API
Status: approved Written from spec.md (approved) and research.md (complete). Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.
Produced alone — tasks.md stays untouched until this plan is approved in turn.
Goal
An agent outside this process — Claude Code during spec, research and review — calls the four GW2 API v2 lookups apps/api already wraps as named MCP tools with declared schemas and trimmed responses, over Streamable HTTP at POST /api/mcp, guarded by an Origin check and a static bearer token. The tools go through the existing Gw2Service, so agent traffic inherits the 300/min token bucket, the 199-id batching and the bounded cache instead of operating outside them.
Approach
Five coordinated changes, each independently testable, ordered so that nothing depends on a later step.
The tool layer, defined independently of the protocol (R4, R5, R6, R7). A plain module that maps a tool name to a Zod input schema, a description written for a model, and a handler calling
Gw2Service. Each handler validates ids at the boundary (positive integers, bounded list length), calls the service, shapes the result to the declared fields, and translatesgw2.errors.tsfailures into actionable text. This layer has no MCP imports and no HTTP: it is testable with a mockedGw2Serviceand nothing else, which is what makes SC2, SC6 and SC7 cheap.The guard (R3, SC3, SC4, SC13). A Nest guard running before the controller: reject a present-and- not-allow-listed
Originwith403, then a missing or non-matching bearer token with a bare401— noWWW-Authenticate, no OAuth discovery routes anywhere in the service. Comparison is constant-time. The token is read through a resolver that throws at bootstrap when the variable is unset, mirroringresolvePortandresolveApiOriginfrom specs 014/015.The MCP server and its transport (R1, R2, SC1). A builder that constructs a fresh
McpServer, registers every tool from step 1, and a thin controller that constructs a fresh transport per request and callshandleRequest(req.raw, reply.raw, req.body). Per research V2 all three of those are load-bearing: a stateless transport throws when reused, and omittingreq.bodybreaks under Fastify because the body is already drained.Wiring and contract hygiene (R8, R9).
McpModuleimportsGw2Moduleand joinsAppModule; the route is excluded from the OpenAPI document soopenapi.jsonstays byte-identical and orval generates no client for a JSON-RPC endpoint;render.yamldeclares the token as a dashboard-set secret.Documentation (R10).
docs/architecture/mcp.mdrecords the tool contract, the targeted protocol revision and why, the auth posture and its limits, a client configuration that commits no secret, and the2026-07-28migration checklist.
Architecture
Claude Code (client 2.1.232, speaks 2025-11-25)
│ POST /api/mcp Authorization: Bearer … (no Origin header)
▼
McpGuard ──── Origin present & not allow-listed? ─── 403
└──── bearer missing / mismatched? ───────── 401 (bare: no WWW-Authenticate)
▼
McpController (thin — constructs per request, delegates, returns nothing)
│ new McpServer + new StreamableHTTPServerTransport ← per request, mandatory
│ transport.handleRequest(req.raw, reply.raw, req.body)
▼
mcp.tools.ts name │ description │ Zod input │ handler
│ gw2_items · gw2_recipes · gw2_prices · gw2_recipe_search
│ validate ids → call → shape to declared fields → map errors
▼
Gw2Service (existing, unchanged, singleton)
└─ token bucket 300/min · 199-id batching · bounded caches
└─ api.guildwars2.com/v2Boundaries: the tool layer knows nothing about MCP or HTTP — it is a name/schema/handler table over Gw2Service, so its tests need no transport and no network. The transport layer knows nothing about GW2 — it registers whatever the tool layer exposes. The guard knows nothing about either. Each of the three can be understood, tested and changed without reading the other two, and the seam between the tool layer and MCP is exactly where a future in-process agent loop would attach instead.
apps/api/src/gw2/ is untouched. This spec adds a protocol surface over it and changes none of its behaviour.
Tech stack
@modelcontextprotocol/sdk1.30.0 —McpServer(server/mcp.js),StreamableHTTPServerTransport(server/streamableHttp.js),InMemoryTransport(inMemory.js) for tests. The single new dependency.- NestJS 11.1.28 on
@nestjs/platform-fastify11.1.28,fastify5.10.0 — existing. - Zod via
nestjs-zod— existing; tool input schemas reuse the project's Zod-first contract posture. - Vitest — existing;
app.inject()for the one HTTP-level test.
Global Constraints
Project-wide requirements every task inherits. Values copied verbatim from spec.md and research.md.
- Targeted protocol revision is
2025-11-25.2026-07-28is explicitly not targeted — no published SDK implements it. Its obligations belong in the migration checklist, not in code. - A fresh
McpServerand a fresh transport per request. The stateless transport throws "Stateless transport cannot be reused across requests" on the second use. Caching either object is a defect, not an optimisation. handleRequestis always called withreq.bodyas the third argument. Fastify has already drained the stream; omitting it yields-32700 Parse error.- No
Gw2Clientis constructed anywhere in the MCP module. Everything goes through the injectedGw2Servicesingleton, or the shared budget and cache are silently bypassed. - Refusals must not look OAuth-capable. Bare
401, noWWW-Authenticateheader, no/.well-known/oauth-protected-resource, no/.well-known/oauth-authorization-server. - An absent
Originheader is allowed. Only a present-and-not-allow-listedOriginis refused403. Native MCP clients send noOrigin; refusing that would reject every real client. - No secret in the repository. The bearer token is an environment variable, declared in
render.yamlwithsync: false. openapi.jsonstays byte-identical andverify:contractstays green.- TypeScript: no
any, no unexplained escape hatches (docs/architecture/typescript.md). - Nest conventions (
docs/architecture/nestjs.md): feature-module layout, thin controllers, the DI value-import rule, Zod-first contracts. - Zero files under any
docs/superpowers/path — asserted by an existing test.
File Structure
Created — apps/api/src/mcp/
| File | Responsibility |
|---|---|
mcp.tools.ts | The four tool definitions: name, model-facing description, Zod input schema, handler over Gw2Service. No MCP imports, no HTTP. |
mcp.shape.ts | Pure field-shaping functions, one per upstream entity. Separated from mcp.tools.ts so the "declared fields only" assertions (SC2) test pure functions with no service mock. |
mcp.errors.ts | Maps gw2.errors.ts failures to MCP tool-error text. Kept apart because R6's "no stack traces, no internal paths, no upstream URL" is a leak-prevention rule that deserves its own focused test. |
mcp.server.ts | Builds an McpServer and registers every tool from mcp.tools.ts. The only file that knows both the tool layer and the SDK. |
mcp.guard.ts | Origin check then bearer check. Returns 403 / bare 401. |
mcp.config.ts | Resolves the bearer token from the environment; throws when unset. Mirrors config/port.ts. |
mcp.controller.ts | Thin: fresh server, fresh transport, handleRequest(req.raw, reply.raw, req.body). Excluded from OpenAPI. |
mcp.module.ts | Imports Gw2Module, declares the controller and guard. |
Modified
| File | Change |
|---|---|
apps/api/src/app.module.ts | Import McpModule. |
apps/api/package.json | Add @modelcontextprotocol/sdk. |
render.yaml | Declare the token env var on the API service with sync: false. |
tests/deploy/render-blueprint.test.ts | Assert the secret is declared and carries no committed value (SC9). |
docs/architecture/mcp.md | New (R10). |
Eight small files rather than three larger ones, because the four responsibilities — shaping, error mapping, protocol wiring, access control — have genuinely different tests, and three of them are testable as pure functions only if they are not entangled with the SDK.
Data & contracts
Tool inputs and result fields are fixed by spec.md R4 and are not restated here; the plan adds only their boundary rules.
- Id lists: positive integers, non-empty, bounded by a named constant stated in the tool description. The bound exists so one call cannot fan out into an unbounded number of upstream batches (R7). The upstream batch cap is 199 (
gw2-client.ts:27); the tool bound is chosen against it intasks.md. gw2_recipe_search: exactly one ofinput/output. Both or neither is a validation error, not a silent preference.- Tool descriptions must state that Mystic Forge recipes are absent from the API (research F3):
/v2/recipes/search?output=<legendary>returns[], and an empty array reads as "no such recipe" rather than "this API cannot answer that". A model learns this from the description or not at all. openapi.jsonis unchanged. The MCP route carries@ApiExcludeEndpoint.
Test strategy
Four layers, cheapest first, matching the file split.
- Pure shaping tests (
mcp.shape.ts) — assert declared fields present and excluded fields absent. No mocks. Covers SC2 and R5's "a future upstream field cannot silently re-enter the payload". - Tool handler tests (
mcp.tools.ts) — mockedGw2Service. Covers validation bounds (SC7), theinput-xor-outputrule, call-count assertions proving no duplicate upstream fetch (SC5), and error mapping (SC6), including an explicit assertion that no stack trace, internal path or upstream URL appears in the message. - Guard tests (
mcp.guard.ts,mcp.config.ts) — missing token, wrong token, absentOrigin(allowed), disallowedOrigin(403), unset environment variable (throws). Covers SC3, SC4, SC13. - Protocol tests —
InMemoryTransport.createLinkedPair()drives a realMcpServerwith no network listener, assertingtools/listreturns exactly four tools with non-empty descriptions and schemas, and thattools/callreaches a handler (SC1). One HTTP-level test via Nest'sapp.inject()covers what the in-memory transport cannot: that the Fastify handoff works at all, and thatGETandDELETEreturn405.
An architecture test in apps/api/src/conventions/ asserts no Gw2Client construction inside src/mcp/ (SC5, second half). SC10 is observational — a real client against the real deploy, recorded with a date in docs/architecture/mcp.md.
No live network in any test. The GW2 API is never called from the suite.
Alternatives considered
- stdio transport, or a standalone
apps/mcp. Shorter diff; rejected in brainstorming because the human asked explicitly for the option with the better learning and professional fit, and remote HTTP servers are what organisations actually deploy. Recorded because the ponytail-minimal answer here was a different one, and the deviation was a stated requirement rather than drift. - Targeting
2026-07-28. Rejected: no published SDK implements it (research F1), and hand-rolling the protocol contradicts the spec's own Assumptions. - Including the MCP route in
openapi.json. Rejected: JSON-RPC over one POST is not a REST resource, and orval would generate a meaningless hook inapps/webwhile churning the committed contract. - A fifth
gw2_find_item_by_nametool. Deferred to its own spec by human decision. The cost premise was refuted (2 requests, not 350), but it needs a boot hook, a cold-start answer and a depth decision — design questions, not a thin wrapper. - OAuth 2.1. The protocol's real answer and a substantial feature; a static token is where internal servers legitimately start.
Risks
| Risk | Mitigation |
|---|---|
The SDK pulls express, hono, cors, jose, ajv, eventsource, express-rate-limit as hard dependencies — including Express, which this project deliberately does not use. | Accepted and recorded in spec.md Assumptions. Nothing in apps/api imports them; they arrive transitively. Worth re-checking install size once, not designing around. |
Claude Code moves to 2026-07-28 and drops initialize. A strictly-modern client fails hard against SDK 1.30.0 — the 400 carries a well-formed JSON-RPC error, which is exactly the shape a client is documented not to retry on. | Contained in one dependency: the remedy is pnpm up @modelcontextprotocol/sdk. The Nest/Fastify glue is untouched by protocol revisions. The migration checklist lives in docs/architecture/mcp.md (R10) so the next reader is not re-deriving it. |
${VAR} expansion in client headers is documented but has a live history of not substituting, and its failure mode sends the literal ${VAR} string, producing a confusing 401. | Verified empirically during implementation; headersHelper or local-scope config are the documented fallbacks. R10 requires only that no token is committed, not a specific mechanism. |
| Agent traffic shares the 300/min budget with the app, so a careless agent could starve real requests. | Intended behaviour, not a defect — one budget is the point. Bounded id lists (R7) cap a single call's fan-out; the bearer token bounds who can call at all. |
A future contributor caches the McpServer or transport as a module singleton, which works for exactly one request and then returns a bodyless 500. | Called out in Global Constraints, and the HTTP-level test issues two sequential requests so the failure is caught by the suite rather than in use. |
Open questions
None blocking. Two decisions are deliberately deferred to tasks.md, where they are implementation detail rather than design:
- The exact id-list bound in R7 (chosen against the 199-id upstream batch cap).
- Whether the
403/401guard is one Nest guard or two — a structural choice with no behavioural consequence, since the order and the responses are fixed here.
Plan 017 Amendment A — six priory_* tools
Status: approved Written from spec.md (Amendment A + Revision A2, approved 2026-08-18) and research.md (complete, A1–A5 all verdicted). Approved by the human in plan mode before any code was written; the agent transcribed that status here and did not set it on its own initiative.
Produced alone — tasks.md stays untouched until this plan is approved in turn. The plan above, for the original four-tool scope, is left exactly as merged: this is a second plan appended, not a rewrite.
For agentic workers: REQUIRED SUB-SKILL:
superpowers:subagent-driven-development, withsuperpowers:test-driven-developmentinside every task. Steps live intasks.md, not here — this is the header half only, per the project constitution.
Goal: Expose this project's six computed endpoints as priory_* MCP tools alongside the existing four gw2_* tools, with the player's GW2 key travelling as a request header so it never enters an agent's transcript.
Architecture: McpController reads X-GW2-Key off the per-request Fastify request and threads it, with five injected services, into the per-request buildMcpServer. buildTools moves from positional parameters to a single deps object. Every new tool calls its Nest service directly — never this API's own HTTP routes. Result shaping stays where R5 put it: pure functions in mcp.shape.ts, extended with the roll-up aggregation and icon stripping.
Tech Stack: NestJS 11 + @nestjs/platform-fastify, @modelcontextprotocol/sdk 1.30.0, Zod, Vitest.
Global Constraints
Every task inherits these. Values copied verbatim from the spec.
- G1 — Per-request construction is mandatory. A fresh
McpServerand a freshStreamableHTTPServerTransportper request. The stateless transport throws "Stateless transport cannot be reused across requests" on second use (R2, research V2). Caching either — module field, closure const, memoised getter — works for exactly one request, then returns a bodyless500. - G2 — The guard is not touched.
McpGuardauthenticatesMCP_AUTH_TOKENand validatesOrigin, nothing else.X-GW2-Keyis a credential we forward, not one we authenticate (R14). - G3 — No tool declares a key parameter. The credential must be un-passable as a tool argument (R14, SC16).
- G4 — The key is never logged, persisted or returned (R14, SC19).
apps/apistores no key today; that does not change. - G5 — Tools call services, never our own HTTP routes (R13).
- G6 — DI value-import rule. Constructor-injected classes need value imports with the
biome-ignore lint/style/useImportTypecomment (docs/architecture/nestjs.md). - G7 —
exactOptionalPropertyTypesis on. Omit optional properties; never pass explicitundefined. - G8 — No
any, no unexplained escape hatches. The one existingas Transportcast stays, with its comment. - G9 —
openapi.jsonmust not change. All MCP routes carry@ApiExcludeEndpoint(R8, SC8). - G10 — Ten tools when done, names matching
^(gw2|priory)_, every description opening withOfficial GW2 API:orPriory-computed:(R12, SC14). - G11 — Bound =
MAX_IDS(100), the existing constant, reused for the materialsidsfilter (R16). - G12 — Run before pushing:
pnpm typecheck,pnpm test,pnpm lint,pnpm docs:build.
The traceability gate is broken, and it must be fixed first
tests/deploy/render-blueprint.test.ts:183 detects rows with:
/^\|\s*((?:P\d+ #\d+|SC\d+))\s*\|(.*)\|/The 17 rows Amendment A added are written | P3 #1 **[A]** | PENDING |. The regex requires | immediately after the criterion, so ****[A]** makes every one of those rows invisible to the test. Verified directly:
INVISIBLE | P3 #1 **[A]** | PENDING |
MATCHES | P3 #1 | PENDING |Consequence: SC12 would report traceability complete while ignoring every Amendment A criterion. That is the one job the gate has. Compounding it, a cell reading PENDING is non-empty and cites no path, so it passes both existing assertions anyway.
This is a defect introduced by the spec amendment, not a pre-existing one, and it must be closed before any task relies on the gate — otherwise every later task's traceability row is unverified.
Approach
File structure
| File | Responsibility | Change |
|---|---|---|
apps/api/src/mcp/mcp.tools.ts | tool definitions | modify — deps object, six new tools |
apps/api/src/mcp/mcp.shape.ts | pure result shaping | modify — roll-up, icon stripping |
apps/api/src/mcp/mcp.errors.ts | failure → actionable text | modify — three new branches |
apps/api/src/mcp/mcp.server.ts | SDK registration | modify — pass deps through |
apps/api/src/mcp/mcp.controller.ts | per-request lifecycle | modify — read header, inject 5 services |
apps/api/src/mcp/mcp.module.ts | DI wiring | modify — import 3 feature modules |
apps/api/src/recipe-graph/recipe-graph.service.ts | graph + pricing | modify — absorb the pricing step |
apps/api/src/recipe-graph/recipe-graph.controller.ts | REST route | modify — call the service |
apps/api/src/legendaries/legendaries.module.ts | DI wiring | modify — add exports |
tests/deploy/render-blueprint.test.ts | traceability gate | modify — Task 0 |
specs/017-mcp-server/spec.md | traceability rows | modify — row format + fill |
docs/architecture/mcp.md | recorded contract | modify — final task |
No new source files. The shaping, error and tool layers already exist and are the right homes; adding files would split responsibilities that currently sit together correctly.
Ordering, and why
- Task 0 — fix the traceability gate. Regex tolerates the
[A]marker (or the marker moves out of the criterion cell), andPENDINGis rejected as a non-answer. Everything downstream depends on this gate being real. Deliberately first, and it will go red until the final task fills the rows — which is the correct behaviour for a gate that is supposed to fail on unfilled traceability. - Task 1 —
LegendariesModuleexports. One line, unblocks DI for two later tasks. - Task 2 — extract the recipe-graph pricing step. The only change to merged, working code. The existing route tests must pass untouched — that is the proof the move changed no behaviour. If they need editing, the extraction is wrong.
- Task 3 —
depsobject refactor. Pure signature change, existing four tools unchanged, whole suite green before any new tool is added. Separating this from Task 4 means a reviewer can reject the refactor without rejecting the tools. - Task 4 — error mapping.
mcp.errors.tsgains missing-header, invalid-key and missing-scope branches. Pure function, no DI, cheapest thing to get right early since four tools depend on it. - Task 5 — shaping and the roll-up. Pure functions:
iconstripping plus the per-category aggregation. Testable with no key and no HTTP. - Task 6 — the two keyless tools (
priory_recipe_tree,priory_legendaries). Proves thedepsplumbing end to end without the credential dimension. - Task 7 — the four account tools, including
X-GW2-Keythreading through the controller. - Task 8 — provenance and schema invariants. SC14 (ten tools, prefixes, description clauses), SC16 (no credential parameter on any tool), SC21 (ranking description states its cost). Written as loops over the registered tool list, so a future tool cannot be added without satisfying them.
- Task 9 — docs and traceability.
docs/architecture/mcp.mdgains thepriory_*contract, theX-GW2-Keyconfiguration with its${VAR}warning, the A1 caveat (observed on one client, not a protocol guarantee) and R18's cost declaration. Fill all 17 traceability rows with real test names, turning Task 0's gate green.
Key decisions this plan locks in
depsobject shape:{ gw2, recipeGraph, legendaries, ranking, account, gw2Key? }—gw2Keyoptional and omitted (notundefined) when the header is absent, per G7.- The roll-up is a pure function over
AccountService.getMaterials()'s existing output, living inmcp.shape.ts. No service change, no key needed to test it, nothing for the web UI to inherit. This is the one piece of new logic in the amendment (R13). priory_recipe_treereusescollectPricedIdsandpriceGraph(recipe-graph/pricing.ts) via the service method Task 2 extracts — it does not re-implement pricing.priory_legendariesreuses the existingLegendariesQueryZod schema (legendaries.schema.ts:26) rather than declaring a parallel one that could drift.- The missing-header check lives in the tool handler, not the guard (G2). A keyless caller still sees all ten tools in
tools/list(SC17).
Risks
- Task 2 is the only edit to working merged code. Mitigation is the untouched-tests rule above.
- Task 0 goes red on purpose and stays red until Task 9. Anyone running the suite mid-plan sees one known failure; the task list must say so explicitly or it reads as a break.
RankingService.rankis >31 s (research A3). Its tool test must mock the service — no test may call the real fan-out, or the suite becomes unusable.
Verification
pnpm typecheck,pnpm test,pnpm lint,pnpm docs:buildall clean.pnpm verify:contractgreen withopenapi.jsonunchanged (G9/SC8).- Manual, against a locally-run instance with
MCP_AUTH_TOKENandX-GW2-Keyconfigured:claude mcp list→✔ Connected;tools/list→ ten tools;priory_legendariesreturnsgeneration;priory_account_materialswith no arguments returns ~9 category rows, and withidsreturns those rows; an account tool with the header removed returns the actionable missing-header error. Recorded with a date indocs/architecture/mcp.md, as SC10 was. priory_legendary_rankingis exercised manually once and its real latency recorded — expected to exceed 31 s and possibly to time out, which R18 documents as expected rather than broken.
Out of scope for this plan
- Optimising
priory_legendary_ranking(R18: no approach agreed; aponytail:comment records the ceiling). - Changing
Gw2Client.account()'s 401/403 collapse (research A4: unreachable state, not worth churn). - OAuth, row-level materials browse,
/healthand/commerce/pricestools (spec Out of scope).