mcp-hosted-prompts.test.mjs
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