dorfteich/apps/api/src/mcp/mcp.e2e.db.test.ts
Claude Fable 5 ffcc337ed0
Some checks failed
CD / Build and push images (push) Successful in 3m57s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Failing after 4m16s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 11s
Page-tree parity for the public REST API and MCP (#110)
The slug-based machine surfaces now see and shape the hierarchy:

- REST: page list/detail carry parent (the parent page's slug, nulled
  when the token's user may not read it — same no-leak rule as the
  internal list); create accepts parent; PATCH accepts parent
  (slug nests, null moves to the top level, appended at the end of the
  new sibling group via the new PagesService.moveToEnd). Cycle/depth
  refusals keep their regular error codes. OpenAPI updated.
- MCP: list_pages returns parent, create_page takes an optional parent
  slug, update_page moves with parent (slug|null); tool errors carry
  the api code (page_cycle covered in the e2e pack).
- ZIP export deliberately stays flat — noted in features.md; the
  hierarchy is organizational only.

e2e: REST pack covers nested create, list shape, move/root-move, 409
page_cycle, 404 unknown parent; MCP pack covers nested create, list
parent, and the cycle tool error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:49:52 +02:00

360 lines
14 KiB
TypeScript

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`);
// Page-tree parity (issue #110): create nested, list carries the parent
// slug, moving into the own subtree is a tool error with the api code.
const nested = await client.callTool({
name: 'create_page',
arguments: { pond: pondSlug, title: `MCP Child ${suffix}`, parent: page.slug },
});
expect(nested.isError).toBeFalsy();
const childPage = JSON.parse(textOf(nested)) as { slug: string; parent: string | null };
expect(childPage.parent).toBe(page.slug);
const pages = await client.callTool({
name: 'list_pages',
arguments: { pond: pondSlug },
});
const items = JSON.parse(textOf(pages)) as { slug: string; parent: string | null }[];
expect(items.find((p) => p.slug === childPage.slug)?.parent).toBe(page.slug);
const cyclic = await client.callTool({
name: 'update_page',
arguments: { pond: pondSlug, page: page.slug, parent: childPage.slug },
});
expect(cyclic.isError).toBe(true);
expect(textOf(cyclic)).toContain('page_cycle');
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);
});
});