All checks were successful
CD / Build and push images (push) Successful in 3m50s
CI / Lint, typecheck, test (push) Successful in 4m2s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 5m37s
CI / Import/export fidelity gate (push) Successful in 47s
AI clients talk to the instance directly at /api/mcp — under the /api/ path (deviation from the issue's literal /mcp) so every existing reverse proxy already routes it; no deployment changes anywhere. - Transport: official @modelcontextprotocol/sdk server, STATELESS — each POST builds a fresh server+transport pair, no session store, replicas stay trivial; GET/DELETE answer 405. Auth per PAT bearer (#104 tokens), per-token rate limit (429 + Retry-After). - Own switches, independent of REST: instance mcp.enabled (admin settings, default off; off = 404, feature invisible) + pond setting mcpEnabled (pond-settings toggle, default off) — pinned independent in both directions by tests. - Tools (thin wrappers over the #104 services, same permission gates, audit-logged writes): list_ponds, list_pages, read_page, search, create_page, update_page (replace semantics through the collab-owned restore path — open editors converge), add_comment, list_labels, set_page_labels (exact replace), export_pond (link to the REST ZIP). Tool errors carry the api error codes; results carry stable slugs/ids. MCP resources stay the documented stage-2 stretch goal. - Deliberately on the SDK's low-level Server API with a hand-written tool table (mcp-tools.ts): the typed registerTool generics drove tsc out of memory in a program this size; manual Zod validation keeps the wire behavior explicit. - PublicApiService exposure filtering parameterized ('api' | 'mcp', shared pondFeatureEnabled helper) — one implementation, two switches. - Docs: "Connect Claude Code / MCP clients" section in public-api.md (claude mcp add one-liner + mcp-remote bridge for stdio clients). Verification: 8-test e2e pack driving the real MCP SDK client over Streamable HTTP against a listening api (initialize + tools/list, switch independence in both directions, anonymous/garbage 401, opt-in 404 semantics, page roundtrip incl. restore-NOTIFY, labels/comments, read scope blocked from writes with scope_required); live check through the web proxy against the seeded stack (tools list, create, read, update, search — LIVE CHECK PASSED); full api suite 61/61 files green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
98 lines
3.5 KiB
TypeScript
98 lines
3.5 KiB
TypeScript
import {
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
HttpException,
|
|
HttpStatus,
|
|
NotFoundException,
|
|
Post,
|
|
Req,
|
|
Res,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
import type { Request, Response } from 'express';
|
|
|
|
import { Public } from '../auth/auth.guard';
|
|
import { RateLimitService } from '../rate-limit/rate-limit.service';
|
|
import { ApiTokensService } from '../public-api/api-tokens.service';
|
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
|
import { McpService } from './mcp.service';
|
|
|
|
/** Same budget as the REST surface — one shared runaway stop per token. */
|
|
const RATE_LIMIT = { limit: 120, windowSeconds: 60 };
|
|
|
|
/**
|
|
* The MCP Streamable-HTTP endpoint (issue #105) at `/api/mcp` — under the
|
|
* `/api/` path on purpose: every existing reverse proxy (stages,
|
|
* self-hosters, the bundled Caddyfile) already routes it to the api, so no
|
|
* deployment changes anywhere. Stateless: each POST builds a fresh server +
|
|
* transport pair (no session store), which keeps api replicas trivial;
|
|
* GET/DELETE (SSE resumption, session teardown) answer 405 accordingly.
|
|
*
|
|
* Gating mirrors the REST guard with the MCP switches: instance
|
|
* `mcp.enabled` off → 404 (feature invisible), then PAT bearer auth and the
|
|
* per-token rate limit. Pond opt-in (`mcpEnabled`) is enforced inside every
|
|
* tool (McpService).
|
|
*/
|
|
@Controller('api/mcp')
|
|
@Public()
|
|
export class McpController {
|
|
constructor(
|
|
private readonly mcp: McpService,
|
|
private readonly tokens: ApiTokensService,
|
|
private readonly settings: InstanceSettingsService,
|
|
private readonly rateLimits: RateLimitService,
|
|
) {}
|
|
|
|
@Post()
|
|
async handle(@Req() request: Request, @Res() response: Response): Promise<void> {
|
|
if (!(await this.settings.get('mcp.enabled'))) throw new NotFoundException();
|
|
|
|
const header = request.headers.authorization ?? '';
|
|
const raw = header.startsWith('Bearer ') ? header.slice('Bearer '.length).trim() : '';
|
|
const validated = raw ? await this.tokens.validate(raw) : null;
|
|
if (!validated) throw new UnauthorizedException();
|
|
|
|
const limited = await this.rateLimits.hit(
|
|
'mcp',
|
|
`token:${validated.token.id}`,
|
|
RATE_LIMIT.limit,
|
|
RATE_LIMIT.windowSeconds,
|
|
);
|
|
if (!limited.allowed) {
|
|
response.setHeader('Retry-After', String(limited.retryAfterSeconds));
|
|
throw new HttpException({ code: 'rate_limited' }, HttpStatus.TOO_MANY_REQUESTS);
|
|
}
|
|
|
|
const server = this.mcp.buildServer(validated.user, validated.token);
|
|
const transport = new StreamableHTTPServerTransport({
|
|
// Stateless mode: no session ids, every request stands alone.
|
|
sessionIdGenerator: undefined,
|
|
});
|
|
response.on('close', () => {
|
|
void transport.close();
|
|
void server.close();
|
|
});
|
|
await server.connect(transport);
|
|
// The body was parsed by the global express.json middleware.
|
|
await transport.handleRequest(request, response, request.body);
|
|
}
|
|
|
|
@Get()
|
|
async methodNotAllowed(@Res() response: Response): Promise<void> {
|
|
if (!(await this.settings.get('mcp.enabled'))) throw new NotFoundException();
|
|
// Stateless server: no SSE stream to resume, no session to delete.
|
|
response.status(HttpStatus.METHOD_NOT_ALLOWED).json({
|
|
jsonrpc: '2.0',
|
|
error: { code: -32000, message: 'Method not allowed — stateless transport, POST only' },
|
|
id: null,
|
|
});
|
|
}
|
|
|
|
@Delete()
|
|
async deleteNotAllowed(@Res() response: Response): Promise<void> {
|
|
await this.methodNotAllowed(response);
|
|
}
|
|
}
|