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[]; parent?: string | null; }; switch (name) { case 'list_ponds': return asJson(await this.publicApi.listPonds(user, token, 'mcp')); case 'list_pages': { await this.assertPondExposed(input.pond!, token); // The zod input already turned the `…_since` strings into Dates (#148). const since = args as { created_since?: Date; updated_since?: Date }; return asJson( await this.publicApi.listPages(user, input.pond!, { createdSince: since.created_since, updatedSince: since.updated_since, }), ); } case 'read_page': await this.assertPondExposed(input.pond!, token); await this.requirePage(user, input.pond!, input.page!, 'read'); return asJson(await this.publicApi.getPage(user, 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 ?? '', parent: input.parent ?? undefined, }), ); 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, parent: input.parent, }), ); 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}` }] }; }