mcp-hosted-metadata-facets.test.mjs
261 lines 8.5 KB
Raw
sha256:fd47ab66017e55331b88ba3a59c34c23e4e05c5aec424251d3a404c5a7998c8e feat(hub): restore integration tile detail modals; add Herm… Human minor ⚠ breaking 16 days ago
1 /**
2 * Hosted MCP get_metadata_facets tests.
3 *
4 * Phase 1D mirrors hosted get_note access: same canister read path, same auth
5 * headers, viewer-level ACL, and a body-free MetadataFacets v0 response.
6 */
7 import { describe, it, afterEach } from 'node:test';
8 import assert from 'node:assert/strict';
9 import { Client } from '@modelcontextprotocol/sdk/client/index.js';
10 import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
11 import { createHostedMcpServer } from '../hub/gateway/mcp-hosted-server.mjs';
12
13 const CANISTER_URL = 'http://canister.test:4322';
14 const BRIDGE_URL = 'http://bridge.test:4321';
15
16 function makeCtx(overrides = {}) {
17 return {
18 userId: 'google:actor',
19 canisterUserId: 'google:owner',
20 vaultId: 'vault-facets',
21 role: 'viewer',
22 token: 'tok-facets',
23 canisterUrl: CANISTER_URL,
24 bridgeUrl: BRIDGE_URL,
25 canisterAuthSecret: 'gw-secret-facets',
26 ...overrides,
27 };
28 }
29
30 function headerGet(headers, name) {
31 if (!headers) return undefined;
32 if (typeof headers.get === 'function') return headers.get(name);
33 return headers[name] ?? headers[name.toLowerCase()];
34 }
35
36 function installFetchMock(handler) {
37 const calls = [];
38 const origFetch = globalThis.fetch;
39 globalThis.fetch = async (url, init) => {
40 calls.push({ url: String(url), init });
41 return handler(String(url), init, calls);
42 };
43 return {
44 calls,
45 restore() {
46 globalThis.fetch = origFetch;
47 },
48 };
49 }
50
51 async function connectPair(ctx = makeCtx()) {
52 const mcpServer = createHostedMcpServer(ctx);
53 const client = new Client({ name: 'hosted-metadata-facets', version: '0.0.1' });
54 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
55 await mcpServer.connect(serverTransport);
56 await client.connect(clientTransport);
57 return { client };
58 }
59
60 function parseToolResult(result) {
61 const text = result.content?.[0]?.text;
62 assert.equal(typeof text, 'string');
63 return JSON.parse(text);
64 }
65
66 describe('hosted MCP get_metadata_facets', () => {
67 let mock;
68 let client;
69
70 afterEach(async () => {
71 try {
72 await client?.close();
73 } catch (_) {}
74 mock?.restore?.();
75 });
76
77 it('lists get_metadata_facets as a read tool for every hosted role', async () => {
78 mock = installFetchMock(() => ({
79 ok: true,
80 status: 200,
81 json: async () => ({}),
82 text: async () => '{}',
83 }));
84
85 for (const role of ['viewer', 'editor', 'evaluator', 'admin']) {
86 ({ client } = await connectPair(makeCtx({ role })));
87 const { tools } = await client.listTools();
88 assert.ok(tools.some((tool) => tool.name === 'get_metadata_facets'), `${role} can list get_metadata_facets`);
89 await client.close();
90 client = undefined;
91 }
92 });
93
94 it('uses the same canister GET path and auth headers as get_note', async () => {
95 mock = installFetchMock(() => ({
96 ok: true,
97 status: 200,
98 json: async () => ({
99 path: 'inbox/hello world.md',
100 frontmatter: '{"project":"Hosted Project","tags":["Alpha","Beta"],"api_key":"must-not-leak"}',
101 body: 'Body must not leak.',
102 }),
103 text: async () => '{}',
104 }));
105 ({ client } = await connectPair());
106
107 await client.callTool({
108 name: 'get_metadata_facets',
109 arguments: { path: 'inbox/hello world.md' },
110 });
111
112 assert.equal(mock.calls.length, 1);
113 assert.equal(mock.calls[0].url, `${CANISTER_URL}/api/v1/notes/${encodeURIComponent('inbox/hello world.md')}`);
114 assert.equal(mock.calls[0].init.method, 'GET');
115 const headers = mock.calls[0].init.headers;
116 assert.equal(headerGet(headers, 'Authorization'), 'Bearer tok-facets');
117 assert.equal(headerGet(headers, 'X-Vault-Id'), 'vault-facets');
118 assert.equal(headerGet(headers, 'X-User-Id'), 'google:owner');
119 assert.equal(headerGet(headers, 'X-Gateway-Auth'), 'gw-secret-facets');
120 });
121
122 it('rejects unsafe requested paths before upstream fetch', async () => {
123 mock = installFetchMock(() => {
124 throw new Error('upstream fetch must not run for unsafe requested paths');
125 });
126 ({ client } = await connectPair());
127
128 for (const unsafePath of ['../secret.md', '\\Users\\owner\\secret.md']) {
129 const result = await client.callTool({
130 name: 'get_metadata_facets',
131 arguments: { path: unsafePath },
132 });
133 const data = parseToolResult(result);
134
135 assert.equal(result.isError, true);
136 assert.equal(data.code, 'UPSTREAM_ERROR');
137 assert.match(data.error, /Invalid path/);
138 }
139 assert.equal(mock.calls.length, 0);
140 });
141
142 it('returns MetadataFacets JSON without body, snippets, full frontmatter, or absolute paths', async () => {
143 mock = installFetchMock(() => ({
144 ok: true,
145 status: 200,
146 json: async () => ({
147 path: 'inbox/hello.md',
148 frontmatter:
149 '{"project":"Hosted Project","tags":["Alpha","Beta"],"date":"2026-05-24","updated":"2026-05-25","causal_chain_id":"Launch Rollout","entity":["Alice B"],"episode_id":"Episode 1","api_key":"must-not-leak","label":"do not include"}',
150 body: 'Body must not leak.',
151 }),
152 text: async () => '{}',
153 }));
154 ({ client } = await connectPair());
155
156 const result = await client.callTool({
157 name: 'get_metadata_facets',
158 arguments: { path: 'inbox/hello.md' },
159 });
160 const data = parseToolResult(result);
161 const serialized = JSON.stringify(data);
162
163 assert.equal(result.isError, undefined);
164 assert.deepEqual(data, {
165 schema: 'knowtation.metadata_facets/v0',
166 path: 'inbox/hello.md',
167 facets: {
168 project: 'hosted-project',
169 tags: ['alpha', 'beta'],
170 date: '2026-05-24',
171 updated: '2026-05-25',
172 causal_chain_id: 'launch-rollout',
173 entity: ['alice-b'],
174 episode_id: 'episode-1',
175 },
176 inferred: {
177 folder: 'inbox',
178 source_type: null,
179 },
180 truncated: false,
181 });
182 assert.equal(Object.hasOwn(data, 'body'), false);
183 assert.equal(Object.hasOwn(data, 'frontmatter'), false);
184 assert.equal(Object.hasOwn(data, 'snippet'), false);
185 assert.equal(Object.hasOwn(data, 'summary'), false);
186 assert.equal(Object.hasOwn(data, 'labels'), false);
187 assert.equal(Object.hasOwn(data, 'metadata_facets'), false);
188 assert.equal(serialized.includes('Body must not leak'), false);
189 assert.equal(serialized.includes('must-not-leak'), false);
190 assert.equal(serialized.includes('do not include'), false);
191 assert.equal(serialized.includes('/Users/'), false);
192 });
193
194 it('uses requested safe path instead of unsafe upstream path in output', async () => {
195 mock = installFetchMock(() => ({
196 ok: true,
197 status: 200,
198 json: async () => ({
199 path: '/Users/owner/private/secret.md',
200 frontmatter: '{"tags":["Safe"]}',
201 body: 'private note body must not leak',
202 }),
203 text: async () => '{}',
204 }));
205 ({ client } = await connectPair());
206
207 const result = await client.callTool({
208 name: 'get_metadata_facets',
209 arguments: { path: 'safe.md' },
210 });
211 const data = parseToolResult(result);
212 const serialized = JSON.stringify(data);
213
214 assert.equal(result.isError, undefined);
215 assert.equal(data.path, 'safe.md');
216 assert.deepEqual(data.facets.tags, ['safe']);
217 assert.equal(serialized.includes('/Users/owner'), false);
218 assert.equal(serialized.includes('private note body'), false);
219 });
220
221 it('returns UPSTREAM_ERROR without changing missing-note behavior', async () => {
222 mock = installFetchMock(() => ({
223 ok: false,
224 status: 404,
225 json: async () => ({ error: 'not found' }),
226 text: async () => '{"error":"not found"}',
227 }));
228 ({ client } = await connectPair());
229
230 const result = await client.callTool({
231 name: 'get_metadata_facets',
232 arguments: { path: 'missing.md' },
233 });
234 const data = parseToolResult(result);
235
236 assert.equal(result.isError, true);
237 assert.equal(data.code, 'UPSTREAM_ERROR');
238 assert.match(data.error, /Upstream 404/);
239 });
240
241 it('returns UPSTREAM_ERROR without changing forbidden-note behavior', async () => {
242 mock = installFetchMock(() => ({
243 ok: false,
244 status: 403,
245 json: async () => ({ error: 'forbidden' }),
246 text: async () => '{"error":"forbidden"}',
247 }));
248 ({ client } = await connectPair());
249
250 const result = await client.callTool({
251 name: 'get_metadata_facets',
252 arguments: { path: 'private.md' },
253 });
254 const data = parseToolResult(result);
255
256 assert.equal(result.isError, true);
257 assert.equal(data.code, 'UPSTREAM_ERROR');
258 assert.match(data.error, /Upstream 403/);
259 assert.equal(JSON.stringify(data).includes('private note body'), false);
260 });
261 });
File History 2 commits
sha256:fd47ab66017e55331b88ba3a59c34c23e4e05c5aec424251d3a404c5a7998c8e feat(hub): restore integration tile detail modals; add Herm… Human minor 16 days ago
sha256:2827ba9e7632a4b141c50caf1e8f7d77abbc3515be20e7465f2bccb0ac4edf91 fix: repair endpoint now sets has_active_subscription when … Human minor 17 days ago