mcp-gateway-proxy.test.mjs
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | import { describe, it } from 'node:test'; |
| 2 | import assert from 'node:assert/strict'; |
| 3 | import http from 'node:http'; |
| 4 | import express from 'express'; |
| 5 | import { allowedToolsForRole, isToolAllowed, filterToolsByRole } from '../hub/gateway/mcp-tool-acl.mjs'; |
| 6 | |
| 7 | describe('mcp-tool-acl', () => { |
| 8 | describe('allowedToolsForRole', () => { |
| 9 | it('viewer gets read-only tools', () => { |
| 10 | const tools = allowedToolsForRole('viewer'); |
| 11 | assert.ok(tools.has('search')); |
| 12 | assert.ok(tools.has('get_note')); |
| 13 | assert.ok(tools.has('get_document_tree')); |
| 14 | assert.ok(tools.has('get_metadata_facets')); |
| 15 | assert.ok(tools.has('list_notes')); |
| 16 | assert.ok(tools.has('summarize')); |
| 17 | assert.ok(tools.has('enrich')); |
| 18 | assert.ok(!tools.has('write')); |
| 19 | assert.ok(!tools.has('index')); |
| 20 | assert.ok(!tools.has('export')); |
| 21 | assert.ok(!tools.has('import')); |
| 22 | }); |
| 23 | |
| 24 | it('editor gets read + write tools', () => { |
| 25 | const tools = allowedToolsForRole('editor'); |
| 26 | assert.ok(tools.has('search')); |
| 27 | assert.ok(tools.has('get_document_tree')); |
| 28 | assert.ok(tools.has('get_metadata_facets')); |
| 29 | assert.ok(tools.has('write')); |
| 30 | assert.ok(tools.has('hub_create_proposal')); |
| 31 | assert.ok(tools.has('capture')); |
| 32 | assert.ok(!tools.has('index')); |
| 33 | assert.ok(!tools.has('export')); |
| 34 | }); |
| 35 | |
| 36 | it('evaluator gets same write-class tools as editor (incl. hub_create_proposal)', () => { |
| 37 | const tools = allowedToolsForRole('evaluator'); |
| 38 | assert.ok(tools.has('get_document_tree')); |
| 39 | assert.ok(tools.has('get_metadata_facets')); |
| 40 | assert.ok(tools.has('write')); |
| 41 | assert.ok(tools.has('hub_create_proposal')); |
| 42 | assert.ok(!tools.has('index')); |
| 43 | }); |
| 44 | |
| 45 | it('admin gets all tools', () => { |
| 46 | const tools = allowedToolsForRole('admin'); |
| 47 | assert.ok(tools.has('search')); |
| 48 | assert.ok(tools.has('get_document_tree')); |
| 49 | assert.ok(tools.has('get_metadata_facets')); |
| 50 | assert.ok(tools.has('write')); |
| 51 | assert.ok(tools.has('hub_create_proposal')); |
| 52 | assert.ok(tools.has('index')); |
| 53 | assert.ok(tools.has('export')); |
| 54 | assert.ok(tools.has('import')); |
| 55 | }); |
| 56 | |
| 57 | it('unknown role defaults to viewer', () => { |
| 58 | const tools = allowedToolsForRole('unknown'); |
| 59 | assert.ok(tools.has('search')); |
| 60 | assert.ok(!tools.has('write')); |
| 61 | assert.ok(!tools.has('index')); |
| 62 | }); |
| 63 | }); |
| 64 | |
| 65 | describe('isToolAllowed', () => { |
| 66 | it('returns true for allowed tool', () => { |
| 67 | assert.ok(isToolAllowed('search', 'viewer')); |
| 68 | assert.ok(isToolAllowed('get_document_tree', 'viewer')); |
| 69 | assert.ok(isToolAllowed('get_metadata_facets', 'viewer')); |
| 70 | assert.ok(isToolAllowed('get_document_tree', 'editor')); |
| 71 | assert.ok(isToolAllowed('get_metadata_facets', 'editor')); |
| 72 | assert.ok(isToolAllowed('get_document_tree', 'evaluator')); |
| 73 | assert.ok(isToolAllowed('get_metadata_facets', 'evaluator')); |
| 74 | assert.ok(isToolAllowed('get_document_tree', 'admin')); |
| 75 | assert.ok(isToolAllowed('get_metadata_facets', 'admin')); |
| 76 | assert.ok(isToolAllowed('write', 'editor')); |
| 77 | assert.ok(isToolAllowed('hub_create_proposal', 'editor')); |
| 78 | assert.ok(isToolAllowed('hub_create_proposal', 'evaluator')); |
| 79 | assert.ok(isToolAllowed('index', 'admin')); |
| 80 | }); |
| 81 | |
| 82 | it('returns false for disallowed tool', () => { |
| 83 | assert.ok(!isToolAllowed('write', 'viewer')); |
| 84 | assert.ok(!isToolAllowed('hub_create_proposal', 'viewer')); |
| 85 | assert.ok(!isToolAllowed('index', 'editor')); |
| 86 | assert.ok(!isToolAllowed('index', 'evaluator')); |
| 87 | }); |
| 88 | }); |
| 89 | |
| 90 | describe('filterToolsByRole', () => { |
| 91 | it('filters tool definitions by role', () => { |
| 92 | const allTools = [ |
| 93 | { name: 'search' }, |
| 94 | { name: 'write' }, |
| 95 | { name: 'index' }, |
| 96 | { name: 'get_note' }, |
| 97 | ]; |
| 98 | const viewerTools = filterToolsByRole(allTools, 'viewer'); |
| 99 | assert.equal(viewerTools.length, 2); |
| 100 | assert.deepEqual(viewerTools.map((t) => t.name).sort(), ['get_note', 'search']); |
| 101 | |
| 102 | const editorTools = filterToolsByRole(allTools, 'editor'); |
| 103 | assert.equal(editorTools.length, 3); |
| 104 | |
| 105 | const adminTools = filterToolsByRole(allTools, 'admin'); |
| 106 | assert.equal(adminTools.length, 4); |
| 107 | }); |
| 108 | }); |
| 109 | }); |
| 110 | |
| 111 | describe('mcp-proxy-router', () => { |
| 112 | describe('parseMcpSessionTtlMs / parseMcpMaxSessionsPerUser', () => { |
| 113 | it('defaults when env unset', async () => { |
| 114 | const { parseMcpSessionTtlMs, parseMcpMaxSessionsPerUser } = await import('../hub/gateway/mcp-proxy.mjs'); |
| 115 | assert.equal(parseMcpSessionTtlMs({}), 8 * 60 * 60 * 1000); |
| 116 | assert.equal(parseMcpMaxSessionsPerUser({}), 8); |
| 117 | }); |
| 118 | |
| 119 | it('respects valid env overrides', async () => { |
| 120 | const { parseMcpSessionTtlMs, parseMcpMaxSessionsPerUser } = await import('../hub/gateway/mcp-proxy.mjs'); |
| 121 | assert.equal(parseMcpSessionTtlMs({ MCP_SESSION_TTL_MS: '3600000' }), 3600000); |
| 122 | assert.equal(parseMcpMaxSessionsPerUser({ MCP_MAX_SESSIONS_PER_USER: '12' }), 12); |
| 123 | }); |
| 124 | |
| 125 | it('clamps TTL and max sessions', async () => { |
| 126 | const { parseMcpSessionTtlMs, parseMcpMaxSessionsPerUser } = await import('../hub/gateway/mcp-proxy.mjs'); |
| 127 | assert.equal(parseMcpSessionTtlMs({ MCP_SESSION_TTL_MS: '1000' }), 5 * 60 * 1000); |
| 128 | assert.equal(parseMcpSessionTtlMs({ MCP_SESSION_TTL_MS: '999999999999' }), 24 * 60 * 60 * 1000); |
| 129 | assert.equal(parseMcpMaxSessionsPerUser({ MCP_MAX_SESSIONS_PER_USER: '1' }), 2); |
| 130 | assert.equal(parseMcpMaxSessionsPerUser({ MCP_MAX_SESSIONS_PER_USER: '99' }), 20); |
| 131 | }); |
| 132 | |
| 133 | it('ignores non-numeric env (falls back to default)', async () => { |
| 134 | const { parseMcpSessionTtlMs, parseMcpMaxSessionsPerUser } = await import('../hub/gateway/mcp-proxy.mjs'); |
| 135 | assert.equal(parseMcpSessionTtlMs({ MCP_SESSION_TTL_MS: 'abc' }), 8 * 60 * 60 * 1000); |
| 136 | assert.equal(parseMcpMaxSessionsPerUser({ MCP_MAX_SESSIONS_PER_USER: 'x' }), 8); |
| 137 | }); |
| 138 | |
| 139 | it('recognizes initialize as the only session-creation JSON-RPC method', async () => { |
| 140 | const { isMcpInitializeRequest } = await import('../hub/gateway/mcp-proxy.mjs'); |
| 141 | assert.equal(isMcpInitializeRequest({ jsonrpc: '2.0', id: 1, method: 'initialize' }), true); |
| 142 | assert.equal(isMcpInitializeRequest({ jsonrpc: '2.0', id: 2, method: 'tools/list' }), false); |
| 143 | assert.equal( |
| 144 | isMcpInitializeRequest([ |
| 145 | { jsonrpc: '2.0', id: 1, method: 'notifications/initialized' }, |
| 146 | { jsonrpc: '2.0', id: 2, method: 'initialize' }, |
| 147 | ]), |
| 148 | true, |
| 149 | ); |
| 150 | }); |
| 151 | }); |
| 152 | |
| 153 | describe('createMcpProxyRouter', () => { |
| 154 | it('creates a router with required methods', async () => { |
| 155 | const { createMcpProxyRouter } = await import('../hub/gateway/mcp-proxy.mjs'); |
| 156 | const router = createMcpProxyRouter({ |
| 157 | getUserId: () => null, |
| 158 | getHostedAccessContext: async () => null, |
| 159 | canisterUrl: 'http://localhost:9999', |
| 160 | bridgeUrl: 'http://localhost:9998', |
| 161 | sessionSecret: 'test-secret', |
| 162 | }); |
| 163 | assert.ok(router); |
| 164 | assert.ok(typeof router === 'function'); |
| 165 | assert.ok(router._sessions instanceof Map); |
| 166 | assert.ok(router._userSessions instanceof Map); |
| 167 | clearInterval(router._cleanup); |
| 168 | }); |
| 169 | |
| 170 | it('rejects unauthenticated requests', async () => { |
| 171 | const { createMcpProxyRouter } = await import('../hub/gateway/mcp-proxy.mjs'); |
| 172 | const router = createMcpProxyRouter({ |
| 173 | getUserId: () => null, |
| 174 | getHostedAccessContext: async () => null, |
| 175 | canisterUrl: 'http://localhost:9999', |
| 176 | bridgeUrl: 'http://localhost:9998', |
| 177 | sessionSecret: 'test-secret', |
| 178 | }); |
| 179 | |
| 180 | let statusCode = null; |
| 181 | let body = null; |
| 182 | const mockReq = { |
| 183 | headers: {}, |
| 184 | method: 'POST', |
| 185 | url: '/', |
| 186 | }; |
| 187 | const mockRes = { |
| 188 | status(code) { statusCode = code; return this; }, |
| 189 | json(data) { body = data; return this; }, |
| 190 | set() { return this; }, |
| 191 | headersSent: false, |
| 192 | }; |
| 193 | const mockNext = () => {}; |
| 194 | |
| 195 | const middleware = router.stack.find((layer) => !layer.route); |
| 196 | if (middleware) { |
| 197 | middleware.handle(mockReq, mockRes, mockNext); |
| 198 | assert.equal(statusCode, 401); |
| 199 | assert.ok(body?.error); |
| 200 | } |
| 201 | |
| 202 | clearInterval(router._cleanup); |
| 203 | }); |
| 204 | |
| 205 | it('session pool is initially empty', async () => { |
| 206 | const { createMcpProxyRouter } = await import('../hub/gateway/mcp-proxy.mjs'); |
| 207 | const router = createMcpProxyRouter({ |
| 208 | getUserId: () => 'user-1', |
| 209 | getHostedAccessContext: async () => ({ role: 'viewer', scope: {} }), |
| 210 | canisterUrl: 'http://localhost:9999', |
| 211 | bridgeUrl: 'http://localhost:9998', |
| 212 | sessionSecret: 'test-secret', |
| 213 | }); |
| 214 | assert.equal(router._sessions.size, 0); |
| 215 | assert.equal(router._userSessions.size, 0); |
| 216 | clearInterval(router._cleanup); |
| 217 | }); |
| 218 | |
| 219 | it('rejects sessionless tools/list without bridge hosted-context lookup', async (t) => { |
| 220 | const { createMcpProxyRouter } = await import('../hub/gateway/mcp-proxy.mjs'); |
| 221 | let hostedContextCalls = 0; |
| 222 | const router = createMcpProxyRouter({ |
| 223 | getUserId: () => 'user-1', |
| 224 | getHostedAccessContext: async () => { |
| 225 | hostedContextCalls += 1; |
| 226 | return { role: 'viewer', scope: {}, allowed_vault_ids: ['Business'] }; |
| 227 | }, |
| 228 | canisterUrl: 'http://localhost:9999', |
| 229 | bridgeUrl: 'http://localhost:9998', |
| 230 | sessionSecret: 'test-secret', |
| 231 | }); |
| 232 | t.after(() => clearInterval(router._cleanup)); |
| 233 | |
| 234 | const app = express(); |
| 235 | app.use(express.json()); |
| 236 | app.use('/mcp', router); |
| 237 | const srv = http.createServer(app); |
| 238 | await new Promise((resolve, reject) => { |
| 239 | srv.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve())); |
| 240 | }); |
| 241 | t.after(() => new Promise((resolve) => srv.close(() => resolve()))); |
| 242 | const port = srv.address().port; |
| 243 | |
| 244 | const res = await fetch(`http://127.0.0.1:${port}/mcp`, { |
| 245 | method: 'POST', |
| 246 | headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, |
| 247 | body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }), |
| 248 | }); |
| 249 | const body = await res.json(); |
| 250 | assert.equal(res.status, 404); |
| 251 | assert.equal(body.error?.message, 'Session not found'); |
| 252 | assert.equal(hostedContextCalls, 0); |
| 253 | }); |
| 254 | }); |
| 255 | }); |
| 256 | |
| 257 | describe('mcp-hosted-server', () => { |
| 258 | it('creates a server instance with role-filtered tools', async () => { |
| 259 | const { createHostedMcpServer } = await import('../hub/gateway/mcp-hosted-server.mjs'); |
| 260 | const server = createHostedMcpServer({ |
| 261 | userId: 'test-user', |
| 262 | vaultId: 'test-vault', |
| 263 | role: 'viewer', |
| 264 | token: 'test-token', |
| 265 | canisterUrl: 'http://localhost:9999', |
| 266 | bridgeUrl: 'http://localhost:9998', |
| 267 | }); |
| 268 | assert.ok(server); |
| 269 | assert.ok(server.server); |
| 270 | }); |
| 271 | |
| 272 | it('viewer does not get write or index tools', async () => { |
| 273 | const { createHostedMcpServer } = await import('../hub/gateway/mcp-hosted-server.mjs'); |
| 274 | const server = createHostedMcpServer({ |
| 275 | userId: 'test-user', |
| 276 | vaultId: 'test-vault', |
| 277 | role: 'viewer', |
| 278 | token: 'test-token', |
| 279 | canisterUrl: 'http://localhost:9999', |
| 280 | bridgeUrl: 'http://localhost:9998', |
| 281 | }); |
| 282 | assert.ok(server); |
| 283 | }); |
| 284 | |
| 285 | it('admin gets all tools', async () => { |
| 286 | const { createHostedMcpServer } = await import('../hub/gateway/mcp-hosted-server.mjs'); |
| 287 | const server = createHostedMcpServer({ |
| 288 | userId: 'admin-user', |
| 289 | vaultId: 'test-vault', |
| 290 | role: 'admin', |
| 291 | token: 'test-token', |
| 292 | canisterUrl: 'http://localhost:9999', |
| 293 | bridgeUrl: 'http://localhost:9998', |
| 294 | }); |
| 295 | assert.ok(server); |
| 296 | }); |
| 297 | }); |