mcp-hosted-relate.test.mjs
304 lines 9.2 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 2 days ago
1 /**
2 * Hosted MCP `relate` — canister source read + bridge semantic search + title hydration.
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 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: 'viewer',
19 token: 'tok-test',
20 canisterUrl: CANISTER_URL,
21 bridgeUrl: BRIDGE_URL,
22 ...overrides,
23 };
24 }
25
26 function installRelateFetchMock({ searchResponse }) {
27 const calls = [];
28 const origFetch = globalThis.fetch;
29 globalThis.fetch = async (url, init) => {
30 const u = String(url);
31 calls.push({ url: u, init });
32 if (u.includes(`${CANISTER_URL}/api/v1/notes/`) && !u.includes('/batch')) {
33 const pathMatch = u.match(/\/notes\/(.+)$/);
34 const rawPath = pathMatch ? decodeURIComponent(pathMatch[1]) : '';
35 if (rawPath === 'ghost.md') {
36 return {
37 ok: false,
38 status: 404,
39 json: async () => ({}),
40 text: async () => 'not found',
41 };
42 }
43 if (rawPath === 'src.md') {
44 return {
45 ok: true,
46 status: 200,
47 json: async () => ({
48 path: 'src.md',
49 body: 'alpha beta unique',
50 // Canister shape: frontmatter is JSON text, not an object
51 frontmatter: '{"title":"Source T","project":"p"}',
52 }),
53 text: async () => '{}',
54 };
55 }
56 if (rawPath === 'neighbor.md') {
57 return {
58 ok: true,
59 status: 200,
60 json: async () => ({
61 path: 'neighbor.md',
62 body: '# Heading from body\n\nBody text.',
63 frontmatter: '{}',
64 }),
65 text: async () => '{}',
66 };
67 }
68 }
69 if (u === `${BRIDGE_URL}/api/v1/search`) {
70 return {
71 ok: true,
72 status: 200,
73 json: async () => searchResponse,
74 text: async () => JSON.stringify(searchResponse),
75 };
76 }
77 return {
78 ok: false,
79 status: 404,
80 json: async () => ({}),
81 text: async () => 'not found',
82 };
83 };
84 return {
85 calls,
86 restore() {
87 globalThis.fetch = origFetch;
88 },
89 };
90 }
91
92 async function connectPair(ctx) {
93 const mcpServer = createHostedMcpServer(ctx ?? makeCtx());
94 const client = new Client({ name: 'relate-test', version: '0.0.1' });
95 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
96 await mcpServer.connect(serverTransport);
97 await client.connect(clientTransport);
98 return { client, mcpServer };
99 }
100
101 describe('hosted MCP relate', () => {
102 let mock;
103 let client;
104
105 afterEach(async () => {
106 mock?.restore();
107 try {
108 await client?.close();
109 } catch (_) {}
110 });
111
112 it('loads source from canister then POSTs semantic search with snippetChars and limit', async () => {
113 mock = installRelateFetchMock({
114 searchResponse: {
115 results: [
116 { path: 'src.md', score: 0.99, snippet: 'self' },
117 { path: 'neighbor.md', score: 0.5, snippet: ' hello world ' },
118 ],
119 query: 'ignored',
120 mode: 'semantic',
121 },
122 });
123 ({ client } = await connectPair());
124
125 await client.callTool({
126 name: 'relate',
127 arguments: { path: 'src.md', limit: 3 },
128 });
129
130 const searchCalls = mock.calls.filter((c) => c.url === `${BRIDGE_URL}/api/v1/search`);
131 assert.equal(searchCalls.length, 1);
132 assert.equal(searchCalls[0].init.method, 'POST');
133 const hdrs = searchCalls[0].init.headers;
134 const xUser =
135 hdrs && typeof hdrs.get === 'function'
136 ? hdrs.get('X-User-Id')
137 : hdrs && (hdrs['X-User-Id'] || hdrs['x-user-id']);
138 assert.equal(xUser, 'u-1');
139 const body = JSON.parse(searchCalls[0].init.body);
140 assert.equal(body.mode, 'semantic');
141 assert.equal(body.snippetChars, 200);
142 assert.equal(body.limit, 36);
143 assert.ok(body.query.includes('Source T'));
144 assert.ok(body.query.includes('alpha beta unique'));
145 assert.equal(body.project, undefined);
146 });
147
148 it('filters out the source path and maps snippets; hydrates titles from canister', async () => {
149 mock = installRelateFetchMock({
150 searchResponse: {
151 results: [
152 { path: 'src.md', score: 1, snippet: 'x' },
153 { path: 'neighbor.md', score: 0.8, snippet: ' a b ' },
154 ],
155 query: 'q',
156 mode: 'semantic',
157 },
158 });
159 ({ client } = await connectPair());
160
161 const result = await client.callTool({
162 name: 'relate',
163 arguments: { path: 'src.md', limit: 5 },
164 });
165
166 assert.ok(!result.isError);
167 const out = JSON.parse(result.content[0].text);
168 assert.equal(out.path, 'src.md');
169 assert.equal(out.related.length, 1);
170 assert.equal(out.related[0].path, 'neighbor.md');
171 assert.equal(out.related[0].title, 'Heading from body');
172 assert.equal(out.related[0].snippet, 'a b');
173 assert.equal(typeof out.related[0].score, 'number');
174 });
175
176 it('passes normalized project slug to bridge search body', async () => {
177 mock = installRelateFetchMock({
178 searchResponse: { results: [], query: '', mode: 'semantic' },
179 });
180 ({ client } = await connectPair());
181
182 await client.callTool({
183 name: 'relate',
184 arguments: { path: 'src.md', project: 'My Project!' },
185 });
186
187 const searchCall = mock.calls.find((c) => c.url === `${BRIDGE_URL}/api/v1/search`);
188 const body = JSON.parse(searchCall.init.body);
189 assert.equal(body.project, 'my-project');
190 });
191
192 it('omits neighbors that 404 on canister (stale vector paths)', async () => {
193 mock = installRelateFetchMock({
194 searchResponse: {
195 results: [
196 { path: 'src.md', score: 0.9, snippet: 'x' },
197 { path: 'ghost.md', score: 0.85, snippet: 'gone' },
198 { path: 'neighbor.md', score: 0.5, snippet: ' a b ' },
199 ],
200 query: 'q',
201 mode: 'semantic',
202 },
203 });
204 ({ client } = await connectPair());
205
206 const result = await client.callTool({
207 name: 'relate',
208 arguments: { path: 'src.md', limit: 5 },
209 });
210
211 assert.ok(!result.isError);
212 const out = JSON.parse(result.content[0].text);
213 assert.equal(out.related.length, 1);
214 assert.equal(out.related[0].path, 'neighbor.md');
215 });
216
217 it('uses vec_distance when bridge returns score 0', async () => {
218 mock = installRelateFetchMock({
219 searchResponse: {
220 results: [
221 { path: 'src.md', score: 0, vec_distance: 4, snippet: 'x' },
222 { path: 'neighbor.md', score: 0, vec_distance: 1, snippet: 'y' },
223 ],
224 query: 'q',
225 mode: 'semantic',
226 },
227 });
228 ({ client } = await connectPair());
229
230 const result = await client.callTool({
231 name: 'relate',
232 arguments: { path: 'src.md', limit: 2 },
233 });
234
235 const out = JSON.parse(result.content[0].text);
236 assert.equal(out.related.length, 1);
237 assert.ok(out.related[0].score > 0);
238 assert.ok(Math.abs(out.related[0].score - 1 / 2) < 1e-9, '1/(1+1) for vec_distance 1');
239 });
240
241 it('omits neighbors when canister GET returns 404 (stale search hit)', async () => {
242 const origFetch = globalThis.fetch;
243 globalThis.fetch = async (url) => {
244 const u = String(url);
245 if (u.includes(`${CANISTER_URL}/api/v1/notes/`) && u.includes('src.md')) {
246 return {
247 ok: true,
248 status: 200,
249 json: async () => ({ path: 'src.md', body: 'body', frontmatter: '{}' }),
250 text: async () => '{}',
251 };
252 }
253 if (u.includes(`${CANISTER_URL}/api/v1/notes/`)) {
254 return { ok: false, status: 404, json: async () => ({}), text: async () => 'not found' };
255 }
256 if (u === `${BRIDGE_URL}/api/v1/search`) {
257 return {
258 ok: true,
259 status: 200,
260 json: async () => ({
261 results: [
262 { path: 'src.md', score: 0.9, snippet: '' },
263 { path: 'projects/x/MY-NEIGHBOR.md', score: 0.5, snippet: 's' },
264 ],
265 query: 'q',
266 mode: 'semantic',
267 }),
268 };
269 }
270 return { ok: false, status: 500, json: async () => ({}), text: async () => '' };
271 };
272 mock = { restore: () => { globalThis.fetch = origFetch; } };
273 ({ client } = await connectPair());
274
275 const result = await client.callTool({
276 name: 'relate',
277 arguments: { path: 'src.md', limit: 2 },
278 });
279
280 const out = JSON.parse(result.content[0].text);
281 assert.equal(out.related.length, 0);
282 });
283
284 it('returns isError on upstream failure', async () => {
285 const origFetch = globalThis.fetch;
286 globalThis.fetch = async () => ({
287 ok: false,
288 status: 502,
289 json: async () => ({}),
290 text: async () => 'bad',
291 });
292 mock = { calls: [], restore: () => { globalThis.fetch = origFetch; } };
293 ({ client } = await connectPair());
294
295 const result = await client.callTool({
296 name: 'relate',
297 arguments: { path: 'missing.md' },
298 });
299
300 assert.equal(result.isError, true);
301 const parsed = JSON.parse(result.content[0].text);
302 assert.ok(parsed.error);
303 });
304 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 2 days ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago