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