mcp-hosted-search.test.mjs
326 lines 9.9 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Tests for the hosted MCP search tool (Phase E).
3 *
4 * Verifies:
5 * 1. Search uses POST (not GET) to the bridge
6 * 2. Tool arg → bridge body mapping (snake_case → camelCase where needed)
7 * 3. Parity fields: fields, snippet_chars, count_only, match, since/until,
8 * order, chain, entity, episode, content_scope
9 * 4. Minimal call sends only `query` in body
10 */
11
12 import { describe, it, before, after, beforeEach, afterEach } from 'node:test';
13 import assert from 'node:assert/strict';
14 import { Client } from '@modelcontextprotocol/sdk/client/index.js';
15 import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
16 import { createHostedMcpServer } from '../hub/gateway/mcp-hosted-server.mjs';
17
18 const BRIDGE_URL = 'http://bridge.test:4321';
19 const CANISTER_URL = 'http://canister.test:4322';
20
21 function makeCtx(overrides = {}) {
22 return {
23 userId: 'u-1',
24 vaultId: 'v-1',
25 role: 'admin',
26 token: 'tok-test',
27 canisterUrl: CANISTER_URL,
28 bridgeUrl: BRIDGE_URL,
29 ...overrides,
30 };
31 }
32
33 /**
34 * Mock globalThis.fetch, record calls to the bridge search endpoint,
35 * and return a canned response.
36 */
37 function installFetchMock(response = { results: [], query: 'q' }) {
38 const calls = [];
39 const origFetch = globalThis.fetch;
40 globalThis.fetch = async (url, init) => {
41 calls.push({ url: String(url), init });
42 return {
43 ok: true,
44 status: 200,
45 json: async () => response,
46 text: async () => JSON.stringify(response),
47 };
48 };
49 return {
50 calls,
51 restore() {
52 globalThis.fetch = origFetch;
53 },
54 };
55 }
56
57 async function connectPair(ctx) {
58 const mcpServer = createHostedMcpServer(ctx ?? makeCtx());
59 const client = new Client({ name: 'test-client', version: '0.0.1' });
60 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
61 await mcpServer.connect(serverTransport);
62 await client.connect(clientTransport);
63 return { client, mcpServer, clientTransport, serverTransport };
64 }
65
66 describe('hosted MCP search — transport and parity', () => {
67 let mock;
68 let client;
69 let mcpServer;
70
71 beforeEach(async () => {
72 mock = installFetchMock();
73 });
74
75 afterEach(async () => {
76 mock.restore();
77 try { await client?.close(); } catch (_) {}
78 });
79
80 it('sends POST to {bridgeUrl}/api/v1/search', async () => {
81 ({ client, mcpServer } = await connectPair());
82 await client.callTool({ name: 'search', arguments: { query: 'hello' } });
83
84 assert.equal(mock.calls.length, 1, 'exactly one fetch call');
85 const { url, init } = mock.calls[0];
86 assert.equal(url, `${BRIDGE_URL}/api/v1/search`);
87 assert.equal(init.method, 'POST');
88 });
89
90 it('does NOT use query-string params (no ? in URL)', async () => {
91 ({ client, mcpServer } = await connectPair());
92 await client.callTool({ name: 'search', arguments: { query: 'test' } });
93
94 assert.ok(!mock.calls[0].url.includes('?'), 'URL must not contain query params');
95 });
96
97 it('sends Content-Type: application/json', async () => {
98 ({ client, mcpServer } = await connectPair());
99 await client.callTool({ name: 'search', arguments: { query: 'ct' } });
100
101 assert.equal(mock.calls[0].init.headers['Content-Type'], 'application/json');
102 });
103
104 it('forwards auth token as Bearer header', async () => {
105 ({ client, mcpServer } = await connectPair());
106 await client.callTool({ name: 'search', arguments: { query: 'auth' } });
107
108 assert.equal(mock.calls[0].init.headers['Authorization'], 'Bearer tok-test');
109 });
110
111 it('forwards X-Vault-Id header', async () => {
112 ({ client, mcpServer } = await connectPair());
113 await client.callTool({ name: 'search', arguments: { query: 'vaultcheck' } });
114
115 assert.equal(mock.calls[0].init.headers['X-Vault-Id'], 'v-1');
116 });
117
118 it('minimal call sends only query in body', async () => {
119 ({ client, mcpServer } = await connectPair());
120 await client.callTool({ name: 'search', arguments: { query: 'minimal' } });
121
122 const body = JSON.parse(mock.calls[0].init.body);
123 assert.deepEqual(body, { query: 'minimal' });
124 });
125
126 it('maps snippet_chars → snippetChars in body', async () => {
127 ({ client, mcpServer } = await connectPair());
128 await client.callTool({
129 name: 'search',
130 arguments: { query: 'snip', snippet_chars: 150 },
131 });
132
133 const body = JSON.parse(mock.calls[0].init.body);
134 assert.equal(body.snippetChars, 150);
135 assert.equal(body.snippet_chars, undefined, 'snake_case key must not appear');
136 });
137
138 it('maps count_only → count_only in body (bridge reads both forms)', async () => {
139 ({ client, mcpServer } = await connectPair());
140 await client.callTool({
141 name: 'search',
142 arguments: { query: 'cnt', count_only: true },
143 });
144
145 const body = JSON.parse(mock.calls[0].init.body);
146 assert.equal(body.count_only, true);
147 });
148
149 it('passes fields through to body', async () => {
150 ({ client, mcpServer } = await connectPair());
151 await client.callTool({
152 name: 'search',
153 arguments: { query: 'f', fields: 'path' },
154 });
155
156 const body = JSON.parse(mock.calls[0].init.body);
157 assert.equal(body.fields, 'path');
158 });
159
160 it('passes match through to body', async () => {
161 ({ client, mcpServer } = await connectPair());
162 await client.callTool({
163 name: 'search',
164 arguments: { query: 'm', mode: 'keyword', match: 'all_terms' },
165 });
166
167 const body = JSON.parse(mock.calls[0].init.body);
168 assert.equal(body.match, 'all_terms');
169 });
170
171 it('passes all filter fields (since, until, order, chain, entity, episode, content_scope)', async () => {
172 ({ client, mcpServer } = await connectPair());
173 await client.callTool({
174 name: 'search',
175 arguments: {
176 query: 'filters',
177 since: '2025-01-01',
178 until: '2025-12-31',
179 order: 'date-asc',
180 chain: 'c1',
181 entity: 'e1',
182 episode: 'ep1',
183 content_scope: 'notes',
184 },
185 });
186
187 const body = JSON.parse(mock.calls[0].init.body);
188 assert.equal(body.since, '2025-01-01');
189 assert.equal(body.until, '2025-12-31');
190 assert.equal(body.order, 'date-asc');
191 assert.equal(body.chain, 'c1');
192 assert.equal(body.entity, 'e1');
193 assert.equal(body.episode, 'ep1');
194 assert.equal(body.content_scope, 'notes');
195 });
196
197 it('passes folder, project, tag, mode, limit', async () => {
198 ({ client, mcpServer } = await connectPair());
199 await client.callTool({
200 name: 'search',
201 arguments: {
202 query: 'old-params',
203 mode: 'keyword',
204 limit: 5,
205 folder: 'inbox',
206 project: 'proj-1',
207 tag: 'urgent',
208 },
209 });
210
211 const body = JSON.parse(mock.calls[0].init.body);
212 assert.equal(body.mode, 'keyword');
213 assert.equal(body.limit, 5);
214 assert.equal(body.folder, 'inbox');
215 assert.equal(body.project, 'proj-1');
216 assert.equal(body.tag, 'urgent');
217 });
218
219 it('full parity call maps every arg correctly', async () => {
220 ({ client, mcpServer } = await connectPair());
221 await client.callTool({
222 name: 'search',
223 arguments: {
224 query: 'full',
225 mode: 'keyword',
226 match: 'all_terms',
227 limit: 3,
228 fields: 'full',
229 snippet_chars: 200,
230 count_only: false,
231 folder: 'notes',
232 project: 'alpha',
233 tag: 'beta',
234 since: '2024-06-01',
235 until: '2024-12-31',
236 order: 'date',
237 chain: 'ch',
238 entity: 'en',
239 episode: 'ep',
240 content_scope: 'approval_logs',
241 },
242 });
243
244 const body = JSON.parse(mock.calls[0].init.body);
245 assert.deepEqual(body, {
246 query: 'full',
247 mode: 'keyword',
248 match: 'all_terms',
249 limit: 3,
250 fields: 'full',
251 snippetChars: 200,
252 count_only: false,
253 folder: 'notes',
254 project: 'alpha',
255 tag: 'beta',
256 since: '2024-06-01',
257 until: '2024-12-31',
258 order: 'date',
259 chain: 'ch',
260 entity: 'en',
261 episode: 'ep',
262 content_scope: 'approval_logs',
263 });
264 });
265
266 it('returns upstream response as JSON text content', async () => {
267 mock.restore();
268 mock = installFetchMock({ results: [{ path: 'a.md' }], query: 'q' });
269 ({ client, mcpServer } = await connectPair());
270 const result = await client.callTool({ name: 'search', arguments: { query: 'q' } });
271
272 assert.ok(result.content?.length > 0);
273 const parsed = JSON.parse(result.content[0].text);
274 assert.deepEqual(parsed.results, [{ path: 'a.md' }]);
275 });
276
277 it('returns isError: true on upstream failure', async () => {
278 mock.restore();
279 const origFetch = globalThis.fetch;
280 globalThis.fetch = async () => ({
281 ok: false,
282 status: 502,
283 json: async () => ({ error: 'bad' }),
284 text: async () => 'bad gateway',
285 });
286 mock = { calls: [], restore: () => { globalThis.fetch = origFetch; } };
287
288 ({ client, mcpServer } = await connectPair());
289 const result = await client.callTool({ name: 'search', arguments: { query: 'fail' } });
290
291 assert.equal(result.isError, true);
292 const parsed = JSON.parse(result.content[0].text);
293 assert.ok(parsed.error);
294 });
295 });
296
297 describe('hosted MCP search — tool listing parity', () => {
298 let mock;
299 let client;
300
301 before(async () => {
302 mock = installFetchMock();
303 });
304
305 after(() => {
306 mock.restore();
307 });
308
309 it('search tool schema lists all parity fields', async () => {
310 // Use viewer role to avoid SDK listTools issue with tools missing inputSchema (e.g. index)
311 ({ client } = await connectPair(makeCtx({ role: 'viewer' })));
312 const { tools } = await client.listTools();
313 const search = tools.find((t) => t.name === 'search');
314 assert.ok(search, 'search tool must be registered');
315
316 const props = search.inputSchema?.properties ?? {};
317 const requiredFields = [
318 'query', 'mode', 'match', 'limit', 'fields', 'snippet_chars',
319 'count_only', 'folder', 'project', 'tag', 'since', 'until',
320 'order', 'chain', 'entity', 'episode', 'content_scope',
321 ];
322 for (const f of requiredFields) {
323 assert.ok(props[f], `schema must include ${f}`);
324 }
325 });
326 });
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