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
267 lines
10 KiB
TypeScript
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}` }] };
|
|
}
|