From 04e21a0aac53243049c8f734d997b585e4e914e2 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 12 Jul 2026 11:36:02 +0200 Subject: [PATCH] Built-in MCP endpoint (Streamable HTTP) on top of the public API (#105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- apps/api/package.json | 1 + apps/api/src/app.module.ts | 2 + apps/api/src/main.ts | 2 +- apps/api/src/mcp/mcp-tools.ts | 172 +++++++++ apps/api/src/mcp/mcp.controller.ts | 97 +++++ apps/api/src/mcp/mcp.e2e.db.test.ts | 335 ++++++++++++++++++ apps/api/src/mcp/mcp.module.ts | 20 ++ apps/api/src/mcp/mcp.service.ts | 253 +++++++++++++ apps/api/src/ponds/ponds.service.ts | 4 +- apps/api/src/public-api/public-api.guard.ts | 6 +- apps/api/src/public-api/public-api.service.ts | 36 +- .../src/settings/instance-settings.service.ts | 3 + apps/api/src/testing/test-app.ts | 2 +- apps/web/src/api-tokens/ApiOptInSetting.tsx | 17 +- apps/web/src/pages/AdminSettingsPage.tsx | 16 +- apps/web/src/pages/PondSettingsPage.tsx | 1 + docs/self-hosting/README.md | 7 +- docs/self-hosting/public-api.md | 40 +++ packages/shared/i18n/de/apiTokens.json | 8 +- packages/shared/i18n/en/apiTokens.json | 8 +- packages/shared/src/ponds.ts | 11 + pnpm-lock.yaml | 111 ++++++ 22 files changed, 1122 insertions(+), 30 deletions(-) create mode 100644 apps/api/src/mcp/mcp-tools.ts create mode 100644 apps/api/src/mcp/mcp.controller.ts create mode 100644 apps/api/src/mcp/mcp.e2e.db.test.ts create mode 100644 apps/api/src/mcp/mcp.module.ts create mode 100644 apps/api/src/mcp/mcp.service.ts diff --git a/apps/api/package.json b/apps/api/package.json index 7aab0e7..989f75d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,6 +18,7 @@ "dependencies": { "@dorfteich/plugin-sdk": "workspace:*", "@dorfteich/shared": "workspace:*", + "@modelcontextprotocol/sdk": "^1.29.0", "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0", "@nestjs/platform-express": "^11.0.0", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index b6a6c83..dc890f5 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -19,6 +19,7 @@ import { LabelsModule } from './labels/labels.module'; import { LegalModule } from './legal/legal.module'; import { LinksModule } from './links/links.module'; import { MailModule } from './mail/mail.module'; +import { McpModule } from './mcp/mcp.module'; import { MembersModule } from './members/members.module'; import { PagesModule } from './pages/pages.module'; import { PermissionsModule } from './permissions/permissions.module'; @@ -71,6 +72,7 @@ import { VersionsModule } from './versions/versions.module'; MembersModule, PublicModule, PublicApiModule, + McpModule, ImportExportModule, PluginsModule, AuthModule, diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 0951022..6c1ae6b 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -76,7 +76,7 @@ async function bootstrap(): Promise { // The public API (issue #104) lives at /api/public/v1 — its controllers // declare the full path and are excluded from the SPA prefix. app.setGlobalPrefix('api/v1', { - exclude: ['api/public/v1', 'api/public/v1/{*path}'], + exclude: ['api/public/v1', 'api/public/v1/{*path}', 'api/mcp'], }); app.enableShutdownHooks(); diff --git a/apps/api/src/mcp/mcp-tools.ts b/apps/api/src/mcp/mcp-tools.ts new file mode 100644 index 0000000..a03844e --- /dev/null +++ b/apps/api/src/mcp/mcp-tools.ts @@ -0,0 +1,172 @@ +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'] }, + }, +]; diff --git a/apps/api/src/mcp/mcp.controller.ts b/apps/api/src/mcp/mcp.controller.ts new file mode 100644 index 0000000..ba40812 --- /dev/null +++ b/apps/api/src/mcp/mcp.controller.ts @@ -0,0 +1,97 @@ +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 { + 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 { + 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 { + await this.methodNotAllowed(response); + } +} diff --git a/apps/api/src/mcp/mcp.e2e.db.test.ts b/apps/api/src/mcp/mcp.e2e.db.test.ts new file mode 100644 index 0000000..1fd0676 --- /dev/null +++ b/apps/api/src/mcp/mcp.e2e.db.test.ts @@ -0,0 +1,335 @@ +import { INestApplication } from '@nestjs/common'; +import { Client as McpClient } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { PAGE_RESTORE_CHANNEL, type ApiTokenCreatedView } from '@dorfteich/shared'; +import { PrismaClient } from '@prisma/client'; +import { Client as PgClient } from 'pg'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { InstanceSettingsService } from '../settings/instance-settings.service'; +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +/** + * The built-in MCP endpoint end to end (issue #105), driven by the real + * MCP SDK client over Streamable HTTP against a listening api: initialize + * + tools/list, the page roundtrip (create → read → update via the + * collab-safe path → search), the independent instance/pond switches (404 + * semantics), scope enforcement, and label management. + */ +describe.skipIf(!hasTestDb)('mcp endpoint (e2e, issue #105)', () => { + let app: INestApplication; + let prisma: PrismaClient; + let baseUrl: string; + const suffix = uniqueSuffix(); + const password = 'mcp ist angebunden 1'; + const ids: Record = {}; + const cookies: Record = {}; + let pondId: string; + let pondSlug: string; + let writeToken: string; + let readToken: string; + + const restoreNotifies: { pageId: string }[] = []; + let listenClient: PgClient; + + const api = () => request(app.getHttpServer()); + + async function connect(token: string): Promise { + const client = new McpClient({ name: 'dorfteich-e2e', version: '0.0.0' }); + const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/api/mcp`), { + requestInit: { headers: { Authorization: `Bearer ${token}` } }, + }); + await client.connect(transport); + return client; + } + + function textOf(result: unknown): string { + const content = (result as { content: { type: string; text?: string }[] }).content; + return content.map((c) => c.text ?? '').join('\n'); + } + + async function makeUser(handle: string): Promise { + const users = app.get(UsersService); + const username = `mcp-${handle}-${suffix}`; + const user = await users.createUser({ + username, + email: `${username}@example.org`, + displayName: `Mcp ${handle}`, + password, + locale: 'en', + }); + await users.markEmailVerified(user.id); + ids[handle] = user.id; + cookies[handle] = sessionCookieOf( + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200), + ); + } + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + app = await createTestApp(); + // The MCP client needs a real HTTP server, not supertest's ephemeral one. + await app.listen(0); + baseUrl = await app.getUrl(); + baseUrl = baseUrl.replace('[::1]', '127.0.0.1').replace(/\/$/, ''); + + await makeUser('owner'); + await makeUser('siteadmin'); + await prisma.user.update({ where: { id: ids.siteadmin! }, data: { isSiteAdmin: true } }); + await api() + .put(`/api/v1/admin/quotas/user/${ids.owner!}/additional_ponds`) + .set('Cookie', cookies.siteadmin!) + .send({ value: 100 }) + .expect(200); + + const pond = await api() + .post('/api/v1/ponds') + .set('Cookie', cookies.owner!) + .send({ name: `MCP Pond ${suffix}` }) + .expect(201); + pondId = pond.body.id; + pondSlug = pond.body.slug; + + const mint = async (scope: 'read' | 'write'): Promise => { + const res = await api() + .post('/api/v1/users/me/api-tokens') + .set('Cookie', cookies.owner!) + .send({ name: `mcp-${scope}-${suffix}`, scope }) + .expect(201); + return (res.body as ApiTokenCreatedView).token; + }; + writeToken = await mint('write'); + readToken = await mint('read'); + + listenClient = new PgClient({ connectionString: process.env.TEST_DATABASE_URL }); + await listenClient.connect(); + listenClient.on('notification', (message) => { + if (message.channel === PAGE_RESTORE_CHANNEL && message.payload) { + restoreNotifies.push(JSON.parse(message.payload) as { pageId: string }); + } + }); + await listenClient.query(`LISTEN ${PAGE_RESTORE_CHANNEL}`); + }); + + afterAll(async () => { + await listenClient.end().catch(() => undefined); + const all = Object.values(ids); + await prisma.instanceSetting.deleteMany({ where: { key: { in: ['mcp.enabled'] } } }); + await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: all } } }); + await prisma.auditEntry.deleteMany({ where: { actorId: { in: all } } }); + await prisma.apiToken.deleteMany({ where: { userId: { in: all } } }); + const ponds = await prisma.pond.findMany({ + where: { ownerId: { in: all } }, + select: { id: true }, + }); + const pondIds = ponds.map((p) => p.id); + await prisma.comment.deleteMany({ where: { page: { pondId: { in: pondIds } } } }); + await prisma.pageVersion.deleteMany({ where: { page: { pondId: { in: pondIds } } } }); + await prisma.page.deleteMany({ where: { pondId: { in: pondIds } } }); + await prisma.label.deleteMany({ where: { pondId: { in: pondIds } } }); + await prisma.roleGrant.deleteMany({ where: { pondId: { in: pondIds } } }); + await prisma.pondUsage.deleteMany({ where: { pondId: { in: pondIds } } }); + await prisma.pond.deleteMany({ where: { id: { in: pondIds } } }); + await prisma.session.deleteMany({ where: { userId: { in: all } } }); + await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } }); + await prisma.rateLimit.deleteMany({}); + await prisma.user.deleteMany({ where: { id: { in: all } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('is invisible while the instance switch is off, independent of the REST switch', async () => { + // The REST switch being ON must not open MCP. + await app.get(InstanceSettingsService).set('api.enabled', true, ids.owner!); + const res = await fetch(`${baseUrl}/api/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Authorization: `Bearer ${writeToken}`, + }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'ping', id: 1 }), + }); + expect(res.status).toBe(404); + await app.get(InstanceSettingsService).set('api.enabled', false, ids.owner!); + await app.get(InstanceSettingsService).set('mcp.enabled', true, ids.owner!); + }); + + it('rejects anonymous and garbage tokens', async () => { + for (const headers of [{}, { Authorization: 'Bearer dt_pat_garbage' }] as Record< + string, + string + >[]) { + const res = await fetch(`${baseUrl}/api/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + ...headers, + }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'ping', id: 1 }), + }); + expect(res.status).toBe(401); + } + }); + + it('initializes and lists the tool set', async () => { + const client = await connect(writeToken); + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name).sort(); + expect(names).toEqual([ + 'add_comment', + 'create_page', + 'export_pond', + 'list_labels', + 'list_pages', + 'list_ponds', + 'read_page', + 'search', + 'set_page_labels', + 'update_page', + ]); + await client.close(); + }); + + it('hides ponds without the MCP opt-in, then exposes them', async () => { + const client = await connect(writeToken); + const empty = await client.callTool({ name: 'list_ponds', arguments: {} }); + expect(JSON.parse(textOf(empty))).toEqual([]); + const denied = await client.callTool({ + name: 'list_pages', + arguments: { pond: pondSlug }, + }); + expect(denied.isError).toBe(true); + expect(textOf(denied)).toContain('not_found'); + + await api() + .patch(`/api/v1/ponds/${pondId}`) + .set('Cookie', cookies.owner!) + .send({ mcpEnabled: true }) + .expect(200); + + const ponds = await client.callTool({ name: 'list_ponds', arguments: {} }); + expect(JSON.parse(textOf(ponds)).map((p: { slug: string }) => p.slug)).toEqual([pondSlug]); + await client.close(); + }); + + it('round-trips a page: create, read, update through the collab path, search', async () => { + const client = await connect(writeToken); + + const created = await client.callTool({ + name: 'create_page', + arguments: { + pond: pondSlug, + title: `MCP Page ${suffix}`, + markdown: `# Von MCP\n\nSeerose${suffix} im **Teich**.`, + }, + }); + expect(created.isError).toBeFalsy(); + const page = JSON.parse(textOf(created)) as { slug: string; markdown: string }; + expect(page.markdown).toContain(`Seerose${suffix}`); + + const read = await client.callTool({ + name: 'read_page', + arguments: { pond: pondSlug, page: page.slug }, + }); + expect(JSON.parse(textOf(read)).markdown).toContain('**Teich**'); + + restoreNotifies.length = 0; + const updated = await client.callTool({ + name: 'update_page', + arguments: { pond: pondSlug, page: page.slug, markdown: 'Ersetzt durch MCP.' }, + }); + expect(updated.isError).toBeFalsy(); + const pageRow = await prisma.page.findFirst({ where: { pondId, slug: page.slug } }); + const versions = await prisma.pageVersion.findMany({ + where: { pageId: pageRow!.id, trigger: 'MANUAL' }, + }); + expect(versions.some((v) => v.label === 'API update')).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(restoreNotifies.some((n) => n.pageId === pageRow!.id)).toBe(true); + + const found = await client.callTool({ + name: 'search', + arguments: { query: `seerose${suffix}` }, + }); + const hits = JSON.parse(textOf(found)) as { pageSlug: string }[]; + expect(hits.map((h) => h.pageSlug)).toContain(page.slug); + + const link = await client.callTool({ + name: 'export_pond', + arguments: { pond: pondSlug }, + }); + expect(JSON.parse(textOf(link)).url).toContain(`/api/public/v1/ponds/${pondSlug}/export`); + + await client.close(); + }); + + it('manages labels and comments through tools', async () => { + const client = await connect(writeToken); + const pageResult = await client.callTool({ + name: 'create_page', + arguments: { pond: pondSlug, title: `Labelled ${suffix}`, markdown: 'x' }, + }); + const page = JSON.parse(textOf(pageResult)) as { slug: string }; + + // Labels are Pond-Admin work — the owner is one. + const label = await api() + .post(`/api/v1/ponds/${pondId}/labels`) + .set('Cookie', cookies.owner!) + .send({ name: `mcp-label-${suffix}` }) + .expect(201); + const labelId = label.body.id as string; + + const tree = await client.callTool({ name: 'list_labels', arguments: { pond: pondSlug } }); + expect(textOf(tree)).toContain(`mcp-label-${suffix}`); + + const set = await client.callTool({ + name: 'set_page_labels', + arguments: { pond: pondSlug, page: page.slug, labelIds: [labelId] }, + }); + expect(JSON.parse(textOf(set)).map((l: { id: string }) => l.id)).toEqual([labelId]); + const cleared = await client.callTool({ + name: 'set_page_labels', + arguments: { pond: pondSlug, page: page.slug, labelIds: [] }, + }); + expect(JSON.parse(textOf(cleared))).toEqual([]); + + const comment = await client.callTool({ + name: 'add_comment', + arguments: { pond: pondSlug, page: page.slug, text: 'Eine **Anmerkung** via MCP' }, + }); + expect(JSON.parse(textOf(comment)).html).toContain('Anmerkung'); + await client.close(); + }); + + it('lets read tokens list/read/search but blocks writes with scope_required', async () => { + const client = await connect(readToken); + const ponds = await client.callTool({ name: 'list_ponds', arguments: {} }); + expect(JSON.parse(textOf(ponds)).length).toBe(1); + const pages = await client.callTool({ name: 'list_pages', arguments: { pond: pondSlug } }); + expect(JSON.parse(textOf(pages)).length).toBeGreaterThan(0); + + const denied = await client.callTool({ + name: 'create_page', + arguments: { pond: pondSlug, title: 'nope' }, + }); + expect(denied.isError).toBe(true); + expect(textOf(denied)).toContain('scope_required'); + await client.close(); + }); + + it('keeps the REST surface closed while only MCP is on', async () => { + await request(app.getHttpServer()) + .get('/api/public/v1/me') + .set('Authorization', `Bearer ${writeToken}`) + .expect(404); + }); +}); diff --git a/apps/api/src/mcp/mcp.module.ts b/apps/api/src/mcp/mcp.module.ts new file mode 100644 index 0000000..cf8af8b --- /dev/null +++ b/apps/api/src/mcp/mcp.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; + +import { LabelsModule } from '../labels/labels.module'; +import { PermissionsModule } from '../permissions/permissions.module'; +import { PublicApiModule } from '../public-api/public-api.module'; +import { RateLimitModule } from '../rate-limit/rate-limit.module'; +import { SettingsModule } from '../settings/settings.module'; +import { McpController } from './mcp.controller'; +import { McpService } from './mcp.service'; + +/** + * Built-in MCP endpoint (issue #105): tools are thin wrappers over the + * public-API services (#104), gated by their own instance + pond switches. + */ +@Module({ + imports: [PublicApiModule, PermissionsModule, LabelsModule, SettingsModule, RateLimitModule], + controllers: [McpController], + providers: [McpService], +}) +export class McpModule {} diff --git a/apps/api/src/mcp/mcp.service.ts b/apps/api/src/mcp/mcp.service.ts new file mode 100644 index 0000000..f0894e5 --- /dev/null +++ b/apps/api/src/mcp/mcp.service.ts @@ -0,0 +1,253 @@ +import { ForbiddenException, HttpException, Injectable, NotFoundException } from '@nestjs/common'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { pondFeatureEnabled, pondSettingsSchema } from '@dorfteich/shared'; +import { ApiToken, User } from '@prisma/client'; +import { z } from 'zod'; + +import { AppConfig } from '../config/app-config.service'; +import { LabelsService } from '../labels/labels.service'; +import { PermissionService } from '../permissions/permission.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { ApiTokensService } from '../public-api/api-tokens.service'; +import { PublicApiService } from '../public-api/public-api.service'; +import { MCP_TOOL_DEFINITIONS, MCP_TOOL_INPUTS, type McpToolName } from './mcp-tools'; + +type ToolResult = { content: { type: 'text'; text: string }[]; isError?: boolean }; + +/** + * The built-in MCP server (issue #105): thin tools over the public-API + * service (#104), authenticated per request with the same personal access + * tokens and gated by its own switches — instance `mcp.enabled` plus the + * per-pond `mcpEnabled` opt-in. One stateless server instance is built per + * request (the transport holds no session), so multiple api replicas stay + * trivial. + * + * Deliberately on the SDK's low-level Server API with the hand-written + * tool table (mcp-tools.ts): the typed `registerTool` helpers sent + * TypeScript's inference out of memory in a program this size, and manual + * Zod validation keeps the wire behavior explicit anyway. + * + * Permission model: a tool call acts AS the token's user. Pond/page checks + * mirror the REST guard exactly (opt-in + restriction → 404 semantics, + * write-on-readable → 403, scopes) — errors surface as MCP tool errors + * carrying the api error code. + */ +@Injectable() +export class McpService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionService, + private readonly publicApi: PublicApiService, + private readonly labels: LabelsService, + private readonly tokens: ApiTokensService, + private readonly config: AppConfig, + ) {} + + buildServer(user: User, token: ApiToken): Server { + const server = new Server( + { name: 'dorfteich', version: this.config.env.APP_VERSION }, + { capabilities: { tools: {} } }, + ); + + server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: MCP_TOOL_DEFINITIONS, + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const name = request.params.name as McpToolName; + const schema = MCP_TOOL_INPUTS[name]; + if (!schema) { + return toolError(`unknown tool: ${String(request.params.name)}`); + } + const parsed = schema.safeParse(request.params.arguments ?? {}); + if (!parsed.success) { + return toolError( + `invalid arguments: ${parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'} ${i.message}`).join('; ')}`, + ); + } + try { + return await this.call(user, token, name, parsed.data as never); + } catch (error) { + const code = + error instanceof HttpException + ? ((error.getResponse() as { code?: string }).code ?? `http_${error.getStatus()}`) + : 'internal_error'; + return toolError(code); + } + }); + + return server; + + // Note: MCP resources (dorfteich://pond/page) are the documented + // stage-2 stretch goal — tools cover every current client flow. + } + + private async call( + user: User, + token: ApiToken, + name: Name, + args: z.infer<(typeof MCP_TOOL_INPUTS)[Name]>, + ): Promise { + const input = args as Record & { labelIds?: string[] }; + switch (name) { + case 'list_ponds': + return asJson(await this.publicApi.listPonds(user, token, 'mcp')); + + case 'list_pages': + await this.assertPondExposed(input.pond!, token); + return asJson(await this.publicApi.listPages(user, input.pond!)); + + case 'read_page': + await this.assertPondExposed(input.pond!, token); + await this.requirePage(user, input.pond!, input.page!, 'read'); + return asJson(await this.publicApi.getPage(input.pond!, input.page!)); + + case 'search': { + if (input.pond) await this.assertPondExposed(input.pond, token); + return asJson( + await this.publicApi.searchPages( + user, + token, + { q: input.query!, pond: input.pond, label: input.label }, + 'mcp', + ), + ); + } + + case 'create_page': + this.requireWriteScope(token); + await this.assertPondExposed(input.pond!, token); + await this.requirePondRole(user, input.pond!, 'editor'); + return asJson( + await this.publicApi.createPage(user, token, input.pond!, { + title: input.title!, + markdown: input.markdown ?? '', + }), + ); + + case 'update_page': + this.requireWriteScope(token); + await this.assertPondExposed(input.pond!, token); + await this.requirePage(user, input.pond!, input.page!, 'write'); + return asJson( + await this.publicApi.updatePage(user, token, input.pond!, input.page!, { + title: input.title, + markdown: input.markdown, + }), + ); + + case 'add_comment': + this.requireWriteScope(token); + await this.assertPondExposed(input.pond!, token); + await this.requirePage(user, input.pond!, input.page!, 'read'); + return asJson( + await this.publicApi.createComment(user, token, input.pond!, input.page!, { + body: input.text!, + }), + ); + + case 'list_labels': + await this.assertPondExposed(input.pond!, token); + await this.requirePondRole(user, input.pond!, 'reader'); + return asJson(await this.publicApi.listLabels(user, input.pond!)); + + case 'set_page_labels': { + this.requireWriteScope(token); + await this.assertPondExposed(input.pond!, token); + const pageRow = await this.requirePage(user, input.pond!, input.page!, 'write'); + const wantedIds = input.labelIds ?? []; + const current = await this.labels.pageLabels(user, pageRow.id); + const wanted = new Set(wantedIds); + for (const label of current) { + if (!wanted.has(label.id)) await this.labels.unassign(user, pageRow.id, label.id); + } + const have = new Set(current.map((label) => label.id)); + for (const labelId of wantedIds) { + if (!have.has(labelId)) { + await this.publicApi.assignLabel(user, token, input.pond!, input.page!, labelId); + } + } + return asJson(await this.labels.pageLabels(user, pageRow.id)); + } + + case 'export_pond': { + await this.assertPondExposed(input.pond!, token); + await this.requirePondRole(user, input.pond!, 'reader'); + const base = this.config.env.APP_BASE_URL.replace(/\/+$/, ''); + return asJson({ + url: `${base}/api/public/v1/ponds/${encodeURIComponent(input.pond!)}/export/markdown`, + hint: 'GET with the same Authorization: Bearer header; responds with a ZIP stream.', + }); + } + } + } + + private requireWriteScope(token: ApiToken): void { + if (this.tokens.scopeOf(token) !== 'write') { + throw new ForbiddenException({ code: 'scope_required' }); + } + } + + /** Mirror of the REST guard with the MCP flag: opted in via `mcpEnabled` + * and within the token's pond restriction, else 404 semantics. */ + private async assertPondExposed(slug: string, token: ApiToken): Promise { + const pond = await this.prisma.pond.findFirst({ + where: { slug, deletedAt: null }, + select: { id: true, settings: true }, + }); + if (!pond) throw new NotFoundException({ code: 'not_found' }); + const settings = pondSettingsSchema.safeParse(pond.settings ?? {}); + if (!settings.success || !pondFeatureEnabled(settings.data, 'mcp')) { + throw new NotFoundException({ code: 'not_found' }); + } + if (token.pondIds.length > 0 && !token.pondIds.includes(pond.id)) { + throw new NotFoundException({ code: 'not_found' }); + } + } + + private async requirePondRole( + user: User, + slug: string, + role: 'reader' | 'editor', + ): Promise { + const pond = await this.prisma.pond.findFirst({ + where: { slug, deletedAt: null }, + select: { id: true }, + }); + if (!pond) throw new NotFoundException({ code: 'not_found' }); + if (await this.permissions.hasPondRole(user, pond.id, role)) return; + if (role !== 'reader' && (await this.permissions.canSeePond(user, pond.id))) { + throw new ForbiddenException({ code: 'forbidden' }); + } + throw new NotFoundException({ code: 'not_found' }); + } + + private async requirePage( + user: User, + pondSlug: string, + pageSlug: string, + action: 'read' | 'write', + ): Promise<{ id: string; pondId: string }> { + const page = await this.prisma.page.findFirst({ + where: { slug: pageSlug, deletedAt: null, pond: { slug: pondSlug, deletedAt: null } }, + select: { id: true, pondId: true }, + }); + if (!page) throw new NotFoundException({ code: 'not_found' }); + if (!(await this.permissions.canAccessPage(user, page, 'read'))) { + throw new NotFoundException({ code: 'not_found' }); + } + if (action === 'write' && !(await this.permissions.canAccessPage(user, page, 'write'))) { + throw new ForbiddenException({ code: 'forbidden' }); + } + return page; + } +} + +function asJson(value: unknown): ToolResult { + return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] }; +} + +function toolError(code: string): ToolResult { + return { isError: true, content: [{ type: 'text', text: `Error: ${code}` }] }; +} diff --git a/apps/api/src/ponds/ponds.service.ts b/apps/api/src/ponds/ponds.service.ts index 280d05c..ea27083 100644 --- a/apps/api/src/ponds/ponds.service.ts +++ b/apps/api/src/ponds/ponds.service.ts @@ -152,7 +152,8 @@ export class PondsService { input.sidebarSort !== undefined || input.fonts !== undefined || input.commentPolicy !== undefined || - input.apiEnabled !== undefined; + input.apiEnabled !== undefined || + input.mcpEnabled !== undefined; const settings = !settingsChanged ? undefined : { @@ -161,6 +162,7 @@ export class PondsService { ...(input.fonts !== undefined ? { fonts: input.fonts } : {}), ...(input.commentPolicy !== undefined ? { commentPolicy: input.commentPolicy } : {}), ...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}), + ...(input.mcpEnabled !== undefined ? { mcpEnabled: input.mcpEnabled } : {}), }; const updated = await this.prisma.pond.update({ where: { id }, diff --git a/apps/api/src/public-api/public-api.guard.ts b/apps/api/src/public-api/public-api.guard.ts index fc4f9ff..8261949 100644 --- a/apps/api/src/public-api/public-api.guard.ts +++ b/apps/api/src/public-api/public-api.guard.ts @@ -10,7 +10,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { pondSettingsSchema, type ApiTokenScope } from '@dorfteich/shared'; +import { pondFeatureEnabled, pondSettingsSchema, type ApiTokenScope } from '@dorfteich/shared'; import type { ApiToken } from '@prisma/client'; import type { Response } from 'express'; @@ -110,7 +110,9 @@ export class PublicApiGuard implements CanActivate { }); if (!pond) throw new NotFoundException(); const settings = pondSettingsSchema.safeParse(pond.settings ?? {}); - if (!settings.success || !settings.data.apiEnabled) throw new NotFoundException(); + if (!settings.success || !pondFeatureEnabled(settings.data, 'api')) { + throw new NotFoundException(); + } if (token.pondIds.length > 0 && !token.pondIds.includes(pond.id)) { throw new NotFoundException(); } diff --git a/apps/api/src/public-api/public-api.service.ts b/apps/api/src/public-api/public-api.service.ts index c1d5914..dd4b893 100644 --- a/apps/api/src/public-api/public-api.service.ts +++ b/apps/api/src/public-api/public-api.service.ts @@ -4,6 +4,7 @@ import { SEARCH_HIGHLIGHT_START, editorSchema, markdownToDoc, + pondFeatureEnabled, pondSettingsSchema, type CommentListFilter, type CreateCommentInput, @@ -21,6 +22,7 @@ import { type PublicSearchResultView, type PublicUpdateLabelInput, type PublicUpdatePageInput, + type PondExposureFeature, type PondView, } from '@dorfteich/shared'; import { Node } from 'prosemirror-model'; @@ -74,13 +76,14 @@ export class PublicApiService { }; } - /** The API-enabled ponds visible to the token's user, within restriction. */ - async listPonds(user: User, token: ApiToken): Promise { - const visible = await this.ponds.listVisible(user); - return visible - .filter((pond) => pond.settings.apiEnabled) - .filter((pond) => token.pondIds.length === 0 || token.pondIds.includes(pond.id)) - .map((pond) => this.pondView(pond)); + /** The exposed ponds visible to the token's user, within restriction. */ + async listPonds( + user: User, + token: ApiToken, + feature: PondExposureFeature = 'api', + ): Promise { + const ponds = await this.listPondRowsExposed(user, token, feature); + return ponds.map((pond) => this.pondView(pond)); } async getPond(slug: string): Promise { @@ -176,8 +179,9 @@ export class PublicApiService { user: User, token: ApiToken, query: PublicSearchQuery, + feature: PondExposureFeature = 'api', ): Promise { - const exposed = await this.exposedPondIds(user, token); + const exposed = await this.exposedPondIds(user, token, feature); let pondId: string | undefined; if (query.pond) { const pond = await this.requirePond(query.pond); @@ -371,15 +375,23 @@ export class PublicApiService { return new Map(labels.map((label) => [label.id, label.name])); } - private async exposedPondIds(user: User, token: ApiToken): Promise> { - const ponds = await this.listPondRowsExposed(user, token); + private async exposedPondIds( + user: User, + token: ApiToken, + feature: PondExposureFeature, + ): Promise> { + const ponds = await this.listPondRowsExposed(user, token, feature); return new Set(ponds.map((pond) => pond.id)); } - private async listPondRowsExposed(user: User, token: ApiToken): Promise<{ id: string }[]> { + private async listPondRowsExposed( + user: User, + token: ApiToken, + feature: PondExposureFeature, + ): Promise { const visible = await this.ponds.listVisible(user); return visible - .filter((pond) => pond.settings.apiEnabled) + .filter((pond) => pondFeatureEnabled(pond.settings, feature)) .filter((pond) => token.pondIds.length === 0 || token.pondIds.includes(pond.id)); } diff --git a/apps/api/src/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts index 394204f..5b4d8d8 100644 --- a/apps/api/src/settings/instance-settings.service.ts +++ b/apps/api/src/settings/instance-settings.service.ts @@ -53,6 +53,9 @@ export const INSTANCE_SETTINGS = { // /api/public/v1 route answers 404 while disabled. Individual ponds // additionally opt in through their pond settings (`apiEnabled`). 'api.enabled': z.boolean().default(false), + // Built-in MCP endpoint master switch (issue #105, default off) — + // independent of the REST switch; ponds opt in via `mcpEnabled`. + 'mcp.enabled': z.boolean().default(false), // Backup targets (ADR 0015, issue #103). The backup sidecar reads these // rows directly (apps/backup settings.ts — keep the schemas in sync); the // Nextcloud app password is NOT here, it lives in the secret store diff --git a/apps/api/src/testing/test-app.ts b/apps/api/src/testing/test-app.ts index 184d940..71c219f 100644 --- a/apps/api/src/testing/test-app.ts +++ b/apps/api/src/testing/test-app.ts @@ -36,7 +36,7 @@ export async function createTestApp( app.useBodyParser('json', { limit: '8mb' }); // Mirrors main.ts: the public API (issue #104) declares its full path. app.setGlobalPrefix('api/v1', { - exclude: ['api/public/v1', 'api/public/v1/{*path}'], + exclude: ['api/public/v1', 'api/public/v1/{*path}', 'api/mcp'], }); await app.init(); return app; diff --git a/apps/web/src/api-tokens/ApiOptInSetting.tsx b/apps/web/src/api-tokens/ApiOptInSetting.tsx index 5a34108..e44f882 100644 --- a/apps/web/src/api-tokens/ApiOptInSetting.tsx +++ b/apps/web/src/api-tokens/ApiOptInSetting.tsx @@ -14,21 +14,23 @@ export function ApiOptInSetting({ pondId, pondSlug, value, + mcpValue, }: { pondId: string; pondSlug: string; value: boolean; + mcpValue: boolean; }): React.JSX.Element { const { t } = useTranslation('apiTokens'); const queryClient = useQueryClient(); const [error, setError] = useState(null); const [saved, setSaved] = useState(false); - const save = async (enabled: boolean): Promise => { + const save = async (patch: { apiEnabled?: boolean; mcpEnabled?: boolean }): Promise => { setError(null); setSaved(false); try { - await apiPatch(`/ponds/${pondId}`, { apiEnabled: enabled }); + await apiPatch(`/ponds/${pondId}`, patch); await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] }); setSaved(true); } catch (err) { @@ -44,11 +46,20 @@ export function ApiOptInSetting({ void save(event.target.checked)} + onChange={(event) => void save({ apiEnabled: event.target.checked })} /> {t('pond.label')}

{t('pond.hint')}

+ +

{t('pond.mcpHint')}

); } diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx index 7ffd275..0f40fbf 100644 --- a/apps/web/src/pages/AdminSettingsPage.tsx +++ b/apps/web/src/pages/AdminSettingsPage.tsx @@ -21,6 +21,7 @@ interface InstanceSettings { 'quota.storageBytes': number; 'quota.maxFileBytes': number; 'api.enabled': boolean; + 'mcp.enabled': boolean; 'upload.allowedExtensions': string[]; 'upload.svgPolicy': 'reject' | 'sanitize'; 'legal.imprint': string; @@ -195,11 +196,11 @@ function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): Re const [error, setError] = useState(null); const [saved, setSaved] = useState(false); - async function save(enabled: boolean): Promise { + async function save(patch: Record): Promise { setError(null); setSaved(false); try { - await apiPatch('/admin/settings', { 'api.enabled': enabled }); + await apiPatch('/admin/settings', patch); await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] }); setSaved(true); } catch (err) { @@ -216,11 +217,20 @@ function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): Re void save(event.target.checked)} + onChange={(event) => void save({ 'api.enabled': event.target.checked })} /> {t('admin.label')}

{t('admin.hint')}

+ +

{t('admin.mcpHint')}

); } diff --git a/apps/web/src/pages/PondSettingsPage.tsx b/apps/web/src/pages/PondSettingsPage.tsx index cd8ef34..d467236 100644 --- a/apps/web/src/pages/PondSettingsPage.tsx +++ b/apps/web/src/pages/PondSettingsPage.tsx @@ -111,6 +111,7 @@ export function PondSettingsPage(): React.JSX.Element { pondId={pond.data.id} pondSlug={pondSlug} value={pond.data.settings.apiEnabled} + mcpValue={pond.data.settings.mcpEnabled} /> )} diff --git a/docs/self-hosting/README.md b/docs/self-hosting/README.md index 99abe27..f90c929 100644 --- a/docs/self-hosting/README.md +++ b/docs/self-hosting/README.md @@ -137,9 +137,10 @@ bundle from Nextcloud by hand. ## Public REST API Scripts and integrations can talk to the instance through a -token-authenticated API at `/api/public/v1` — off by default, enabled per -instance and per pond. Details, token walkthrough, and the OpenAPI -document: [public-api.md](public-api.md). +token-authenticated API at `/api/public/v1`, and MCP clients (Claude Code +and friends) through the built-in MCP endpoint at `/api/mcp` — both off by +default, enabled per instance and per pond. Details, token walkthrough, +and the OpenAPI document: [public-api.md](public-api.md). ## Health & troubleshooting diff --git a/docs/self-hosting/public-api.md b/docs/self-hosting/public-api.md index 2bd0e7b..5089bef 100644 --- a/docs/self-hosting/public-api.md +++ b/docs/self-hosting/public-api.md @@ -52,6 +52,46 @@ curl -H "Authorization: Bearer dt_pat_..." \ Deliberately not in v1 (stage 2): attachment upload, version endpoints, webhooks. +## Connect Claude Code / MCP clients + +The instance ships its own MCP endpoint (Streamable HTTP) at `/api/mcp` — +no extra process. It has **its own switches**, independent of the REST +API: the instance switch under _Admin → Settings → Public API_ ("Enable +the built-in MCP endpoint") and a per-pond opt-in in the pond settings. +Authentication uses the same personal access tokens. + +Claude Code (or any Streamable-HTTP client): + +```sh +claude mcp add --transport http dorfteich https://your-instance.example/api/mcp \ + --header "Authorization: Bearer dt_pat_..." +``` + +Stdio-only clients bridge with `mcp-remote`: + +```json +{ + "mcpServers": { + "dorfteich": { + "command": "npx", + "args": [ + "mcp-remote", + "https://your-instance.example/api/mcp", + "--header", + "Authorization: Bearer dt_pat_..." + ] + } + } +} +``` + +Tools: `list_ponds`, `list_pages`, `read_page`, `search`, `create_page`, +`update_page` (replace semantics, collab-safe like the REST PATCH), +`add_comment`, `list_labels`, `set_page_labels`, `export_pond`. A tool +call acts as the token's user; write tools need the `write` scope, and +ponds without the MCP opt-in stay invisible (404 semantics). The endpoint +is stateless — no sessions to manage, safe behind load balancers. + ## Security notes - Bearer tokens only — no cookies are involved, so there is no CSRF diff --git a/packages/shared/i18n/de/apiTokens.json b/packages/shared/i18n/de/apiTokens.json index dd520e5..cf2b0d6 100644 --- a/packages/shared/i18n/de/apiTokens.json +++ b/packages/shared/i18n/de/apiTokens.json @@ -39,13 +39,17 @@ "title": "Öffentliche API", "label": "Diesen Teich über die öffentliche API freigeben", "hint": "Standardmäßig aus. Wenn aktiviert, erreichen Nutzer diesen Teich mit ihren API-Tokens — mit genau den Berechtigungen, die sie hier ohnehin haben. Der instanzweite API-Schalter muss ebenfalls an sein.", - "saved": "Gespeichert." + "saved": "Gespeichert.", + "mcpLabel": "Diesen Teich für KI-Assistenten freigeben (MCP)", + "mcpHint": "Standardmäßig aus und unabhängig vom REST-Schalter. Wenn aktiviert, erreichen MCP-Clients wie Claude Code diesen Teich mit einem API-Token — wieder mit genau den Berechtigungen des Nutzers. Der instanzweite MCP-Schalter muss ebenfalls an sein." }, "admin": { "title": "Öffentliche API", "label": "Öffentliche REST-API aktivieren", "hint": "Hauptschalter (standardmäßig aus). Nutzer erstellen dann Personal-Access-Tokens in ihren Einstellungen; jeder Teich gibt sich zusätzlich über seine Teich-Einstellungen frei. Dokumentation: /api/public/v1/openapi.json", "save": "Speichern", - "saved": "Gespeichert." + "saved": "Gespeichert.", + "mcpLabel": "Eingebauten MCP-Endpoint aktivieren", + "mcpHint": "Hauptschalter (standardmäßig aus), unabhängig von der REST-API. MCP-Clients verbinden sich mit einem API-Token auf /api/mcp; jeder Teich gibt sich zusätzlich über seine Teich-Einstellungen frei. Siehe docs/self-hosting/public-api.md." } } diff --git a/packages/shared/i18n/en/apiTokens.json b/packages/shared/i18n/en/apiTokens.json index 6ad8296..2b1fc4a 100644 --- a/packages/shared/i18n/en/apiTokens.json +++ b/packages/shared/i18n/en/apiTokens.json @@ -39,13 +39,17 @@ "title": "Public API", "label": "Expose this pond through the public API", "hint": "Off by default. When enabled, users can reach this pond with their API tokens — with exactly the permissions they have here anyway. The instance-wide API switch must also be on.", - "saved": "Saved." + "saved": "Saved.", + "mcpLabel": "Expose this pond to AI assistants (MCP)", + "mcpHint": "Off by default and independent of the REST toggle. When enabled, MCP clients such as Claude Code can reach this pond with an API token — again with exactly the user's permissions. The instance-wide MCP switch must also be on." }, "admin": { "title": "Public API", "label": "Enable the public REST API", "hint": "Master switch (default off). Users then create personal access tokens in their settings; each pond additionally opts in via its pond settings. Documentation: /api/public/v1/openapi.json", "save": "Save", - "saved": "Saved." + "saved": "Saved.", + "mcpLabel": "Enable the built-in MCP endpoint", + "mcpHint": "Master switch (default off), independent of the REST API. MCP clients connect to /api/mcp with an API token; each pond additionally opts in via its pond settings. See docs/self-hosting/public-api.md." } } diff --git a/packages/shared/src/ponds.ts b/packages/shared/src/ponds.ts index 3a4ec1d..78f6c0d 100644 --- a/packages/shared/src/ponds.ts +++ b/packages/shared/src/ponds.ts @@ -43,9 +43,19 @@ export const pondSettingsSchema = z.object({ * without it the pond and its content answer 404 through the API even * for a token whose user could see them in the app. */ apiEnabled: z.boolean().default(false), + /** Per-pond opt-in to the built-in MCP endpoint (issue #105, default + * off) — independent of the REST opt-in. */ + mcpEnabled: z.boolean().default(false), }); export type PondSettings = z.infer; +/** The machine-access surfaces a pond can opt into (issues #104/#105). */ +export type PondExposureFeature = 'api' | 'mcp'; + +export function pondFeatureEnabled(settings: PondSettings, feature: PondExposureFeature): boolean { + return feature === 'api' ? settings.apiEnabled : settings.mcpEnabled; +} + export const pondNameSchema = z .string() .trim() @@ -66,6 +76,7 @@ export const updatePondInputSchema = z fonts: pondFontsSchema, commentPolicy: z.enum(COMMENT_POLICIES), apiEnabled: z.boolean(), + mcpEnabled: z.boolean(), }) .partial(); export type UpdatePondInput = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4a991d..205e6dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@dorfteich/shared': specifier: workspace:* version: link:../../packages/shared + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@3.25.76) '@nestjs/common': specifier: ^11.0.0 version: 11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -1778,6 +1781,12 @@ packages: y-protocols: ^1.0.6 yjs: ^13.6.8 + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@hookform/resolvers@5.4.0': resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} peerDependencies: @@ -1989,6 +1998,16 @@ packages: '@mermaid-js/parser@1.2.0': resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/canvas-android-arm64@0.1.80': resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==} engines: {node: '>= 10'} @@ -3888,10 +3907,24 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -4136,6 +4169,10 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + hono@4.12.29: + resolution: {integrity: sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==} + engines: {node: '>=16.9.0'} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -4213,6 +4250,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -4389,6 +4430,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -4429,6 +4473,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -4918,6 +4965,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -6196,6 +6247,11 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -7371,6 +7427,10 @@ snapshots: transitivePeerDependencies: - srvx + '@hono/node-server@1.19.14(hono@4.12.29)': + dependencies: + hono: 4.12.29 + '@hookform/resolvers@5.4.0(react-hook-form@7.80.0(react@19.2.7))': dependencies: '@standard-schema/utils': 0.3.0 @@ -7583,6 +7643,28 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.29) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.29 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + '@napi-rs/canvas-android-arm64@0.1.80': optional: true @@ -8589,6 +8671,10 @@ snapshots: optionalDependencies: ajv: 8.18.0 + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv-keywords@3.5.2(ajv@6.15.0): dependencies: ajv: 6.15.0 @@ -9683,8 +9769,19 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + expect-type@1.4.0: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + express@5.2.1: dependencies: accepts: 2.0.0 @@ -9983,6 +10080,8 @@ snapshots: help-me@5.0.0: {} + hono@4.12.29: {} + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -10058,6 +10157,8 @@ snapshots: internmap@2.0.3: {} + ip-address@10.2.0: {} + ipaddr.js@1.9.1: {} is-array-buffer@3.0.5: @@ -10224,6 +10325,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.3: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -10271,6 +10374,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -10745,6 +10850,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -12192,6 +12299,10 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.25.76: {} zod@4.4.3: {}