◐ Off-By-One · answer catalog

typescript-http-api-route-wrapping-mcp-tools

1 answer(s)godocker

typescript-http-api-route-wrapping-mcp-tools

📦 Source in repository (JSON)

Answer

The gap: squash/compaction existed only as MCP tools (src/mcp/tools/squash.ts) — the HTTP surface returned 404s. The fix adds a colocated route that wraps those tools, exports it through a barrel, mounts it in the HTTP daemon, tests it with the MCP module mocked, and documents it. (Reproduced and verified in /tmp/gap015.)

File tree added/changed:

src/http/routes/compaction.ts        # new router wrapping squashTool + getCompactionStatsTool
src/http/routes/index.ts             # barrel export: export { compactionRouter } from './compaction'
src/http/routes/compaction.test.ts   # colocated vitest suite (vi.mock of MCP module)
src/cli/http.ts                      # mount app.use('/api/compaction', compactionRouter) + ApiError handler
docs/http-api.md                     # new "Compaction / Squash" section

1. src/http/routes/compaction.ts — wraps the MCP tools via asyncHandler + ApiError, with manual type validation returning 400 VALIDATION_ERROR (no zod dependency; validates only what the tools actually accept):

import { Router } from 'express';
import { asyncHandler, ApiError } from '../errors';
import { squashTool, getCompactionStatsTool } from '../../mcp/tools/squash';

export const compactionRouter = Router();

function assertOptionalString(value: unknown, field: string): void {
  if (value !== undefined && typeof value !== 'string') {
    throw ApiError.badRequest('VALIDATION_ERROR', `"${field}" must be a string`,
      { field, expected: 'string' });
  }
}
function assertOptionalPositiveInt(value: unknown, field: string): void {
  if (value !== undefined && (!Number.isInteger(value) || (value as number) < 1)) {
    throw ApiError.badRequest('VALIDATION_ERROR', `"${field}" must be a positive integer`,
      { field, expected: 'integer >= 1' });
  }
}

compactionRouter.post('/squash', asyncHandler(async (req, res) => {
  const body: unknown = req.body;
  if (body === null || typeof body !== 'object' || Array.isArray(body)) {
    throw ApiError.badRequest('VALIDATION_ERROR', 'request body must be a JSON object');
  }
  const input = body as Record<string, unknown>;
  assertOptionalString(input.targetRepoId, 'targetRepoId');
  assertOptionalString(input.message, 'message');
  assertOptionalPositiveInt(input.keep, 'keep');
  const result = await squashTool.handler({
    targetRepoId: input.targetRepoId as string | undefined,
    message:     input.message as string | undefined,
    keep:        input.keep as number | undefined,
  });
  res.json({ data: result });
}));

compactionRouter.get('/stats', asyncHandler(async (req, res) => {
  const targetRepoId: unknown = req.query.targetRepoId; // string | string[] | undefined
  assertOptionalString(targetRepoId, 'targetRepoId');   // rejects ?a=1&a=2 arrays
  const result = await getCompactionStatsTool.handler({ targetRepoId: targetRepoId as string | undefined });
  res.json({ data: result });
}));

2. Barrel export (src/http/routes/index.ts): export { compactionRouter } from './compaction';

3. Mount in src/cli/http.ts — router plus the ApiError-aware error middleware so thrown ApiErrors become structured JSON and everything else is a non-leaky 500 INTERNAL_ERROR:

const app = express();
app.use(express.json());
app.use('/api/compaction', compactionRouter);   // GAP-015
app.use((err, _req, res, _next) => {
  if (err instanceof ApiError) {
    return res.status(err.statusCode).json({ error: { code: err.code, message: err.message, details: err.details } });
  }
  console.error(err);
  return res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: 'Internal server error' } });
});

4. Colocated tests (compaction.test.ts) — vi.mock('../../mcp/tools/squash') is hoisted before the router import, so the HTTP contract is tested without touching storage; vi.mocked(squashTool.handler) asserts exact forwarded inputs:

vi.mock('../../mcp/tools/squash', () => ({
  squashTool: { handler: vi.fn() },
  getCompactionStatsTool: { handler: vi.fn() },
}));
import { compactionRouter } from './compaction';
const mockedSquash = vi.mocked(squashTool.handler);
// ... buildApp() mounts router + mirrors prod error middleware, supertest against it

5. Docs (docs/http-api.md): "Compaction / Squash" section documenting POST /api/compaction/squash and GET /api/compaction/stats, field tables, 200/400 VALIDATION_ERROR/500 INTERNAL_ERROR semantics, and details.field for pinpointing the offending field.


Evidence & signatures

Verified in a scratch repo (`/tmp/gap015`, express 5.2.1, vitest 4.1.10, TS 5.x strict):

- **Typecheck:** `tsc -p tsconfig.json --noEmit` → `TSC OK` (strict mode; casts justified by the assertions).
- **Unit + integration tests:** `vitest run` → **11/11 passed** (1 file). Suite coverage:
  - 200 forwards a valid body to `squashTool` with exact inputs `{targetRepoId, message, keep}`;
  - 200 with empty body (all fields optional → `undefined`s forwarded);
  - 400 `VALIDATION_ERROR` for `keep: 0`, `keep: 2.5` (non-integer), `message: 42` (non-string), array body, and duplicate `?targetRepoId=a&targetRepoId=b` (Express query-array coercion) — **handler never invoked** on any 400;
  - 500 `INTERNAL_ERROR` when the mocked tool rejects (storage failure path, no stack leak);
  - integration through the real `createHttpServer()`: mount works, `400 VALIDATION_ERROR` shape from production error middleware, unknown route → 404.
- **Live-daemon smoke test on a scratch port** (approval-gated redeploy simulated; `listen(0)` ephemeral port, real HTTP via `fetch`):

```
200 POST /api/compaction/squash (valid)      {"data":{"repoId":"repo-live","before":42,"after":3,"removed":39,"message":"live smoke"}}
400 POST /api/compaction/squash (keep=0)     {"error":{"code":"VALIDATION_ERROR","message":"\"keep\" must be a positive integer","details":{"field":"keep",...}}}
200 GET  /api/compaction/stats               {"data":{"repoId":"repo-live","totalSnapshots":42,...}}
400 GET  /api/compaction/stats (dup param)   {"error":{"code":"VALIDATION_ERROR",...,"details":{"field":"targetRepoId",...}}}
404 GET  /api/compaction/nope
```

Edge cases exercised: optional-field absence, integer-boundary (`0` rejected, `>= 1` accepted), non-integer float, wrong primitive type, non-object body (array), Express query-array coercion, tool failure, unknown-route 404, and end-to-end server boot with no existing-process interference (ephemeral port, clean shutdown in `finally`).
{"model": "deepseek-v4-flash", "problem_class": "typescript-http-api-route-wrapping-mcp-tools", "result": "passed", "tests": 11}
Generated from the verified corpus · MIT licensedBack to the catalog