mcp-hosted-resources-r1.test.mjs
275 lines 9.1 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * R1 + R2 hosted MCP: `knowtation://hosted/vault/{+path}` — note reads (get_note) and folder JSON (list_notes).
3 */
4
5 import { describe, it, afterEach } from 'node:test';
6 import assert from 'node:assert/strict';
7 import { Client } from '@modelcontextprotocol/sdk/client/index.js';
8 import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
9 import { createHostedMcpServer } from '../hub/gateway/mcp-hosted-server.mjs';
10
11 const CANISTER_URL = 'http://canister.test:4322';
12 const BRIDGE_URL = 'http://bridge.test:4321';
13
14 /**
15 * @param {unknown} getNoteResponse - JSON for GET /api/v1/notes/:path
16 * @param {{ notes?: unknown[], total?: number }} [listNotesResponse] - JSON for GET /api/v1/notes?…
17 */
18 function installNoteFetchMock(getNoteResponse, listNotesResponse = { notes: [], total: 0 }) {
19 const calls = [];
20 const origFetch = globalThis.fetch;
21 globalThis.fetch = async (url, init) => {
22 const u = String(url);
23 calls.push({ url: u, init });
24 if (u.includes('/api/v1/notes?')) {
25 return {
26 ok: true,
27 status: 200,
28 json: async () => listNotesResponse,
29 text: async () => JSON.stringify(listNotesResponse),
30 };
31 }
32 if (u.startsWith(`${CANISTER_URL}/api/v1/notes/`)) {
33 return {
34 ok: true,
35 status: 200,
36 json: async () => getNoteResponse,
37 text: async () => JSON.stringify(getNoteResponse),
38 };
39 }
40 return {
41 ok: true,
42 status: 200,
43 json: async () => ({}),
44 text: async () => '{}',
45 };
46 };
47 return {
48 calls,
49 restore() {
50 globalThis.fetch = origFetch;
51 },
52 };
53 }
54
55 async function connect(ctx) {
56 const mcpServer = createHostedMcpServer(ctx);
57 const client = new Client({ name: 'r1-resource-test', version: '0.0.1' });
58 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
59 await mcpServer.connect(serverTransport);
60 await client.connect(clientTransport);
61 return { client, mcpServer, clientTransport, serverTransport };
62 }
63
64 describe('hosted MCP R1 — vault note resource template', () => {
65 let mock;
66 let client;
67
68 afterEach(async () => {
69 mock?.restore();
70 try {
71 await client?.close();
72 } catch (_) {}
73 });
74
75 it('readResource uses GET canister /notes/:path with same headers intent as get_note', async () => {
76 mock = installNoteFetchMock({
77 path: 'inbox/hello.md',
78 frontmatter: { title: 'Hello' },
79 body: 'Body line',
80 });
81 ({ client } = await connect({
82 userId: 'u-actor',
83 canisterUserId: 'u-canister',
84 vaultId: 'v-1',
85 role: 'viewer',
86 token: 'tok',
87 canisterUrl: CANISTER_URL,
88 bridgeUrl: BRIDGE_URL,
89 }));
90
91 const uri = 'knowtation://hosted/vault/inbox/hello.md';
92 const read = await client.readResource({ uri });
93 assert.equal(read.contents.length, 1);
94 assert.equal(read.contents[0].mimeType, 'text/markdown');
95 assert.match(read.contents[0].text, /title:\s*Hello/);
96 assert.match(read.contents[0].text, /Body line/);
97
98 const noteCalls = mock.calls.filter((c) => c.url.includes('/api/v1/notes/'));
99 assert.equal(noteCalls.length, 1);
100 assert.equal(noteCalls[0].url, `${CANISTER_URL}/api/v1/notes/inbox%2Fhello.md`);
101 assert.equal(noteCalls[0].init.method, 'GET');
102 const h = noteCalls[0].init.headers;
103 const hdr = (k) => (typeof h.get === 'function' ? h.get(k) : h[k]);
104 assert.equal(hdr('X-User-Id'), 'u-canister');
105 assert.equal(hdr('X-Vault-Id'), 'v-1');
106 assert.equal(hdr('Authorization'), 'Bearer tok');
107 });
108
109 it('lists resource template for hosted vault path pattern', async () => {
110 mock = installNoteFetchMock({ path: 'x.md', frontmatter: {}, body: '' });
111 ({ client } = await connect({
112 userId: 'u1',
113 vaultId: 'v1',
114 role: 'viewer',
115 token: 't',
116 canisterUrl: CANISTER_URL,
117 bridgeUrl: BRIDGE_URL,
118 }));
119
120 const listed = await client.listResourceTemplates();
121 const names = (listed.resourceTemplates || []).map((t) => t.name || t.uriTemplate);
122 assert.ok(
123 names.some((n) => String(n).includes('hosted-vault-note') || String(n).includes('hosted/vault')),
124 `expected hosted vault note template, got: ${JSON.stringify(names)}`
125 );
126 });
127
128 it('vault-info static resource still listed', async () => {
129 mock = installNoteFetchMock({ path: 'x.md', frontmatter: {}, body: '' });
130 ({ client } = await connect({
131 userId: 'u1',
132 vaultId: 'v1',
133 role: 'viewer',
134 token: 't',
135 canisterUrl: CANISTER_URL,
136 bridgeUrl: BRIDGE_URL,
137 }));
138 const { resources } = await client.listResources();
139 const vault = resources.find((r) => r.uri === 'knowtation://hosted/vault-info');
140 assert.ok(vault, 'vault-info still present');
141 });
142
143 it('resources/list merges template list so Cursor-style clients see note URIs (cap 50)', async () => {
144 const listNotesResponse = {
145 notes: [
146 { path: 'inbox/a.md', frontmatter: { title: 'A' }, body: 'alpha' },
147 { path: 'projects/p/b.md', frontmatter: {}, body: '# B\nbody' },
148 ],
149 total: 2,
150 };
151 mock = installNoteFetchMock({ path: 'x.md', frontmatter: {}, body: '' }, listNotesResponse);
152 ({ client } = await connect({
153 userId: 'u1',
154 vaultId: 'v1',
155 role: 'viewer',
156 token: 't',
157 canisterUrl: CANISTER_URL,
158 bridgeUrl: BRIDGE_URL,
159 }));
160
161 const { resources } = await client.listResources();
162 const uris = resources.map((r) => r.uri);
163 assert.ok(uris.includes('knowtation://hosted/vault-info'));
164 assert.ok(uris.includes('knowtation://hosted/vault/inbox/a.md'));
165 assert.ok(uris.includes('knowtation://hosted/vault/projects/p/b.md'));
166
167 const listCalls = mock.calls.filter((c) => {
168 const u = String(c.url);
169 return (
170 u.includes('/api/v1/notes?') &&
171 u.includes('limit=50') &&
172 u.includes('offset=0') &&
173 !u.includes('folder=')
174 );
175 });
176 assert.ok(listCalls.length >= 1, 'vault note + R3 image list both use first-page canister list');
177 assert.match(listCalls[0].url, /limit=50/);
178 });
179
180 it('rejects path traversal', async () => {
181 mock = installNoteFetchMock({});
182 ({ client } = await connect({
183 userId: 'u1',
184 vaultId: 'v1',
185 role: 'viewer',
186 token: 't',
187 canisterUrl: CANISTER_URL,
188 bridgeUrl: BRIDGE_URL,
189 }));
190
191 await assert.rejects(
192 () => client.readResource({ uri: 'knowtation://hosted/vault/../secret.md' }),
193 /Invalid path|McpError|invalid/i
194 );
195 });
196
197 it('readResource for non-.md path uses GET /api/v1/notes?folder=… (R2 folder listing)', async () => {
198 mock = installNoteFetchMock(
199 {},
200 {
201 notes: [{ path: 'inbox/a.md', frontmatter: {}, body: '' }],
202 total: 50,
203 },
204 );
205 ({ client } = await connect({
206 userId: 'u1',
207 vaultId: 'v1',
208 role: 'viewer',
209 token: 't',
210 canisterUrl: CANISTER_URL,
211 bridgeUrl: BRIDGE_URL,
212 }));
213
214 const read = await client.readResource({ uri: 'knowtation://hosted/vault/inbox' });
215 assert.equal(read.contents.length, 1);
216 assert.equal(read.contents[0].mimeType, 'application/json');
217 const j = JSON.parse(read.contents[0].text);
218 assert.equal(j.folder, '/inbox');
219 assert.equal(j.total, 50);
220 assert.equal(j.notes.length, 1);
221 assert.equal(j.truncated, false, '50 notes fits in one page of 100');
222
223 const listCalls = mock.calls.filter((c) => String(c.url).includes('/api/v1/notes?'));
224 assert.ok(listCalls.length >= 1);
225 const hit = listCalls.find((c) => c.url.includes('folder=inbox') && c.url.includes('limit=100'));
226 assert.ok(hit, `expected folder=inbox in list URL, got: ${listCalls.map((c) => c.url).join(' | ')}`);
227 });
228 });
229
230 describe('hosted MCP R2 — vault-listing static resource', () => {
231 let mock;
232 let client;
233
234 afterEach(async () => {
235 mock?.restore();
236 try {
237 await client?.close();
238 } catch (_) {}
239 });
240
241 it('readResource uses GET /api/v1/notes?limit=100&offset=0', async () => {
242 mock = installNoteFetchMock({}, { notes: [{ path: 'a.md', frontmatter: {}, body: '' }], total: 99 });
243 ({ client } = await connect({
244 userId: 'u1',
245 vaultId: 'v1',
246 role: 'viewer',
247 token: 't',
248 canisterUrl: CANISTER_URL,
249 bridgeUrl: BRIDGE_URL,
250 }));
251
252 const read = await client.readResource({ uri: 'knowtation://hosted/vault-listing' });
253 assert.equal(read.contents[0].mimeType, 'application/json');
254 const j = JSON.parse(read.contents[0].text);
255 assert.equal(j.total, 99);
256
257 const listCalls = mock.calls.filter((c) => String(c.url).includes('/api/v1/notes?'));
258 assert.ok(listCalls.some((c) => c.url.includes('limit=100') && c.url.includes('offset=0')));
259 });
260
261 it('listResources includes knowtation://hosted/vault-listing', async () => {
262 mock = installNoteFetchMock({}, { notes: [], total: 0 });
263 ({ client } = await connect({
264 userId: 'u1',
265 vaultId: 'v1',
266 role: 'viewer',
267 token: 't',
268 canisterUrl: CANISTER_URL,
269 bridgeUrl: BRIDGE_URL,
270 }));
271 const { resources } = await client.listResources();
272 const row = resources.find((r) => r.uri === 'knowtation://hosted/vault-listing');
273 assert.ok(row, 'vault-listing listed');
274 });
275 });
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 1 day ago