dorfteich/apps/api/src/mcp/mcp-tools.ts
Claude Fable 5 04e21a0aac
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
Built-in MCP endpoint (Streamable HTTP) on top of the public API (#105)
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
2026-07-12 11:36:02 +02:00

173 lines
5.5 KiB
TypeScript

import { z } from 'zod';
/**
* The MCP tool table (issue #105): names, English descriptions, and input
* schemas — the JSON Schema served on tools/list is written by hand next
* to the Zod schema that actually validates calls. Kept as plain data (and
* validated in mcp.service.ts, not through the SDK's typed helpers): the
* SDK's generic tool registration blows up TypeScript's inference when
* combined with a program this size.
*/
const pond = z.string().min(1);
const page = z.string().min(1);
export const MCP_TOOL_INPUTS = {
list_ponds: z.object({}),
list_pages: z.object({ pond }),
read_page: z.object({ pond, page }),
search: z.object({
query: z.string().min(1),
pond: pond.optional(),
label: z.string().min(1).optional(),
}),
create_page: z.object({
pond,
title: z.string().min(1).max(200),
markdown: z.string().default(''),
}),
update_page: z
.object({
pond,
page,
title: z.string().min(1).max(200).optional(),
markdown: z.string().optional(),
})
.refine((input) => input.title !== undefined || input.markdown !== undefined, {
message: 'title or markdown required',
}),
add_comment: z.object({ pond, page, text: z.string().min(1) }),
list_labels: z.object({ pond }),
set_page_labels: z.object({ pond, page, labelIds: z.array(z.string()) }),
export_pond: z.object({ pond }),
} as const;
export type McpToolName = keyof typeof MCP_TOOL_INPUTS;
const pondProp = { type: 'string', description: 'Pond slug (from list_ponds)' } as const;
const pageProp = { type: 'string', description: 'Page slug (from list_pages or search)' } as const;
/** What tools/list advertises; must stay in sync with the Zod table above. */
export const MCP_TOOL_DEFINITIONS: {
name: McpToolName;
description: string;
inputSchema: object;
}[] = [
{
name: 'list_ponds',
description:
'List the ponds (workspaces) this token can reach through MCP. ' +
'Returns slugs — every other tool takes a pond slug.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'list_pages',
description: 'List the readable pages of a pond: slug, title, labels, timestamps.',
inputSchema: { type: 'object', properties: { pond: pondProp }, required: ['pond'] },
},
{
name: 'read_page',
description: 'Read one page as Markdown plus metadata (title, labels, timestamps).',
inputSchema: {
type: 'object',
properties: { pond: pondProp, page: pageProp },
required: ['pond', 'page'],
},
},
{
name: 'search',
description:
'Full-text search across the reachable ponds. Returns pond/page slugs and snippets ' +
'with matches wrapped in **…** — chain into read_page.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search terms' },
pond: { ...pondProp, description: 'Restrict to one pond (slug)' },
label: { type: 'string', description: 'Restrict to pages carrying this label id' },
},
required: ['query'],
},
},
{
name: 'create_page',
description:
'Create a page from a title and Markdown content (requires a write-scope token and ' +
'editor rights in the pond). Returns the created page including its slug.',
inputSchema: {
type: 'object',
properties: {
pond: pondProp,
title: { type: 'string', maxLength: 200 },
markdown: { type: 'string', description: 'Initial content as Markdown' },
},
required: ['pond', 'title'],
},
},
{
name: 'update_page',
description:
'Update a page: set a new title and/or REPLACE the whole content with the given ' +
'Markdown (write scope required). The change is applied through the live ' +
'collaborative document, so open editors converge; the previous state stays in the ' +
'version history.',
inputSchema: {
type: 'object',
properties: {
pond: pondProp,
page: pageProp,
title: { type: 'string', maxLength: 200 },
markdown: { type: 'string', description: 'Replacement content as Markdown' },
},
required: ['pond', 'page'],
},
},
{
name: 'add_comment',
description:
"Comment on a page (write scope; the pond's comment policy applies). Markdown is " +
'supported.',
inputSchema: {
type: 'object',
properties: {
pond: pondProp,
page: pageProp,
text: { type: 'string', description: 'Comment body (Markdown)' },
},
required: ['pond', 'page', 'text'],
},
},
{
name: 'list_labels',
description: "The pond's label tree (ids + names) — ids feed set_page_labels.",
inputSchema: { type: 'object', properties: { pond: pondProp }, required: ['pond'] },
},
{
name: 'set_page_labels',
description:
"Replace a page's labels with exactly the given label ids (write scope). An empty " +
'list removes all labels.',
inputSchema: {
type: 'object',
properties: {
pond: pondProp,
page: pageProp,
labelIds: {
type: 'array',
items: { type: 'string' },
description: 'Label ids from list_labels',
},
},
required: ['pond', 'page', 'labelIds'],
},
},
{
name: 'export_pond',
description:
'A download link for the whole pond as a Markdown ZIP. The link goes through the ' +
'public REST API, so the instance REST switch and the same token are needed to ' +
'fetch it.',
inputSchema: { type: 'object', properties: { pond: pondProp }, required: ['pond'] },
},
];