mcp-hosted-import.test.mjs
208 lines 6.7 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Hosted MCP import tool: POST multipart to bridge /api/v1/import with JWT + X-Vault-Id.
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-1',
17 vaultId: 'v-1',
18 role: 'admin',
19 token: 'tok-test',
20 canisterUrl: CANISTER_URL,
21 bridgeUrl: BRIDGE_URL,
22 ...overrides,
23 };
24 }
25
26 function installFetchMock(json = { imported: [{ path: 'x.md' }], count: 1 }) {
27 const calls = [];
28 const origFetch = globalThis.fetch;
29 globalThis.fetch = async (url, init) => {
30 calls.push({ url: String(url), init });
31 return {
32 ok: true,
33 status: 200,
34 headers: { get: () => 'application/json' },
35 text: async () => JSON.stringify(json),
36 json: async () => json,
37 };
38 };
39 return {
40 calls,
41 restore() {
42 globalThis.fetch = origFetch;
43 },
44 };
45 }
46
47 async function connectPair(ctx) {
48 const mcpServer = createHostedMcpServer(ctx ?? makeCtx());
49 const client = new Client({ name: 'import-test', version: '0.0.1' });
50 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
51 await mcpServer.connect(serverTransport);
52 await client.connect(clientTransport);
53 return { client, mcpServer };
54 }
55
56 describe('hosted MCP import — bridge multipart', () => {
57 let mock;
58 let client;
59
60 beforeEach(() => {
61 mock = installFetchMock();
62 });
63
64 afterEach(async () => {
65 mock.restore();
66 try {
67 await client?.close();
68 } catch (_) {}
69 });
70
71 it('sends POST to {bridgeUrl}/api/v1/import with Authorization and X-Vault-Id', async () => {
72 ({ client } = await connectPair());
73 const payload = Buffer.from('# hi\n', 'utf8').toString('base64');
74 await client.callTool({
75 name: 'import',
76 arguments: { source_type: 'markdown', file_base64: payload, filename: 'note.md' },
77 });
78
79 assert.equal(mock.calls.length, 1);
80 assert.equal(mock.calls[0].url, `${BRIDGE_URL}/api/v1/import`);
81 assert.equal(mock.calls[0].init.method, 'POST');
82 const h = mock.calls[0].init.headers;
83 assert.equal(h['Authorization'], 'Bearer tok-test');
84 assert.equal(h['X-Vault-Id'], 'v-1');
85 assert.ok(!h['Content-Type'], 'fetch must set multipart boundary (no manual application/json)');
86 });
87
88 it('does not send X-Gateway-Auth (bridge import, not direct canister)', async () => {
89 ({ client } = await connectPair(
90 makeCtx({ canisterAuthSecret: 'secret-for-canister-only' })
91 ));
92 const payload = Buffer.from('x', 'utf8').toString('base64');
93 await client.callTool({
94 name: 'import',
95 arguments: { source_type: 'markdown', file_base64: payload, filename: 'a.md' },
96 });
97 const h = mock.calls[0].init.headers;
98 assert.equal(h['X-Gateway-Auth'], undefined);
99 });
100
101 it('FormData includes source_type, file, optional project and tags', async () => {
102 ({ client } = await connectPair());
103 const payload = Buffer.from('body', 'utf8').toString('base64');
104 await client.callTool({
105 name: 'import',
106 arguments: {
107 source_type: 'markdown',
108 file_base64: payload,
109 filename: 'z.md',
110 project: 'demo-proj',
111 tags: ['a', 'b'],
112 },
113 });
114 const body = mock.calls[0].init.body;
115 assert.ok(body instanceof FormData);
116 assert.equal(body.get('source_type'), 'markdown');
117 assert.equal(body.get('project'), 'demo-proj');
118 assert.equal(body.get('tags'), 'a,b');
119 assert.ok(body.get('file'), 'file field set');
120 });
121
122 it('accepts source_type docx (multipart parity with Hub)', async () => {
123 ({ client } = await connectPair());
124 const payload = Buffer.from('PK\x03\x04', 'utf8').toString('base64');
125 await client.callTool({
126 name: 'import',
127 arguments: { source_type: 'docx', file_base64: payload, filename: 'x.docx' },
128 });
129 const body = mock.calls[0].init.body;
130 assert.ok(body instanceof FormData);
131 assert.equal(body.get('source_type'), 'docx');
132 });
133
134 it('rejects base64 that decodes to an empty file', async () => {
135 ({ client } = await connectPair());
136 const res = await client.callTool({
137 name: 'import',
138 arguments: { source_type: 'markdown', file_base64: '%%%', filename: 'x.md' },
139 });
140 assert.ok(res.isError);
141 const text = res.content[0].text;
142 assert.ok(/empty/i.test(text), text);
143 });
144
145 it('import tool is not registered for viewer', async () => {
146 ({ client } = await connectPair(makeCtx({ role: 'viewer' })));
147 const res = await client.callTool({
148 name: 'import',
149 arguments: { source_type: 'markdown', file_base64: Buffer.from('x').toString('base64'), filename: 'x.md' },
150 });
151 assert.ok(res.isError);
152 });
153 });
154
155 describe('hosted MCP import_url — bridge JSON', () => {
156 let mock;
157 let client;
158
159 beforeEach(() => {
160 mock = installFetchMock({ imported: [{ path: 'inbox/imports/url/abc.md' }], count: 1 });
161 });
162
163 afterEach(async () => {
164 mock.restore();
165 try {
166 await client?.close();
167 } catch (_) {}
168 });
169
170 it('POST {bridgeUrl}/api/v1/import-url with JSON body and auth headers', async () => {
171 ({ client } = await connectPair());
172 await client.callTool({
173 name: 'import_url',
174 arguments: { url: 'https://example.com/a', mode: 'bookmark', project: 'p1', tags: 't1,t2' },
175 });
176 assert.equal(mock.calls.length, 1);
177 assert.equal(mock.calls[0].url, `${BRIDGE_URL}/api/v1/import-url`);
178 assert.equal(mock.calls[0].init.method, 'POST');
179 const h = mock.calls[0].init.headers;
180 assert.equal(h['Authorization'], 'Bearer tok-test');
181 assert.equal(h['X-Vault-Id'], 'v-1');
182 assert.equal(h['Content-Type'], 'application/json');
183 const body = JSON.parse(mock.calls[0].init.body);
184 assert.equal(body.url, 'https://example.com/a');
185 assert.equal(body.mode, 'bookmark');
186 assert.equal(body.project, 'p1');
187 assert.equal(body.tags, 't1,t2');
188 });
189
190 it('import_url rejects non-https URL without calling bridge', async () => {
191 ({ client } = await connectPair());
192 const res = await client.callTool({
193 name: 'import_url',
194 arguments: { url: 'http://example.com/x' },
195 });
196 assert.ok(res.isError);
197 assert.equal(mock.calls.length, 0);
198 });
199
200 it('import_url is not registered for viewer', async () => {
201 ({ client } = await connectPair(makeCtx({ role: 'viewer' })));
202 const res = await client.callTool({
203 name: 'import_url',
204 arguments: { url: 'https://example.com/' },
205 });
206 assert.ok(res.isError);
207 });
208 });
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