mcp-hosted-tag-suggest.test.mjs
260 lines 7.9 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Hosted MCP `tag_suggest` — canister note read + bridge POST /api/v1/search + optional get_note per hit for tags.
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 installTagSuggestFetchMock({ searchResponse, neighborFrontmatter = '{"tags":"alpha, beta-tag"}' } = {}) {
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 === 'src.md') {
36 return {
37 ok: true,
38 status: 200,
39 json: async () => ({
40 path: 'src.md',
41 body: 'topic gamma',
42 frontmatter: '{"title":"Source","tags":"existing-one"}',
43 }),
44 text: async () => '{}',
45 };
46 }
47 if (rawPath === 'neighbor.md') {
48 return {
49 ok: true,
50 status: 200,
51 json: async () => ({
52 path: 'neighbor.md',
53 body: 'x',
54 frontmatter: neighborFrontmatter,
55 }),
56 text: async () => '{}',
57 };
58 }
59 }
60 if (u === `${BRIDGE_URL}/api/v1/search`) {
61 return {
62 ok: true,
63 status: 200,
64 json: async () => searchResponse,
65 text: async () => JSON.stringify(searchResponse),
66 };
67 }
68 return {
69 ok: false,
70 status: 404,
71 json: async () => ({}),
72 text: async () => 'not found',
73 };
74 };
75 return {
76 calls,
77 restore() {
78 globalThis.fetch = origFetch;
79 },
80 };
81 }
82
83 async function connectPair(ctx) {
84 const mcpServer = createHostedMcpServer(ctx ?? makeCtx());
85 const client = new Client({ name: 'tag-suggest-test', version: '0.0.1' });
86 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
87 await mcpServer.connect(serverTransport);
88 await client.connect(clientTransport);
89 return { client, mcpServer };
90 }
91
92 describe('hosted MCP tag_suggest', () => {
93 let mock = { restore() {} };
94 let client;
95
96 afterEach(async () => {
97 mock.restore();
98 try {
99 await client?.close();
100 } catch (_) {}
101 });
102
103 it('loads source from canister then POSTs semantic search with default neighbor limit and snippetChars 200', async () => {
104 mock = installTagSuggestFetchMock({
105 searchResponse: {
106 results: [{ path: 'neighbor.md', score: 0.5, tags: ['alpha'], snippet: 's' }],
107 query: 'q',
108 mode: 'semantic',
109 },
110 });
111 ({ client } = await connectPair());
112
113 await client.callTool({
114 name: 'tag_suggest',
115 arguments: { path: 'src.md' },
116 });
117
118 const searchCalls = mock.calls.filter((c) => c.url === `${BRIDGE_URL}/api/v1/search`);
119 assert.equal(searchCalls.length, 1);
120 assert.equal(searchCalls[0].init.method, 'POST');
121 const hdrs = searchCalls[0].init.headers;
122 const xUser =
123 hdrs && typeof hdrs.get === 'function'
124 ? hdrs.get('X-User-Id')
125 : hdrs && (hdrs['X-User-Id'] || hdrs['x-user-id']);
126 assert.equal(xUser, 'u-1');
127 const body = JSON.parse(searchCalls[0].init.body);
128 assert.equal(body.mode, 'semantic');
129 assert.equal(body.snippetChars, 200);
130 assert.equal(body.limit, 40);
131 assert.ok(body.query.includes('Source'));
132 assert.ok(body.query.includes('topic gamma'));
133 });
134
135 it('passes neighbor_limit through to bridge search when set', async () => {
136 mock = installTagSuggestFetchMock({
137 searchResponse: {
138 results: [{ path: 'neighbor.md', score: 0.5, tags: ['alpha'], snippet: 's' }],
139 query: 'q',
140 mode: 'semantic',
141 },
142 });
143 ({ client } = await connectPair());
144
145 await client.callTool({
146 name: 'tag_suggest',
147 arguments: { path: 'src.md', neighbor_limit: 22 },
148 });
149
150 const searchCalls = mock.calls.filter((c) => c.url === `${BRIDGE_URL}/api/v1/search`);
151 const body = JSON.parse(searchCalls[0].init.body);
152 assert.equal(body.limit, 22);
153 });
154
155 it('aggregates tags from search hits, excludes source path and existing tags', async () => {
156 mock = installTagSuggestFetchMock({
157 searchResponse: {
158 results: [
159 { path: 'src.md', score: 0.99, tags: ['existing-one'], snippet: 'x' },
160 { path: 'neighbor.md', score: 0.5, tags: ['alpha', 'beta-tag'], snippet: 'y' },
161 ],
162 query: 'q',
163 mode: 'semantic',
164 },
165 });
166 ({ client } = await connectPair());
167
168 const result = await client.callTool({
169 name: 'tag_suggest',
170 arguments: { path: 'src.md' },
171 });
172
173 assert.ok(!result.isError);
174 const out = JSON.parse(result.content[0].text);
175 assert.deepEqual(out.existing_tags, ['existing-one']);
176 assert.ok(out.suggested_tags.includes('alpha'));
177 assert.ok(out.suggested_tags.includes('beta-tag'));
178 });
179
180 it('falls back to canister get_note when hit has empty tags', async () => {
181 mock = installTagSuggestFetchMock({
182 searchResponse: {
183 results: [{ path: 'neighbor.md', score: 0.5, tags: [], snippet: 's' }],
184 query: 'q',
185 mode: 'semantic',
186 },
187 });
188 ({ client } = await connectPair());
189
190 const result = await client.callTool({
191 name: 'tag_suggest',
192 arguments: { path: 'src.md' },
193 });
194
195 assert.ok(!result.isError);
196 const out = JSON.parse(result.content[0].text);
197 assert.ok(out.suggested_tags.includes('alpha'));
198 assert.ok(out.suggested_tags.includes('beta-tag'));
199 const neighborGets = mock.calls.filter(
200 (c) => c.url === `${CANISTER_URL}/api/v1/notes/neighbor.md` || c.url.includes('/notes/neighbor.md')
201 );
202 assert.ok(neighborGets.length >= 1);
203 });
204
205 it('supports body-only input with empty existing_tags', async () => {
206 mock = installTagSuggestFetchMock({
207 searchResponse: {
208 results: [{ path: 'neighbor.md', score: 0.5, tags: ['alpha'], snippet: 's' }],
209 query: 'q',
210 mode: 'semantic',
211 },
212 });
213 ({ client } = await connectPair());
214
215 const result = await client.callTool({
216 name: 'tag_suggest',
217 arguments: { body: 'free text for neighbors' },
218 });
219
220 assert.ok(!result.isError);
221 const out = JSON.parse(result.content[0].text);
222 assert.deepEqual(out.existing_tags, []);
223 assert.deepEqual(out.suggested_tags, ['alpha']);
224 });
225
226 it('returns INVALID when neither path nor body', async () => {
227 mock = installTagSuggestFetchMock({ searchResponse: { results: [], query: '', mode: 'semantic' } });
228 ({ client } = await connectPair());
229
230 const result = await client.callTool({
231 name: 'tag_suggest',
232 arguments: {},
233 });
234
235 assert.equal(result.isError, true);
236 const parsed = JSON.parse(result.content[0].text);
237 assert.equal(parsed.code, 'INVALID');
238 });
239
240 it('returns isError on upstream failure', async () => {
241 const origFetch = globalThis.fetch;
242 globalThis.fetch = async () => ({
243 ok: false,
244 status: 502,
245 json: async () => ({}),
246 text: async () => 'bad',
247 });
248 mock = { restore: () => { globalThis.fetch = origFetch; } };
249 ({ client } = await connectPair());
250
251 const result = await client.callTool({
252 name: 'tag_suggest',
253 arguments: { path: 'src.md' },
254 });
255
256 assert.equal(result.isError, true);
257 const parsed = JSON.parse(result.content[0].text);
258 assert.ok(parsed.error);
259 });
260 });
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