Tasks 017 — MCP server over the GW2 official API
Status: approved Written from plan.md (approved). Status is set by the human, never by the agent: proposed → approved. Implementation is entered only through plan mode, even once this list is approved.
For agentic workers: REQUIRED SUB-SKILL:
superpowers:subagent-driven-development, withsuperpowers:test-driven-developmentinside every task andsuperpowers:systematic-debuggingon any surprise. Steps use checkbox (- [ ]) syntax.
Run from apps/api unless a command says otherwise. Tests: pnpm vitest run <path>.
Global constraints (every task inherits these)
Copied from plan.md. Violating any of these is a defect even if tests pass.
- Targeted protocol revision is
2025-11-25.2026-07-28is not targeted. - A fresh
McpServerand a fresh transport per request. Caching either is a defect. handleRequestalways receivesreq.bodyas its third argument.- No
Gw2Clientis constructed insidesrc/mcp/. Everything goes through injectedGw2Service. - Bare
401— noWWW-Authenticate, no/.well-known/oauth-*routes. - An absent
Originheader is allowed; only present-and-not-allow-listed is refused403. - No secret in the repository.
openapi.jsonstays byte-identical;verify:contractstays green.- No
any, no unexplained escape hatches. - Zero files under any
docs/superpowers/path.
Task 0: Let a guard throw HTTP exceptions
Added during step 4's plan mode. conventions.arch.test.ts runs g5HttpExceptionsOnlyInControllers (apps/api/src/conventions/guards.ts:162-190), which flags any file that is not *.controller.ts constructing a *Exception from @nestjs/common. Task 3's guard throws two, so that test would fail.
Returning false from canActivate is not a workaround: Nest maps it to 403 unconditionally, and R3 needs 403 for Origin and 401 for the token as distinct outcomes.
Human decision: G5's intent is "not in services", not "controllers only". A Nest guard exists only on the HTTP path and returning a status is its entire job, so the rule was broader than its purpose.
Must land before Task 3.
Files:
- Modify:
apps/api/src/conventions/guards.ts - Modify:
apps/api/src/conventions/guards.test.ts - Modify:
docs/architecture/nestjs.md
Interfaces:
Produces:
g5HttpExceptionsOnlyInControllersskips*.guard.ts. Task 3 depends on it.[ ] Step 1: Write the failing test
Add to apps/api/src/conventions/guards.test.ts, following the shape of the existing G5 cases:
it('017 T0/G5: a *.guard.ts may construct HttpExceptions', () => {
const tree = treeOf({
'mcp/mcp.guard.ts': `
import { ForbiddenException } from '@nestjs/common';
export class McpGuard { check() { throw new ForbiddenException('nope'); } }
`,
});
expect(g5HttpExceptionsOnlyInControllers(tree)).toEqual([]);
});
it('017 T0/G5: a service still may not', () => {
const tree = treeOf({
'mcp/mcp.service.ts': `
import { ForbiddenException } from '@nestjs/common';
export class McpService { check() { throw new ForbiddenException('nope'); } }
`,
});
expect(g5HttpExceptionsOnlyInControllers(tree)).toHaveLength(1);
});Use whatever fixture helper the existing tests in that file use for building a TreeModel — match the neighbouring cases rather than inventing a second style.
- [ ] Step 2: Run it and watch it fail
Run: pnpm vitest run src/conventions/guards.test.ts -t "017 T0" Expected: the first case FAILS with one G5 violation; the second already passes.
- [ ] Step 3: Widen the rule by one line
In apps/api/src/conventions/guards.ts, immediately after the existing controller skip:
if (model.path.endsWith('.controller.ts')) continue;
// A Nest guard is an HTTP-boundary component, exactly like a controller: it runs per request and
// returning a status IS its job. G5's purpose is to keep HTTP concerns out of SERVICES, not to
// name controllers specifically — so a guard throwing 401/403 is the rule working, not an
// exception to it. Decided in 017 step 4; recorded in docs/architecture/nestjs.md.
if (model.path.endsWith('.guard.ts')) continue;- [ ] Step 4: Run the conventions suite
Run: pnpm vitest run src/conventions/ Expected: PASS, including conventions.arch.test.ts against the real tree.
- [ ] Step 5: Record the reasoning
In docs/architecture/nestjs.md, beside the existing G5 description, note that the rule reads "not in services" and that *.controller.ts and *.guard.ts are both HTTP-boundary files permitted to throw HttpExceptions. Without this, the next reader sees a loosened rule and no reason for it.
- [ ] Step 6: Commit
git add apps/api/src/conventions/guards.ts apps/api/src/conventions/guards.test.ts docs/architecture/nestjs.md
git commit -m "api: let *.guard.ts throw HTTP exceptions (G5 means 'not in services')"Task 1: Install the SDK
The only dependency this feature adds. apps/api/src/gw2/ is not touched by any task — level was dropped from R4 rather than widening Gw2ItemSchema for the lowest-value field in the set, so the GW2 client boundary stays exactly as it is.
Files:
- Modify:
apps/api/package.json
Interfaces:
Produces:
@modelcontextprotocol/sdkavailable to tasks 7 and 8.[ ] Step 1: Install
pnpm --filter @gw2priory/api add @modelcontextprotocol/sdk- [ ] Step 2: Confirm the version and the CommonJS entry point
Run: pnpm --filter @gw2priory/api exec node -e "console.log(require('@modelcontextprotocol/sdk/server/mcp.js') && 'cjs ok')" Expected: prints cjs ok. apps/api is "type": "commonjs"; the SDK is dual-published, and this confirms the require path resolves before any code depends on it.
- [ ] Step 3: Confirm the existing suite still passes
Run: pnpm vitest run Expected: PASS. The SDK pulls express, hono, cors, jose, ajv, eventsource and express-rate-limit transitively (spec Assumptions); nothing should import them, and nothing should break.
- [ ] Step 4: Commit
git add apps/api/package.json pnpm-lock.yaml
git commit -m "api: add the MCP SDK dependency"Task 2: Token resolution that fails loudly
Files:
- Create:
apps/api/src/mcp/mcp.config.ts - Test:
apps/api/src/mcp/mcp.config.test.ts
Interfaces:
Produces:
resolveMcpToken(env: NodeJS.ProcessEnv): string— returns the token, throws when unset or blank. Task 3 consumes it.[ ] Step 1: Write the failing test
import { describe, expect, it } from 'vitest';
import { resolveMcpToken } from './mcp.config';
describe('017 T2 — resolveMcpToken', () => {
it('SC4: returns the configured token', () => {
expect(resolveMcpToken({ MCP_AUTH_TOKEN: 's3cret' })).toBe('s3cret');
});
it('SC4: throws when unset', () => {
expect(() => resolveMcpToken({})).toThrow(/MCP_AUTH_TOKEN/);
});
it('SC4: throws when blank', () => {
expect(() => resolveMcpToken({ MCP_AUTH_TOKEN: ' ' })).toThrow(/MCP_AUTH_TOKEN/);
});
});- [ ] Step 2: Run it and watch it fail
Run: pnpm vitest run src/mcp/mcp.config.test.ts Expected: FAIL — cannot resolve ./mcp.config.
- [ ] Step 3: Implement
/**
* Resolves the MCP bearer token, mirroring `resolvePort`'s shape but NOT its tolerance: an unset
* variable throws rather than defaulting (spec 017 R3). An unguarded MCP endpoint and a silently
* dead one are both worse than a refused start. The value is never logged.
*/
export function resolveMcpToken(env: NodeJS.ProcessEnv): string {
const token = env.MCP_AUTH_TOKEN?.trim();
if (!token) {
throw new Error(
'MCP_AUTH_TOKEN is not set. The MCP endpoint refuses to start unguarded.',
);
}
return token;
}- [ ] Step 4: Run the tests
Run: pnpm vitest run src/mcp/mcp.config.test.ts Expected: PASS (3 tests).
- [ ] Step 5: Commit
git add apps/api/src/mcp/mcp.config.ts apps/api/src/mcp/mcp.config.test.ts
git commit -m "api: resolve MCP token, failing loudly when unset"Task 3: The guard — Origin then bearer
Files:
- Create:
apps/api/src/mcp/mcp.guard.ts - Test:
apps/api/src/mcp/mcp.guard.test.ts
Interfaces:
Consumes:
resolveMcpToken(Task 2).Produces:
McpGuard— a NestCanActivate. Task 8 attaches it to the controller.[ ] Step 1: Write the failing test
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { beforeEach, describe, expect, it } from 'vitest';
import { McpGuard } from './mcp.guard';
const ctx = (headers: Record<string, string>): ExecutionContext =>
({ switchToHttp: () => ({ getRequest: () => ({ headers }) }) }) as ExecutionContext;
describe('017 T3 — McpGuard', () => {
let guard: McpGuard;
beforeEach(() => {
guard = new McpGuard({ MCP_AUTH_TOKEN: 'good' });
});
it('SC3: allows a valid token with no Origin header', () => {
expect(guard.canActivate(ctx({ authorization: 'Bearer good' }))).toBe(true);
});
it('SC3: rejects a missing Authorization header', () => {
expect(() => guard.canActivate(ctx({}))).toThrow(UnauthorizedException);
});
it('SC3: rejects a wrong token', () => {
expect(() => guard.canActivate(ctx({ authorization: 'Bearer bad' }))).toThrow(UnauthorizedException);
});
it('SC13: rejects a disallowed Origin with 403, before the token is even considered', () => {
expect(() =>
guard.canActivate(ctx({ origin: 'https://evil.example', authorization: 'Bearer good' })),
).toThrow(ForbiddenException);
});
it('SC13: allows an allow-listed Origin', () => {
expect(
guard.canActivate(ctx({ origin: 'http://localhost:5173', authorization: 'Bearer good' })),
).toBe(true);
});
it('R3: the 401 carries no WWW-Authenticate hint', () => {
try {
guard.canActivate(ctx({}));
} catch (e) {
expect(JSON.stringify(e)).not.toMatch(/WWW-Authenticate/i);
}
});
});- [ ] Step 2: Run it and watch it fail
Run: pnpm vitest run src/mcp/mcp.guard.test.ts Expected: FAIL — cannot resolve ./mcp.guard.
- [ ] Step 3: Implement
import {
type CanActivate,
type ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { timingSafeEqual } from 'node:crypto';
import { CORS_ALLOWLIST } from '../config/cors';
import { resolveMcpToken } from './mcp.config';
/**
* Two independent checks, in order (spec 017 R3).
*
* 1. `Origin`, when PRESENT and not allow-listed, is refused 403 — the MCP spec makes this a MUST,
* to stop a page in a browser reaching a locally-bound MCP server via DNS rebinding. An ABSENT
* `Origin` is allowed: native MCP clients are not browsers and send none (research F1), so
* refusing it would reject every real client while stopping no attack.
* 2. The bearer token, compared in constant time. A failure is a BARE 401 — no `WWW-Authenticate`
* header, and this service exposes no `/.well-known/oauth-*` route. Any OAuth hint risks the
* client abandoning the configured header for a discovery flow (research V4).
*/
@Injectable()
export class McpGuard implements CanActivate {
private readonly token: string;
constructor(env: NodeJS.ProcessEnv = process.env) {
this.token = resolveMcpToken(env);
}
canActivate(context: ExecutionContext): boolean {
const headers = context.switchToHttp().getRequest<{
headers: Record<string, string | undefined>;
}>().headers;
const origin = headers.origin;
if (origin !== undefined && !CORS_ALLOWLIST.includes(origin)) {
throw new ForbiddenException('origin not allowed');
}
const presented = headers.authorization?.match(/^Bearer (.+)$/)?.[1]?.trim();
if (!presented || !this.matches(presented)) {
throw new UnauthorizedException('invalid or missing MCP token');
}
return true;
}
private matches(presented: string): boolean {
const a = Buffer.from(presented);
const b = Buffer.from(this.token);
// timingSafeEqual throws on length mismatch, so compare lengths first — that leak is the
// token's length, which is not the secret.
return a.length === b.length && timingSafeEqual(a, b);
}
}- [ ] Step 4: Run the tests
Run: pnpm vitest run src/mcp/mcp.guard.test.ts Expected: PASS (6 tests).
- [ ] Step 5: Commit
git add apps/api/src/mcp/mcp.guard.ts apps/api/src/mcp/mcp.guard.test.ts
git commit -m "api: guard the MCP route on Origin then bearer token"Task 4: Response shaping
Files:
- Create:
apps/api/src/mcp/mcp.shape.ts - Test:
apps/api/src/mcp/mcp.shape.test.ts
Interfaces:
Produces:
shapeItem(i: Gw2Item),shapeRecipe(r: Gw2Recipe),shapePrice(p: Gw2Price). Task 6 consumes all three.[ ] Step 1: Write the failing test
import { describe, expect, it } from 'vitest';
import { shapeItem, shapePrice, shapeRecipe } from './mcp.shape';
describe('017 T4 — shaping', () => {
it('SC2: shapeItem keeps the declared fields and drops the rest', () => {
const shaped = shapeItem({
id: 19721,
name: 'Glob of Ectoplasm',
type: 'CraftingMaterial',
rarity: 'Exotic',
flags: ['NoSell'],
vendor_value: 16,
icon: 'https://render.guildwars2.com/file/abc/123.png',
details: { type: 'Greatsword' },
});
expect(shaped).toEqual({
id: 19721,
name: 'Glob of Ectoplasm',
rarity: 'Exotic',
type: 'CraftingMaterial',
flags: ['NoSell'],
vendor_value: 16,
});
expect(shaped).not.toHaveProperty('icon');
expect(shaped).not.toHaveProperty('details');
});
it('SC2: shapePrice flattens buys/sells and drops whitelisted', () => {
expect(
shapePrice({
id: 19721,
whitelisted: true,
buys: { quantity: 513923, unit_price: 2598 },
sells: { quantity: 3418571, unit_price: 2601 },
}),
).toEqual({ id: 19721, buy: 2598, sell: 2601 });
});
it('SC2: shapeRecipe keeps ingredients and drops chat_link-era noise', () => {
const shaped = shapeRecipe({
id: 8762,
type: 'Refinement',
output_item_id: 19685,
output_item_count: 1,
disciplines: ['Artificer'],
min_rating: 75,
flags: ['AutoLearned'],
ingredients: [{ item_id: 19721, count: 2 }],
});
expect(shaped).toEqual({
id: 8762,
output_item_id: 19685,
output_item_count: 1,
disciplines: ['Artificer'],
min_rating: 75,
ingredients: [{ item_id: 19721, count: 2 }],
});
expect(shaped).not.toHaveProperty('flags');
expect(shaped).not.toHaveProperty('type');
});
});- [ ] Step 2: Run it and watch it fail
Run: pnpm vitest run src/mcp/mcp.shape.test.ts Expected: FAIL — cannot resolve ./mcp.shape.
- [ ] Step 3: Implement
import type { Gw2Item, Gw2Price, Gw2Recipe } from '../gw2/gw2.schemas';
/**
* Field shaping for tool results (spec 017 R5). Measured on 50 representative ids: items 72.9%
* smaller, prices 72.7%, recipes 31.3% (research V3, against MINIFIED upstream JSON). The weight is
* `details`, `icon` and `game_types` — not `description`/`chat_link`. This is a context-window
* saving, not a bandwidth one: HTTP gzips the wire regardless.
*
* These are explicit constructions, not `delete`/omit helpers, so a NEW upstream field cannot leak
* in by default. The tests assert absence for exactly that reason.
*
* `flags` is retained deliberately despite being ~31% of the shaped items payload: buyable-vs-gated
* classification depends on it.
*/
export type ShapedItem = {
id: number;
name: string;
rarity: string;
type: string;
flags: string[];
vendor_value: number;
};
// No `level`: `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 (spec R5).
export function shapeItem(i: Gw2Item): ShapedItem {
return {
id: i.id,
name: i.name,
rarity: i.rarity,
type: i.type,
flags: i.flags,
vendor_value: i.vendor_value,
};
}
export type ShapedRecipe = {
id: number;
output_item_id: number;
output_item_count: number;
disciplines: string[];
min_rating: number;
ingredients: { item_id: number; count: number }[];
};
export function shapeRecipe(r: Gw2Recipe): ShapedRecipe {
return {
id: r.id,
output_item_id: r.output_item_id,
output_item_count: r.output_item_count,
disciplines: r.disciplines,
min_rating: r.min_rating,
ingredients: r.ingredients.map((g) => ({ item_id: g.item_id, count: g.count })),
};
}
export type ShapedPrice = { id: number; buy: number; sell: number };
export function shapePrice(p: Gw2Price): ShapedPrice {
return { id: p.id, buy: p.buys.unit_price, sell: p.sells.unit_price };
}- [ ] Step 4: Run the tests
Run: pnpm vitest run src/mcp/mcp.shape.test.ts Expected: PASS (3 tests).
- [ ] Step 5: Commit
git add apps/api/src/mcp/mcp.shape.ts apps/api/src/mcp/mcp.shape.test.ts
git commit -m "api: shape MCP tool results to the declared fields"Task 5: Error mapping that leaks nothing
Files:
- Create:
apps/api/src/mcp/mcp.errors.ts - Test:
apps/api/src/mcp/mcp.errors.test.ts
Interfaces:
Produces:
toToolErrorText(error: unknown): string. Task 6 consumes it.[ ] Step 1: Write the failing test
import { describe, expect, it } from 'vitest';
import {
Gw2RateLimitError,
Gw2RequestError,
Gw2ValidationError,
} from '../gw2/gw2.errors';
import { toToolErrorText } from './mcp.errors';
describe('017 T5 — toToolErrorText', () => {
it('SC6: a rate-limit failure says so and says what to do', () => {
const text = toToolErrorText(new Gw2RateLimitError('429 after 3 retries'));
expect(text).toMatch(/rate limit/i);
expect(text).toMatch(/retry/i);
});
// NOTE: keep the dots in `api\.guildwars2\.com` ESCAPED. Guard G4 greps every file's raw text —
// test files included — for the literal `api.guildwars2.com` outside `src/gw2/`, and the escapes
// are the only reason this assertion does not trip it. "Tidying" them away turns the arch test red.
it('SC6: an unexpected failure leaks no stack, path or upstream URL', () => {
const err = new Gw2RequestError('boom');
err.stack = 'Error: boom\n at /Users/someone/apps/api/src/gw2/gw2-client.ts:193';
const text = toToolErrorText(err);
expect(text).not.toMatch(/api\.guildwars2\.com/);
expect(text).not.toMatch(/\/Users\//);
expect(text).not.toMatch(/at .*\.ts:/);
});
it('SC6: a schema drift is reported as an upstream shape problem', () => {
expect(toToolErrorText(new Gw2ValidationError('bad'))).toMatch(/unexpected shape/i);
});
it('SC6: a non-Error value does not crash the mapper', () => {
expect(typeof toToolErrorText('nope')).toBe('string');
});
});- [ ] Step 2: Run it and watch it fail
Run: pnpm vitest run src/mcp/mcp.errors.test.ts Expected: FAIL — cannot resolve ./mcp.errors.
- [ ] Step 3: Implement
import {
Gw2RateLimitError,
Gw2RequestError,
Gw2ValidationError,
} from '../gw2/gw2.errors';
/**
* Maps a GW2 client failure to text a model can act on (spec 017 R6, SC6).
*
* Deliberately returns FIXED strings rather than interpolating `error.message`: upstream messages
* carry URLs and the stack carries absolute paths, and neither belongs on the wire to a client.
* The taxonomy comes from `gw2.errors.ts`, so this switch is exhaustive over the known cases and
* falls through to a generic line for anything else.
*/
export function toToolErrorText(error: unknown): string {
if (error instanceof Gw2RateLimitError) {
return 'GW2 API rate limit reached. Wait a few seconds and retry; the shared budget refills at 5 requests/second.';
}
if (error instanceof Gw2ValidationError) {
return 'The GW2 API returned an unexpected shape for this request. This usually means the upstream schema drifted; it is not retryable.';
}
if (error instanceof Gw2RequestError) {
return 'The GW2 API rejected this request. Check the ids are valid and retry.';
}
return 'The lookup failed for an unexpected reason.';
}- [ ] Step 4: Run the tests
Run: pnpm vitest run src/mcp/mcp.errors.test.ts Expected: PASS (4 tests).
- [ ] Step 5: Commit
git add apps/api/src/mcp/mcp.errors.ts apps/api/src/mcp/mcp.errors.test.ts
git commit -m "api: map GW2 failures to leak-free MCP tool errors"Task 6: The four tool definitions
Files:
- Create:
apps/api/src/mcp/mcp.tools.ts - Test:
apps/api/src/mcp/mcp.tools.test.ts
Interfaces:
- Consumes:
shapeItem/shapeRecipe/shapePrice(Task 4),toToolErrorText(Task 5),Gw2Service(existing). - Produces:
MAX_IDS: number,buildTools(gw2: Gw2Service): McpToolDefinition[]whereMcpToolDefinition = { name: string; description: string; inputSchema: z.ZodType; handler: (args: unknown) => Promise<unknown> }. Task 7 registers them.
Decision recorded here (deferred from plan.md): MAX_IDS = 100. Below the 199-id upstream batch cap, so one tool call is at most one upstream batch. A round number is easier for a model to respect than 199, and the descriptions state it.
- [ ] Step 1: Write the failing test
import { describe, expect, it, vi } from 'vitest';
import type { Gw2Service } from '../gw2/gw2.service';
import { Gw2RateLimitError } from '../gw2/gw2.errors';
import { buildTools, MAX_IDS } from './mcp.tools';
const svc = (over: Partial<Gw2Service> = {}) =>
({ items: vi.fn(), recipes: vi.fn(), prices: vi.fn(), searchRecipes: vi.fn(), ...over }) as unknown as Gw2Service;
const tool = (gw2: Gw2Service, name: string) => {
const found = buildTools(gw2).find((t) => t.name === name);
if (!found) throw new Error(`no tool ${name}`);
return found;
};
describe('017 T6 — tools', () => {
it('SC1: exposes exactly the four tools, each described', () => {
const tools = buildTools(svc());
expect(tools.map((t) => t.name).sort()).toEqual([
'gw2_items', 'gw2_prices', 'gw2_recipe_search', 'gw2_recipes',
]);
for (const t of tools) expect(t.description.length).toBeGreaterThan(20);
});
it('SC2: gw2_items shapes the service result', async () => {
const gw2 = svc({
items: vi.fn().mockResolvedValue([
{ id: 1, name: 'X', type: 'T', rarity: 'R', flags: [], vendor_value: 3, icon: 'u' },
]),
});
expect(await tool(gw2, 'gw2_items').handler({ ids: [1] })).toEqual([
{ id: 1, name: 'X', rarity: 'R', type: 'T', flags: [], vendor_value: 3 },
]);
});
it('SC7: rejects an empty id list before calling the service', async () => {
const gw2 = svc();
await expect(tool(gw2, 'gw2_items').handler({ ids: [] })).rejects.toThrow();
expect(gw2.items).not.toHaveBeenCalled();
});
it('SC7: rejects more than MAX_IDS ids before calling the service', async () => {
const gw2 = svc();
const ids = Array.from({ length: MAX_IDS + 1 }, (_, n) => n + 1);
await expect(tool(gw2, 'gw2_items').handler({ ids })).rejects.toThrow();
expect(gw2.items).not.toHaveBeenCalled();
});
it('SC7: rejects non-positive ids', async () => {
const gw2 = svc();
await expect(tool(gw2, 'gw2_items').handler({ ids: [0] })).rejects.toThrow();
expect(gw2.items).not.toHaveBeenCalled();
});
it('R7: gw2_recipe_search requires exactly one of input/output', async () => {
const gw2 = svc();
await expect(tool(gw2, 'gw2_recipe_search').handler({})).rejects.toThrow();
await expect(tool(gw2, 'gw2_recipe_search').handler({ input: 1, output: 2 })).rejects.toThrow();
expect(gw2.searchRecipes).not.toHaveBeenCalled();
});
it('SC5: one call reaches the service exactly once', async () => {
const gw2 = svc({ prices: vi.fn().mockResolvedValue([]) });
await tool(gw2, 'gw2_prices').handler({ ids: [1, 2] });
expect(gw2.prices).toHaveBeenCalledTimes(1);
});
it('SC6: an upstream failure surfaces as mapped text, not the raw error', async () => {
const gw2 = svc({ items: vi.fn().mockRejectedValue(new Gw2RateLimitError('429')) });
await expect(tool(gw2, 'gw2_items').handler({ ids: [1] })).rejects.toThrow(/rate limit/i);
});
it('F3: the recipe tool descriptions warn that Mystic Forge recipes are absent', () => {
const tools = buildTools(svc());
for (const name of ['gw2_recipes', 'gw2_recipe_search']) {
const t = tools.find((x) => x.name === name);
expect(t?.description).toMatch(/mystic forge/i);
}
});
});- [ ] Step 2: Run it and watch it fail
Run: pnpm vitest run src/mcp/mcp.tools.test.ts Expected: FAIL — cannot resolve ./mcp.tools.
- [ ] Step 3: Implement
import { z } from 'zod';
import type { Gw2Service } from '../gw2/gw2.service';
import { toToolErrorText } from './mcp.errors';
import { shapeItem, shapePrice, shapeRecipe } from './mcp.shape';
/**
* Bound on a single call's id list (spec 017 R7). Below the 199-id upstream batch cap
* (`gw2-client.ts`), so one tool call costs at most one upstream batch and cannot fan out.
* Stated in every id-taking description so a model can respect it.
*/
export const MAX_IDS = 100;
const IdList = z
.array(z.number().int().positive())
.min(1)
.max(MAX_IDS);
// Exactly one of input/output — both or neither is a validation error, never a silent preference.
const SearchInput = z
.object({ input: z.number().int().positive().optional(), output: z.number().int().positive().optional() })
.refine((q) => (q.input === undefined) !== (q.output === undefined), {
message: 'provide exactly one of `input` or `output`',
});
export type McpToolDefinition = {
name: string;
description: string;
inputSchema: z.ZodType;
handler: (args: unknown) => Promise<unknown>;
};
// Descriptions are written for a MODEL, not a human reader: they state what the tool answers, the
// id bound, and — for the recipe tools — the single most misleading fact about this API (research
// F3): Mystic Forge recipes are absent, so a legendary search returns [] rather than an error.
const FORGE_CAVEAT =
'Mystic Forge recipes are NOT in the GW2 API: searching for a legendary weapon returns an empty array, which means "this API cannot answer that", not "no such recipe".';
export function buildTools(gw2: Gw2Service): McpToolDefinition[] {
const run = async <T>(work: () => Promise<T>): Promise<T> => {
try {
return await work();
} catch (error) {
throw new Error(toToolErrorText(error));
}
};
return [
{
name: 'gw2_items',
description: `Look up GW2 items by id. Returns id, name, rarity, type, flags and vendor_value. Up to ${MAX_IDS} ids per call. Item ids are numeric; this API has no name search.`,
inputSchema: z.object({ ids: IdList }),
handler: async (args) => {
const { ids } = z.object({ ids: IdList }).parse(args);
return run(async () => (await gw2.items(ids)).map(shapeItem));
},
},
{
name: 'gw2_recipes',
description: `Look up crafting-station recipes by RECIPE id (not item id). Returns output item, count, disciplines, min_rating and ingredients. Up to ${MAX_IDS} ids per call. ${FORGE_CAVEAT}`,
inputSchema: z.object({ ids: IdList }),
handler: async (args) => {
const { ids } = z.object({ ids: IdList }).parse(args);
return run(async () => (await gw2.recipes(ids)).map(shapeRecipe));
},
},
{
name: 'gw2_prices',
description: `Look up current Trading Post prices by item id. Returns the best buy and sell unit prices in copper. Up to ${MAX_IDS} ids per call. Account-bound items are not tradeable and will be missing from the result.`,
inputSchema: z.object({ ids: IdList }),
handler: async (args) => {
const { ids } = z.object({ ids: IdList }).parse(args);
return run(async () => (await gw2.prices(ids)).map(shapePrice));
},
},
{
name: 'gw2_recipe_search',
description: `Find station recipe ids by ingredient (\`input\`) or by product (\`output\`). Provide exactly one. Returns recipe ids to pass to gw2_recipes. ${FORGE_CAVEAT}`,
inputSchema: SearchInput,
handler: async (args) => {
const query = SearchInput.parse(args);
return run(() =>
gw2.searchRecipes(
query.input !== undefined ? { input: query.input } : { output: query.output as number },
),
);
},
},
];
}- [ ] Step 4: Run the tests
Run: pnpm vitest run src/mcp/mcp.tools.test.ts Expected: PASS (9 tests).
- [ ] Step 5: Commit
git add apps/api/src/mcp/mcp.tools.ts apps/api/src/mcp/mcp.tools.test.ts
git commit -m "api: define the four GW2 MCP tools with bounded validated input"Task 7: Build the MCP server, proven over the in-memory transport
Files:
- Create:
apps/api/src/mcp/mcp.server.ts - Test:
apps/api/src/mcp/mcp.server.test.ts
Interfaces:
Consumes:
buildTools(Task 6).Produces:
buildMcpServer(gw2: Gw2Service): McpServer. Task 8 calls it once per request.[ ] Step 1: Write the failing test
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { describe, expect, it, vi } from 'vitest';
import type { Gw2Service } from '../gw2/gw2.service';
import { buildMcpServer } from './mcp.server';
describe('017 T7 — MCP server over the in-memory transport', () => {
const connect = async (gw2: Gw2Service) => {
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
const server = buildMcpServer(gw2);
const client = new Client({ name: 'test', version: '0.0.0' });
await Promise.all([server.connect(serverSide), client.connect(clientSide)]);
return client;
};
it('SC1: tools/list returns exactly four described tools', async () => {
const client = await connect({ items: vi.fn() } as unknown as Gw2Service);
const { tools } = await client.listTools();
expect(tools.map((t) => t.name).sort()).toEqual([
'gw2_items', 'gw2_prices', 'gw2_recipe_search', 'gw2_recipes',
]);
for (const t of tools) {
expect(t.description ?? '').not.toBe('');
expect(t.inputSchema).toBeDefined();
}
});
it('SC1: tools/call reaches the handler', async () => {
const items = vi.fn().mockResolvedValue([
{ id: 19721, name: 'Glob of Ectoplasm', type: 'CraftingMaterial', rarity: 'Exotic', flags: [], vendor_value: 16 },
]);
const client = await connect({ items } as unknown as Gw2Service);
const result = await client.callTool({ name: 'gw2_items', arguments: { ids: [19721] } });
expect(items).toHaveBeenCalledWith([19721]);
expect(JSON.stringify(result)).toMatch(/Glob of Ectoplasm/);
});
});- [ ] Step 2: Run it and watch it fail
Run: pnpm vitest run src/mcp/mcp.server.test.ts Expected: FAIL — cannot resolve ./mcp.server.
- [ ] Step 3: Implement
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { Gw2Service } from '../gw2/gw2.service';
import { buildTools } from './mcp.tools';
/**
* Builds an MCP server with the four GW2 tools registered (spec 017 R1, R4).
*
* Called ONCE PER REQUEST — see `mcp.controller.ts`. Do not hoist this into a module singleton:
* the stateless transport it pairs with throws on reuse, so a cached server works for exactly one
* request and then fails (research V2, plan.md Global Constraints).
*/
export function buildMcpServer(gw2: Gw2Service): McpServer {
const server = new McpServer({ name: 'gw2priory', version: '0.0.0' });
for (const tool of buildTools(gw2)) {
server.registerTool(
tool.name,
{ description: tool.description, inputSchema: tool.inputSchema },
async (args: unknown) => ({
content: [{ type: 'text' as const, text: JSON.stringify(await tool.handler(args)) }],
}),
);
}
return server;
}- [ ] Step 4: Run the tests
Run: pnpm vitest run src/mcp/mcp.server.test.ts Expected: PASS (2 tests).
Signature confirmed during step 4's plan mode, so no adaptation is expected: registerTool(name, config, cb) takes inputSchema?: InputArgs where InputArgs extends undefined | ZodRawShapeCompat | AnySchema and AnySchema = z3.ZodTypeAny | z4.$ZodType (server/zod-compat.d.ts:3-5) — a z.object(...) is accepted directly. If it nonetheless disagrees, adapt to the installed typings rather than casting: no any, and a cast would hide a real mismatch.
Known limitation, state it in the description rather than the schema. SearchInput uses .refine(), and a refinement does not survive into the JSON Schema published to the client. The "exactly one of input / output" rule is enforced server-side in the handler (it already is) and is stated in the tool description — the model never sees it in the schema.
- [ ] Step 5: Commit
git add apps/api/src/mcp/mcp.server.ts apps/api/src/mcp/mcp.server.test.ts
git commit -m "api: build the MCP server and register the GW2 tools"Task 8: Controller, module, and the HTTP-level proof
The task that catches the two failure modes reasoning would miss: transport reuse, and the Fastify body handoff.
Files:
- Create:
apps/api/src/mcp/mcp.controller.ts - Create:
apps/api/src/mcp/mcp.module.ts - Modify:
apps/api/src/app.module.ts - Test:
apps/api/src/mcp/mcp.controller.test.ts
Interfaces:
Consumes:
buildMcpServer(Task 7),McpGuard(Task 3).Produces:
POST /api/mcp;GET/DELETE→405.[ ] Step 1: Write the failing test
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AppModule } from '../app.module';
const rpc = (id: number, method: string, params?: unknown) => ({
jsonrpc: '2.0', id, method, ...(params ? { params } : {}),
});
describe('017 T8 — POST /api/mcp', () => {
let app: NestFastifyApplication;
beforeAll(async () => {
process.env.MCP_AUTH_TOKEN = 'test-token';
const ref = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = ref.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.setGlobalPrefix('api');
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async () => {
await app.close();
delete process.env.MCP_AUTH_TOKEN;
});
const post = (body: unknown, token = 'test-token') =>
app.inject({
method: 'POST',
url: '/api/mcp',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
},
payload: body,
});
it('SC3: rejects a missing token', async () => {
const res = await app.inject({ method: 'POST', url: '/api/mcp', payload: rpc(1, 'initialize') });
expect(res.statusCode).toBe(401);
expect(res.headers['www-authenticate']).toBeUndefined();
});
it('P1 #1: initialize then tools/list returns the four tools', async () => {
const init = await post(
rpc(1, 'initialize', {
protocolVersion: '2025-11-25',
capabilities: {},
clientInfo: { name: 'test', version: '0.0.0' },
}),
);
expect(init.statusCode).toBe(200);
const list = await post(rpc(2, 'tools/list'));
expect(list.statusCode).toBe(200);
expect(list.body).toMatch(/gw2_items/);
});
it('Global constraint: two sequential requests both succeed (no cached transport)', async () => {
const first = await post(rpc(3, 'tools/list'));
const second = await post(rpc(4, 'tools/list'));
expect(first.statusCode).toBe(200);
expect(second.statusCode).toBe(200);
expect(second.body).toMatch(/gw2_items/);
});
it('R2: GET and DELETE are 405', async () => {
for (const method of ['GET', 'DELETE'] as const) {
const res = await app.inject({
method,
url: '/api/mcp',
headers: { authorization: 'Bearer test-token' },
});
expect(res.statusCode).toBe(405);
}
});
});- [ ] Step 2: Run it and watch it fail
Run: pnpm vitest run src/mcp/mcp.controller.test.ts Expected: FAIL — the route does not exist (404).
- [ ] Step 3: Implement the controller
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import {
Controller,
Delete,
Get,
HttpCode,
Post,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiExcludeEndpoint } from '@nestjs/swagger';
import type { FastifyReply, FastifyRequest } from 'fastify';
// biome-ignore lint/style/useImportType: value import — Nest DI needs the runtime reference.
import { Gw2Service } from '../gw2/gw2.service';
// biome-ignore lint/style/useImportType: value import — Nest reads the guard class at runtime.
import { McpGuard } from './mcp.guard';
import { buildMcpServer } from './mcp.server';
/**
* The MCP endpoint (spec 017 R1, R2, R8). Thin by design: it owns per-request lifecycle and nothing
* else.
*
* THREE THINGS HERE ARE LOAD-BEARING, all proven by spike (research V2):
* 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.
* 2. `req.body` as the third argument — Fastify has already drained the stream, so omitting it
* yields `-32700 Parse error`.
* 3. `sessionIdGenerator: undefined` — stateless. No `Mcp-Session-Id` is minted or echoed.
*
* `@ApiExcludeEndpoint` keeps this out of `openapi.json`: JSON-RPC over one POST is not a REST
* resource, and including it would make orval generate a meaningless hook in `apps/web` and churn
* the committed contract.
*/
@Controller('mcp')
@UseGuards(McpGuard)
export class McpController {
constructor(private readonly gw2: Gw2Service) {}
@Post()
@ApiExcludeEndpoint()
async handle(
@Req() req: FastifyRequest,
@Res() reply: FastifyReply,
): Promise<void> {
const server = buildMcpServer(this.gw2);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
reply.raw.on('close', () => {
void transport.close();
void server.close();
});
await server.connect(transport);
await transport.handleRequest(req.raw, reply.raw, req.body);
}
// The 2025-11-25 client opens one standalone SSE GET, which it tolerates being refused; the
// 2026-07-28 revision requires 405 outright. Neither is needed: stateless mints no session, so
// nothing is ever DELETEd.
@Get()
@Delete()
@HttpCode(405)
@ApiExcludeEndpoint()
unsupported(): void {}
}- [ ] Step 4: Implement the module and wire it in
apps/api/src/mcp/mcp.module.ts:
import { Module } from '@nestjs/common';
import { Gw2Module } from '../gw2/gw2.module';
import { McpController } from './mcp.controller';
import { McpGuard } from './mcp.guard';
// No `exports`: nothing injects these. A provider is exported only when another module needs it
// (nestjs.md, feature-module layout).
@Module({
imports: [Gw2Module],
controllers: [McpController],
providers: [McpGuard],
})
export class McpModule {}In apps/api/src/app.module.ts, add the import and the entry:
import { McpModule } from './mcp/mcp.module';
// … and add `McpModule,` to the `imports` array.- [ ] Step 5: Run the tests
Run: pnpm vitest run src/mcp/mcp.controller.test.ts Expected: PASS (4 tests). If initialize fails with -32700, the req.body argument is missing. If the second sequential request returns a bodyless 500, something is caching the server or transport.
- [ ] Step 6: Run the whole API suite
Run: pnpm vitest run Expected: PASS. main.bootstrap.test.ts boots AppModule, so an unset MCP_AUTH_TOKEN will now throw there — set it in that test's setup, the same way this task's test does.
- [ ] Step 7: Commit
git add apps/api/src/mcp/ apps/api/src/app.module.ts
git commit -m "api: serve MCP over streamable HTTP at POST /api/mcp"Task 9: Prove the contract did not move
Files:
Test:
apps/api/src/generate-openapi.test.ts[ ] Step 1: Add the assertion
it('017 SC8: the MCP route is absent from the document', () => {
const doc = buildOpenApiDocument(); // use whatever this suite already calls
expect(Object.keys(doc.paths)).not.toContain('/mcp');
expect(JSON.stringify(doc)).not.toMatch(/mcp/i);
});- [ ] Step 2: Run it
Run: pnpm vitest run src/generate-openapi.test.ts Expected: PASS — @ApiExcludeEndpoint already keeps it out. A failure means the decorator is missing or misplaced.
- [ ] Step 3: Verify the committed contract is byte-identical
Run (repo root): pnpm verify:contract Expected: PASS with no diff. If openapi.json changed, the exclusion is not working — fix the decorator; do not commit a regenerated document.
- [ ] Step 4: Commit
git add apps/api/src/generate-openapi.test.ts
git commit -m "api: assert the MCP route stays out of the OpenAPI contract"Task 10: Declare the secret on the deploy
Files:
Modify:
render.yamlTest:
tests/deploy/render-blueprint.test.ts[ ] Step 1: Write the failing test
it('017 SC9: the API declares MCP_AUTH_TOKEN as a dashboard-set secret', () => {
const api = blueprint.services.find((s) => s.name.includes('api'));
const entry = api?.envVars?.find((v) => v.key === 'MCP_AUTH_TOKEN');
expect(entry).toBeDefined();
expect(entry?.sync).toBe(false);
expect(entry).not.toHaveProperty('value');
});- [ ] Step 2: Run it and watch it fail
Run (repo root): pnpm vitest run tests/deploy/render-blueprint.test.ts Expected: FAIL — no such env var.
- [ ] Step 3: Add it to
render.yaml
Under the API service's envVars:
- key: MCP_AUTH_TOKEN
sync: falsesync: false means Render prompts for the value in the dashboard and it is never committed.
- [ ] Step 4: Run the test
Run (repo root): pnpm vitest run tests/deploy/render-blueprint.test.ts Expected: PASS.
- [ ] Step 5: Commit
git add render.yaml tests/deploy/render-blueprint.test.ts
git commit -m "deploy: declare MCP_AUTH_TOKEN as a dashboard-set secret"Task 11: Document it, and close the traceability table
Files:
Create:
docs/architecture/mcp.mdModify:
specs/017-mcp-server/spec.md(traceability table only)[ ] Step 1: Write
docs/architecture/mcp.md
Cover, in this order:
- What it is —
POST /api/mcp, four tools overGw2Service, who it is for. - Targeted protocol revision
2025-11-25, and why — the spec is at2026-07-28, but@modelcontextprotocol/sdk1.30.0 tops out at2025-11-25and Claude Code 2.1.232 negotiates the same. Cite research F1. - The
2026-07-28migration checklist — mandatoryMCP-Protocol-Version/Mcp-Method/Mcp-Nameheaders with400+-32020on mismatch;server/discover;ttlMs/cacheScopeontools/list;202on notifications;404+-32601on unknown method; sessions and the GET endpoint gone; do not advertisesubscriptions/listenon a host that cuts long-held streams. Note the trigger: the day the client dropsinitialize, a strictly-modern request fails hard against 1.30.0, and the remedy is an SDK upgrade, not a rewrite. - Three implementation rules that look optional and are not — fresh server and transport per request;
req.bodyashandleRequest's third argument; never construct aGw2Client. - Auth posture and its limits —
Originthen bearer; bare401, noWWW-Authenticate, no.well-known/oauth-*, and why (research V4). State plainly that a static shared token is not per-user auth and does not survive being shared. - Client configuration, committing no secret:
{
"mcpServers": {
"gw2priory": {
"type": "http",
"url": "${GW2PRIORY_MCP_URL:-http://localhost:3000/api/mcp}",
"headers": { "Authorization": "Bearer ${GW2PRIORY_MCP_TOKEN}" }
}
}
}Warn that a url entry without type is read as a stdio server, and that ${VAR} expansion in headers has a history of not substituting — if it sends the literal ${VAR} string, use headersHelper or local-scope config instead. 7. The measured shaping numbers (items 72.9%, prices 72.7%, recipes 31.3% vs minified) and that this is a context-window saving, not bandwidth. 8. A verification log with a dated entry for SC10.
- [ ] Step 2: Verify SC10 against the real deploy
Once merged and deployed, register the deployed URL with a real client, list the tools, and call one. Record the date, the client version and the outcome in the verification log. If the deploy is not yet available, record that SC10 is pending with the reason — do not mark it verified.
- [ ] Step 3: Fill in the traceability table in
spec.md
Every row gets a named test or a dated manual record. SC12 asserts no empty cell. The mapping falls out of the tasks above: T3 covers SC3/SC4/SC13, T4 covers SC2, T5 covers SC6, T6 covers SC5/SC7, T7 covers SC1, T8 covers P1/P2 and R2's 405, T9 covers SC8, T10 covers SC9, and SC10 is the dated manual record from Step 2.
- [ ] Step 4: Full verification
Run (repo root): pnpm typecheck && pnpm test && pnpm verify:contract Expected: all green. Do not claim completion without pasting the output — evidence before assertions.
- [ ] Step 5: Commit
git add docs/architecture/mcp.md specs/017-mcp-server/spec.md
git commit -m "docs: record the MCP server contract, auth posture and migration checklist"Coverage check
| Spec item | Task |
|---|---|
| R1 module layout | 8 |
R2 transport, revision, 405 | 8 |
R3 token, Origin, bare 401 | 2, 3 |
| R4 four tools | 6 |
| R5 shaping | 4 |
| R6 error mapping | 5 |
| R7 bounded validated ids | 6 |
| R8 OpenAPI exclusion | 8, 9 |
| R9 blueprint secret | 10 |
| R10 documentation | 11 |
R11 no docs/superpowers/, prior suites | 8 (full suite), 11 |
| SC1 | 7 |
| SC2 | 4 |
| SC3, SC4, SC13 | 3 |
| SC5 | 6, plus the architecture test noted below |
| SC6 | 5, 6 |
| SC7 | 6 |
| SC8 | 9 |
| SC9 | 10 |
| SC10 | 11 |
| SC11, SC12 | 11 |
One deliberate omission to close during implementation: plan.md calls for an architecture test in apps/api/src/conventions/ asserting no Gw2Client construction inside src/mcp/. It is not its own task because conventions.arch.test.ts already exists and the addition is one case in a table — fold it into Task 8, where the module first exists. If that file's shape does not accommodate it, raise it rather than skipping it: it is the second half of SC5.
Tasks 017 Amendment A — six priory_* tools
Status: approved Written from plan.md's Amendment A half (approved). Ten tasks, A0–A9, numbered apart from the original T0–T11 above, which are done and are not revisited.
Status is set by the human, never by the agent. Approved by the human on 2026-08-18; the agent transcribed that decision here on explicit instruction.
Global constraints (every Amendment A task inherits these)
G1–G12 from plan.md. The four that get violated by accident, restated:
- G1 — fresh
McpServerand fresh transport per request. Never cache either. - G2 —
McpGuardis not modified by any task here. - G3 — no tool declares a key/token/credential parameter.
- G7 —
exactOptionalPropertyTypes: omit optional properties, never pass explicitundefined.
Plus one that applies only to this half:
- G13 — no test may call
RankingService.rankfor real. The live fan-out is >31 s (research A3). Every ranking test mocks the service.
Deviation from plan.md, flagged rather than done quietly
plan.md has Task 0 add the PENDING-rejection assertion immediately and stay red until Task 9. This task list does not do that, because a persistently red suite destroys the pass/fail signal every later task depends on — each task's "run the tests" step would show a failure it must learn to ignore, which is how a real regression gets waved through.
Split instead, same end state:
- A0 fixes the row format so the 17 rows become visible to the existing empty-cell and path-exists assertions. Suite stays green.
- A9 fills the rows with real test names and then adds the
PENDING-rejection assertion, which is green on arrival because nothing saysPENDINGany more.
The hole closes either way. This ordering keeps every intermediate commit honest.
Task A0: Make the traceability gate see Amendment A's rows
Files:
- Modify:
specs/017-mcp-server/spec.md(17 traceability rows) - Test:
tests/deploy/render-blueprint.test.ts(existing, unchanged this task)
Interfaces:
- Consumes: nothing.
- Produces: nothing in code. Produces a working gate every later task's traceability row relies on.
Why first. tests/deploy/render-blueprint.test.ts:183 matches rows with /^\|\s*((?:P\d+ #\d+|SC\d+))\s*\|(.*)\|/. It requires | immediately after the criterion. The rows Amendment A added read | P3 #1 **[A]** | PENDING |, so none of them match and SC12 checks none of them. Confirmed by running the regex:
INVISIBLE | P3 #1 **[A]** | PENDING |
MATCHES | P3 #1 | PENDING |- [ ] Step 1: Prove the rows are invisible today
node -e '
const re = /^\|\s*((?:P\d+ #\d+|SC\d+))\s*\|(.*)\|/;
const rows = require("fs").readFileSync("specs/017-mcp-server/spec.md","utf8")
.split("\n").filter(l => re.test(l));
console.log("rows the gate can see:", rows.length);
'Expected: a count that excludes all 17 Amendment A rows (i.e. the pre-amendment count).
- [ ] Step 2: Drop
**[A]**from the criterion cell of all 17 rows
The marker moves out of the criterion cell entirely — it is redundant there, since every P3/P4/SC14+ criterion is Amendment A by definition.
| P3 #1 | PENDING |
| P3 #2 | PENDING |
| P3 #3 | PENDING |
| P3 #4 | PENDING |
| P4 #1 | PENDING |
| P4 #2 | PENDING |
| P4 #3 | PENDING |
| P4 #4 | PENDING |
| P4 #5 | PENDING |
| P4 #6 | PENDING |
| SC14 | PENDING |
| SC15 | PENDING |
| SC16 | PENDING |
| SC17 | PENDING |
| SC18 | PENDING |
| SC19 | PENDING |
| SC20 | PENDING |
| SC21 | PENDING |(18 lines — SC21 was added by Revision A2 after the original 17; the count is 18.)
- [ ] Step 3: Re-run Step 1's probe
Expected: the count rises by 18. The rows are now subject to "no criterion cell is empty" and "every backtick-cited .ts path exists".
- [ ] Step 4: Run the gate
Run: pnpm vitest run tests/deploy/render-blueprint.test.ts Expected: PASS. PENDING is non-empty and cites no path, so it satisfies both current assertions. That is the remaining hole, and A9 closes it. Do not add the rejection assertion here.
- [ ] Step 5: Commit
git add specs/017-mcp-server/spec.md
git commit -m "specs: 017 make Amendment A traceability rows visible to the SC12 gate"Task A1: Export the legendaries services
Files:
- Modify:
apps/api/src/legendaries/legendaries.module.ts - Test:
apps/api/src/legendaries/legendaries.module.test.ts
Interfaces:
Produces:
LegendariesServiceandRankingServicebecome injectable outsideLegendariesModule.[ ] Step 1: Write the failing test
Append to legendaries.module.test.ts:
it('017 A1: exports LegendariesService and RankingService for McpModule', async () => {
const mod = await Test.createTestingModule({
imports: [LegendariesModule],
})
.overrideProvider(Gw2Service)
.useValue({ items: vi.fn(), prices: vi.fn() })
.compile();
// A consumer module can only inject what LegendariesModule exports.
expect(mod.get(LegendariesService, { strict: false })).toBeDefined();
expect(mod.get(RankingService, { strict: false })).toBeDefined();
});- [ ] Step 2: Run it and watch it fail
Run: pnpm vitest run apps/api/src/legendaries/legendaries.module.test.ts Expected: FAIL — the services are providers but not exports.
- [ ] Step 3: Add the exports
@Module({
imports: [Gw2Module, RecipeGraphModule, AccountModule],
controllers: [LegendariesController],
providers: [LegendariesService, RankingService],
// Exported for McpModule (017 Amendment A): the priory_legendaries and
// priory_legendary_ranking tools inject these directly rather than calling our own HTTP routes.
exports: [LegendariesService, RankingService],
})
export class LegendariesModule {}- [ ] Step 4: Run the legendaries suite
Run: pnpm vitest run apps/api/src/legendaries Expected: PASS, nothing else changed.
- [ ] Step 5: Commit
git add apps/api/src/legendaries/
git commit -m "api: export LegendariesService and RankingService for McpModule"Task A2: Move the priced-tree assembly into RecipeGraphService
Files:
- Modify:
apps/api/src/recipe-graph/recipe-graph.service.ts - Modify:
apps/api/src/recipe-graph/recipe-graph.controller.ts - Modify:
apps/api/src/recipe-graph/recipe-graph.errors.ts - Test:
apps/api/src/recipe-graph/recipe-graph.service.test.ts - Do not modify:
apps/api/src/recipe-graph/recipe-graph.controller.test.ts
Interfaces:
- Produces:
RecipeGraphService.resolvePriced(itemId: number): Promise<PricedRoot>andItemNotFoundError.
The safety rule for this task. This is the only edit to merged, working code. recipe-graph.controller.test.ts must pass untouched. If it needs editing, the extraction changed behaviour and is wrong — revert rather than accommodate.
A constraint that shapes the design. conventions.arch.test.ts's SC5/G5: HTTP exceptions only in controllers forbids the service throwing NotFoundException. The 404 currently comes from the controller checking graph.nodes[itemId]?.name === null. So the service throws a domain error — matching the existing CycleError pattern in the same file — and each caller maps it: the controller to 404, the MCP tool to error text.
- [ ] Step 1: Write the failing test
Append to recipe-graph.service.test.ts:
describe('017 A2: resolvePriced', () => {
it('returns the priced root, pricing the buyable ids in one batched call', async () => {
const prices = vi.fn().mockResolvedValue([
{ id: 19721, buys: { unit_price: 100 }, sells: { unit_price: 120 } },
]);
const svc = buildService({ gw2: { prices } });
const root = await svc.resolvePriced(19721);
expect(root.summary.rootId).toBe(19721);
expect(root.unitCost).not.toBeUndefined();
expect(prices).toHaveBeenCalledTimes(1);
});
it('throws ItemNotFoundError when the GW2 API did not know the id', async () => {
const svc = buildService({ unknownRoot: true });
await expect(svc.resolvePriced(1)).rejects.toBeInstanceOf(ItemNotFoundError);
});
});- [ ] Step 2: Run it and watch it fail
Run: pnpm vitest run apps/api/src/recipe-graph/recipe-graph.service.test.ts Expected: FAIL — resolvePriced is not a function.
- [ ] Step 3: Add the domain error
Append to recipe-graph.errors.ts:
/**
* Thrown by `RecipeGraphService.resolvePriced` when the root item resolved to null metadata — the
* GW2 API omitted the id (206/404). A DOMAIN error, not an HTTP one: `conventions.arch.test.ts`'s
* G5 forbids HTTP exceptions in services, so each caller maps this itself (the controller to a 404,
* the MCP tool to error text).
*/
export class ItemNotFoundError extends Error {
constructor(readonly itemId: number) {
super(`item ${itemId} not found`);
this.name = 'ItemNotFoundError';
}
}- [ ] Step 4: Add
resolvePriced, moving the controller's three lines verbatim
Inject Gw2Service (value import + biome-ignore, per G6) and add:
/**
* `resolve` plus the pricing pass — moved here from `recipe-graph.controller.ts` (017 Amendment A,
* R17) so the REST route and the priory_recipe_tree MCP tool share ONE priced-tree implementation
* rather than two that can diverge. The controller's observable behaviour is unchanged; its existing
* tests pass untouched, which is the proof.
*/
async resolvePriced(itemId: number): Promise<PricedRoot> {
const graph = await this.resolve(itemId);
if (graph.nodes[itemId]?.name === null) {
throw new ItemNotFoundError(itemId);
}
const priced = await this.gw2.prices(collectPricedIds(graph));
const priceMap: PriceMap = new Map(
priced.map((p) => [p.id, { buys: p.buys, sells: p.sells }]),
);
return priceGraph(graph, priceMap).root;
}- [ ] Step 5: Reduce the controller to mapping
@Get(':itemId')
@ZodResponse({ status: 200, type: RecipeTreeDto })
async get(@Param('itemId', ParseIntPipe) itemId: number): Promise<PricedRoot> {
// ParseIntPipe already 400s a non-integer param; this guard rejects a parsed non-positive id.
if (itemId <= 0) {
throw new BadRequestException('itemId must be a positive integer');
}
try {
return await this.recipeGraph.resolvePriced(itemId);
} catch (e) {
// The service throws a domain error (G5: no HTTP exceptions in services); the 404 is ours.
if (e instanceof ItemNotFoundError) {
throw new NotFoundException(e.message);
}
throw e;
}
}Gw2Service is no longer a constructor dependency of the controller — remove it and its import.
- [ ] Step 6: Run the recipe-graph suite, controller tests UNTOUCHED
Run: pnpm vitest run apps/api/src/recipe-graph Expected: PASS, including recipe-graph.controller.test.ts with zero edits to that file. If it fails, the extraction is wrong. Do not edit the test to match.
- [ ] Step 7: Run the conventions suite
Run: pnpm vitest run apps/api/src/conventions Expected: PASS — ItemNotFoundError is not an HTTP exception, so G5 stays green.
- [ ] Step 8: Commit
git add apps/api/src/recipe-graph/
git commit -m "api: move the priced-tree assembly into RecipeGraphService (one implementation, two callers)"Task A3: buildTools takes a dependencies object
Files:
- Modify:
apps/api/src/mcp/mcp.tools.ts - Modify:
apps/api/src/mcp/mcp.server.ts - Modify:
apps/api/src/mcp/mcp.controller.ts - Modify:
apps/api/src/mcp/mcp.module.ts - Test:
apps/api/src/mcp/mcp.tools.test.ts,apps/api/src/mcp/mcp.server.test.ts
Interfaces:
- Produces:
export type McpDepsandbuildTools(deps: McpDeps),buildMcpServer(deps: McpDeps).
No new tools in this task. Signature change only, so a reviewer can reject the refactor without rejecting the tools.
- [ ] Step 1: Update the test helper to the new signature (the failing test)
In mcp.tools.test.ts, replace the svc/tool helpers:
const deps = (over: Partial<McpDeps> = {}): McpDeps =>
({
gw2: {
items: vi.fn(), recipes: vi.fn(), prices: vi.fn(), searchRecipes: vi.fn(),
},
recipeGraph: { resolvePriced: vi.fn() },
legendaries: { list: vi.fn() },
ranking: { rank: vi.fn() }, // G13: always mocked, never the real fan-out
account: { getAccount: vi.fn(), getMaterials: vi.fn(), getWallet: vi.fn() },
...over,
}) as unknown as McpDeps;
const tool = (d: McpDeps, name: string) => {
const found = buildTools(d).find((t) => t.name === name);
if (!found) throw new Error(`no tool ${name}`);
return found;
};Every existing call site changes from svc({ items: … }) to deps({ gw2: { …, items: … } }).
- [ ] Step 2: Run it and watch it fail
Run: pnpm vitest run apps/api/src/mcp Expected: FAIL — buildTools still takes a positional Gw2Service.
- [ ] Step 3: Introduce the type and change the signature
In mcp.tools.ts:
/**
* Everything the tool layer needs, as one object rather than six positional parameters (017
* Amendment A). `gw2Key` is the caller's own GW2 API key, read from the `X-GW2-Key` request header
* by `mcp.controller.ts` — OMITTED, never `undefined`, when the header is absent (G7).
*/
export type McpDeps = {
gw2: Gw2Service;
recipeGraph: RecipeGraphService;
legendaries: LegendariesService;
ranking: RankingService;
account: AccountService;
gw2Key?: string;
};
export function buildTools(deps: McpDeps): McpToolDefinition[] {
const gw2 = deps.gw2;
// …existing four tools unchanged below this line…
}- [ ] Step 4: Thread it through the server and controller
mcp.server.ts: buildMcpServer(deps: McpDeps), for (const tool of buildTools(deps)).
mcp.controller.ts: inject all five services (value imports + biome-ignore, G6) and build the deps. The key is read here and nowhere else:
const rawKey = req.headers['x-gw2-key'];
const gw2Key = typeof rawKey === 'string' ? rawKey.trim() : '';
const server = buildMcpServer({
gw2: this.gw2,
recipeGraph: this.recipeGraph,
legendaries: this.legendaries,
ranking: this.ranking,
account: this.account,
// Omitted, not `undefined`: exactOptionalPropertyTypes (G7). An absent header is a
// missing-key tool error, NOT a guard rejection — the guard owns MCP_AUTH_TOKEN only (G2).
...(gw2Key === '' ? {} : { gw2Key }),
});mcp.module.ts: imports: [Gw2Module, RecipeGraphModule, LegendariesModule, AccountModule].
- [ ] Step 5: Run the whole API suite
Run: pnpm --filter @gw2priory/api test Expected: PASS. Four tools, same behaviour, new plumbing.
- [ ] Step 6: Commit
git add apps/api/src/mcp/
git commit -m "api: buildTools takes a deps object and the MCP route reads X-GW2-Key"Task A4: Three new error branches
Files:
- Modify:
apps/api/src/mcp/mcp.errors.ts - Test:
apps/api/src/mcp/mcp.errors.test.ts
Interfaces:
Produces:
MISSING_KEY_TEXTconstant;toToolErrorTexthandlingGw2UnauthorizedErrorandGw2ForbiddenError.[ ] Step 1: Write the failing tests
it('SC18: an invalid key says the key was rejected, and names no scope', () => {
const text = toToolErrorText(new Gw2UnauthorizedError('GW2 rejected the API key'));
expect(text).toMatch(/rejected/i);
expect(text).toMatch(/X-GW2-Key/);
expect(text).not.toMatch(/scope/i);
});
it('SC18: a missing scope names the scope, distinctly from an invalid key', () => {
const text = toToolErrorText(new Gw2ForbiddenError('inventories'));
expect(text).toContain('inventories');
expect(text).toMatch(/scope/i);
expect(text).not.toEqual(
toToolErrorText(new Gw2UnauthorizedError('GW2 rejected the API key')),
);
});
it('SC17: the missing-header text names the header to configure', () => {
expect(MISSING_KEY_TEXT).toContain('X-GW2-Key');
});
it('SC19: no branch echoes a key-shaped value it was never given', () => {
for (const e of [
new Gw2UnauthorizedError('GW2 rejected the API key'),
new Gw2ForbiddenError('wallet'),
]) {
expect(toToolErrorText(e)).not.toMatch(/[0-9A-F]{8}-[0-9A-F]{4}/i);
}
});- [ ] Step 2: Run them and watch them fail
Run: pnpm vitest run apps/api/src/mcp/mcp.errors.test.ts Expected: FAIL — both errors currently fall through to "failed for an unexpected reason".
- [ ] Step 3: Implement
/**
* Text for a tool called without the `X-GW2-Key` header (017 R15/SC17). Names the header, because a
* caller who knows which header is missing can fix it; "unauthorized" cannot be acted on.
*/
export const MISSING_KEY_TEXT =
'This tool reads your own GW2 account and needs your GW2 API key. Add an "X-GW2-Key" header to your gw2priory MCP client configuration, alongside the existing Authorization header, then reconnect.';Add to toToolErrorText, before the Gw2RequestError branch:
if (error instanceof Gw2ForbiddenError) {
// The scope name is public information; the key is not, and never appears here.
return `Your GW2 API key is missing the "${error.scope}" scope. Re-create the key at account.arena.net/applications with that scope enabled, then update the X-GW2-Key header.`;
}
if (error instanceof Gw2UnauthorizedError) {
return 'GW2 rejected your API key — it is invalid or expired. Check the X-GW2-Key header, or re-create the key at account.arena.net/applications.';
}- [ ] Step 4: Run the tests
Run: pnpm vitest run apps/api/src/mcp/mcp.errors.test.ts Expected: PASS.
- [ ] Step 5: Commit
git add apps/api/src/mcp/
git commit -m "api: map key-rejection and missing-scope failures to actionable MCP tool errors"Task A5: Shaping and the per-category roll-up
Files:
- Modify:
apps/api/src/mcp/mcp.shape.ts - Test:
apps/api/src/mcp/mcp.shape.test.ts
Interfaces:
- Produces:
shapeMaterial,rollUpMaterials,shapeWalletEntry,shapeRankingRowand their result types.
This is the amendment's one piece of new logic (R13) — a grouping and three sums, in a pure function, testable with no key and no HTTP.
- [ ] Step 1: Write the failing tests
const mat = (over: Partial<Material> = {}): Material => ({
id: 1, count: 5, category: 5, categoryName: 'Cooking Materials', categoryOrder: 2,
name: 'Carrot', icon: 'https://render.guildwars2.com/x.png', rarity: 'Basic',
sellPrice: 10, ...over,
});
it('SC20: shapeMaterial drops icon, category and categoryOrder', () => {
const s = shapeMaterial(mat());
expect(s).toEqual({
id: 1, count: 5, name: 'Carrot', rarity: 'Basic',
categoryName: 'Cooking Materials', sellPrice: 10,
});
expect('icon' in s).toBe(false);
});
it('SC20: rollUpMaterials aggregates per category, owned rows only', () => {
const rows = [
mat({ id: 1, count: 5, sellPrice: 10 }),
mat({ id: 2, count: 3, sellPrice: 100 }),
mat({ id: 3, count: 0, sellPrice: 999 }), // unowned: excluded
mat({ id: 4, count: 2, sellPrice: null, categoryName: 'Gemstones', categoryOrder: 1 }),
];
expect(rollUpMaterials(rows)).toEqual([
// categoryOrder 1 sorts first; a null sellPrice contributes 0, not NaN.
{ category: 'Gemstones', distinctItems: 1, totalCount: 2, totalSellValue: 0 },
{ category: 'Cooking Materials', distinctItems: 2, totalCount: 8, totalSellValue: 350 },
]);
});
it('SC20: rollUpMaterials returns [] when nothing is owned', () => {
expect(rollUpMaterials([mat({ count: 0 })])).toEqual([]);
});
it('SC20: shapeWalletEntry and shapeRankingRow drop icon', () => {
expect(
shapeWalletEntry({ id: 1, value: 9, name: 'Gold', icon: 'x', order: 3 }),
).toEqual({ id: 1, name: 'Gold', value: 9 });
const row = shapeRankingRow({
id: 30703, name: 'Twilight', icon: 'x', subtype: 'Greatsword', netSell: 5,
marketCost: 4, myCost: 3, personalProfit: 2, gatedInputs: [], craftable: true,
});
expect('icon' in row).toBe(false);
expect(row.personalProfit).toBe(2);
});- [ ] Step 2: Run them and watch them fail
Run: pnpm vitest run apps/api/src/mcp/mcp.shape.test.ts Expected: FAIL — none of the four functions exist.
- [ ] Step 3: Implement
export type ShapedMaterial = {
id: number; count: number; name: string; rarity: string;
categoryName: string; sellPrice: number | null;
};
// `category` (numeric) and `categoryOrder` are display/sort concerns for the web UI; an agent reads
// `categoryName`. `icon` is unrenderable here (R16).
export function shapeMaterial(m: Material): ShapedMaterial {
return {
id: m.id, count: m.count, name: m.name, rarity: m.rarity,
categoryName: m.categoryName, sellPrice: m.sellPrice,
};
}
export type MaterialCategoryRollup = {
category: string; distinctItems: number; totalCount: number; totalSellValue: number;
};
/**
* The no-arguments answer for priory_account_materials (R16, revised after research A2 measured the
* row-level default at ~18,100 tokens). Owned rows only, grouped by category, ordered by the game's
* own `categoryOrder`. A null `sellPrice` contributes 0 — an unpriced material is not a NaN.
*
* The amendment's one piece of new logic, deliberately a pure function over AccountService's
* existing output: no service change, and no key needed to test it.
*/
export function rollUpMaterials(rows: Material[]): MaterialCategoryRollup[] {
const byCategory = new Map<
string, { order: number; distinctItems: number; totalCount: number; totalSellValue: number }
>();
for (const r of rows) {
if (r.count <= 0) continue;
const acc = byCategory.get(r.categoryName) ?? {
order: r.categoryOrder, distinctItems: 0, totalCount: 0, totalSellValue: 0,
};
acc.distinctItems += 1;
acc.totalCount += r.count;
acc.totalSellValue += r.count * (r.sellPrice ?? 0);
byCategory.set(r.categoryName, acc);
}
return [...byCategory.entries()]
.sort(([, a], [, b]) => a.order - b.order)
.map(([category, a]) => ({
category,
distinctItems: a.distinctItems,
totalCount: a.totalCount,
totalSellValue: a.totalSellValue,
}));
}
export type ShapedWalletEntry = { id: number; name: string; value: number };
export function shapeWalletEntry(w: WalletEntry): ShapedWalletEntry {
return { id: w.id, name: w.name, value: w.value };
}
export type ShapedRankingRow = Omit<RankingRow, 'icon'>;
// Only `icon` is dropped: every money column and `gatedInputs` is load-bearing for the question
// this tool answers, and SC15's sibling reasoning applies — do not invent a different shape.
export function shapeRankingRow(r: RankingRow): ShapedRankingRow {
return {
id: r.id, name: r.name, subtype: r.subtype, netSell: r.netSell,
marketCost: r.marketCost, myCost: r.myCost, personalProfit: r.personalProfit,
gatedInputs: r.gatedInputs, craftable: r.craftable,
};
}- [ ] Step 4: Run the tests
Run: pnpm vitest run apps/api/src/mcp/mcp.shape.test.ts Expected: PASS.
- [ ] Step 5: Commit
git add apps/api/src/mcp/
git commit -m "api: shape account results and roll materials up per category"Task A6: The two keyless priory_* tools
Files:
- Modify:
apps/api/src/mcp/mcp.tools.ts - Test:
apps/api/src/mcp/mcp.tools.test.ts
Interfaces:
- Consumes:
McpDeps(A3),ItemNotFoundError+resolvePriced(A2),LegendariesQuery(legendaries.schema.ts:26). - Produces: tools
priory_recipe_tree,priory_legendaries.
A known, unmeasured ceiling. PricedRoot carries every node's full recipes[] array and prunes no children, so a deep tree can be a large result. SC15 pins this tool's value to what GET /api/recipe-graph/:itemId returns, so it must not be reshaped. Record the ceiling with a ponytail: comment; changing it is a future spec decision, not this task's.
- [ ] Step 1: Write the failing tests
it('P3 #2: priory_recipe_tree returns the priced root from the shared service', async () => {
const root = { summary: { rootId: 19721 }, unitCost: 5 };
const d = deps({ recipeGraph: { resolvePriced: vi.fn().mockResolvedValue(root) } });
await expect(tool(d, 'priory_recipe_tree').handler({ itemId: 19721 })).resolves.toBe(root);
});
it('P3 #3: an unknown item id becomes a not-found tool error', async () => {
const d = deps({
recipeGraph: { resolvePriced: vi.fn().mockRejectedValue(new ItemNotFoundError(1)) },
});
await expect(tool(d, 'priory_recipe_tree').handler({ itemId: 1 })).rejects.toThrow(/not found/i);
});
it('R7: priory_recipe_tree rejects a non-positive id before the service', async () => {
const resolvePriced = vi.fn();
const d = deps({ recipeGraph: { resolvePriced } });
await expect(tool(d, 'priory_recipe_tree').handler({ itemId: 0 })).rejects.toThrow();
expect(resolvePriced).not.toHaveBeenCalled();
});
it('P3 #4: priory_legendaries passes the filter through and returns generation', async () => {
const list = vi.fn().mockResolvedValue([{ id: 30703, name: 'Twilight', generation: 1 }]);
const d = deps({ legendaries: { list } });
const out = await tool(d, 'priory_legendaries').handler({ generation: 1 });
expect(list).toHaveBeenCalledWith({ generation: 1 });
expect(out).toEqual([{ id: 30703, name: 'Twilight', generation: 1 }]);
});- [ ] Step 2: Run them and watch them fail
Run: pnpm vitest run apps/api/src/mcp/mcp.tools.test.ts Expected: FAIL — no tool priory_recipe_tree.
- [ ] Step 3: Implement
Add to the returned array in buildTools:
{
name: 'priory_recipe_tree',
description:
'Priory-computed: the full buy-versus-craft decision tree for an item, priced from live Trading Post data. Each node carries unitBuyPrice, craftCost, unitCost and a buy/craft/gated decision, and the root carries a summary with totalCraftCost and profit. This is THIS PROJECT\'S calculation, not an official ArenaNet figure. Use it instead of pricing a tree yourself from gw2_recipes and gw2_prices.',
inputSchema: RecipeTreeInput,
handler: async (args) => {
const { itemId } = RecipeTreeInput.parse(args);
// ponytail: unbounded result size — PricedRoot keeps every node's full recipes[] and prunes no
// children, so a deep tree is a large payload. SC15 pins this to the REST route's value, so it
// must not be reshaped here; bounding it is a spec decision, not a local one.
try {
return await deps.recipeGraph.resolvePriced(itemId);
} catch (error) {
if (error instanceof ItemNotFoundError) {
throw new Error(
`Item ${itemId} was not found in the GW2 API, so no recipe tree can be built for it.`,
);
}
throw new Error(toToolErrorText(error));
}
},
},
{
name: 'priory_legendaries',
description:
'Priory-computed: this project\'s curated list of legendary items, optionally filtered by generation (1-3) or item type. `generation` is OUR curation and does not exist in the GW2 API — no gw2_* tool can answer it.',
inputSchema: LegendariesQuery,
handler: async (args) => {
const filter = LegendariesQuery.parse(args);
return run(() => deps.legendaries.list(filter));
},
},with, beside IdsInput:
const RecipeTreeInput = z.object({ itemId: z.number().int().positive() });- [ ] Step 4: Run the MCP suite
Run: pnpm vitest run apps/api/src/mcp Expected: PASS.
- [ ] Step 5: Commit
git add apps/api/src/mcp/
git commit -m "api: add the two keyless priory_* tools"Task A7: The four account tools
Files:
- Modify:
apps/api/src/mcp/mcp.tools.ts - Test:
apps/api/src/mcp/mcp.tools.test.ts,apps/api/src/mcp/mcp.controller.test.ts
Interfaces:
Consumes:
MISSING_KEY_TEXT(A4), the shapers (A5),deps.gw2Key(A3).Produces: tools
priory_account,priory_account_materials,priory_account_wallet,priory_legendary_ranking.[ ] Step 1: Write the failing tests
it('P4 #1: priory_account_materials with no ids returns the category roll-up', async () => {
const getMaterials = vi.fn().mockResolvedValue([
{ id: 1, count: 5, category: 5, categoryName: 'Cooking Materials', categoryOrder: 2,
name: 'Carrot', icon: 'x', rarity: 'Basic', sellPrice: 10 },
]);
const d = deps({ account: { getMaterials }, gw2Key: 'KEY' });
const out = await tool(d, 'priory_account_materials').handler({});
expect(getMaterials).toHaveBeenCalledWith('KEY');
expect(out).toEqual([
{ category: 'Cooking Materials', distinctItems: 1, totalCount: 5, totalSellValue: 50 },
]);
});
it('SC20: with ids it returns those rows, count 0 included', async () => {
const rows = [
{ id: 1, count: 0, category: 5, categoryName: 'C', categoryOrder: 1,
name: 'A', icon: 'x', rarity: 'Basic', sellPrice: null },
{ id: 2, count: 7, category: 5, categoryName: 'C', categoryOrder: 1,
name: 'B', icon: 'x', rarity: 'Basic', sellPrice: 3 },
];
const d = deps({ account: { getMaterials: vi.fn().mockResolvedValue(rows) }, gw2Key: 'KEY' });
const out = await tool(d, 'priory_account_materials').handler({ ids: [1] });
expect(out).toEqual([
{ id: 1, count: 0, name: 'A', rarity: 'Basic', categoryName: 'C', sellPrice: null },
]);
});
it('SC17: each account tool without a key returns the missing-header error', async () => {
const d = deps(); // no gw2Key
for (const name of [
'priory_account', 'priory_account_materials',
'priory_account_wallet', 'priory_legendary_ranking',
]) {
await expect(tool(d, name).handler({})).rejects.toThrow(/X-GW2-Key/);
}
});
it('SC17: a keyless caller still sees all ten tools', () => {
expect(buildTools(deps()).length).toBe(10);
});
it('SC18: a missing scope surfaces with the scope named', async () => {
const d = deps({
account: { getWallet: vi.fn().mockRejectedValue(new Gw2ForbiddenError('wallet')) },
gw2Key: 'KEY',
});
await expect(tool(d, 'priory_account_wallet').handler({})).rejects.toThrow(/wallet/);
});
it('SC19: the key never appears in a successful result', async () => {
const d = deps({
account: { getAccount: vi.fn().mockResolvedValue({ name: 'Wallaka.1234' }) },
gw2Key: 'SECRET-KEY-VALUE',
});
const out = await tool(d, 'priory_account').handler({});
expect(JSON.stringify(out)).not.toContain('SECRET-KEY-VALUE');
});
it('G13/R18: priory_legendary_ranking shapes the mocked ranking, dropping icon', async () => {
const rank = vi.fn().mockResolvedValue([
{ id: 30703, name: 'Twilight', icon: 'x', subtype: 'Greatsword', netSell: 5,
marketCost: 4, myCost: 3, personalProfit: 2, gatedInputs: [], craftable: true },
]);
const d = deps({ ranking: { rank }, gw2Key: 'KEY' });
const out = await tool(d, 'priory_legendary_ranking').handler({});
expect(rank).toHaveBeenCalledWith('KEY');
expect(JSON.stringify(out)).not.toContain('icon');
});And in mcp.controller.test.ts, at HTTP level:
it('P4 #1: X-GW2-Key reaches the tool handler', async () => {
// tools/call priory_account over the real route with both headers set; the stubbed
// AccountService asserts it received the header's value.
});
it('SC17: the same call without X-GW2-Key returns the missing-header text, not a 401', async () => {
// the guard must NOT reject it (G2) — the response is 200 with a tool error.
});- [ ] Step 2: Run them and watch them fail
Run: pnpm vitest run apps/api/src/mcp Expected: FAIL — no tool priory_account.
- [ ] Step 3: Implement
Beside IdsInput in mcp.tools.ts:
// G3: no key parameter. The credential arrives as a header, so it CANNOT be passed as an argument
// even by a model that tries — which is what keeps it out of transcripts (R14/SC16).
const NoInput = z.object({});
const MaterialsInput = z.object({
ids: z.array(z.number().int().positive()).min(1).max(MAX_IDS).optional(),
});Inside buildTools, beside run:
const requireKey = (): string => {
const key = deps.gw2Key;
if (key === undefined || key === '') {
throw new Error(MISSING_KEY_TEXT);
}
return key;
};Then the four tools. priory_account_materials is the one with two shapes:
{
name: 'priory_account_materials',
description: `Priory-computed: YOUR material storage, read with your own GW2 API key. Called with no arguments it returns a per-category roll-up (about nine rows: category, distinct items owned, total count, total sell value) — NOT the full item list, which is thousands of tokens. Pass \`ids\` (up to ${MAX_IDS}) to get those exact materials with their counts, including count 0 for ones you own none of. Needs the "X-GW2-Key" header and the inventories scope.`,
inputSchema: MaterialsInput,
handler: async (args) => {
const { ids } = MaterialsInput.parse(args);
const key = requireKey();
return run(async () => {
const rows = await deps.account.getMaterials(key);
if (ids === undefined) {
return rollUpMaterials(rows);
}
const wanted = new Set(ids);
return rows.filter((r) => wanted.has(r.id)).map(shapeMaterial);
});
},
},priory_account returns { name } from getAccount; priory_account_wallet maps shapeWalletEntry; priory_legendary_ranking maps shapeRankingRow and carries R18's cost declaration in its description:
{
name: 'priory_legendary_ranking',
description:
'Priory-computed: every legendary ranked by YOUR personal crafting profit, using materials you already own. EXPENSIVE — this reads every character\'s inventory, so it takes tens of seconds (over 31 s measured on a 19-character account), scales with your character count, and uses a meaningful share of a shared rate-limit budget. It may exceed the tool timeout; if it does, retrying once within five minutes is usually fast because the account reads are cached, though a server restart clears that. Prefer priory_account_materials for narrower questions.',
inputSchema: NoInput,
handler: async () => {
const key = requireKey();
// ponytail: cost is 1 + one-request-per-character + 2, linear in character count and not
// tunable here (research A3). No optimisation is agreed; see spec 017 R18.
return run(async () => (await deps.ranking.rank(key)).map(shapeRankingRow));
},
},- [ ] Step 4: Run the whole API suite
Run: pnpm --filter @gw2priory/api test Expected: PASS.
- [ ] Step 5: Commit
git add apps/api/src/mcp/
git commit -m "api: add the four account tools, keyed by the X-GW2-Key header"Task A8: Invariants over the whole tool list
Files:
- Test:
apps/api/src/mcp/mcp.tools.test.ts - Modify (only if a tool fails an invariant):
apps/api/src/mcp/mcp.tools.ts
Interfaces:
- Consumes: the full ten-tool list.
- Produces: nothing. Produces invariants a future tool cannot be added without satisfying.
Written as loops over buildTools, never as a per-tool list — an assertion enumerating today's ten tools passes forever once an eleventh is added wrong.
- [ ] Step 1: Write the failing tests
describe('017 A8 — invariants over every registered tool', () => {
const all = buildTools(deps());
it('SC14: exactly ten tools, every name prefixed gw2_ or priory_', () => {
expect(all.length).toBe(10);
for (const t of all) expect(t.name).toMatch(/^(gw2|priory)_/);
});
it('SC14: every description opens by declaring provenance', () => {
for (const t of all) {
const expected = t.name.startsWith('gw2_')
? 'Official GW2 API:'
: 'Priory-computed:';
expect(t.description.startsWith(expected), `${t.name}: ${t.description.slice(0, 40)}`).toBe(true);
}
});
it('SC16: no tool declares a credential parameter', () => {
const banned = /key|token|secret|password|credential|authorization/i;
for (const t of all) {
const shape = (t.inputSchema as z.ZodObject<z.ZodRawShape>).shape ?? {};
for (const prop of Object.keys(shape)) {
expect(banned.test(prop), `${t.name} declares "${prop}"`).toBe(false);
}
}
});
it('SC21: the ranking description states its cost and the retry advice', () => {
const d = all.find((t) => t.name === 'priory_legendary_ranking')?.description ?? '';
expect(d).toMatch(/31 s|tens of seconds/);
expect(d).toMatch(/character count/);
expect(d).toMatch(/retry/i);
});
});Note: priory_recipe_search's schema is a ZodEffects (it uses .refine()), so the SC16 loop must reach .shape through the base object — unwrap with _def.schema where shape is absent, or assert over Object.keys(z.toJSONSchema(t.inputSchema).properties ?? {}). Use whichever the installed Zod version supports; do not cast to any (G8).
- [ ] Step 2: Run them
Run: pnpm vitest run apps/api/src/mcp/mcp.tools.test.ts Expected: FAIL on any description not yet opening with its provenance clause. Fix the descriptions, not the assertion — including the four original gw2_* ones, which predate R12 and must gain Official GW2 API: prefixes.
- [ ] Step 3: Prefix the four original descriptions
The gw2_* descriptions from T6 keep their text, prefixed with Official GW2 API: . Their existing assertions check length and substrings, so they stay green.
- [ ] Step 4: Add the G4 architecture case
Confirm conventions.arch.test.ts's existing G4 case (no Gw2Client under src/mcp/) still passes with the five new service dependencies — none of them is a Gw2Client.
Run: pnpm vitest run apps/api/src/conventions Expected: PASS.
- [ ] Step 5: Run everything
Run: pnpm typecheck && pnpm --filter @gw2priory/api test && pnpm lint Expected: all clean.
- [ ] Step 6: Commit
git add apps/api/src/mcp/
git commit -m "api: assert provenance, tool count and no-credential-parameter over every MCP tool"Task A9: Document it, close the traceability gate
Files:
- Modify:
docs/architecture/mcp.md - Modify:
specs/017-mcp-server/spec.md(18 traceability rows + status) - Modify:
tests/deploy/render-blueprint.test.ts
Interfaces:
Consumes: every test name from A1–A8.
Produces: a gate that cannot pass on an unfilled row.
[ ] Step 1: Extend
docs/architecture/mcp.md
Add, in the existing style:
The six
priory_*tools and their result fields, beside the fourgw2_*ones.The
gw2_/priory_provenance convention and why (R12): an agent must not report our arithmetic as an ArenaNet figure.X-GW2-Keyclient configuration, with both headers, and the${VAR}warning already recorded for the bearer token — noting the failure mode is worse here: an unexpandedX-GW2-Keyreaches GW2 as a literal string and is rejected as an invalid key, which reads as "my key is wrong" rather than "my config did not expand".The A1 caveat: header forwarding on every request is observed on Claude Code 2.1.234, not a protocol guarantee. Do not present it as one.
Correct the stale F1 claim at the "never sends
Mcp-MethodorMcp-Name" line: 2.1.234 does sendMcp-Methodon aserver/discoverprobe at2026-07-28before falling back to2025-11-25(research A1-b). R2 is unaffected; the migration checklist's trigger is closer than F1 implied.R18's cost declaration for
priory_legendary_ranking, and A2's measured numbers for the roll-up decision.[ ] Step 2: Fill all 18 traceability rows
Replace every PENDING with the real file — test name citations from A1–A8, in the format the existing rows use. Every cited .ts path must exist on disk — the gate checks.
- [ ] Step 3: Write the failing assertion
Append to the 017 SC12 block in tests/deploy/render-blueprint.test.ts:
it('no criterion cell is an unfilled placeholder', () => {
// A cell reading PENDING is non-empty and cites no path, so it satisfied both assertions above
// while proving nothing. Amendment A added 18 such rows; this is what stops them recurring.
expect(
rows.filter((r) => /^PENDING\b/i.test(r.cell)).map((r) => r.criterion),
).toEqual([]);
});- [ ] Step 4: Run the gate
Run: pnpm vitest run tests/deploy/render-blueprint.test.ts Expected: PASS — rows filled, so nothing matches PENDING. Temporarily revert one row to PENDING and re-run to confirm the assertion actually bites; then restore it.
- [ ] Step 5: Full verification
Run: pnpm typecheck && pnpm test && pnpm lint && pnpm docs:build Expected: all clean, openapi.json unchanged.
- [ ] Step 6: Manual verification against a running instance
Register a local instance with both headers, then record in docs/architecture/mcp.md with the date: claude mcp list → ✔ Connected; tools/list → ten tools; priory_legendaries returns generation; priory_account_materials with no arguments returns the roll-up and with ids returns rows; an account tool with X-GW2-Key removed returns the missing-header text. Exercise priory_legendary_ranking once and record its real latency, timeout included if it times out.
- [ ] Step 7: Commit
git add docs/architecture/mcp.md specs/017-mcp-server/spec.md tests/deploy/render-blueprint.test.ts
git commit -m "docs: record the priory_* contract; close the traceability gate on PENDING rows"Criterion → task map
| Criterion | Task |
|---|---|
| P3 #1 | A8 |
| P3 #2, #3 | A2, A6 |
| P3 #4 | A6 |
| P4 #1, #2 | A7, A8 |
| P4 #3 | A4, A7 |
| P4 #4, #5 | A4, A7 |
| P4 #6 | A4, A7 |
| SC14 | A8 |
| SC15 | A2, A6 |
| SC16 | A8 |
| SC17 | A4, A7 |
| SC18 | A4, A7 |
| SC19 | A4, A7 |
| SC20 | A5, A7 |
| SC21 | A8 |
| SC12 (gate) | A0, A9 |
One thing deliberately left for implementation to resolve: A8's SC16 loop needs to read property names off priory_recipe_search's .refine()-wrapped schema, whose .shape is not directly on the object. The task names two viable routes and forbids an any cast. If neither works on the installed Zod version, raise it rather than weakening the assertion — it is the test that makes G3 real.