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 = {}; const cookies: Record = {}; 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 { 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 { 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 => { 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('Anmerkung'); 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); }); });