mcp-hosted-resources-r3.test.mjs
477 lines 14.9 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * R3+ hosted MCP: templates-index, template/{+name}, vault-image/…/… , memory/topic/{slug}.
3 */
4
5 import dns from 'node:dns/promises';
6 import { describe, it, afterEach } from 'node:test';
7 import assert from 'node:assert/strict';
8 import { Client } from '@modelcontextprotocol/sdk/client/index.js';
9 import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
10 import { createHostedMcpServer } from '../hub/gateway/mcp-hosted-server.mjs';
11
12 const CANISTER_URL = 'http://canister.test:4322';
13 const BRIDGE_URL = 'http://bridge.test:4321';
14
15 /**
16 * @param {{
17 * getNoteResponses?: Record<string, unknown>,
18 * listNotesResponse?: { notes?: unknown[], total?: number },
19 * memoryResponse?: { events?: unknown[], count?: number },
20 * }} opts
21 */
22 function installFetchMock(opts = {}) {
23 const calls = [];
24 const getNoteResponses = opts.getNoteResponses || {};
25 const listNotesResponse = opts.listNotesResponse ?? { notes: [], total: 0 };
26 const memoryResponse = opts.memoryResponse ?? { events: [], count: 0 };
27 const origFetch = globalThis.fetch;
28 globalThis.fetch = async (url, init) => {
29 const u = String(url);
30 calls.push({ url: u, init });
31 if (u.includes(`${BRIDGE_URL}/api/v1/memory`)) {
32 return {
33 ok: true,
34 status: 200,
35 json: async () => memoryResponse,
36 text: async () => JSON.stringify(memoryResponse),
37 };
38 }
39 if (u.includes('/api/v1/notes?')) {
40 return {
41 ok: true,
42 status: 200,
43 json: async () => listNotesResponse,
44 text: async () => JSON.stringify(listNotesResponse),
45 };
46 }
47 const notePrefix = `${CANISTER_URL}/api/v1/notes/`;
48 if (u.startsWith(notePrefix)) {
49 const path = decodeURIComponent(u.slice(notePrefix.length));
50 const body = getNoteResponses[path];
51 if (body !== undefined) {
52 return {
53 ok: true,
54 status: 200,
55 json: async () => body,
56 text: async () => JSON.stringify(body),
57 };
58 }
59 }
60 return {
61 ok: true,
62 status: 200,
63 json: async () => ({}),
64 text: async () => '{}',
65 };
66 };
67 return {
68 calls,
69 restore() {
70 globalThis.fetch = origFetch;
71 },
72 };
73 }
74
75 /**
76 * Like {@link installFetchMock} but `GET /api/v1/notes?` responses are chosen by `offset` query (paginated list).
77 * @param {{ pages: Array<{ notes?: unknown[], total?: number }>, getNoteResponses?: Record<string, unknown>, memoryResponse?: { events?: unknown[], count?: number } }} opts
78 */
79 function installFetchMockPaginatedNotes(opts) {
80 const calls = [];
81 const pages = opts.pages;
82 const getNoteResponses = opts.getNoteResponses || {};
83 const memoryResponse = opts.memoryResponse ?? { events: [], count: 0 };
84 const origFetch = globalThis.fetch;
85 globalThis.fetch = async (url, init) => {
86 const u = String(url);
87 calls.push({ url: u, init });
88 if (u.includes(`${BRIDGE_URL}/api/v1/memory`)) {
89 return {
90 ok: true,
91 status: 200,
92 json: async () => memoryResponse,
93 text: async () => JSON.stringify(memoryResponse),
94 };
95 }
96 if (u.includes('/api/v1/notes?')) {
97 let payload = { notes: [], total: 0 };
98 try {
99 const parsed = new URL(u);
100 const offset = parseInt(parsed.searchParams.get('offset') || '0', 10);
101 const limit = parseInt(parsed.searchParams.get('limit') || '50', 10);
102 const idx = Math.floor(offset / limit);
103 payload = pages[idx] ?? { notes: [], total: 0 };
104 } catch (_) {
105 payload = pages[0] ?? { notes: [], total: 0 };
106 }
107 return {
108 ok: true,
109 status: 200,
110 json: async () => payload,
111 text: async () => JSON.stringify(payload),
112 };
113 }
114 const notePrefix = `${CANISTER_URL}/api/v1/notes/`;
115 if (u.startsWith(notePrefix)) {
116 const path = decodeURIComponent(u.slice(notePrefix.length));
117 const body = getNoteResponses[path];
118 if (body !== undefined) {
119 return {
120 ok: true,
121 status: 200,
122 json: async () => body,
123 text: async () => JSON.stringify(body),
124 };
125 }
126 }
127 return {
128 ok: true,
129 status: 200,
130 json: async () => ({}),
131 text: async () => '{}',
132 };
133 };
134 return {
135 calls,
136 restore() {
137 globalThis.fetch = origFetch;
138 },
139 };
140 }
141
142 async function connect(ctx) {
143 const mcpServer = createHostedMcpServer(ctx);
144 const client = new Client({ name: 'r3-resource-test', version: '0.0.1' });
145 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
146 await mcpServer.connect(serverTransport);
147 await client.connect(clientTransport);
148 return { client, mcpServer, clientTransport, serverTransport };
149 }
150
151 describe('hosted MCP R3 — template resources', () => {
152 let mock;
153 let client;
154
155 afterEach(async () => {
156 mock?.restore();
157 try {
158 await client?.close();
159 } catch (_) {}
160 });
161
162 it('templates-index lists template paths from folder=templates', async () => {
163 mock = installFetchMock({
164 listNotesResponse: {
165 notes: [
166 { path: 'templates/capture.md', frontmatter: {}, body: 'x' },
167 { path: 'templates/other/x.md', frontmatter: {}, body: 'y' },
168 ],
169 total: 2,
170 },
171 });
172 ({ client } = await connect({
173 userId: 'u1',
174 vaultId: 'v1',
175 role: 'viewer',
176 token: 't',
177 canisterUrl: CANISTER_URL,
178 bridgeUrl: BRIDGE_URL,
179 }));
180
181 const read = await client.readResource({ uri: 'knowtation://hosted/templates-index' });
182 assert.equal(read.contents[0].mimeType, 'application/json');
183 const j = JSON.parse(read.contents[0].text);
184 assert.deepEqual(j.templates, ['templates/capture.md', 'templates/other/x.md']);
185 const folderCalls = mock.calls.filter((c) => String(c.url).includes('folder=templates'));
186 assert.equal(folderCalls.length, 1);
187 });
188
189 it('readResource template file uses GET note templates/name.md', async () => {
190 mock = installFetchMock({
191 getNoteResponses: {
192 'templates/capture.md': {
193 path: 'templates/capture.md',
194 frontmatter: { title: 'Cap' },
195 body: 'Template body',
196 },
197 },
198 listNotesResponse: { notes: [], total: 0 },
199 });
200 ({ client } = await connect({
201 userId: 'u1',
202 vaultId: 'v1',
203 role: 'viewer',
204 token: 't',
205 canisterUrl: CANISTER_URL,
206 bridgeUrl: BRIDGE_URL,
207 }));
208
209 const read = await client.readResource({ uri: 'knowtation://hosted/template/capture' });
210 assert.equal(read.contents[0].mimeType, 'text/markdown');
211 assert.match(read.contents[0].text, /Template body/);
212 const noteCalls = mock.calls.filter((c) => c.url.includes('/api/v1/notes/templates%2Fcapture.md'));
213 assert.equal(noteCalls.length, 1);
214 assert.equal(noteCalls[0].init.method, 'GET');
215 });
216 });
217
218 describe('hosted MCP R3 — memory topic resource', () => {
219 let mock;
220 let client;
221
222 afterEach(async () => {
223 mock?.restore();
224 try {
225 await client?.close();
226 } catch (_) {}
227 });
228
229 it('readResource filters events by topic slug via extractTopicFromEvent', async () => {
230 mock = installFetchMock({
231 memoryResponse: {
232 events: [
233 { type: 'search', ts: '2026-01-01', data: { topic: 'alpha', q: 'x' } },
234 { type: 'search', ts: '2026-01-02', data: { topic: 'beta', q: 'y' } },
235 ],
236 count: 2,
237 },
238 });
239 ({ client } = await connect({
240 userId: 'u1',
241 vaultId: 'v1',
242 role: 'viewer',
243 token: 't',
244 canisterUrl: CANISTER_URL,
245 bridgeUrl: BRIDGE_URL,
246 }));
247
248 const read = await client.readResource({ uri: 'knowtation://hosted/memory/topic/alpha' });
249 const j = JSON.parse(read.contents[0].text);
250 assert.equal(j.count, 1);
251 assert.equal(j.events[0].data.topic, 'alpha');
252
253 const memCalls = mock.calls.filter((c) => String(c.url).startsWith(`${BRIDGE_URL}/api/v1/memory`));
254 assert.ok(memCalls.length >= 1);
255 const h = memCalls[0].init.headers;
256 const hdr = (k) => (typeof h.get === 'function' ? h.get(k) : h[k]);
257 assert.equal(hdr('Authorization'), 'Bearer t');
258 });
259 });
260
261 describe('hosted MCP R3 — note image resource', () => {
262 let mock;
263 let client;
264
265 afterEach(async () => {
266 mock?.restore();
267 try {
268 await client?.close();
269 } catch (_) {}
270 });
271
272 it('readResource rejects out-of-range image index (no outbound image fetch)', async () => {
273 mock = installFetchMock({
274 getNoteResponses: {
275 'inbox/p.md': {
276 path: 'inbox/p.md',
277 frontmatter: {},
278 body: '![a](https://example.com/one.png)',
279 },
280 },
281 listNotesResponse: { notes: [], total: 0 },
282 });
283 ({ client } = await connect({
284 userId: 'u1',
285 vaultId: 'v1',
286 role: 'viewer',
287 token: 't',
288 canisterUrl: CANISTER_URL,
289 bridgeUrl: BRIDGE_URL,
290 }));
291
292 await assert.rejects(
293 () => client.readResource({ uri: 'knowtation://hosted/vault-image/inbox/p.md/3' }),
294 /out of range|McpError|invalid/i
295 );
296 const httpsCalls = mock.calls.filter((c) => String(c.url).startsWith('https://'));
297 assert.equal(httpsCalls.length, 0, 'should not fetch remote image URL when index is invalid');
298 });
299
300 it('readResource vault-image/…/0 returns image/* (canonical R3 URI)', async () => {
301 /** Avoid real DNS in CI/sandbox; fetchImageAsBase64 resolves hostname before fetch. */
302 const origLookup = dns.lookup;
303 dns.lookup = async () => ({ address: '8.8.8.8', family: 4 });
304 const pngBuf = Buffer.from(
305 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
306 'base64',
307 );
308 const inner = installFetchMock({
309 getNoteResponses: {
310 'inbox/deep/smoke.md': {
311 path: 'inbox/deep/smoke.md',
312 frontmatter: {},
313 body: '![](https://example.org/prove.png)',
314 },
315 },
316 listNotesResponse: { notes: [], total: 0 },
317 });
318 mock = { calls: inner.calls, restore: inner.restore };
319 const chainFetch = globalThis.fetch;
320 globalThis.fetch = async (url, init) => {
321 const u = String(url);
322 if (u === 'https://example.org/prove.png') {
323 return {
324 ok: true,
325 status: 200,
326 headers: {
327 get: (name) => {
328 const n = String(name).toLowerCase();
329 if (n === 'content-type') return 'image/png';
330 if (n === 'content-length') return String(pngBuf.length);
331 return null;
332 },
333 },
334 arrayBuffer: async () => pngBuf.buffer.slice(pngBuf.byteOffset, pngBuf.byteOffset + pngBuf.byteLength),
335 };
336 }
337 return chainFetch(url, init);
338 };
339 ({ client } = await connect({
340 userId: 'u1',
341 vaultId: 'v1',
342 role: 'viewer',
343 token: 't',
344 canisterUrl: CANISTER_URL,
345 bridgeUrl: BRIDGE_URL,
346 }));
347 try {
348 const read = await client.readResource({
349 uri: 'knowtation://hosted/vault-image/inbox/deep/smoke.md/0',
350 });
351 assert.notEqual(read.contents[0].mimeType, 'application/json', 'must not be folder listing JSON');
352 assert.match(String(read.contents[0].mimeType), /^image\//);
353 assert.ok(read.contents[0].blob);
354 } finally {
355 globalThis.fetch = chainFetch;
356 dns.lookup = origLookup;
357 }
358 });
359
360 it('legacy …/vault/…/note.md/image/0 still returns image (hosted-vault-note regex)', async () => {
361 const origLookup = dns.lookup;
362 dns.lookup = async () => ({ address: '8.8.8.8', family: 4 });
363 const pngBuf = Buffer.from(
364 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
365 'base64',
366 );
367 const inner = installFetchMock({
368 getNoteResponses: {
369 'inbox/deep/smoke.md': {
370 path: 'inbox/deep/smoke.md',
371 frontmatter: {},
372 body: '![](https://example.org/prove.png)',
373 },
374 },
375 listNotesResponse: { notes: [], total: 0 },
376 });
377 mock = { calls: inner.calls, restore: inner.restore };
378 const chainFetch = globalThis.fetch;
379 globalThis.fetch = async (url, init) => {
380 const u = String(url);
381 if (u === 'https://example.org/prove.png') {
382 return {
383 ok: true,
384 status: 200,
385 headers: {
386 get: (name) => {
387 const n = String(name).toLowerCase();
388 if (n === 'content-type') return 'image/png';
389 if (n === 'content-length') return String(pngBuf.length);
390 return null;
391 },
392 },
393 arrayBuffer: async () => pngBuf.buffer.slice(pngBuf.byteOffset, pngBuf.byteOffset + pngBuf.byteLength),
394 };
395 }
396 return chainFetch(url, init);
397 };
398 ({ client } = await connect({
399 userId: 'u1',
400 vaultId: 'v1',
401 role: 'viewer',
402 token: 't',
403 canisterUrl: CANISTER_URL,
404 bridgeUrl: BRIDGE_URL,
405 }));
406 try {
407 const read = await client.readResource({
408 uri: 'knowtation://hosted/vault/inbox/deep/smoke.md/image/0',
409 });
410 assert.match(String(read.contents[0].mimeType), /^image\//);
411 } finally {
412 globalThis.fetch = chainFetch;
413 dns.lookup = origLookup;
414 }
415 });
416
417 it('resources/list merges image URIs from notes (cap 50)', async () => {
418 mock = installFetchMock({
419 listNotesResponse: {
420 notes: [{ path: 'n.md', frontmatter: {}, body: '![](https://example.com/x.png)' }],
421 total: 1,
422 },
423 });
424 ({ client } = await connect({
425 userId: 'u1',
426 vaultId: 'v1',
427 role: 'viewer',
428 token: 't',
429 canisterUrl: CANISTER_URL,
430 bridgeUrl: BRIDGE_URL,
431 }));
432
433 const { resources } = await client.listResources();
434 const uris = resources.map((r) => r.uri);
435 assert.ok(uris.some((u) => u === 'knowtation://hosted/vault-image/n.md/0'), `got: ${uris.join(',')}`);
436 });
437
438 it('resources/list paginates canister notes until embedded images are found (not only offset 0)', async () => {
439 const filler = Array.from({ length: 50 }, (_, i) => ({
440 path: `bulk/no-img-${i}.md`,
441 frontmatter: {},
442 body: 'text only',
443 }));
444 mock = installFetchMockPaginatedNotes({
445 pages: [
446 { notes: filler, total: 51 },
447 {
448 notes: [
449 {
450 path: 'inbox/paged-image.md',
451 frontmatter: {},
452 body: '![](https://example.com/from-page-2.png)',
453 },
454 ],
455 total: 51,
456 },
457 ],
458 });
459 ({ client } = await connect({
460 userId: 'u1',
461 vaultId: 'v1',
462 role: 'viewer',
463 token: 't',
464 canisterUrl: CANISTER_URL,
465 bridgeUrl: BRIDGE_URL,
466 }));
467
468 const { resources } = await client.listResources();
469 const uris = resources.map((r) => r.uri);
470 assert.ok(
471 uris.some((u) => u === 'knowtation://hosted/vault-image/inbox/paged-image.md/0'),
472 `expected vault-image URI from second page, got: ${uris.join(',')}`
473 );
474 const listCalls = mock.calls.filter((c) => String(c.url).includes(`${CANISTER_URL}/api/v1/notes?`));
475 assert.ok(listCalls.length >= 2, `expected at least 2 canister list calls, got ${listCalls.length}`);
476 });
477 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago