Built-in MCP endpoint (Streamable HTTP) on top of the public API (#105)
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

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
This commit is contained in:
Claude Fable 5 2026-07-12 11:36:02 +02:00
parent 52975bad0a
commit 04e21a0aac
22 changed files with 1122 additions and 30 deletions

View File

@ -18,6 +18,7 @@
"dependencies": { "dependencies": {
"@dorfteich/plugin-sdk": "workspace:*", "@dorfteich/plugin-sdk": "workspace:*",
"@dorfteich/shared": "workspace:*", "@dorfteich/shared": "workspace:*",
"@modelcontextprotocol/sdk": "^1.29.0",
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0", "@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",

View File

@ -19,6 +19,7 @@ import { LabelsModule } from './labels/labels.module';
import { LegalModule } from './legal/legal.module'; import { LegalModule } from './legal/legal.module';
import { LinksModule } from './links/links.module'; import { LinksModule } from './links/links.module';
import { MailModule } from './mail/mail.module'; import { MailModule } from './mail/mail.module';
import { McpModule } from './mcp/mcp.module';
import { MembersModule } from './members/members.module'; import { MembersModule } from './members/members.module';
import { PagesModule } from './pages/pages.module'; import { PagesModule } from './pages/pages.module';
import { PermissionsModule } from './permissions/permissions.module'; import { PermissionsModule } from './permissions/permissions.module';
@ -71,6 +72,7 @@ import { VersionsModule } from './versions/versions.module';
MembersModule, MembersModule,
PublicModule, PublicModule,
PublicApiModule, PublicApiModule,
McpModule,
ImportExportModule, ImportExportModule,
PluginsModule, PluginsModule,
AuthModule, AuthModule,

View File

@ -76,7 +76,7 @@ async function bootstrap(): Promise<void> {
// The public API (issue #104) lives at /api/public/v1 — its controllers // The public API (issue #104) lives at /api/public/v1 — its controllers
// declare the full path and are excluded from the SPA prefix. // declare the full path and are excluded from the SPA prefix.
app.setGlobalPrefix('api/v1', { app.setGlobalPrefix('api/v1', {
exclude: ['api/public/v1', 'api/public/v1/{*path}'], exclude: ['api/public/v1', 'api/public/v1/{*path}', 'api/mcp'],
}); });
app.enableShutdownHooks(); app.enableShutdownHooks();

View File

@ -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'] },
},
];

View File

@ -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<void> {
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<void> {
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<void> {
await this.methodNotAllowed(response);
}
}

View File

@ -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<string, string> = {};
const cookies: Record<string, string> = {};
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<McpClient> {
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<void> {
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<string> => {
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('<strong>Anmerkung</strong>');
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);
});
});

View File

@ -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 {}

View File

@ -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<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[] };
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<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}` }] };
}

View File

@ -152,7 +152,8 @@ export class PondsService {
input.sidebarSort !== undefined || input.sidebarSort !== undefined ||
input.fonts !== undefined || input.fonts !== undefined ||
input.commentPolicy !== undefined || input.commentPolicy !== undefined ||
input.apiEnabled !== undefined; input.apiEnabled !== undefined ||
input.mcpEnabled !== undefined;
const settings = !settingsChanged const settings = !settingsChanged
? undefined ? undefined
: { : {
@ -161,6 +162,7 @@ export class PondsService {
...(input.fonts !== undefined ? { fonts: input.fonts } : {}), ...(input.fonts !== undefined ? { fonts: input.fonts } : {}),
...(input.commentPolicy !== undefined ? { commentPolicy: input.commentPolicy } : {}), ...(input.commentPolicy !== undefined ? { commentPolicy: input.commentPolicy } : {}),
...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}), ...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}),
...(input.mcpEnabled !== undefined ? { mcpEnabled: input.mcpEnabled } : {}),
}; };
const updated = await this.prisma.pond.update({ const updated = await this.prisma.pond.update({
where: { id }, where: { id },

View File

@ -10,7 +10,7 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; 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 { ApiToken } from '@prisma/client';
import type { Response } from 'express'; import type { Response } from 'express';
@ -110,7 +110,9 @@ export class PublicApiGuard implements CanActivate {
}); });
if (!pond) throw new NotFoundException(); if (!pond) throw new NotFoundException();
const settings = pondSettingsSchema.safeParse(pond.settings ?? {}); 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)) { if (token.pondIds.length > 0 && !token.pondIds.includes(pond.id)) {
throw new NotFoundException(); throw new NotFoundException();
} }

View File

@ -4,6 +4,7 @@ import {
SEARCH_HIGHLIGHT_START, SEARCH_HIGHLIGHT_START,
editorSchema, editorSchema,
markdownToDoc, markdownToDoc,
pondFeatureEnabled,
pondSettingsSchema, pondSettingsSchema,
type CommentListFilter, type CommentListFilter,
type CreateCommentInput, type CreateCommentInput,
@ -21,6 +22,7 @@ import {
type PublicSearchResultView, type PublicSearchResultView,
type PublicUpdateLabelInput, type PublicUpdateLabelInput,
type PublicUpdatePageInput, type PublicUpdatePageInput,
type PondExposureFeature,
type PondView, type PondView,
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import { Node } from 'prosemirror-model'; import { Node } from 'prosemirror-model';
@ -74,13 +76,14 @@ export class PublicApiService {
}; };
} }
/** The API-enabled ponds visible to the token's user, within restriction. */ /** The exposed ponds visible to the token's user, within restriction. */
async listPonds(user: User, token: ApiToken): Promise<PublicPondView[]> { async listPonds(
const visible = await this.ponds.listVisible(user); user: User,
return visible token: ApiToken,
.filter((pond) => pond.settings.apiEnabled) feature: PondExposureFeature = 'api',
.filter((pond) => token.pondIds.length === 0 || token.pondIds.includes(pond.id)) ): Promise<PublicPondView[]> {
.map((pond) => this.pondView(pond)); const ponds = await this.listPondRowsExposed(user, token, feature);
return ponds.map((pond) => this.pondView(pond));
} }
async getPond(slug: string): Promise<PublicPondView> { async getPond(slug: string): Promise<PublicPondView> {
@ -176,8 +179,9 @@ export class PublicApiService {
user: User, user: User,
token: ApiToken, token: ApiToken,
query: PublicSearchQuery, query: PublicSearchQuery,
feature: PondExposureFeature = 'api',
): Promise<PublicSearchResultView[]> { ): Promise<PublicSearchResultView[]> {
const exposed = await this.exposedPondIds(user, token); const exposed = await this.exposedPondIds(user, token, feature);
let pondId: string | undefined; let pondId: string | undefined;
if (query.pond) { if (query.pond) {
const pond = await this.requirePond(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])); return new Map(labels.map((label) => [label.id, label.name]));
} }
private async exposedPondIds(user: User, token: ApiToken): Promise<Set<string>> { private async exposedPondIds(
const ponds = await this.listPondRowsExposed(user, token); user: User,
token: ApiToken,
feature: PondExposureFeature,
): Promise<Set<string>> {
const ponds = await this.listPondRowsExposed(user, token, feature);
return new Set(ponds.map((pond) => pond.id)); 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<PondView[]> {
const visible = await this.ponds.listVisible(user); const visible = await this.ponds.listVisible(user);
return visible return visible
.filter((pond) => pond.settings.apiEnabled) .filter((pond) => pondFeatureEnabled(pond.settings, feature))
.filter((pond) => token.pondIds.length === 0 || token.pondIds.includes(pond.id)); .filter((pond) => token.pondIds.length === 0 || token.pondIds.includes(pond.id));
} }

View File

@ -53,6 +53,9 @@ export const INSTANCE_SETTINGS = {
// /api/public/v1 route answers 404 while disabled. Individual ponds // /api/public/v1 route answers 404 while disabled. Individual ponds
// additionally opt in through their pond settings (`apiEnabled`). // additionally opt in through their pond settings (`apiEnabled`).
'api.enabled': z.boolean().default(false), '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 // Backup targets (ADR 0015, issue #103). The backup sidecar reads these
// rows directly (apps/backup settings.ts — keep the schemas in sync); the // rows directly (apps/backup settings.ts — keep the schemas in sync); the
// Nextcloud app password is NOT here, it lives in the secret store // Nextcloud app password is NOT here, it lives in the secret store

View File

@ -36,7 +36,7 @@ export async function createTestApp(
app.useBodyParser('json', { limit: '8mb' }); app.useBodyParser('json', { limit: '8mb' });
// Mirrors main.ts: the public API (issue #104) declares its full path. // Mirrors main.ts: the public API (issue #104) declares its full path.
app.setGlobalPrefix('api/v1', { 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(); await app.init();
return app; return app;

View File

@ -14,21 +14,23 @@ export function ApiOptInSetting({
pondId, pondId,
pondSlug, pondSlug,
value, value,
mcpValue,
}: { }: {
pondId: string; pondId: string;
pondSlug: string; pondSlug: string;
value: boolean; value: boolean;
mcpValue: boolean;
}): React.JSX.Element { }): React.JSX.Element {
const { t } = useTranslation('apiTokens'); const { t } = useTranslation('apiTokens');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [error, setError] = useState<unknown>(null); const [error, setError] = useState<unknown>(null);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const save = async (enabled: boolean): Promise<void> => { const save = async (patch: { apiEnabled?: boolean; mcpEnabled?: boolean }): Promise<void> => {
setError(null); setError(null);
setSaved(false); setSaved(false);
try { try {
await apiPatch(`/ponds/${pondId}`, { apiEnabled: enabled }); await apiPatch(`/ponds/${pondId}`, patch);
await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] }); await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] });
setSaved(true); setSaved(true);
} catch (err) { } catch (err) {
@ -44,11 +46,20 @@ export function ApiOptInSetting({
<input <input
type="checkbox" type="checkbox"
checked={value} checked={value}
onChange={(event) => void save(event.target.checked)} onChange={(event) => void save({ apiEnabled: event.target.checked })}
/> />
{t('pond.label')} {t('pond.label')}
</label> </label>
<p className="api-opt-in__hint">{t('pond.hint')}</p> <p className="api-opt-in__hint">{t('pond.hint')}</p>
<label className="api-opt-in__label">
<input
type="checkbox"
checked={mcpValue}
onChange={(event) => void save({ mcpEnabled: event.target.checked })}
/>
{t('pond.mcpLabel')}
</label>
<p className="api-opt-in__hint">{t('pond.mcpHint')}</p>
</div> </div>
); );
} }

View File

@ -21,6 +21,7 @@ interface InstanceSettings {
'quota.storageBytes': number; 'quota.storageBytes': number;
'quota.maxFileBytes': number; 'quota.maxFileBytes': number;
'api.enabled': boolean; 'api.enabled': boolean;
'mcp.enabled': boolean;
'upload.allowedExtensions': string[]; 'upload.allowedExtensions': string[];
'upload.svgPolicy': 'reject' | 'sanitize'; 'upload.svgPolicy': 'reject' | 'sanitize';
'legal.imprint': string; 'legal.imprint': string;
@ -195,11 +196,11 @@ function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): Re
const [error, setError] = useState<unknown>(null); const [error, setError] = useState<unknown>(null);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
async function save(enabled: boolean): Promise<void> { async function save(patch: Record<string, boolean>): Promise<void> {
setError(null); setError(null);
setSaved(false); setSaved(false);
try { try {
await apiPatch('/admin/settings', { 'api.enabled': enabled }); await apiPatch('/admin/settings', patch);
await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] }); await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
setSaved(true); setSaved(true);
} catch (err) { } catch (err) {
@ -216,11 +217,20 @@ function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): Re
<input <input
type="checkbox" type="checkbox"
checked={settings['api.enabled']} checked={settings['api.enabled']}
onChange={(event) => void save(event.target.checked)} onChange={(event) => void save({ 'api.enabled': event.target.checked })}
/> />
{t('admin.label')} {t('admin.label')}
</label> </label>
<p className="api-opt-in__hint">{t('admin.hint')}</p> <p className="api-opt-in__hint">{t('admin.hint')}</p>
<label className="api-opt-in__label">
<input
type="checkbox"
checked={settings['mcp.enabled']}
onChange={(event) => void save({ 'mcp.enabled': event.target.checked })}
/>
{t('admin.mcpLabel')}
</label>
<p className="api-opt-in__hint">{t('admin.mcpHint')}</p>
</section> </section>
); );
} }

View File

@ -111,6 +111,7 @@ export function PondSettingsPage(): React.JSX.Element {
pondId={pond.data.id} pondId={pond.data.id}
pondSlug={pondSlug} pondSlug={pondSlug}
value={pond.data.settings.apiEnabled} value={pond.data.settings.apiEnabled}
mcpValue={pond.data.settings.mcpEnabled}
/> />
</section> </section>
)} )}

View File

@ -137,9 +137,10 @@ bundle from Nextcloud by hand.
## Public REST API ## Public REST API
Scripts and integrations can talk to the instance through a Scripts and integrations can talk to the instance through a
token-authenticated API at `/api/public/v1` — off by default, enabled per token-authenticated API at `/api/public/v1`, and MCP clients (Claude Code
instance and per pond. Details, token walkthrough, and the OpenAPI and friends) through the built-in MCP endpoint at `/api/mcp` — both off by
document: [public-api.md](public-api.md). default, enabled per instance and per pond. Details, token walkthrough,
and the OpenAPI document: [public-api.md](public-api.md).
## Health & troubleshooting ## Health & troubleshooting

View File

@ -52,6 +52,46 @@ curl -H "Authorization: Bearer dt_pat_..." \
Deliberately not in v1 (stage 2): attachment upload, version endpoints, Deliberately not in v1 (stage 2): attachment upload, version endpoints,
webhooks. 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 ## Security notes
- Bearer tokens only — no cookies are involved, so there is no CSRF - Bearer tokens only — no cookies are involved, so there is no CSRF

View File

@ -39,13 +39,17 @@
"title": "Öffentliche API", "title": "Öffentliche API",
"label": "Diesen Teich über die öffentliche API freigeben", "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.", "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": { "admin": {
"title": "Öffentliche API", "title": "Öffentliche API",
"label": "Öffentliche REST-API aktivieren", "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", "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", "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."
} }
} }

View File

@ -39,13 +39,17 @@
"title": "Public API", "title": "Public API",
"label": "Expose this pond through the 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.", "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": { "admin": {
"title": "Public API", "title": "Public API",
"label": "Enable the public REST 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", "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", "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."
} }
} }

View File

@ -43,9 +43,19 @@ export const pondSettingsSchema = z.object({
* without it the pond and its content answer 404 through the API even * without it the pond and its content answer 404 through the API even
* for a token whose user could see them in the app. */ * for a token whose user could see them in the app. */
apiEnabled: z.boolean().default(false), 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<typeof pondSettingsSchema>; export type PondSettings = z.infer<typeof pondSettingsSchema>;
/** 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 export const pondNameSchema = z
.string() .string()
.trim() .trim()
@ -66,6 +76,7 @@ export const updatePondInputSchema = z
fonts: pondFontsSchema, fonts: pondFontsSchema,
commentPolicy: z.enum(COMMENT_POLICIES), commentPolicy: z.enum(COMMENT_POLICIES),
apiEnabled: z.boolean(), apiEnabled: z.boolean(),
mcpEnabled: z.boolean(),
}) })
.partial(); .partial();
export type UpdatePondInput = z.infer<typeof updatePondInputSchema>; export type UpdatePondInput = z.infer<typeof updatePondInputSchema>;

111
pnpm-lock.yaml generated
View File

@ -35,6 +35,9 @@ importers:
'@dorfteich/shared': '@dorfteich/shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared
'@modelcontextprotocol/sdk':
specifier: ^1.29.0
version: 1.29.0(zod@3.25.76)
'@nestjs/common': '@nestjs/common':
specifier: ^11.0.0 specifier: ^11.0.0
version: 11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2)
@ -1778,6 +1781,12 @@ packages:
y-protocols: ^1.0.6 y-protocols: ^1.0.6
yjs: ^13.6.8 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': '@hookform/resolvers@5.4.0':
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
peerDependencies: peerDependencies:
@ -1989,6 +1998,16 @@ packages:
'@mermaid-js/parser@1.2.0': '@mermaid-js/parser@1.2.0':
resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} 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': '@napi-rs/canvas-android-arm64@0.1.80':
resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==} resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
@ -3888,10 +3907,24 @@ packages:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'} 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: expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'} 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: express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
@ -4136,6 +4169,10 @@ packages:
help-me@5.0.0: help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} 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: html-encoding-sniffer@4.0.0:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -4213,6 +4250,10 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'} engines: {node: '>=12'}
ip-address@10.2.0:
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
engines: {node: '>= 12'}
ipaddr.js@1.9.1: ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@ -4389,6 +4430,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true hasBin: true
jose@6.2.3:
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
joycon@3.1.1: joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -4429,6 +4473,9 @@ packages:
json-schema-traverse@1.0.0: json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} 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: json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@ -4918,6 +4965,10 @@ packages:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'} 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: pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
@ -6196,6 +6247,11 @@ packages:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'} 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: zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
@ -7371,6 +7427,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- srvx - 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))': '@hookform/resolvers@5.4.0(react-hook-form@7.80.0(react@19.2.7))':
dependencies: dependencies:
'@standard-schema/utils': 0.3.0 '@standard-schema/utils': 0.3.0
@ -7583,6 +7643,28 @@ snapshots:
dependencies: dependencies:
'@chevrotain/types': 11.1.2 '@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': '@napi-rs/canvas-android-arm64@0.1.80':
optional: true optional: true
@ -8589,6 +8671,10 @@ snapshots:
optionalDependencies: optionalDependencies:
ajv: 8.18.0 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): ajv-keywords@3.5.2(ajv@6.15.0):
dependencies: dependencies:
ajv: 6.15.0 ajv: 6.15.0
@ -9683,8 +9769,19 @@ snapshots:
events@3.3.0: {} events@3.3.0: {}
eventsource-parser@3.1.0: {}
eventsource@3.0.7:
dependencies:
eventsource-parser: 3.1.0
expect-type@1.4.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: express@5.2.1:
dependencies: dependencies:
accepts: 2.0.0 accepts: 2.0.0
@ -9983,6 +10080,8 @@ snapshots:
help-me@5.0.0: {} help-me@5.0.0: {}
hono@4.12.29: {}
html-encoding-sniffer@4.0.0: html-encoding-sniffer@4.0.0:
dependencies: dependencies:
whatwg-encoding: 3.1.1 whatwg-encoding: 3.1.1
@ -10058,6 +10157,8 @@ snapshots:
internmap@2.0.3: {} internmap@2.0.3: {}
ip-address@10.2.0: {}
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
is-array-buffer@3.0.5: is-array-buffer@3.0.5:
@ -10224,6 +10325,8 @@ snapshots:
jiti@2.7.0: {} jiti@2.7.0: {}
jose@6.2.3: {}
joycon@3.1.1: {} joycon@3.1.1: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@ -10271,6 +10374,8 @@ snapshots:
json-schema-traverse@1.0.0: {} json-schema-traverse@1.0.0: {}
json-schema-typed@8.0.2: {}
json-stable-stringify-without-jsonify@1.0.1: {} json-stable-stringify-without-jsonify@1.0.1: {}
json5@2.2.3: {} json5@2.2.3: {}
@ -10745,6 +10850,8 @@ snapshots:
pirates@4.0.7: {} pirates@4.0.7: {}
pkce-challenge@5.0.1: {}
pkg-types@1.3.1: pkg-types@1.3.1:
dependencies: dependencies:
confbox: 0.1.8 confbox: 0.1.8
@ -12192,6 +12299,10 @@ snapshots:
compress-commons: 6.0.2 compress-commons: 6.0.2
readable-stream: 4.7.0 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@3.25.76: {}
zod@4.4.3: {} zod@4.4.3: {}