dorfteich/apps/api/src/mcp/mcp.service.ts
Claude Fable 5 d73b120d06 #148: Seitenlisten filtern nach createdSince/updatedSince
Neues pageListQuerySchema (ISO 8601, Kulanz für Datum ohne Zeit),
Query-Parameter auf interner und Public-API-Seitenliste, Prisma-where
mit gte; neue Indizes (pondId, createdAt)/(pondId, updatedAt) als
Migration. OpenAPI-Parameter, MCP-Parität (list_pages
created_since/updated_since), Doku (api-guide, mcp-guide,
public-api.md), DB-Test inkl. 400 bei ungültigem Datum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-20 00:49:52 +02:00

267 lines
10 KiB
TypeScript

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<Name extends McpToolName>(
user: User,
token: ApiToken,
name: Name,
args: z.infer<(typeof MCP_TOOL_INPUTS)[Name]>,
): Promise<ToolResult> {
const input = args as Record<string, string> & {
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<void> {
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<void> {
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}` }] };
}