mcp-hosted-note-outline.test.mjs
253 lines 8.1 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 2 days ago
1 /**
2 * Hosted MCP get_note_outline tests.
3 *
4 * Phase 1D must mirror hosted get_note access: same canister read path, same
5 * auth headers, viewer-level ACL, and a body-free NoteOutline 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-outline',
21 role: 'viewer',
22 token: 'tok-outline',
23 canisterUrl: CANISTER_URL,
24 bridgeUrl: BRIDGE_URL,
25 canisterAuthSecret: 'gw-secret-outline',
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-note-outline', 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_note_outline', () => {
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('viewer role lists get_note_outline as a read tool', async () => {
78 mock = installFetchMock(() => ({
79 ok: true,
80 status: 200,
81 json: async () => ({}),
82 text: async () => '{}',
83 }));
84 ({ client } = await connectPair());
85
86 const { tools } = await client.listTools();
87 assert.ok(tools.some((tool) => tool.name === 'get_note_outline'));
88 });
89
90 it('uses the same canister GET path and auth headers as get_note', async () => {
91 mock = installFetchMock(() => ({
92 ok: true,
93 status: 200,
94 json: async () => ({
95 path: 'inbox/hello world.md',
96 frontmatter: '{"title":"Hosted Title","api_key":"must-not-leak"}',
97 body: '# Intro\n\nBody must not leak.\n\n## Next',
98 }),
99 text: async () => '{}',
100 }));
101 ({ client } = await connectPair());
102
103 await client.callTool({
104 name: 'get_note_outline',
105 arguments: { path: 'inbox/hello world.md' },
106 });
107
108 assert.equal(mock.calls.length, 1);
109 assert.equal(mock.calls[0].url, `${CANISTER_URL}/api/v1/notes/${encodeURIComponent('inbox/hello world.md')}`);
110 assert.equal(mock.calls[0].init.method, 'GET');
111 const headers = mock.calls[0].init.headers;
112 assert.equal(headerGet(headers, 'Authorization'), 'Bearer tok-outline');
113 assert.equal(headerGet(headers, 'X-Vault-Id'), 'vault-outline');
114 assert.equal(headerGet(headers, 'X-User-Id'), 'google:owner');
115 assert.equal(headerGet(headers, 'X-Gateway-Auth'), 'gw-secret-outline');
116 });
117
118 it('returns NoteOutline JSON without body, snippets, full frontmatter, or absolute paths', async () => {
119 mock = installFetchMock(() => ({
120 ok: true,
121 status: 200,
122 json: async () => ({
123 path: 'inbox/hello.md',
124 frontmatter: '{"title":"Hosted Title","api_key":"must-not-leak"}',
125 body: '# Intro\n\nBody must not leak.\n\n## Next',
126 }),
127 text: async () => '{}',
128 }));
129 ({ client } = await connectPair());
130
131 const result = await client.callTool({
132 name: 'get_note_outline',
133 arguments: { path: 'inbox/hello.md' },
134 });
135 const data = parseToolResult(result);
136 const serialized = JSON.stringify(data);
137
138 assert.equal(result.isError, undefined);
139 assert.equal(data.schema, 'knowtation.note_outline/v1');
140 assert.equal(data.path, 'inbox/hello.md');
141 assert.equal(data.title, 'Hosted Title');
142 assert.deepEqual(data.headings, [
143 { level: 1, text: 'Intro', id: 'h1-intro-0001' },
144 { level: 2, text: 'Next', id: 'h2-next-0002' },
145 ]);
146 assert.equal(data.truncated, false);
147 assert.equal(Object.hasOwn(data, 'body'), false);
148 assert.equal(Object.hasOwn(data, 'frontmatter'), false);
149 assert.equal(Object.hasOwn(data, 'snippet'), false);
150 assert.equal(serialized.includes('Body must not leak'), false);
151 assert.equal(serialized.includes('must-not-leak'), false);
152 assert.equal(serialized.includes('/Users/'), false);
153 });
154
155 it('returns UPSTREAM_ERROR without changing missing-note behavior', async () => {
156 mock = installFetchMock(() => ({
157 ok: false,
158 status: 404,
159 json: async () => ({ error: 'not found' }),
160 text: async () => '{"error":"not found"}',
161 }));
162 ({ client } = await connectPair());
163
164 const result = await client.callTool({
165 name: 'get_note_outline',
166 arguments: { path: 'missing.md' },
167 });
168 const data = parseToolResult(result);
169
170 assert.equal(result.isError, true);
171 assert.equal(data.code, 'UPSTREAM_ERROR');
172 assert.match(data.error, /Upstream 404/);
173 });
174
175 it('returns UPSTREAM_ERROR without changing forbidden-note behavior', async () => {
176 mock = installFetchMock(() => ({
177 ok: false,
178 status: 403,
179 json: async () => ({ error: 'forbidden' }),
180 text: async () => '{"error":"forbidden"}',
181 }));
182 ({ client } = await connectPair());
183
184 const result = await client.callTool({
185 name: 'get_note_outline',
186 arguments: { path: 'private.md' },
187 });
188 const data = parseToolResult(result);
189
190 assert.equal(result.isError, true);
191 assert.equal(data.code, 'UPSTREAM_ERROR');
192 assert.match(data.error, /Upstream 403/);
193 assert.equal(JSON.stringify(data).includes('private note body'), false);
194 });
195
196 it('does not expose outline-specific resource URIs', async () => {
197 mock = installFetchMock((url) => {
198 if (url.includes('/api/v1/notes?')) {
199 return {
200 ok: true,
201 status: 200,
202 json: async () => ({ notes: [{ path: 'inbox/a.md', frontmatter: {}, body: '# A' }], total: 1 }),
203 text: async () => '{"notes":[],"total":0}',
204 };
205 }
206 return {
207 ok: true,
208 status: 200,
209 json: async () => ({}),
210 text: async () => '{}',
211 };
212 });
213 ({ client } = await connectPair());
214
215 const [{ resources }, { resourceTemplates }] = await Promise.all([
216 client.listResources(),
217 client.listResourceTemplates(),
218 ]);
219 const resourceText = JSON.stringify({ resources, resourceTemplates });
220
221 assert.equal(resourceText.includes('outline'), false);
222 assert.equal(resourceText.includes('get_note_outline'), false);
223 });
224
225 it('ignores unsafe upstream paths and does not leak absolute path or body in errors', async () => {
226 mock = installFetchMock(() => ({
227 ok: true,
228 status: 200,
229 json: async () => ({
230 path: '/Users/aaron/private/secret.md',
231 frontmatter: '{"title":"Safe Title"}',
232 body: '# Safe Heading\n\nprivate note body must not leak',
233 }),
234 text: async () => '{}',
235 }));
236 ({ client } = await connectPair());
237
238 const result = await client.callTool({
239 name: 'get_note_outline',
240 arguments: { path: 'safe.md' },
241 });
242 const data = parseToolResult(result);
243 const serialized = JSON.stringify(data);
244
245 assert.equal(result.isError, undefined);
246 assert.equal(data.path, 'safe.md');
247 assert.deepEqual(data.headings, [
248 { level: 1, text: 'Safe Heading', id: 'h1-safe-heading-0001' },
249 ]);
250 assert.equal(serialized.includes('/Users/aaron'), false);
251 assert.equal(serialized.includes('private note body'), false);
252 });
253 });
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 3 days ago