mcp-gateway-proxy.test.mjs file-level

at sha256:6 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:6 fix(mcp): bound hosted discovery context Bound hosted MCP context setu… · aaronrene · Jun 8, 2026
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 });