mcp-hosted-canister-user-parity.test.mjs
324 lines 11.2 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Hosted MCP must send the same canister X-User-Id as the Hub gateway (effective workspace user).
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 function headerGet(headers, name) {
15 if (!headers) return undefined;
16 if (typeof headers.get === 'function') return headers.get(name);
17 return headers[name] ?? headers[name.toLowerCase()];
18 }
19
20 describe('hosted MCP canister user parity', () => {
21 let origFetch;
22
23 afterEach(() => {
24 globalThis.fetch = origFetch;
25 });
26
27 it('list_notes uses canisterUserId for X-User-Id when set', async () => {
28 /** @type {Record<string, string> | import('node:fetch').Headers | undefined} */
29 let sawHeaders;
30 origFetch = globalThis.fetch;
31 globalThis.fetch = async (url, init) => {
32 const u = String(url);
33 if (u.startsWith(`${CANISTER_URL}/api/v1/notes`)) {
34 sawHeaders = init?.headers;
35 return {
36 ok: true,
37 status: 200,
38 json: async () => ({ notes: [], total: 0 }),
39 text: async () => '{"notes":[],"total":0}',
40 };
41 }
42 return { ok: false, status: 404, json: async () => ({}), text: async () => '' };
43 };
44
45 const mcpServer = createHostedMcpServer({
46 userId: 'google:actor',
47 canisterUserId: 'google:owner',
48 vaultId: 'default',
49 role: 'viewer',
50 token: 'tok',
51 canisterUrl: CANISTER_URL,
52 bridgeUrl: BRIDGE_URL,
53 });
54 const client = new Client({ name: 'parity-test', version: '0.0.1' });
55 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
56 await mcpServer.connect(serverTransport);
57 await client.connect(clientTransport);
58 try {
59 await client.callTool({ name: 'list_notes', arguments: {} });
60 assert.equal(headerGet(sawHeaders, 'X-User-Id'), 'google:owner');
61 } finally {
62 await client.close();
63 }
64 });
65
66 it('list_notes falls back to userId when canisterUserId omitted', async () => {
67 let sawHeaders;
68 origFetch = globalThis.fetch;
69 globalThis.fetch = async (url, init) => {
70 const u = String(url);
71 if (u.startsWith(`${CANISTER_URL}/api/v1/notes`)) {
72 sawHeaders = init?.headers;
73 return {
74 ok: true,
75 status: 200,
76 json: async () => ({ notes: [], total: 0 }),
77 text: async () => '{"notes":[],"total":0}',
78 };
79 }
80 return { ok: false, status: 404, json: async () => ({}), text: async () => '' };
81 };
82
83 const mcpServer = createHostedMcpServer({
84 userId: 'google:only',
85 vaultId: 'default',
86 role: 'viewer',
87 token: 'tok',
88 canisterUrl: CANISTER_URL,
89 bridgeUrl: BRIDGE_URL,
90 });
91 const client = new Client({ name: 'parity-test-2', version: '0.0.1' });
92 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
93 await mcpServer.connect(serverTransport);
94 await client.connect(clientTransport);
95 try {
96 await client.callTool({ name: 'list_notes', arguments: {} });
97 assert.equal(headerGet(sawHeaders, 'X-User-Id'), 'google:only');
98 } finally {
99 await client.close();
100 }
101 });
102
103 it('get_note uses canisterUserId for X-User-Id when set', async () => {
104 let sawHeaders;
105 origFetch = globalThis.fetch;
106 globalThis.fetch = async (url, init) => {
107 const u = String(url);
108 if (u.includes(`${CANISTER_URL}/api/v1/notes/`) && u.endsWith('only.md')) {
109 sawHeaders = init?.headers;
110 return {
111 ok: true,
112 status: 200,
113 json: async () => ({ path: 'only.md', body: 'b', frontmatter: '{}' }),
114 text: async () => '{}',
115 };
116 }
117 return { ok: false, status: 404, json: async () => ({}), text: async () => '' };
118 };
119
120 const mcpServer = createHostedMcpServer({
121 userId: 'google:actor',
122 canisterUserId: 'google:owner',
123 vaultId: 'default',
124 role: 'viewer',
125 token: 'tok',
126 canisterUrl: CANISTER_URL,
127 bridgeUrl: BRIDGE_URL,
128 });
129 const client = new Client({ name: 'parity-get-note', version: '0.0.1' });
130 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
131 await mcpServer.connect(serverTransport);
132 await client.connect(clientTransport);
133 try {
134 await client.callTool({ name: 'get_note', arguments: { path: 'only.md' } });
135 assert.equal(headerGet(sawHeaders, 'X-User-Id'), 'google:owner');
136 } finally {
137 await client.close();
138 }
139 });
140
141 it('write uses canisterUserId for X-User-Id when set', async () => {
142 let sawHeaders;
143 origFetch = globalThis.fetch;
144 globalThis.fetch = async (url, init) => {
145 const u = String(url);
146 if (u === `${CANISTER_URL}/api/v1/notes` && String(init?.method || 'GET').toUpperCase() === 'POST') {
147 sawHeaders = init?.headers;
148 return {
149 ok: true,
150 status: 200,
151 json: async () => ({ path: 'new.md', ok: true }),
152 text: async () => '{}',
153 };
154 }
155 return { ok: false, status: 404, json: async () => ({}), text: async () => '' };
156 };
157
158 const mcpServer = createHostedMcpServer({
159 userId: 'google:actor',
160 canisterUserId: 'google:owner',
161 vaultId: 'default',
162 role: 'editor',
163 token: 'tok',
164 canisterUrl: CANISTER_URL,
165 bridgeUrl: BRIDGE_URL,
166 });
167 const client = new Client({ name: 'parity-write', version: '0.0.1' });
168 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
169 await mcpServer.connect(serverTransport);
170 await client.connect(clientTransport);
171 try {
172 await client.callTool({
173 name: 'write',
174 arguments: { path: 'new.md', body: '# hello' },
175 });
176 assert.equal(headerGet(sawHeaders, 'X-User-Id'), 'google:owner');
177 } finally {
178 await client.close();
179 }
180 });
181
182 it('capture uses canisterUserId for X-User-Id when set', async () => {
183 let sawHeaders;
184 let postBody;
185 origFetch = globalThis.fetch;
186 globalThis.fetch = async (url, init) => {
187 const u = String(url);
188 if (u === `${CANISTER_URL}/api/v1/notes` && String(init?.method || 'GET').toUpperCase() === 'POST') {
189 sawHeaders = init?.headers;
190 postBody = init?.body != null ? JSON.parse(String(init.body)) : null;
191 return {
192 ok: true,
193 status: 200,
194 json: async () => ({ path: postBody?.path ?? 'inbox/x.md', written: true }),
195 text: async () => '{}',
196 };
197 }
198 return { ok: false, status: 404, json: async () => ({}), text: async () => '' };
199 };
200
201 const mcpServer = createHostedMcpServer({
202 userId: 'google:actor',
203 canisterUserId: 'google:owner',
204 vaultId: 'default',
205 role: 'editor',
206 token: 'tok',
207 canisterUrl: CANISTER_URL,
208 bridgeUrl: BRIDGE_URL,
209 });
210 const client = new Client({ name: 'parity-capture', version: '0.0.1' });
211 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
212 await mcpServer.connect(serverTransport);
213 await client.connect(clientTransport);
214 try {
215 await client.callTool({
216 name: 'capture',
217 arguments: { text: 'Hello capture parity' },
218 });
219 assert.equal(headerGet(sawHeaders, 'X-User-Id'), 'google:owner');
220 assert.ok(postBody && typeof postBody.path === 'string');
221 assert.ok(postBody.path.startsWith('inbox/'));
222 assert.equal(postBody.body, 'Hello capture parity');
223 assert.equal(postBody.frontmatter?.source, 'mcp-capture');
224 assert.equal(postBody.frontmatter?.inbox, true);
225 } finally {
226 await client.close();
227 }
228 });
229
230 it('export uses canisterUserId for X-User-Id when set', async () => {
231 let sawHeaders;
232 origFetch = globalThis.fetch;
233 globalThis.fetch = async (url, init) => {
234 const u = String(url);
235 if (u.endsWith('/api/v1/export')) {
236 sawHeaders = init?.headers;
237 const enc = new TextEncoder();
238 const bytes = enc.encode('{"notes":[]}');
239 const buf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
240 return {
241 ok: true,
242 status: 200,
243 arrayBuffer: async () => buf,
244 headers: { get: () => 'application/json' },
245 };
246 }
247 return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0), text: async () => '' };
248 };
249
250 const mcpServer = createHostedMcpServer({
251 userId: 'google:actor',
252 canisterUserId: 'google:owner',
253 vaultId: 'default',
254 role: 'admin',
255 token: 'tok',
256 canisterUrl: CANISTER_URL,
257 bridgeUrl: BRIDGE_URL,
258 });
259 const client = new Client({ name: 'parity-export', version: '0.0.1' });
260 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
261 await mcpServer.connect(serverTransport);
262 await client.connect(clientTransport);
263 try {
264 await client.callTool({ name: 'export', arguments: {} });
265 assert.equal(headerGet(sawHeaders, 'X-User-Id'), 'google:owner');
266 } finally {
267 await client.close();
268 }
269 });
270
271 it('vault-info resource exposes actor userId and canisterUserId', async () => {
272 origFetch = globalThis.fetch;
273 globalThis.fetch = async (url) => {
274 const u = String(url);
275 if (u.startsWith(`${CANISTER_URL}/api/v1/notes`)) {
276 return {
277 ok: true,
278 status: 200,
279 json: async () => ({ notes: [], total: 0 }),
280 text: async () => '{"notes":[],"total":0}',
281 };
282 }
283 // R3+ resources/list merges memory topic template list → GET bridge /api/v1/memory
284 if (u.startsWith(`${BRIDGE_URL}/api/v1/memory`)) {
285 return {
286 ok: true,
287 status: 200,
288 json: async () => ({ events: [], count: 0 }),
289 text: async () => '{"events":[],"count":0}',
290 };
291 }
292 return { ok: false, status: 404, json: async () => ({}), text: async () => '' };
293 };
294
295 const mcpServer = createHostedMcpServer({
296 userId: 'google:actor',
297 canisterUserId: 'google:owner',
298 vaultId: 'default',
299 role: 'viewer',
300 token: 'tok',
301 canisterUrl: CANISTER_URL,
302 bridgeUrl: BRIDGE_URL,
303 scope: { projects: ['launch'] },
304 });
305 const client = new Client({ name: 'parity-resource', version: '0.0.1' });
306 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
307 await mcpServer.connect(serverTransport);
308 await client.connect(clientTransport);
309 try {
310 const { resources } = await client.listResources();
311 const vault = resources.find((r) => r.uri === 'knowtation://hosted/vault-info');
312 assert.ok(vault, 'vault-info resource listed');
313 const read = await client.readResource({ uri: 'knowtation://hosted/vault-info' });
314 const text = read.contents[0].text;
315 const j = JSON.parse(text);
316 assert.equal(j.userId, 'google:actor');
317 assert.equal(j.canisterUserId, 'google:owner');
318 assert.equal(j.vaultId, 'default');
319 assert.deepEqual(j.scope, { projects: ['launch'] });
320 } finally {
321 await client.close();
322 }
323 });
324 });
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