mcp-hosted-export.test.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Hosted MCP export — GET canister /api/v1/export (parity with hub/bridge/server.mjs backup fetch). |
| 3 | */ |
| 4 | |
| 5 | import { describe, it, beforeEach, 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 BRIDGE_URL = 'http://bridge.test:4321'; |
| 12 | const CANISTER_URL = 'http://canister.test:4322'; |
| 13 | |
| 14 | function makeCtx(overrides = {}) { |
| 15 | return { |
| 16 | userId: 'u-export', |
| 17 | vaultId: 'v-export', |
| 18 | role: 'admin', |
| 19 | token: 'tok-export', |
| 20 | canisterUrl: CANISTER_URL, |
| 21 | bridgeUrl: BRIDGE_URL, |
| 22 | canisterAuthSecret: 'gw-secret', |
| 23 | ...overrides, |
| 24 | }; |
| 25 | } |
| 26 | |
| 27 | function installFetchMock(handler) { |
| 28 | const calls = []; |
| 29 | const origFetch = globalThis.fetch; |
| 30 | globalThis.fetch = async (url, init) => { |
| 31 | calls.push({ url: String(url), init }); |
| 32 | return handler(url, init, calls); |
| 33 | }; |
| 34 | return { |
| 35 | calls, |
| 36 | restore() { |
| 37 | globalThis.fetch = origFetch; |
| 38 | }, |
| 39 | }; |
| 40 | } |
| 41 | |
| 42 | describe('hosted MCP export — canister request shape', () => { |
| 43 | let mock; |
| 44 | let client; |
| 45 | |
| 46 | beforeEach(() => { |
| 47 | const notes = [{ path: 'a.md', frontmatter: '{}', body: 'x' }]; |
| 48 | mock = installFetchMock(() => ({ |
| 49 | ok: true, |
| 50 | status: 200, |
| 51 | arrayBuffer: async () => new TextEncoder().encode(JSON.stringify({ notes })).buffer, |
| 52 | json: async () => ({ notes }), |
| 53 | text: async () => JSON.stringify({ notes }), |
| 54 | })); |
| 55 | }); |
| 56 | |
| 57 | afterEach(async () => { |
| 58 | mock.restore(); |
| 59 | try { |
| 60 | await client?.close(); |
| 61 | } catch (_) {} |
| 62 | }); |
| 63 | |
| 64 | it('sends GET to {canisterUrl}/api/v1/export', async () => { |
| 65 | const mcpServer = createHostedMcpServer(makeCtx()); |
| 66 | client = new Client({ name: 'export-test', version: '0.0.1' }); |
| 67 | const [ct, st] = InMemoryTransport.createLinkedPair(); |
| 68 | await mcpServer.connect(st); |
| 69 | await client.connect(ct); |
| 70 | await client.callTool({ name: 'export', arguments: {} }); |
| 71 | |
| 72 | assert.equal(mock.calls.length, 1); |
| 73 | assert.equal(mock.calls[0].url, `${CANISTER_URL}/api/v1/export`); |
| 74 | assert.equal(mock.calls[0].init.method, 'GET'); |
| 75 | }); |
| 76 | |
| 77 | it('sends Authorization, X-Vault-Id, X-User-Id, X-Gateway-Auth', async () => { |
| 78 | const mcpServer = createHostedMcpServer(makeCtx()); |
| 79 | client = new Client({ name: 'export-test', version: '0.0.1' }); |
| 80 | const [ct, st] = InMemoryTransport.createLinkedPair(); |
| 81 | await mcpServer.connect(st); |
| 82 | await client.connect(ct); |
| 83 | await client.callTool({ name: 'export', arguments: {} }); |
| 84 | |
| 85 | const h = mock.calls[0].init.headers; |
| 86 | assert.equal(h['Authorization'], 'Bearer tok-export'); |
| 87 | assert.equal(h['X-Vault-Id'], 'v-export'); |
| 88 | assert.equal(h['X-User-Id'], 'u-export'); |
| 89 | assert.equal(h['X-Gateway-Auth'], 'gw-secret'); |
| 90 | }); |
| 91 | |
| 92 | it('returns parsed notes JSON as tool content', async () => { |
| 93 | const mcpServer = createHostedMcpServer(makeCtx()); |
| 94 | client = new Client({ name: 'export-test', version: '0.0.1' }); |
| 95 | const [ct, st] = InMemoryTransport.createLinkedPair(); |
| 96 | await mcpServer.connect(st); |
| 97 | await client.connect(ct); |
| 98 | const result = await client.callTool({ name: 'export', arguments: {} }); |
| 99 | |
| 100 | assert.ok(result.content?.length); |
| 101 | const parsed = JSON.parse(result.content[0].text); |
| 102 | assert.equal(parsed.notes.length, 1); |
| 103 | assert.equal(parsed.notes[0].path, 'a.md'); |
| 104 | }); |
| 105 | |
| 106 | it('returns EXPORT_TOO_LARGE when body exceeds cap', async () => { |
| 107 | mock.restore(); |
| 108 | const huge = 'x'.repeat(5 * 1024 * 1024); |
| 109 | mock = installFetchMock(() => ({ |
| 110 | ok: true, |
| 111 | status: 200, |
| 112 | arrayBuffer: async () => new TextEncoder().encode(JSON.stringify({ notes: [{ body: huge }] })).buffer, |
| 113 | json: async () => ({ notes: [{ body: huge }] }), |
| 114 | text: async () => JSON.stringify({ notes: [{ body: huge }] }), |
| 115 | })); |
| 116 | |
| 117 | const mcpServer = createHostedMcpServer(makeCtx()); |
| 118 | client = new Client({ name: 'export-test', version: '0.0.1' }); |
| 119 | const [ct, st] = InMemoryTransport.createLinkedPair(); |
| 120 | await mcpServer.connect(st); |
| 121 | await client.connect(ct); |
| 122 | const result = await client.callTool({ name: 'export', arguments: {} }); |
| 123 | |
| 124 | assert.equal(result.isError, true); |
| 125 | const err = JSON.parse(result.content[0].text); |
| 126 | assert.equal(err.code, 'EXPORT_TOO_LARGE'); |
| 127 | assert.ok(String(err.error).includes('MCP-only')); |
| 128 | }); |
| 129 | |
| 130 | it('returns isError on upstream HTTP failure', async () => { |
| 131 | mock.restore(); |
| 132 | mock = installFetchMock(() => ({ |
| 133 | ok: false, |
| 134 | status: 403, |
| 135 | arrayBuffer: async () => new TextEncoder().encode('{"error":"Gateway authentication required"}').buffer, |
| 136 | text: async () => '{"error":"Gateway authentication required"}', |
| 137 | })); |
| 138 | |
| 139 | const mcpServer = createHostedMcpServer(makeCtx()); |
| 140 | client = new Client({ name: 'export-test', version: '0.0.1' }); |
| 141 | const [ct, st] = InMemoryTransport.createLinkedPair(); |
| 142 | await mcpServer.connect(st); |
| 143 | await client.connect(ct); |
| 144 | const result = await client.callTool({ name: 'export', arguments: {} }); |
| 145 | |
| 146 | assert.equal(result.isError, true); |
| 147 | const err = JSON.parse(result.content[0].text); |
| 148 | assert.equal(err.code, 'UPSTREAM_ERROR'); |
| 149 | }); |
| 150 | |
| 151 | it('viewer role does not register export', async () => { |
| 152 | mock.restore(); |
| 153 | mock = installFetchMock(() => ({ |
| 154 | ok: true, |
| 155 | status: 200, |
| 156 | arrayBuffer: async () => new TextEncoder().encode('{}').buffer, |
| 157 | json: async () => ({}), |
| 158 | text: async () => '{}', |
| 159 | })); |
| 160 | const mcpServer = createHostedMcpServer(makeCtx({ role: 'viewer' })); |
| 161 | const c = new Client({ name: 'export-viewer', version: '0.0.1' }); |
| 162 | const [ct, st] = InMemoryTransport.createLinkedPair(); |
| 163 | await mcpServer.connect(st); |
| 164 | await c.connect(ct); |
| 165 | try { |
| 166 | const { tools } = await c.listTools(); |
| 167 | assert.ok(!tools.some((t) => t.name === 'export')); |
| 168 | } finally { |
| 169 | await c.close(); |
| 170 | mock.restore(); |
| 171 | } |
| 172 | }); |
| 173 | }); |
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