/** * Hosted MCP get_document_tree tests. * * Phase 1E mirrors hosted get_note_outline access: same canister read path, * same auth headers, viewer-level ACL, and a body-free DocumentTree response. */ import { describe, it, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { createHostedMcpServer } from '../hub/gateway/mcp-hosted-server.mjs'; const CANISTER_URL = 'http://canister.test:4322'; const BRIDGE_URL = 'http://bridge.test:4321'; function makeCtx(overrides = {}) { return { userId: 'google:actor', canisterUserId: 'google:owner', vaultId: 'vault-tree', role: 'viewer', token: 'tok-tree', canisterUrl: CANISTER_URL, bridgeUrl: BRIDGE_URL, canisterAuthSecret: 'gw-secret-tree', ...overrides, }; } function headerGet(headers, name) { if (!headers) return undefined; if (typeof headers.get === 'function') return headers.get(name); return headers[name] ?? headers[name.toLowerCase()]; } function installFetchMock(handler) { const calls = []; const origFetch = globalThis.fetch; globalThis.fetch = async (url, init) => { calls.push({ url: String(url), init }); return handler(String(url), init, calls); }; return { calls, restore() { globalThis.fetch = origFetch; }, }; } async function connectPair(ctx = makeCtx()) { const mcpServer = createHostedMcpServer(ctx); const client = new Client({ name: 'hosted-document-tree', version: '0.0.1' }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await mcpServer.connect(serverTransport); await client.connect(clientTransport); return { client }; } function parseToolResult(result) { const text = result.content?.[0]?.text; assert.equal(typeof text, 'string'); return JSON.parse(text); } describe('hosted MCP get_document_tree', () => { let mock; let client; afterEach(async () => { try { await client?.close(); } catch (_) {} mock?.restore?.(); }); it('lists get_document_tree as a read tool for every hosted role', async () => { mock = installFetchMock(() => ({ ok: true, status: 200, json: async () => ({}), text: async () => '{}', })); for (const role of ['viewer', 'editor', 'evaluator', 'admin']) { ({ client } = await connectPair(makeCtx({ role }))); const { tools } = await client.listTools(); assert.ok(tools.some((tool) => tool.name === 'get_document_tree'), `${role} can list get_document_tree`); await client.close(); client = undefined; } }); it('uses the same canister GET path and auth headers as get_note', async () => { mock = installFetchMock(() => ({ ok: true, status: 200, json: async () => ({ path: 'inbox/hello world.md', frontmatter: '{"title":"Hosted Tree","api_key":"must-not-leak"}', body: '# Intro\n\nBody must not leak.\n\n## Next', }), text: async () => '{}', })); ({ client } = await connectPair()); await client.callTool({ name: 'get_document_tree', arguments: { path: 'inbox/hello world.md' }, }); assert.equal(mock.calls.length, 1); assert.equal(mock.calls[0].url, `${CANISTER_URL}/api/v1/notes/${encodeURIComponent('inbox/hello world.md')}`); assert.equal(mock.calls[0].init.method, 'GET'); const headers = mock.calls[0].init.headers; assert.equal(headerGet(headers, 'Authorization'), 'Bearer tok-tree'); assert.equal(headerGet(headers, 'X-Vault-Id'), 'vault-tree'); assert.equal(headerGet(headers, 'X-User-Id'), 'google:owner'); assert.equal(headerGet(headers, 'X-Gateway-Auth'), 'gw-secret-tree'); }); it('rejects unsafe requested paths before upstream fetch', async () => { mock = installFetchMock(() => { throw new Error('upstream fetch must not run for unsafe requested paths'); }); ({ client } = await connectPair()); for (const unsafePath of ['../secret.md', '\\Users\\owner\\secret.md']) { const result = await client.callTool({ name: 'get_document_tree', arguments: { path: unsafePath }, }); const data = parseToolResult(result); assert.equal(result.isError, true); assert.equal(data.code, 'UPSTREAM_ERROR'); assert.match(data.error, /Invalid path/); } assert.equal(mock.calls.length, 0); }); it('returns DocumentTree JSON without body, snippets, full frontmatter, or absolute paths', async () => { mock = installFetchMock(() => ({ ok: true, status: 200, json: async () => ({ path: 'inbox/hello.md', frontmatter: '{"title":"Hosted Tree","api_key":"must-not-leak"}', body: '# Intro\n\nBody must not leak.\n\n## Next', }), text: async () => '{}', })); ({ client } = await connectPair()); const result = await client.callTool({ name: 'get_document_tree', arguments: { path: 'inbox/hello.md' }, }); const data = parseToolResult(result); const serialized = JSON.stringify(data); assert.equal(result.isError, undefined); assert.equal(data.schema, 'knowtation.document_tree/v0'); assert.equal(data.path, 'inbox/hello.md'); assert.equal(data.title, 'Hosted Tree'); assert.deepEqual(data.root, { children: [ { id: 'h1-intro-0001', level: 1, text: 'Intro', children: [ { id: 'h2-next-0002', level: 2, text: 'Next', children: [], }, ], }, ], }); assert.equal(data.truncated, false); assert.equal(Object.hasOwn(data, 'body'), false); assert.equal(Object.hasOwn(data, 'frontmatter'), false); assert.equal(Object.hasOwn(data, 'snippet'), false); assert.equal(Object.hasOwn(data, 'summary'), false); assert.equal(Object.hasOwn(data, 'vectors'), false); assert.equal(Object.hasOwn(data, 'labels'), false); assert.equal(Object.hasOwn(data, 'metadata_facets'), false); assert.equal(serialized.includes('Body must not leak'), false); assert.equal(serialized.includes('must-not-leak'), false); assert.equal(serialized.includes('/Users/'), false); }); it('returns UPSTREAM_ERROR without changing missing-note behavior', async () => { mock = installFetchMock(() => ({ ok: false, status: 404, json: async () => ({ error: 'not found' }), text: async () => '{"error":"not found"}', })); ({ client } = await connectPair()); const result = await client.callTool({ name: 'get_document_tree', arguments: { path: 'missing.md' }, }); const data = parseToolResult(result); assert.equal(result.isError, true); assert.equal(data.code, 'UPSTREAM_ERROR'); assert.match(data.error, /Upstream 404/); }); it('returns UPSTREAM_ERROR without changing forbidden-note behavior', async () => { mock = installFetchMock(() => ({ ok: false, status: 403, json: async () => ({ error: 'forbidden' }), text: async () => '{"error":"forbidden"}', })); ({ client } = await connectPair()); const result = await client.callTool({ name: 'get_document_tree', arguments: { path: 'private.md' }, }); const data = parseToolResult(result); assert.equal(result.isError, true); assert.equal(data.code, 'UPSTREAM_ERROR'); assert.match(data.error, /Upstream 403/); assert.equal(JSON.stringify(data).includes('private note body'), false); }); it('does not expose document tree-specific resource URIs', async () => { mock = installFetchMock((url) => { if (url.includes('/api/v1/notes?')) { return { ok: true, status: 200, json: async () => ({ notes: [{ path: 'inbox/a.md', frontmatter: {}, body: '# A' }], total: 1 }), text: async () => '{"notes":[],"total":0}', }; } return { ok: true, status: 200, json: async () => ({}), text: async () => '{}', }; }); ({ client } = await connectPair()); const [{ resources }, { resourceTemplates }] = await Promise.all([ client.listResources(), client.listResourceTemplates(), ]); const resourceText = JSON.stringify({ resources, resourceTemplates }); assert.equal(resourceText.includes('document-tree'), false); assert.equal(resourceText.includes('get_document_tree'), false); }); it('ignores unsafe upstream paths and does not leak absolute path or body', async () => { mock = installFetchMock(() => ({ ok: true, status: 200, json: async () => ({ path: '/Users/aaron/private/secret.md', frontmatter: '{"title":"Safe Tree"}', body: '# Safe Heading\n\nprivate note body must not leak', }), text: async () => '{}', })); ({ client } = await connectPair()); const result = await client.callTool({ name: 'get_document_tree', arguments: { path: 'safe.md' }, }); const data = parseToolResult(result); const serialized = JSON.stringify(data); assert.equal(result.isError, undefined); assert.equal(data.path, 'safe.md'); assert.deepEqual(data.root.children, [ { id: 'h1-safe-heading-0001', level: 1, text: 'Safe Heading', children: [], }, ]); assert.equal(serialized.includes('/Users/aaron'), false); assert.equal(serialized.includes('private note body'), false); }); });