mcp-hosted-document-tree.test.mjs file-level

at sha256:3 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:4 fix(security): pin patched transitive deps to clear Dependabot moderate… · aaronrene · Jun 11, 2026
1 /**
2 * Hosted MCP get_document_tree tests.
3 *
4 * Phase 1E mirrors hosted get_note_outline access: same canister read path,
5 * same auth headers, viewer-level ACL, and a body-free DocumentTree response.
6 */
7 import { describe, it, afterEach } from 'node:test';
8 import assert from 'node:assert/strict';
9 import { Client } from '@modelcontextprotocol/sdk/client/index.js';
10 import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
11 import { createHostedMcpServer } from '../hub/gateway/mcp-hosted-server.mjs';
12
13 const CANISTER_URL = 'http://canister.test:4322';
14 const BRIDGE_URL = 'http://bridge.test:4321';
15
16 function makeCtx(overrides = {}) {
17 return {
18 userId: 'google:actor',
19 canisterUserId: 'google:owner',
20 vaultId: 'vault-tree',
21 role: 'viewer',
22 token: 'tok-tree',
23 canisterUrl: CANISTER_URL,
24 bridgeUrl: BRIDGE_URL,
25 canisterAuthSecret: 'gw-secret-tree',
26 ...overrides,
27 };
28 }
29
30 function headerGet(headers, name) {
31 if (!headers) return undefined;
32 if (typeof headers.get === 'function') return headers.get(name);
33 return headers[name] ?? headers[name.toLowerCase()];
34 }
35
36 function installFetchMock(handler) {
37 const calls = [];
38 const origFetch = globalThis.fetch;
39 globalThis.fetch = async (url, init) => {
40 calls.push({ url: String(url), init });
41 return handler(String(url), init, calls);
42 };
43 return {
44 calls,
45 restore() {
46 globalThis.fetch = origFetch;
47 },
48 };
49 }
50
51 async function connectPair(ctx = makeCtx()) {
52 const mcpServer = createHostedMcpServer(ctx);
53 const client = new Client({ name: 'hosted-document-tree', version: '0.0.1' });
54 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
55 await mcpServer.connect(serverTransport);
56 await client.connect(clientTransport);
57 return { client };
58 }
59
60 function parseToolResult(result) {
61 const text = result.content?.[0]?.text;
62 assert.equal(typeof text, 'string');
63 return JSON.parse(text);
64 }
65
66 describe('hosted MCP get_document_tree', () => {
67 let mock;
68 let client;
69
70 afterEach(async () => {
71 try {
72 await client?.close();
73 } catch (_) {}
74 mock?.restore?.();
75 });
76
77 it('lists get_document_tree as a read tool for every hosted role', async () => {
78 mock = installFetchMock(() => ({
79 ok: true,
80 status: 200,
81 json: async () => ({}),
82 text: async () => '{}',
83 }));
84
85 for (const role of ['viewer', 'editor', 'evaluator', 'admin']) {
86 ({ client } = await connectPair(makeCtx({ role })));
87 const { tools } = await client.listTools();
88 assert.ok(tools.some((tool) => tool.name === 'get_document_tree'), `${role} can list get_document_tree`);
89 await client.close();
90 client = undefined;
91 }
92 });
93
94 it('uses the same canister GET path and auth headers as get_note', async () => {
95 mock = installFetchMock(() => ({
96 ok: true,
97 status: 200,
98 json: async () => ({
99 path: 'inbox/hello world.md',
100 frontmatter: '{"title":"Hosted Tree","api_key":"must-not-leak"}',
101 body: '# Intro\n\nBody must not leak.\n\n## Next',
102 }),
103 text: async () => '{}',
104 }));
105 ({ client } = await connectPair());
106
107 await client.callTool({
108 name: 'get_document_tree',
109 arguments: { path: 'inbox/hello world.md' },
110 });
111
112 assert.equal(mock.calls.length, 1);
113 assert.equal(mock.calls[0].url, `${CANISTER_URL}/api/v1/notes/${encodeURIComponent('inbox/hello world.md')}`);
114 assert.equal(mock.calls[0].init.method, 'GET');
115 const headers = mock.calls[0].init.headers;
116 assert.equal(headerGet(headers, 'Authorization'), 'Bearer tok-tree');
117 assert.equal(headerGet(headers, 'X-Vault-Id'), 'vault-tree');
118 assert.equal(headerGet(headers, 'X-User-Id'), 'google:owner');
119 assert.equal(headerGet(headers, 'X-Gateway-Auth'), 'gw-secret-tree');
120 });
121
122 it('rejects unsafe requested paths before upstream fetch', async () => {
123 mock = installFetchMock(() => {
124 throw new Error('upstream fetch must not run for unsafe requested paths');
125 });
126 ({ client } = await connectPair());
127
128 for (const unsafePath of ['../secret.md', '\\Users\\owner\\secret.md']) {
129 const result = await client.callTool({
130 name: 'get_document_tree',
131 arguments: { path: unsafePath },
132 });
133 const data = parseToolResult(result);
134
135 assert.equal(result.isError, true);
136 assert.equal(data.code, 'UPSTREAM_ERROR');
137 assert.match(data.error, /Invalid path/);
138 }
139 assert.equal(mock.calls.length, 0);
140 });
141
142 it('returns DocumentTree JSON without body, snippets, full frontmatter, or absolute paths', async () => {
143 mock = installFetchMock(() => ({
144 ok: true,
145 status: 200,
146 json: async () => ({
147 path: 'inbox/hello.md',
148 frontmatter: '{"title":"Hosted Tree","api_key":"must-not-leak"}',
149 body: '# Intro\n\nBody must not leak.\n\n## Next',
150 }),
151 text: async () => '{}',
152 }));
153 ({ client } = await connectPair());
154
155 const result = await client.callTool({
156 name: 'get_document_tree',
157 arguments: { path: 'inbox/hello.md' },
158 });
159 const data = parseToolResult(result);
160 const serialized = JSON.stringify(data);
161
162 assert.equal(result.isError, undefined);
163 assert.equal(data.schema, 'knowtation.document_tree/v0');
164 assert.equal(data.path, 'inbox/hello.md');
165 assert.equal(data.title, 'Hosted Tree');
166 assert.deepEqual(data.root, {
167 children: [
168 {
169 id: 'h1-intro-0001',
170 level: 1,
171 text: 'Intro',
172 children: [
173 {
174 id: 'h2-next-0002',
175 level: 2,
176 text: 'Next',
177 children: [],
178 },
179 ],
180 },
181 ],
182 });
183 assert.equal(data.truncated, false);
184 assert.equal(Object.hasOwn(data, 'body'), false);
185 assert.equal(Object.hasOwn(data, 'frontmatter'), false);
186 assert.equal(Object.hasOwn(data, 'snippet'), false);
187 assert.equal(Object.hasOwn(data, 'summary'), false);
188 assert.equal(Object.hasOwn(data, 'vectors'), false);
189 assert.equal(Object.hasOwn(data, 'labels'), false);
190 assert.equal(Object.hasOwn(data, 'metadata_facets'), false);
191 assert.equal(serialized.includes('Body must not leak'), false);
192 assert.equal(serialized.includes('must-not-leak'), false);
193 assert.equal(serialized.includes('/Users/'), false);
194 });
195
196 it('returns UPSTREAM_ERROR without changing missing-note behavior', async () => {
197 mock = installFetchMock(() => ({
198 ok: false,
199 status: 404,
200 json: async () => ({ error: 'not found' }),
201 text: async () => '{"error":"not found"}',
202 }));
203 ({ client } = await connectPair());
204
205 const result = await client.callTool({
206 name: 'get_document_tree',
207 arguments: { path: 'missing.md' },
208 });
209 const data = parseToolResult(result);
210
211 assert.equal(result.isError, true);
212 assert.equal(data.code, 'UPSTREAM_ERROR');
213 assert.match(data.error, /Upstream 404/);
214 });
215
216 it('returns UPSTREAM_ERROR without changing forbidden-note behavior', async () => {
217 mock = installFetchMock(() => ({
218 ok: false,
219 status: 403,
220 json: async () => ({ error: 'forbidden' }),
221 text: async () => '{"error":"forbidden"}',
222 }));
223 ({ client } = await connectPair());
224
225 const result = await client.callTool({
226 name: 'get_document_tree',
227 arguments: { path: 'private.md' },
228 });
229 const data = parseToolResult(result);
230
231 assert.equal(result.isError, true);
232 assert.equal(data.code, 'UPSTREAM_ERROR');
233 assert.match(data.error, /Upstream 403/);
234 assert.equal(JSON.stringify(data).includes('private note body'), false);
235 });
236
237 it('does not expose document tree-specific resource URIs', async () => {
238 mock = installFetchMock((url) => {
239 if (url.includes('/api/v1/notes?')) {
240 return {
241 ok: true,
242 status: 200,
243 json: async () => ({ notes: [{ path: 'inbox/a.md', frontmatter: {}, body: '# A' }], total: 1 }),
244 text: async () => '{"notes":[],"total":0}',
245 };
246 }
247 return {
248 ok: true,
249 status: 200,
250 json: async () => ({}),
251 text: async () => '{}',
252 };
253 });
254 ({ client } = await connectPair());
255
256 const [{ resources }, { resourceTemplates }] = await Promise.all([
257 client.listResources(),
258 client.listResourceTemplates(),
259 ]);
260 const resourceText = JSON.stringify({ resources, resourceTemplates });
261
262 assert.equal(resourceText.includes('document-tree'), false);
263 assert.equal(resourceText.includes('get_document_tree'), false);
264 });
265
266 it('ignores unsafe upstream paths and does not leak absolute path or body', async () => {
267 mock = installFetchMock(() => ({
268 ok: true,
269 status: 200,
270 json: async () => ({
271 path: '/Users/aaron/private/secret.md',
272 frontmatter: '{"title":"Safe Tree"}',
273 body: '# Safe Heading\n\nprivate note body must not leak',
274 }),
275 text: async () => '{}',
276 }));
277 ({ client } = await connectPair());
278
279 const result = await client.callTool({
280 name: 'get_document_tree',
281 arguments: { path: 'safe.md' },
282 });
283 const data = parseToolResult(result);
284 const serialized = JSON.stringify(data);
285
286 assert.equal(result.isError, undefined);
287 assert.equal(data.path, 'safe.md');
288 assert.deepEqual(data.root.children, [
289 {
290 id: 'h1-safe-heading-0001',
291 level: 1,
292 text: 'Safe Heading',
293 children: [],
294 },
295 ]);
296 assert.equal(serialized.includes('/Users/aaron'), false);
297 assert.equal(serialized.includes('private note body'), false);
298 });
299 });