mcp-hosted-prompts.test.mjs
417 lines 13.7 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Hosted MCP prompts/list + getPrompt: JSON Schema export (Zod args) and upstream fetch wiring.
3 */
4
5 import { describe, it } 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 /** Golden prompt IDs for viewer (excludes write-from-capture → editor minimum). */
15 const PROMPTS_VIEWER = [
16 'causal-chain',
17 'content-plan',
18 'daily-brief',
19 'extract-entities',
20 'knowledge-gap',
21 'meeting-notes',
22 'memory-context',
23 'memory-informed-search',
24 'project-summary',
25 'resume-session',
26 'search-and-synthesize',
27 'temporal-summary',
28 ];
29
30 /** All hosted prompts when role meets editor for write-from-capture. */
31 const PROMPTS_ALL = [...PROMPTS_VIEWER, 'write-from-capture'];
32
33 function sortNames(names) {
34 return [...names].sort((a, b) => a.localeCompare(b));
35 }
36
37 async function listPromptNamesForRole(role) {
38 const mcpServer = createHostedMcpServer({
39 userId: 'u-test',
40 vaultId: 'v-test',
41 role,
42 token: 'tok-test',
43 canisterUrl: CANISTER_URL,
44 bridgeUrl: BRIDGE_URL,
45 });
46 const client = new Client({ name: 'prompts-list-test', version: '0.0.1' });
47 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
48 await mcpServer.connect(serverTransport);
49 await client.connect(clientTransport);
50 try {
51 const { prompts } = await client.listPrompts();
52 assert.ok(Array.isArray(prompts), 'prompts/list must return an array');
53 assert.ok(prompts.length > 0, `${role}: at least one prompt must be listed`);
54 for (const p of prompts) {
55 assert.ok(p.name, 'each prompt has a name');
56 assert.ok(
57 p.arguments != null && typeof p.arguments === 'object',
58 `prompt ${p.name} must have arguments object (prompts/list serialization)`
59 );
60 }
61 return prompts.map((p) => p.name);
62 } finally {
63 try {
64 await client.close();
65 } catch (_) {}
66 }
67 }
68
69 function installFetchMock(listNotesBody) {
70 const calls = [];
71 const origFetch = globalThis.fetch;
72 globalThis.fetch = async (url, init) => {
73 calls.push({ url: String(url), init });
74 const u = String(url);
75 if (u.includes(`${CANISTER_URL}/api/v1/notes?`)) {
76 return {
77 ok: true,
78 status: 200,
79 json: async () => listNotesBody,
80 text: async () => JSON.stringify(listNotesBody),
81 };
82 }
83 return {
84 ok: true,
85 status: 200,
86 json: async () => ({}),
87 text: async () => '{}',
88 };
89 };
90 return {
91 calls,
92 restore() {
93 globalThis.fetch = origFetch;
94 },
95 };
96 }
97
98 describe('hosted MCP prompts/list (JSON Schema export)', () => {
99 it('viewer role lists twelve prompts (no write-from-capture)', async () => {
100 const names = sortNames(await listPromptNamesForRole('viewer'));
101 assert.deepEqual(names, sortNames(PROMPTS_VIEWER));
102 });
103
104 it('editor role lists thirteen prompts including write-from-capture', async () => {
105 const names = sortNames(await listPromptNamesForRole('editor'));
106 assert.deepEqual(names, sortNames(PROMPTS_ALL));
107 });
108
109 it('admin role lists same thirteen prompts as editor', async () => {
110 const names = sortNames(await listPromptNamesForRole('admin'));
111 assert.deepEqual(names, sortNames(PROMPTS_ALL));
112 });
113 });
114
115 describe('hosted MCP getPrompt — daily-brief', () => {
116 it('calls canister GET /api/v1/notes with since and limit', async () => {
117 const mock = installFetchMock({
118 notes: [{ path: 'inbox/a.md', frontmatter: { title: 'A', date: '2026-04-01' }, body: 'Hello world' }],
119 total: 1,
120 });
121 const mcpServer = createHostedMcpServer({
122 userId: 'u-test',
123 vaultId: 'v-test',
124 role: 'viewer',
125 token: 'tok-test',
126 canisterUrl: CANISTER_URL,
127 bridgeUrl: BRIDGE_URL,
128 });
129 const client = new Client({ name: 'get-prompt-test', version: '0.0.1' });
130 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
131 await mcpServer.connect(serverTransport);
132 await client.connect(clientTransport);
133 try {
134 const res = await client.getPrompt({
135 name: 'daily-brief',
136 arguments: { date: '2026-04-10' },
137 });
138 assert.ok(res.messages && res.messages.length >= 2, 'prompt returns messages');
139 const listCalls = mock.calls.filter((c) => c.url.startsWith(`${CANISTER_URL}/api/v1/notes?`));
140 assert.equal(listCalls.length, 1, 'one list_notes style fetch');
141 assert.ok(listCalls[0].url.includes('since=2026-04-10'), 'since query param');
142 assert.ok(listCalls[0].url.includes('limit=80'), 'limit query param');
143 const m = listCalls[0].init?.method;
144 assert.ok(m === undefined || m === 'GET', 'canister list uses GET');
145 assert.equal(listCalls[0].init?.headers?.['X-Vault-Id'], 'v-test');
146 assert.equal(listCalls[0].init?.headers?.['Authorization'], 'Bearer tok-test');
147 } finally {
148 mock.restore();
149 try {
150 await client.close();
151 } catch (_) {}
152 }
153 });
154 });
155
156 describe('hosted MCP getPrompt — knowledge-gap', () => {
157 it('POSTs bridge /api/v1/search with semantic limit 15', async () => {
158 const calls = [];
159 const origFetch = globalThis.fetch;
160 globalThis.fetch = async (url, init) => {
161 calls.push({ url: String(url), init });
162 const u = String(url);
163 if (u === `${BRIDGE_URL}/api/v1/search`) {
164 return {
165 ok: true,
166 status: 200,
167 json: async () => ({ results: [{ path: 'inbox/q.md', snippet: 'snippet text' }] }),
168 text: async () => '{}',
169 };
170 }
171 return {
172 ok: true,
173 status: 200,
174 json: async () => ({}),
175 text: async () => '{}',
176 };
177 };
178
179 const mcpServer = createHostedMcpServer({
180 userId: 'u-test',
181 vaultId: 'v-test',
182 role: 'viewer',
183 token: 'tok-test',
184 canisterUrl: CANISTER_URL,
185 bridgeUrl: BRIDGE_URL,
186 });
187 const client = new Client({ name: 'kg-prompt-test', version: '0.0.1' });
188 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
189 await mcpServer.connect(serverTransport);
190 await client.connect(clientTransport);
191 try {
192 const res = await client.getPrompt({
193 name: 'knowledge-gap',
194 arguments: { query: 'pricing strategy' },
195 });
196 assert.ok(res.messages && res.messages.length >= 1);
197 const searchCalls = calls.filter((c) => c.url === `${BRIDGE_URL}/api/v1/search`);
198 assert.equal(searchCalls.length, 1);
199 assert.equal(searchCalls[0].init?.method, 'POST');
200 const body = JSON.parse(searchCalls[0].init?.body || '{}');
201 assert.equal(body.query, 'pricing strategy');
202 assert.equal(body.mode, 'semantic');
203 assert.equal(body.limit, 15);
204 assert.equal(body.fields, 'path+snippet');
205 assert.equal(body.snippetChars, 200);
206 } finally {
207 globalThis.fetch = origFetch;
208 try {
209 await client.close();
210 } catch (_) {}
211 }
212 });
213 });
214
215 describe('hosted MCP getPrompt — causal-chain', () => {
216 it('search includes chain filter then GET note per path', async () => {
217 const calls = [];
218 const origFetch = globalThis.fetch;
219 globalThis.fetch = async (url, init) => {
220 calls.push({ url: String(url), init });
221 const u = String(url);
222 if (u === `${BRIDGE_URL}/api/v1/search`) {
223 return {
224 ok: true,
225 status: 200,
226 json: async () => ({ results: [{ path: 'notes/a.md' }] }),
227 text: async () => '{}',
228 };
229 }
230 if (u === `${CANISTER_URL}/api/v1/notes/notes%2Fa.md`) {
231 return {
232 ok: true,
233 status: 200,
234 json: async () => ({
235 path: 'notes/a.md',
236 body: 'body',
237 frontmatter: { date: '2026-01-02', causal_chain_id: 'my-chain' },
238 }),
239 text: async () => '{}',
240 };
241 }
242 return {
243 ok: true,
244 status: 200,
245 json: async () => ({}),
246 text: async () => '{}',
247 };
248 };
249
250 const mcpServer = createHostedMcpServer({
251 userId: 'u-test',
252 vaultId: 'v-test',
253 role: 'viewer',
254 token: 'tok-test',
255 canisterUrl: CANISTER_URL,
256 bridgeUrl: BRIDGE_URL,
257 });
258 const client = new Client({ name: 'cc-prompt-test', version: '0.0.1' });
259 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
260 await mcpServer.connect(serverTransport);
261 await client.connect(clientTransport);
262 try {
263 const res = await client.getPrompt({
264 name: 'causal-chain',
265 arguments: { chain_id: 'My-Chain' },
266 });
267 assert.ok(res.messages && res.messages.length >= 1);
268 const searchCalls = calls.filter((c) => c.url === `${BRIDGE_URL}/api/v1/search`);
269 assert.equal(searchCalls.length, 1);
270 const body = JSON.parse(searchCalls[0].init?.body || '{}');
271 assert.equal(body.chain, 'my-chain');
272 assert.equal(body.mode, 'semantic');
273 const getCalls = calls.filter((c) => c.url.includes(`${CANISTER_URL}/api/v1/notes/`) && !c.url.includes('?'));
274 assert.ok(getCalls.length >= 1, 'at least one canister GET note');
275 } finally {
276 globalThis.fetch = origFetch;
277 try {
278 await client.close();
279 } catch (_) {}
280 }
281 });
282 });
283
284 describe('hosted MCP getPrompt — memory-context', () => {
285 it('GETs bridge /api/v1/memory with limit and optional type', async () => {
286 const calls = [];
287 const origFetch = globalThis.fetch;
288 globalThis.fetch = async (url, init) => {
289 calls.push({ url: String(url), init });
290 const u = String(url);
291 if (u.startsWith(`${BRIDGE_URL}/api/v1/memory?`)) {
292 return {
293 ok: true,
294 status: 200,
295 json: async () => ({
296 events: [{ ts: '2026-04-20T12:00:00.000Z', type: 'search', data: { query: 'x' } }],
297 count: 1,
298 }),
299 text: async () => '{}',
300 };
301 }
302 return {
303 ok: true,
304 status: 200,
305 json: async () => ({}),
306 text: async () => '{}',
307 };
308 };
309
310 const mcpServer = createHostedMcpServer({
311 userId: 'u-test',
312 vaultId: 'v-test',
313 role: 'viewer',
314 token: 'tok-test',
315 canisterUrl: CANISTER_URL,
316 bridgeUrl: BRIDGE_URL,
317 });
318 const client = new Client({ name: 'mem-ctx-prompt-test', version: '0.0.1' });
319 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
320 await mcpServer.connect(serverTransport);
321 await client.connect(clientTransport);
322 try {
323 const res = await client.getPrompt({
324 name: 'memory-context',
325 arguments: { limit: '15', type: 'search' },
326 });
327 assert.ok(res.messages && res.messages.length >= 1);
328 const memCalls = calls.filter((c) => c.url.startsWith(`${BRIDGE_URL}/api/v1/memory?`));
329 assert.equal(memCalls.length, 1);
330 assert.ok(memCalls[0].url.includes('limit=15'), memCalls[0].url);
331 assert.ok(memCalls[0].url.includes('type=search'), memCalls[0].url);
332 assert.equal(memCalls[0].init?.headers?.['Authorization'], 'Bearer tok-test');
333 assert.equal(memCalls[0].init?.headers?.['X-Vault-Id'], 'v-test');
334 } finally {
335 globalThis.fetch = origFetch;
336 try {
337 await client.close();
338 } catch (_) {}
339 }
340 });
341 });
342
343 describe('hosted MCP getPrompt — memory-informed-search', () => {
344 it('POSTs vault search then GETs memory type=search then GETs notes', async () => {
345 const calls = [];
346 const origFetch = globalThis.fetch;
347 globalThis.fetch = async (url, init) => {
348 calls.push({ url: String(url), init });
349 const u = String(url);
350 if (u === `${BRIDGE_URL}/api/v1/search`) {
351 return {
352 ok: true,
353 status: 200,
354 json: async () => ({ results: [{ path: 'inbox/hit.md' }] }),
355 text: async () => '{}',
356 };
357 }
358 if (u.startsWith(`${BRIDGE_URL}/api/v1/memory?`) && u.includes('type=search')) {
359 return {
360 ok: true,
361 status: 200,
362 json: async () => ({ events: [], count: 0 }),
363 text: async () => '{}',
364 };
365 }
366 if (u === `${CANISTER_URL}/api/v1/notes/inbox%2Fhit.md`) {
367 return {
368 ok: true,
369 status: 200,
370 json: async () => ({
371 path: 'inbox/hit.md',
372 body: 'b',
373 frontmatter: {},
374 }),
375 text: async () => '{}',
376 };
377 }
378 return {
379 ok: true,
380 status: 200,
381 json: async () => ({}),
382 text: async () => '{}',
383 };
384 };
385
386 const mcpServer = createHostedMcpServer({
387 userId: 'u-test',
388 vaultId: 'v-test',
389 role: 'viewer',
390 token: 'tok-test',
391 canisterUrl: CANISTER_URL,
392 bridgeUrl: BRIDGE_URL,
393 });
394 const client = new Client({ name: 'mem-inf-prompt-test', version: '0.0.1' });
395 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
396 await mcpServer.connect(serverTransport);
397 await client.connect(clientTransport);
398 try {
399 const res = await client.getPrompt({
400 name: 'memory-informed-search',
401 arguments: { query: 'widgets' },
402 });
403 assert.ok(res.messages && res.messages.length >= 1);
404 assert.equal(calls.filter((c) => c.url === `${BRIDGE_URL}/api/v1/search`).length, 1);
405 const memCalls = calls.filter((c) => c.url.startsWith(`${BRIDGE_URL}/api/v1/memory?`));
406 assert.equal(memCalls.length, 1);
407 assert.ok(memCalls[0].url.includes('type=search'), memCalls[0].url);
408 const getCalls = calls.filter((c) => c.url.includes(`${CANISTER_URL}/api/v1/notes/inbox%2Fhit.md`));
409 assert.ok(getCalls.length >= 1);
410 } finally {
411 globalThis.fetch = origFetch;
412 try {
413 await client.close();
414 } catch (_) {}
415 }
416 });
417 });
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