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
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>
360 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|