Skip to content

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, with superpowers:test-driven-development inside every task and superpowers:systematic-debugging on 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-28 is not targeted.
  • A fresh McpServer and a fresh transport per request. Caching either is a defect.
  • handleRequest always receives req.body as its third argument.
  • No Gw2Client is constructed inside src/mcp/. Everything goes through injected Gw2Service.
  • Bare 401 — no WWW-Authenticate, no /.well-known/oauth-* routes.
  • An absent Origin header is allowed; only present-and-not-allow-listed is refused 403.
  • No secret in the repository.
  • openapi.json stays byte-identical; verify:contract stays 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: g5HttpExceptionsOnlyInControllers skips *.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:

ts
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:

ts
    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
bash
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/sdk available to tasks 7 and 8.

  • [ ] Step 1: Install

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

ts
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
ts
/**
 * 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
bash
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 Nest CanActivate. Task 8 attaches it to the controller.

  • [ ] Step 1: Write the failing test

ts
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
ts
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
bash
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

ts
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
ts
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
bash
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

ts
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
ts
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
bash
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[] where McpToolDefinition = { 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
ts
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
ts
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
bash
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

ts
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
ts
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
bash
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

ts
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
ts
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:

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:

ts
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
bash
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

ts
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
bash
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.yaml

  • Test: tests/deploy/render-blueprint.test.ts

  • [ ] Step 1: Write the failing test

ts
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:

yaml
      - key: MCP_AUTH_TOKEN
        sync: false

sync: 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
bash
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.md

  • Modify: specs/017-mcp-server/spec.md (traceability table only)

  • [ ] Step 1: Write docs/architecture/mcp.md

Cover, in this order:

  1. What it is — POST /api/mcp, four tools over Gw2Service, who it is for.
  2. Targeted protocol revision 2025-11-25, and why — the spec is at 2026-07-28, but @modelcontextprotocol/sdk 1.30.0 tops out at 2025-11-25 and Claude Code 2.1.232 negotiates the same. Cite research F1.
  3. The 2026-07-28 migration checklist — mandatory MCP-Protocol-Version / Mcp-Method / Mcp-Name headers with 400 + -32020 on mismatch; server/discover; ttlMs / cacheScope on tools/list; 202 on notifications; 404 + -32601 on unknown method; sessions and the GET endpoint gone; do not advertise subscriptions/listen on a host that cuts long-held streams. Note the trigger: the day the client drops initialize, a strictly-modern request fails hard against 1.30.0, and the remedy is an SDK upgrade, not a rewrite.
  4. Three implementation rules that look optional and are not — fresh server and transport per request; req.body as handleRequest's third argument; never construct a Gw2Client.
  5. Auth posture and its limits — Origin then bearer; bare 401, no WWW-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.
  6. Client configuration, committing no secret:
json
{
  "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
bash
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 itemTask
R1 module layout8
R2 transport, revision, 4058
R3 token, Origin, bare 4012, 3
R4 four tools6
R5 shaping4
R6 error mapping5
R7 bounded validated ids6
R8 OpenAPI exclusion8, 9
R9 blueprint secret10
R10 documentation11
R11 no docs/superpowers/, prior suites8 (full suite), 11
SC17
SC24
SC3, SC4, SC133
SC56, plus the architecture test noted below
SC65, 6
SC76
SC89
SC910
SC1011
SC11, SC1211

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 McpServer and fresh transport per request. Never cache either.
  • G2 — McpGuard is not modified by any task here.
  • G3 — no tool declares a key/token/credential parameter.
  • G7 — exactOptionalPropertyTypes: omit optional properties, never pass explicit undefined.

Plus one that applies only to this half:

  • G13 — no test may call RankingService.rank for 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 says PENDING any 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:

text
INVISIBLE  | P3 #1 **[A]** | PENDING |
MATCHES    | P3 #1 | PENDING |
  • [ ] Step 1: Prove the rows are invisible today
bash
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.

text
| 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
bash
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: LegendariesService and RankingService become injectable outside LegendariesModule.

  • [ ] Step 1: Write the failing test

Append to legendaries.module.test.ts:

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
ts
@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
bash
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> and ItemNotFoundError.

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:

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:

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:

ts
/**
 * `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
ts
@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
bash
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 McpDeps and buildTools(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:

ts
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:

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:

ts
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
bash
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_TEXT constant; toToolErrorText handling Gw2UnauthorizedError and Gw2ForbiddenError.

  • [ ] Step 1: Write the failing tests

ts
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
ts
/**
 * 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:

ts
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
bash
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, shapeRankingRow and 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
ts
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
ts
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
bash
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
ts
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:

ts
{
  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:

ts
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
bash
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

ts
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:

ts
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:

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:

ts
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:

ts
{
  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:

ts
{
  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
bash
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
ts
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
bash
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 four gw2_* ones.

  • The gw2_ / priory_ provenance convention and why (R12): an agent must not report our arithmetic as an ArenaNet figure.

  • X-GW2-Key client configuration, with both headers, and the ${VAR} warning already recorded for the bearer token — noting the failure mode is worse here: an unexpanded X-GW2-Key reaches 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-Method or Mcp-Name" line: 2.1.234 does send Mcp-Method on a server/discover probe at 2026-07-28 before falling back to 2025-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:

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
bash
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 ​

CriterionTask
P3 #1A8
P3 #2, #3A2, A6
P3 #4A6
P4 #1, #2A7, A8
P4 #3A4, A7
P4 #4, #5A4, A7
P4 #6A4, A7
SC14A8
SC15A2, A6
SC16A8
SC17A4, A7
SC18A4, A7
SC19A4, A7
SC20A5, A7
SC21A8
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.