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
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:
parent
52975bad0a
commit
04e21a0aac
@ -18,6 +18,7 @@
|
||||
"dependencies": {
|
||||
"@dorfteich/plugin-sdk": "workspace:*",
|
||||
"@dorfteich/shared": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
|
||||
@ -19,6 +19,7 @@ import { LabelsModule } from './labels/labels.module';
|
||||
import { LegalModule } from './legal/legal.module';
|
||||
import { LinksModule } from './links/links.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
import { McpModule } from './mcp/mcp.module';
|
||||
import { MembersModule } from './members/members.module';
|
||||
import { PagesModule } from './pages/pages.module';
|
||||
import { PermissionsModule } from './permissions/permissions.module';
|
||||
@ -71,6 +72,7 @@ import { VersionsModule } from './versions/versions.module';
|
||||
MembersModule,
|
||||
PublicModule,
|
||||
PublicApiModule,
|
||||
McpModule,
|
||||
ImportExportModule,
|
||||
PluginsModule,
|
||||
AuthModule,
|
||||
|
||||
@ -76,7 +76,7 @@ async function bootstrap(): Promise<void> {
|
||||
// The public API (issue #104) lives at /api/public/v1 — its controllers
|
||||
// declare the full path and are excluded from the SPA prefix.
|
||||
app.setGlobalPrefix('api/v1', {
|
||||
exclude: ['api/public/v1', 'api/public/v1/{*path}'],
|
||||
exclude: ['api/public/v1', 'api/public/v1/{*path}', 'api/mcp'],
|
||||
});
|
||||
app.enableShutdownHooks();
|
||||
|
||||
|
||||
172
apps/api/src/mcp/mcp-tools.ts
Normal file
172
apps/api/src/mcp/mcp-tools.ts
Normal 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'] },
|
||||
},
|
||||
];
|
||||
97
apps/api/src/mcp/mcp.controller.ts
Normal file
97
apps/api/src/mcp/mcp.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
335
apps/api/src/mcp/mcp.e2e.db.test.ts
Normal file
335
apps/api/src/mcp/mcp.e2e.db.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
20
apps/api/src/mcp/mcp.module.ts
Normal file
20
apps/api/src/mcp/mcp.module.ts
Normal 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 {}
|
||||
253
apps/api/src/mcp/mcp.service.ts
Normal file
253
apps/api/src/mcp/mcp.service.ts
Normal 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}` }] };
|
||||
}
|
||||
@ -152,7 +152,8 @@ export class PondsService {
|
||||
input.sidebarSort !== undefined ||
|
||||
input.fonts !== undefined ||
|
||||
input.commentPolicy !== undefined ||
|
||||
input.apiEnabled !== undefined;
|
||||
input.apiEnabled !== undefined ||
|
||||
input.mcpEnabled !== undefined;
|
||||
const settings = !settingsChanged
|
||||
? undefined
|
||||
: {
|
||||
@ -161,6 +162,7 @@ export class PondsService {
|
||||
...(input.fonts !== undefined ? { fonts: input.fonts } : {}),
|
||||
...(input.commentPolicy !== undefined ? { commentPolicy: input.commentPolicy } : {}),
|
||||
...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}),
|
||||
...(input.mcpEnabled !== undefined ? { mcpEnabled: input.mcpEnabled } : {}),
|
||||
};
|
||||
const updated = await this.prisma.pond.update({
|
||||
where: { id },
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { pondSettingsSchema, type ApiTokenScope } from '@dorfteich/shared';
|
||||
import { pondFeatureEnabled, pondSettingsSchema, type ApiTokenScope } from '@dorfteich/shared';
|
||||
import type { ApiToken } from '@prisma/client';
|
||||
import type { Response } from 'express';
|
||||
|
||||
@ -110,7 +110,9 @@ export class PublicApiGuard implements CanActivate {
|
||||
});
|
||||
if (!pond) throw new NotFoundException();
|
||||
const settings = pondSettingsSchema.safeParse(pond.settings ?? {});
|
||||
if (!settings.success || !settings.data.apiEnabled) throw new NotFoundException();
|
||||
if (!settings.success || !pondFeatureEnabled(settings.data, 'api')) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
if (token.pondIds.length > 0 && !token.pondIds.includes(pond.id)) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
SEARCH_HIGHLIGHT_START,
|
||||
editorSchema,
|
||||
markdownToDoc,
|
||||
pondFeatureEnabled,
|
||||
pondSettingsSchema,
|
||||
type CommentListFilter,
|
||||
type CreateCommentInput,
|
||||
@ -21,6 +22,7 @@ import {
|
||||
type PublicSearchResultView,
|
||||
type PublicUpdateLabelInput,
|
||||
type PublicUpdatePageInput,
|
||||
type PondExposureFeature,
|
||||
type PondView,
|
||||
} from '@dorfteich/shared';
|
||||
import { Node } from 'prosemirror-model';
|
||||
@ -74,13 +76,14 @@ export class PublicApiService {
|
||||
};
|
||||
}
|
||||
|
||||
/** The API-enabled ponds visible to the token's user, within restriction. */
|
||||
async listPonds(user: User, token: ApiToken): Promise<PublicPondView[]> {
|
||||
const visible = await this.ponds.listVisible(user);
|
||||
return visible
|
||||
.filter((pond) => pond.settings.apiEnabled)
|
||||
.filter((pond) => token.pondIds.length === 0 || token.pondIds.includes(pond.id))
|
||||
.map((pond) => this.pondView(pond));
|
||||
/** The exposed ponds visible to the token's user, within restriction. */
|
||||
async listPonds(
|
||||
user: User,
|
||||
token: ApiToken,
|
||||
feature: PondExposureFeature = 'api',
|
||||
): Promise<PublicPondView[]> {
|
||||
const ponds = await this.listPondRowsExposed(user, token, feature);
|
||||
return ponds.map((pond) => this.pondView(pond));
|
||||
}
|
||||
|
||||
async getPond(slug: string): Promise<PublicPondView> {
|
||||
@ -176,8 +179,9 @@ export class PublicApiService {
|
||||
user: User,
|
||||
token: ApiToken,
|
||||
query: PublicSearchQuery,
|
||||
feature: PondExposureFeature = 'api',
|
||||
): Promise<PublicSearchResultView[]> {
|
||||
const exposed = await this.exposedPondIds(user, token);
|
||||
const exposed = await this.exposedPondIds(user, token, feature);
|
||||
let pondId: string | undefined;
|
||||
if (query.pond) {
|
||||
const pond = await this.requirePond(query.pond);
|
||||
@ -371,15 +375,23 @@ export class PublicApiService {
|
||||
return new Map(labels.map((label) => [label.id, label.name]));
|
||||
}
|
||||
|
||||
private async exposedPondIds(user: User, token: ApiToken): Promise<Set<string>> {
|
||||
const ponds = await this.listPondRowsExposed(user, token);
|
||||
private async exposedPondIds(
|
||||
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));
|
||||
}
|
||||
|
||||
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);
|
||||
return visible
|
||||
.filter((pond) => pond.settings.apiEnabled)
|
||||
.filter((pond) => pondFeatureEnabled(pond.settings, feature))
|
||||
.filter((pond) => token.pondIds.length === 0 || token.pondIds.includes(pond.id));
|
||||
}
|
||||
|
||||
|
||||
@ -53,6 +53,9 @@ export const INSTANCE_SETTINGS = {
|
||||
// /api/public/v1 route answers 404 while disabled. Individual ponds
|
||||
// additionally opt in through their pond settings (`apiEnabled`).
|
||||
'api.enabled': z.boolean().default(false),
|
||||
// Built-in MCP endpoint master switch (issue #105, default off) —
|
||||
// independent of the REST switch; ponds opt in via `mcpEnabled`.
|
||||
'mcp.enabled': z.boolean().default(false),
|
||||
// Backup targets (ADR 0015, issue #103). The backup sidecar reads these
|
||||
// rows directly (apps/backup settings.ts — keep the schemas in sync); the
|
||||
// Nextcloud app password is NOT here, it lives in the secret store
|
||||
|
||||
@ -36,7 +36,7 @@ export async function createTestApp(
|
||||
app.useBodyParser('json', { limit: '8mb' });
|
||||
// Mirrors main.ts: the public API (issue #104) declares its full path.
|
||||
app.setGlobalPrefix('api/v1', {
|
||||
exclude: ['api/public/v1', 'api/public/v1/{*path}'],
|
||||
exclude: ['api/public/v1', 'api/public/v1/{*path}', 'api/mcp'],
|
||||
});
|
||||
await app.init();
|
||||
return app;
|
||||
|
||||
@ -14,21 +14,23 @@ export function ApiOptInSetting({
|
||||
pondId,
|
||||
pondSlug,
|
||||
value,
|
||||
mcpValue,
|
||||
}: {
|
||||
pondId: string;
|
||||
pondSlug: string;
|
||||
value: boolean;
|
||||
mcpValue: boolean;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('apiTokens');
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const save = async (enabled: boolean): Promise<void> => {
|
||||
const save = async (patch: { apiEnabled?: boolean; mcpEnabled?: boolean }): Promise<void> => {
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
await apiPatch(`/ponds/${pondId}`, { apiEnabled: enabled });
|
||||
await apiPatch(`/ponds/${pondId}`, patch);
|
||||
await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] });
|
||||
setSaved(true);
|
||||
} catch (err) {
|
||||
@ -44,11 +46,20 @@ export function ApiOptInSetting({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={(event) => void save(event.target.checked)}
|
||||
onChange={(event) => void save({ apiEnabled: event.target.checked })}
|
||||
/>
|
||||
{t('pond.label')}
|
||||
</label>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ interface InstanceSettings {
|
||||
'quota.storageBytes': number;
|
||||
'quota.maxFileBytes': number;
|
||||
'api.enabled': boolean;
|
||||
'mcp.enabled': boolean;
|
||||
'upload.allowedExtensions': string[];
|
||||
'upload.svgPolicy': 'reject' | 'sanitize';
|
||||
'legal.imprint': string;
|
||||
@ -195,11 +196,11 @@ function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): Re
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
async function save(enabled: boolean): Promise<void> {
|
||||
async function save(patch: Record<string, boolean>): Promise<void> {
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
await apiPatch('/admin/settings', { 'api.enabled': enabled });
|
||||
await apiPatch('/admin/settings', patch);
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
|
||||
setSaved(true);
|
||||
} catch (err) {
|
||||
@ -216,11 +217,20 @@ function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): Re
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings['api.enabled']}
|
||||
onChange={(event) => void save(event.target.checked)}
|
||||
onChange={(event) => void save({ 'api.enabled': event.target.checked })}
|
||||
/>
|
||||
{t('admin.label')}
|
||||
</label>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -111,6 +111,7 @@ export function PondSettingsPage(): React.JSX.Element {
|
||||
pondId={pond.data.id}
|
||||
pondSlug={pondSlug}
|
||||
value={pond.data.settings.apiEnabled}
|
||||
mcpValue={pond.data.settings.mcpEnabled}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@ -137,9 +137,10 @@ bundle from Nextcloud by hand.
|
||||
## Public REST API
|
||||
|
||||
Scripts and integrations can talk to the instance through a
|
||||
token-authenticated API at `/api/public/v1` — off by default, enabled per
|
||||
instance and per pond. Details, token walkthrough, and the OpenAPI
|
||||
document: [public-api.md](public-api.md).
|
||||
token-authenticated API at `/api/public/v1`, and MCP clients (Claude Code
|
||||
and friends) through the built-in MCP endpoint at `/api/mcp` — both off by
|
||||
default, enabled per instance and per pond. Details, token walkthrough,
|
||||
and the OpenAPI document: [public-api.md](public-api.md).
|
||||
|
||||
## Health & troubleshooting
|
||||
|
||||
|
||||
@ -52,6 +52,46 @@ curl -H "Authorization: Bearer dt_pat_..." \
|
||||
Deliberately not in v1 (stage 2): attachment upload, version endpoints,
|
||||
webhooks.
|
||||
|
||||
## Connect Claude Code / MCP clients
|
||||
|
||||
The instance ships its own MCP endpoint (Streamable HTTP) at `/api/mcp` —
|
||||
no extra process. It has **its own switches**, independent of the REST
|
||||
API: the instance switch under _Admin → Settings → Public API_ ("Enable
|
||||
the built-in MCP endpoint") and a per-pond opt-in in the pond settings.
|
||||
Authentication uses the same personal access tokens.
|
||||
|
||||
Claude Code (or any Streamable-HTTP client):
|
||||
|
||||
```sh
|
||||
claude mcp add --transport http dorfteich https://your-instance.example/api/mcp \
|
||||
--header "Authorization: Bearer dt_pat_..."
|
||||
```
|
||||
|
||||
Stdio-only clients bridge with `mcp-remote`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"dorfteich": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://your-instance.example/api/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer dt_pat_..."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Tools: `list_ponds`, `list_pages`, `read_page`, `search`, `create_page`,
|
||||
`update_page` (replace semantics, collab-safe like the REST PATCH),
|
||||
`add_comment`, `list_labels`, `set_page_labels`, `export_pond`. A tool
|
||||
call acts as the token's user; write tools need the `write` scope, and
|
||||
ponds without the MCP opt-in stay invisible (404 semantics). The endpoint
|
||||
is stateless — no sessions to manage, safe behind load balancers.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Bearer tokens only — no cookies are involved, so there is no CSRF
|
||||
|
||||
@ -39,13 +39,17 @@
|
||||
"title": "Öffentliche API",
|
||||
"label": "Diesen Teich über die öffentliche API freigeben",
|
||||
"hint": "Standardmäßig aus. Wenn aktiviert, erreichen Nutzer diesen Teich mit ihren API-Tokens — mit genau den Berechtigungen, die sie hier ohnehin haben. Der instanzweite API-Schalter muss ebenfalls an sein.",
|
||||
"saved": "Gespeichert."
|
||||
"saved": "Gespeichert.",
|
||||
"mcpLabel": "Diesen Teich für KI-Assistenten freigeben (MCP)",
|
||||
"mcpHint": "Standardmäßig aus und unabhängig vom REST-Schalter. Wenn aktiviert, erreichen MCP-Clients wie Claude Code diesen Teich mit einem API-Token — wieder mit genau den Berechtigungen des Nutzers. Der instanzweite MCP-Schalter muss ebenfalls an sein."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Öffentliche API",
|
||||
"label": "Öffentliche REST-API aktivieren",
|
||||
"hint": "Hauptschalter (standardmäßig aus). Nutzer erstellen dann Personal-Access-Tokens in ihren Einstellungen; jeder Teich gibt sich zusätzlich über seine Teich-Einstellungen frei. Dokumentation: /api/public/v1/openapi.json",
|
||||
"save": "Speichern",
|
||||
"saved": "Gespeichert."
|
||||
"saved": "Gespeichert.",
|
||||
"mcpLabel": "Eingebauten MCP-Endpoint aktivieren",
|
||||
"mcpHint": "Hauptschalter (standardmäßig aus), unabhängig von der REST-API. MCP-Clients verbinden sich mit einem API-Token auf /api/mcp; jeder Teich gibt sich zusätzlich über seine Teich-Einstellungen frei. Siehe docs/self-hosting/public-api.md."
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,13 +39,17 @@
|
||||
"title": "Public API",
|
||||
"label": "Expose this pond through the public API",
|
||||
"hint": "Off by default. When enabled, users can reach this pond with their API tokens — with exactly the permissions they have here anyway. The instance-wide API switch must also be on.",
|
||||
"saved": "Saved."
|
||||
"saved": "Saved.",
|
||||
"mcpLabel": "Expose this pond to AI assistants (MCP)",
|
||||
"mcpHint": "Off by default and independent of the REST toggle. When enabled, MCP clients such as Claude Code can reach this pond with an API token — again with exactly the user's permissions. The instance-wide MCP switch must also be on."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Public API",
|
||||
"label": "Enable the public REST API",
|
||||
"hint": "Master switch (default off). Users then create personal access tokens in their settings; each pond additionally opts in via its pond settings. Documentation: /api/public/v1/openapi.json",
|
||||
"save": "Save",
|
||||
"saved": "Saved."
|
||||
"saved": "Saved.",
|
||||
"mcpLabel": "Enable the built-in MCP endpoint",
|
||||
"mcpHint": "Master switch (default off), independent of the REST API. MCP clients connect to /api/mcp with an API token; each pond additionally opts in via its pond settings. See docs/self-hosting/public-api.md."
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,9 +43,19 @@ export const pondSettingsSchema = z.object({
|
||||
* without it the pond and its content answer 404 through the API even
|
||||
* for a token whose user could see them in the app. */
|
||||
apiEnabled: z.boolean().default(false),
|
||||
/** Per-pond opt-in to the built-in MCP endpoint (issue #105, default
|
||||
* off) — independent of the REST opt-in. */
|
||||
mcpEnabled: z.boolean().default(false),
|
||||
});
|
||||
export type PondSettings = z.infer<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
|
||||
.string()
|
||||
.trim()
|
||||
@ -66,6 +76,7 @@ export const updatePondInputSchema = z
|
||||
fonts: pondFontsSchema,
|
||||
commentPolicy: z.enum(COMMENT_POLICIES),
|
||||
apiEnabled: z.boolean(),
|
||||
mcpEnabled: z.boolean(),
|
||||
})
|
||||
.partial();
|
||||
export type UpdatePondInput = z.infer<typeof updatePondInputSchema>;
|
||||
|
||||
111
pnpm-lock.yaml
generated
111
pnpm-lock.yaml
generated
@ -35,6 +35,9 @@ importers:
|
||||
'@dorfteich/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.29.0
|
||||
version: 1.29.0(zod@3.25.76)
|
||||
'@nestjs/common':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@ -1778,6 +1781,12 @@ packages:
|
||||
y-protocols: ^1.0.6
|
||||
yjs: ^13.6.8
|
||||
|
||||
'@hono/node-server@1.19.14':
|
||||
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
|
||||
'@hookform/resolvers@5.4.0':
|
||||
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
|
||||
peerDependencies:
|
||||
@ -1989,6 +1998,16 @@ packages:
|
||||
'@mermaid-js/parser@1.2.0':
|
||||
resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.29.0':
|
||||
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@cfworker/json-schema': ^4.1.1
|
||||
zod: ^3.25 || ^4.0
|
||||
peerDependenciesMeta:
|
||||
'@cfworker/json-schema':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.80':
|
||||
resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==}
|
||||
engines: {node: '>= 10'}
|
||||
@ -3888,10 +3907,24 @@ packages:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
|
||||
eventsource-parser@3.1.0:
|
||||
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
eventsource@3.0.7:
|
||||
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
expect-type@1.4.0:
|
||||
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
express-rate-limit@8.5.2:
|
||||
resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
express: '>= 4.11'
|
||||
|
||||
express@5.2.1:
|
||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
engines: {node: '>= 18'}
|
||||
@ -4136,6 +4169,10 @@ packages:
|
||||
help-me@5.0.0:
|
||||
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
||||
|
||||
hono@4.12.29:
|
||||
resolution: {integrity: sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
html-encoding-sniffer@4.0.0:
|
||||
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
|
||||
engines: {node: '>=18'}
|
||||
@ -4213,6 +4250,10 @@ packages:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ip-address@10.2.0:
|
||||
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@ -4389,6 +4430,9 @@ packages:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.2.3:
|
||||
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||
|
||||
joycon@3.1.1:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
@ -4429,6 +4473,9 @@ packages:
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
json-schema-typed@8.0.2:
|
||||
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1:
|
||||
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
||||
|
||||
@ -4918,6 +4965,10 @@ packages:
|
||||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
pkce-challenge@5.0.1:
|
||||
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||
|
||||
@ -6196,6 +6247,11 @@ packages:
|
||||
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
zod-to-json-schema@3.25.2:
|
||||
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
|
||||
peerDependencies:
|
||||
zod: ^3.25.28 || ^4
|
||||
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
@ -7371,6 +7427,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- srvx
|
||||
|
||||
'@hono/node-server@1.19.14(hono@4.12.29)':
|
||||
dependencies:
|
||||
hono: 4.12.29
|
||||
|
||||
'@hookform/resolvers@5.4.0(react-hook-form@7.80.0(react@19.2.7))':
|
||||
dependencies:
|
||||
'@standard-schema/utils': 0.3.0
|
||||
@ -7583,6 +7643,28 @@ snapshots:
|
||||
dependencies:
|
||||
'@chevrotain/types': 11.1.2
|
||||
|
||||
'@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.14(hono@4.12.29)
|
||||
ajv: 8.20.0
|
||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
content-type: 1.0.5
|
||||
cors: 2.8.6
|
||||
cross-spawn: 7.0.6
|
||||
eventsource: 3.0.7
|
||||
eventsource-parser: 3.1.0
|
||||
express: 5.2.1
|
||||
express-rate-limit: 8.5.2(express@5.2.1)
|
||||
hono: 4.12.29
|
||||
jose: 6.2.3
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
raw-body: 3.0.2
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.25.2(zod@3.25.76)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.80':
|
||||
optional: true
|
||||
|
||||
@ -8589,6 +8671,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
ajv: 8.18.0
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.20.0):
|
||||
optionalDependencies:
|
||||
ajv: 8.20.0
|
||||
|
||||
ajv-keywords@3.5.2(ajv@6.15.0):
|
||||
dependencies:
|
||||
ajv: 6.15.0
|
||||
@ -9683,8 +9769,19 @@ snapshots:
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
eventsource-parser@3.1.0: {}
|
||||
|
||||
eventsource@3.0.7:
|
||||
dependencies:
|
||||
eventsource-parser: 3.1.0
|
||||
|
||||
expect-type@1.4.0: {}
|
||||
|
||||
express-rate-limit@8.5.2(express@5.2.1):
|
||||
dependencies:
|
||||
express: 5.2.1
|
||||
ip-address: 10.2.0
|
||||
|
||||
express@5.2.1:
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
@ -9983,6 +10080,8 @@ snapshots:
|
||||
|
||||
help-me@5.0.0: {}
|
||||
|
||||
hono@4.12.29: {}
|
||||
|
||||
html-encoding-sniffer@4.0.0:
|
||||
dependencies:
|
||||
whatwg-encoding: 3.1.1
|
||||
@ -10058,6 +10157,8 @@ snapshots:
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
ip-address@10.2.0: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
is-array-buffer@3.0.5:
|
||||
@ -10224,6 +10325,8 @@ snapshots:
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
jose@6.2.3: {}
|
||||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
@ -10271,6 +10374,8 @@ snapshots:
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
json-schema-typed@8.0.2: {}
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||
|
||||
json5@2.2.3: {}
|
||||
@ -10745,6 +10850,8 @@ snapshots:
|
||||
|
||||
pirates@4.0.7: {}
|
||||
|
||||
pkce-challenge@5.0.1: {}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
dependencies:
|
||||
confbox: 0.1.8
|
||||
@ -12192,6 +12299,10 @@ snapshots:
|
||||
compress-commons: 6.0.2
|
||||
readable-stream: 4.7.0
|
||||
|
||||
zod-to-json-schema@3.25.2(zod@3.25.76):
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.4.3: {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user