mcp-hosted-server.mjs
3,193 lines 130.1 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Issue #1 Phase D2 — Hosted MCP server variant for the Hub gateway.
3 * Creates a per-session McpServer backed by canister (notes CRUD) and bridge (search/index).
4 * Tools are role-filtered based on user permissions.
5 */
6
7 import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
8 import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
9 import { z } from 'zod';
10 import { IMPORT_SOURCE_TYPES } from '../../lib/import-source-types.mjs';
11 import {
12 displayTitleFromHostedNote,
13 parseCanisterFrontmatter,
14 titleFromCanisterFrontmatter,
15 titleFromPathStem,
16 } from '../../lib/canister-frontmatter.mjs';
17 import { normalizeSlug, effectiveProjectSlug, normalizeMetadataFacets } from '../../lib/vault.mjs';
18 import { buildNoteOutline } from '../../lib/note-outline.mjs';
19 import { buildDocumentTree } from '../../lib/document-tree.mjs';
20 import { buildSectionSource } from '../../lib/section-source.mjs';
21 import { extractCheckboxTasksFromBody } from '../../lib/extract-tasks.mjs';
22 import { materializeListFrontmatter, tagsFromFm } from './note-facets.mjs';
23 import { findFirstWikilinkToTargetInBody, vaultBasenameTargetKey } from '../../lib/wikilink.mjs';
24 import { kmeans } from '../../lib/kmeans.mjs';
25 import { buildCaptureInboxWritePayload } from '../../lib/capture-inbox.mjs';
26 import { noteToMarkdown } from '../../mcp/resources/note.mjs';
27 import {
28 textContent,
29 maybeAppendSamplingPrefill,
30 snippet,
31 parseIntSafe,
32 MAX_EMBEDDED_NOTES,
33 MAX_ENTITY_NOTES,
34 PROJECT_SUMMARY_NOTES,
35 CONTENT_PLAN_NOTES,
36 } from '../../mcp/prompts/helpers.mjs';
37 import { extractImageUrls } from '../../lib/media-url-extract.mjs';
38 import { extractTopicFromEvent, slugify } from '../../lib/memory-event.mjs';
39 import { fetchImageAsBase64 } from '../../mcp/resources/image-fetch.mjs';
40 import { isToolAllowed, isPromptAllowed, allowedPromptsForRole } from './mcp-tool-acl.mjs';
41
42 /** @type {[string, string, ...string[]]} */
43 const IMPORT_SOURCE_ENUM = /** @type {any} */ ([...IMPORT_SOURCE_TYPES]);
44
45 const BRIDGE_IMPORT_MAX_BYTES = 100 * 1024 * 1024;
46
47 /**
48 * Hosted MCP `export` calls the same upstream as hub/bridge/server.mjs vault backup:
49 * GET {canisterUrl}/api/v1/export with X-User-Id / X-Vault-Id (+ gateway secret when configured).
50 * Response shape is built in hub/icp/src/hub/main.mo (pathKind == "export", GET): JSON object with a `notes` array.
51 *
52 * Full vault JSON can exceed MCP context limits; responses larger than this byte count are rejected
53 * with code EXPORT_TOO_LARGE (MCP-only cap; Hub / vault_sync / direct canister export are not limited by this).
54 */
55 const HOSTED_MCP_EXPORT_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
56
57 /** Same slice as `lib/relate.mjs` when building the embedding text from the source note. */
58 const RELATE_BODY_SLICE = 12000;
59
60 /** Same cap as `lib/tag-suggest.mjs` (`runTagSuggest`) when building text for semantic neighbors. */
61 const TAG_SUGGEST_TEXT_SLICE = 12000;
62
63 /** Default bridge semantic search limit for tag aggregation (neighbor rows before dedupe / existing-tag filter). */
64 const TAG_SUGGEST_NEIGHBOR_LIMIT_DEFAULT = 40;
65 /** Hard cap for optional `neighbor_limit` tool argument (latency: up to this many canister GETs when bridge rows lack tags). */
66 const TAG_SUGGEST_NEIGHBOR_LIMIT_MAX = 80;
67
68 /**
69 * Hosted `backlinks`: max canister notes examined (each may trigger one `GET …/notes/:path`).
70 * Soft cap to limit latency and load; partial vault coverage sets `backlinks_truncated: true`.
71 */
72 const HOSTED_BACKLINKS_MAX_NOTES = 2000;
73 const HOSTED_BACKLINKS_PAGE_SIZE = 100;
74
75 /**
76 * Hosted `extract_tasks`: max canister list rows processed (each may trigger one `GET …/notes/:path` when body empty).
77 */
78 const HOSTED_EXTRACT_TASKS_MAX_NOTES = 2000;
79 const HOSTED_EXTRACT_TASKS_PAGE_SIZE = 100;
80
81 /**
82 * Hosted `cluster`: max canister list rows processed while collecting up to {@link HOSTED_CLUSTER_MAX_NOTES} notes.
83 * Same soft cap pattern as `extract_tasks` / `backlinks`.
84 */
85 const HOSTED_CLUSTER_MAX_LIST_ROWS = 2000;
86 const HOSTED_CLUSTER_PAGE_SIZE = 100;
87 /** Max notes embedded per call (parity with local `runCluster` / `lib/cluster-semantic.mjs`). */
88 const HOSTED_CLUSTER_MAX_NOTES = 200;
89 const HOSTED_CLUSTER_TEXT_SLICE = 800;
90
91 /** Max notes expanded into MCP `resources/list` for the hosted vault note template (SDK merges `list` results there). */
92 const HOSTED_VAULT_RESOURCE_LIST_MAX = 50;
93
94 /** R2: static `knowtation://hosted/vault-listing` uses this cap (same canister list as `list_notes`). */
95 const HOSTED_VAULT_LISTING_RESOURCE_LIMIT = 100;
96
97 /** R3: `resources/list` merge cap for embedded image URIs (matches self-hosted `MCP_RESOURCE_PAGE_SIZE`). */
98 const HOSTED_IMAGE_RESOURCE_LIST_MAX = 50;
99 /**
100 * R3: canister `GET /api/v1/notes` page size while scanning the vault for embedded images to merge into
101 * `resources/list`. Paginate until we collect {@link HOSTED_IMAGE_RESOURCE_LIST_MAX} image URIs, the canister
102 * returns no more notes, or we hit {@link HOSTED_IMAGE_LIST_MAX_NOTES_SCANNED} (fairness on very large vaults).
103 */
104 const HOSTED_IMAGE_LIST_NOTES_PAGE_SIZE = 50;
105 /** R3: upper bound on notes examined per `resources/list` image merge (limits gateway latency / upstream load). */
106 const HOSTED_IMAGE_LIST_MAX_NOTES_SCANNED = 5000;
107
108 /** R3: bridge `GET /api/v1/memory` max slice for topic derivation / filtering (bridge hard cap 100). */
109 const HOSTED_MEMORY_TOPIC_BRIDGE_LIMIT = 100;
110
111 /** R3: templates folder listing uses the same page size as vault listing resources. */
112 const HOSTED_TEMPLATES_LIST_LIMIT = 100;
113
114 function vaultPathKey(p) {
115 return String(p ?? '').replace(/\\/g, '/').trim();
116 }
117
118 function relateSnippet(s) {
119 return String(s ?? '').slice(0, 200).replace(/\s+/g, ' ').trim();
120 }
121
122 function pathMatchesFolderForExtractTasks(p, folderOpt) {
123 if (folderOpt == null || String(folderOpt).trim() === '') return true;
124 const prefix = String(folderOpt).replace(/\\/g, '/').replace(/\/$/, '') + '/';
125 const exact = String(folderOpt).replace(/\\/g, '/').replace(/\/$/, '');
126 return p === exact || p.startsWith(prefix);
127 }
128
129 function dateKeyFromHostedFrontmatter(fm) {
130 const raw = fm.date ?? fm.updated;
131 if (raw == null) return '';
132 const s = String(raw).trim();
133 if (!s) return '';
134 return s.slice(0, 10);
135 }
136
137 /**
138 * Client-side filters mirroring local `runExtractTasks` (path + materialized canister frontmatter).
139 * @param {string} path
140 * @param {unknown} frontmatterRaw
141 * @param {{ folder?: string, project?: string, tag?: string, since?: string, until?: string }} f
142 */
143 function hostedNotePassesExtractFilters(path, frontmatterRaw, f) {
144 const p = vaultPathKey(path);
145 if (!p) return false;
146 if (!pathMatchesFolderForExtractTasks(p, f.folder)) return false;
147 const fm = materializeListFrontmatter(frontmatterRaw);
148 if (f.project != null && String(f.project).trim() !== '') {
149 const wp = normalizeSlug(String(f.project));
150 if (effectiveProjectSlug(p, fm) !== wp) return false;
151 }
152 if (f.tag != null && String(f.tag).trim() !== '') {
153 const wt = normalizeSlug(String(f.tag));
154 const tagSet = tagsFromFm(fm).map((t) => normalizeSlug(String(t))).filter(Boolean);
155 if (!tagSet.includes(wt)) return false;
156 }
157 const d = dateKeyFromHostedFrontmatter(fm);
158 if (f.since != null && String(f.since).trim() !== '') {
159 const s = String(f.since).trim().slice(0, 10);
160 if (!d || d < s) return false;
161 }
162 if (f.until != null && String(f.until).trim() !== '') {
163 const u = String(f.until).trim().slice(0, 10);
164 if (!d || d > u) return false;
165 }
166 return true;
167 }
168
169 /**
170 * Text passed to bridge `POST /api/v1/embed` (document vectors), aligned with local `runCluster`.
171 * @param {unknown} frontmatterRaw
172 * @param {unknown} bodyFull
173 */
174 function hostedClusterEmbedText(frontmatterRaw, bodyFull) {
175 const title = titleFromCanisterFrontmatter(frontmatterRaw);
176 const body = bodyFull != null ? String(bodyFull) : '';
177 const t = `${title ? `${title}\n` : ''}${body.slice(0, HOSTED_CLUSTER_TEXT_SLICE)}`;
178 return t.trim();
179 }
180
181 /**
182 * Tags already on the target note (slug form), matching local `runTagSuggest` (`note.tags` vs frontmatter).
183 * @param {Record<string, unknown>} note
184 * @returns {string[]}
185 */
186 function hostedExistingTagsFromCanisterNote(note) {
187 const tagsTop = note && typeof note === 'object' && Array.isArray(note.tags) ? note.tags : null;
188 if (tagsTop && tagsTop.length) {
189 return tagsTop.map((t) => normalizeSlug(String(t))).filter(Boolean);
190 }
191 const fm = materializeListFrontmatter(note?.frontmatter);
192 return tagsFromFm(fm).map((t) => normalizeSlug(String(t))).filter(Boolean);
193 }
194
195 /**
196 * Tags from a bridge search hit and/or canister note JSON.
197 * @param {unknown} tagsRaw
198 * @param {Record<string, unknown>} [canisterNote]
199 * @returns {string[]}
200 */
201 function hostedTagsFromHitOrNote(tagsRaw, canisterNote) {
202 if (Array.isArray(tagsRaw) && tagsRaw.length) {
203 return tagsRaw.map((t) => normalizeSlug(String(t))).filter(Boolean);
204 }
205 if (canisterNote && typeof canisterNote === 'object') {
206 return hostedExistingTagsFromCanisterNote(canisterNote);
207 }
208 return [];
209 }
210
211 /**
212 * Validate hosted note paths before making upstream read calls for derived DocumentTree
213 * data. Hosted has no local vault root, so this enforces the same vault-relative shape
214 * expected by the builder and avoids forwarding traversal-like paths upstream.
215 * @param {unknown} rawPath
216 * @returns {string}
217 */
218 function normalizeHostedDocumentTreePath(rawPath) {
219 if (typeof rawPath !== 'string' || rawPath.trim() === '') {
220 throw new Error('Invalid path: path must be a non-empty vault-relative path');
221 }
222 const forward = rawPath.trim().replace(/\\/g, '/');
223 if (forward.startsWith('/') || /^[A-Za-z]:\//.test(forward)) {
224 throw new Error(`Invalid path: path must be vault-relative (${rawPath})`);
225 }
226 const parts = forward.split('/').filter(Boolean);
227 if (parts.includes('..')) {
228 throw new Error(`Invalid path: path cannot escape vault (${rawPath})`);
229 }
230 return parts.join('/');
231 }
232
233 function sanitizeHostedSectionSourceError(error) {
234 const msg = error?.message || String(error ?? '');
235 if (/^Invalid path\b/.test(msg)) return 'Invalid path';
236 const upstreamStatus = msg.match(/^Upstream\s+(\d{3})\b/);
237 if (upstreamStatus) return `Upstream ${upstreamStatus[1]}`;
238 return 'Runtime failure';
239 }
240
241 /**
242 * Bridge semantic hits may expose `score` only, or `vec_distance` when sqlite coerces distance.
243 * @param {Record<string, unknown>} h
244 * @returns {number}
245 */
246 function scoreFromBridgeSearchHit(h) {
247 if (!h || typeof h !== 'object') return 0;
248 const sc = h.score;
249 if (typeof sc === 'number' && Number.isFinite(sc) && sc > 0) return sc;
250 if (typeof sc === 'string') {
251 const n = Number(sc);
252 if (Number.isFinite(n) && n > 0) return n;
253 }
254 const vd = h.vec_distance;
255 if (typeof vd === 'number' && Number.isFinite(vd) && vd >= 0) return 1 / (1 + vd);
256 if (typeof vd === 'string') {
257 const n = Number(vd);
258 if (Number.isFinite(n) && n >= 0) return 1 / (1 + n);
259 }
260 if (typeof sc === 'number' && Number.isFinite(sc)) return sc;
261 return 0;
262 }
263
264 function jsonResponse(obj) {
265 return { content: [{ type: 'text', text: JSON.stringify(obj) }] };
266 }
267
268 function jsonError(msg, code = 'ERROR') {
269 return { content: [{ type: 'text', text: JSON.stringify({ error: msg, code }) }], isError: true };
270 }
271
272 /** Aligns with `MAX_MEMORY_EVENTS` in `mcp/prompts/helpers.mjs` / `formatMemoryEventsAsync`. */
273 const MAX_MEMORY_EVENTS_FORMAT = 30;
274
275 /**
276 * Format bridge `GET /api/v1/memory` JSON (`{ events, count }`) like `formatMemoryEventsAsync` (local).
277 * @param {unknown} memoryJson
278 * @param {{ limit?: number }} [opts]
279 * @returns {{ text: string, count: number }}
280 */
281 function formatMemoryEventsFromBridgeResponse(memoryJson, opts = {}) {
282 const raw = Array.isArray(/** @type {{ events?: unknown[] }} */ (memoryJson)?.events)
283 ? /** @type {{ events?: unknown[] }} */ (memoryJson).events
284 : [];
285 const cap = Math.min(Math.max(1, opts.limit != null ? Number(opts.limit) : 20), MAX_MEMORY_EVENTS_FORMAT);
286 const events = raw.slice(0, cap);
287 if (events.length === 0) return { text: '(No memory events found.)', count: 0 };
288 const lines = events.map((e) => {
289 const ee = /** @type {{ ts?: string, type?: string, data?: unknown }} */ (e);
290 const d = ee.data != null && typeof ee.data === 'object' ? ee.data : {};
291 const summary = JSON.stringify(d).slice(0, 200);
292 return `- **${ee.ts}** [${ee.type}] ${summary}`;
293 });
294 return { text: lines.join('\n'), count: events.length };
295 }
296
297 /**
298 * @param {unknown[]} events
299 * @param {string} topicParam
300 */
301 function filterHostedMemoryEventsByTopic(events, topicParam) {
302 const want = slugify(String(topicParam || ''));
303 if (!want) return [];
304 return events.filter((e) => extractTopicFromEvent(/** @type {object} */ (e)) === want);
305 }
306
307 /**
308 * @param {unknown[]} events
309 * @returns {string[]}
310 */
311 function uniqueHostedMemoryTopicSlugs(events) {
312 const s = new Set();
313 for (const e of events) {
314 s.add(extractTopicFromEvent(/** @type {object} */ (e)));
315 }
316 return [...s].sort();
317 }
318
319 /**
320 * Fetch JSON from an upstream service with auth forwarding.
321 * @param {string} url
322 * @param {{ method?: string, body?: unknown, token?: string, vaultId?: string, userId?: string }} [opts]
323 */
324 async function upstreamFetch(url, opts = {}) {
325 const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
326 if (opts.token) headers['Authorization'] = `Bearer ${opts.token}`;
327 if (opts.vaultId) headers['X-Vault-Id'] = opts.vaultId;
328 if (opts.userId) headers['X-User-Id'] = opts.userId;
329 if (opts.canisterAuthSecret) headers['X-Gateway-Auth'] = opts.canisterAuthSecret;
330 const res = await fetch(url, {
331 method: opts.method || 'GET',
332 headers,
333 body: opts.body ? JSON.stringify(opts.body) : undefined,
334 });
335 if (!res.ok) {
336 const text = await res.text().catch(() => '');
337 throw new Error(`Upstream ${res.status}: ${text.slice(0, 200)}`);
338 }
339 return res.json();
340 }
341
342 /**
343 * POST JSON to this Hub gateway REST API (e.g. POST /api/v1/proposals) so middleware runs
344 * (billing, proposal policy + review triggers) before the canister.
345 *
346 * @param {string} gatewayBaseUrl e.g. https://hub.example.com (no trailing slash)
347 * @param {{ token: string, vaultId: string }} auth
348 * @param {Record<string, unknown>} body
349 */
350 async function gatewayHubPostJson(gatewayBaseUrl, auth, body) {
351 const base = String(gatewayBaseUrl || '').replace(/\/$/, '');
352 const url = `${base}/api/v1/proposals`;
353 const headers = {
354 Accept: 'application/json',
355 'Content-Type': 'application/json',
356 Authorization: `Bearer ${auth.token}`,
357 'X-Vault-Id': String(auth.vaultId || 'default').trim() || 'default',
358 };
359 const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
360 const text = await res.text().catch(() => '');
361 let data = null;
362 try {
363 data = text ? JSON.parse(text) : null;
364 } catch {
365 data = { error: text.slice(0, 500), code: 'INVALID_JSON' };
366 }
367 if (!res.ok) {
368 const msg =
369 data && typeof data === 'object' && typeof data.error === 'string' && data.error.trim()
370 ? data.error.trim()
371 : `Hub returned ${res.status}`;
372 const err = new Error(msg);
373 /** @type {any} */ (err).status = res.status;
374 /** @type {any} */ (err).code = data && typeof data === 'object' ? data.code : undefined;
375 /** @type {any} */ (err).detail = data && typeof data === 'object' ? data.detail : undefined;
376 /** @type {any} */ (err).hub = data && typeof data === 'object' ? data : undefined;
377 throw err;
378 }
379 return data;
380 }
381
382 /** @param {unknown} e */
383 function jsonHubUpstreamError(e) {
384 const ex = /** @type {any} */ (e);
385 const status = typeof ex?.status === 'number' ? ex.status : undefined;
386 const code = typeof ex?.code === 'string' ? ex.code : undefined;
387 const detail = ex?.detail;
388 const hub = ex?.hub;
389 const payload = {
390 error: ex?.message || String(e),
391 code: code || 'HUB_ERROR',
392 ...(status !== undefined ? { http_status: status } : {}),
393 ...(detail != null && String(detail) !== '' ? { detail: String(detail) } : {}),
394 };
395 if (hub && typeof hub === 'object' && hub !== null && !Array.isArray(hub)) {
396 const o = /** @type {Record<string, unknown>} */ (hub);
397 for (const k of ['proposal_id', 'path', 'status', 'evaluation_status', 'review_queue']) {
398 if (o[k] !== undefined) {
399 if (!payload.hub_fields) payload.hub_fields = {};
400 /** @type {Record<string, unknown>} */ (payload.hub_fields)[k] = o[k];
401 }
402 }
403 }
404 return { content: [{ type: 'text', text: JSON.stringify(payload) }], isError: true };
405 }
406
407 /**
408 * GET JSON from the canister with a hard cap on response body bytes (hosted export safety).
409 * @param {string} url
410 * @param {{ token?: string, vaultId?: string, userId?: string, canisterAuthSecret?: string }} opts
411 * @param {number} maxBytes
412 * @returns {Promise<unknown>}
413 */
414 async function canisterGetJsonWithByteLimit(url, opts, maxBytes) {
415 const headers = { 'Content-Type': 'application/json', Accept: 'application/json' };
416 if (opts.token) headers['Authorization'] = `Bearer ${opts.token}`;
417 if (opts.vaultId) headers['X-Vault-Id'] = opts.vaultId;
418 if (opts.userId) headers['X-User-Id'] = opts.userId;
419 if (opts.canisterAuthSecret) headers['X-Gateway-Auth'] = opts.canisterAuthSecret;
420 const res = await fetch(url, { method: 'GET', headers });
421 const buf = await res.arrayBuffer();
422 const text = new TextDecoder('utf-8').decode(buf);
423 if (!res.ok) {
424 throw new Error(`Upstream ${res.status}: ${text.slice(0, 200)}`);
425 }
426 if (buf.byteLength > maxBytes) {
427 const err = new Error(
428 `Export response is ${buf.byteLength} bytes; this MCP tool allows at most ${maxBytes} bytes (MCP-only safety limit, not a vault or canister limit). For a full vault export with no MCP size cap, use the Hub (e.g. GitHub backup / Back up now) or other non-MCP flows such as vault_sync; operators may also call the canister GET /api/v1/export outside MCP.`
429 );
430 /** @type {any} */ (err).code = 'EXPORT_TOO_LARGE';
431 throw err;
432 }
433 try {
434 return JSON.parse(text);
435 } catch {
436 throw new Error('Invalid JSON from canister export');
437 }
438 }
439
440 /**
441 * POST multipart to bridge /api/v1/import (same headers as hub/gateway proxyImportToBridge).
442 * @param {string} bridgeUrl
443 * @param {{ token?: string, vaultId?: string, userId?: string }} fetchOpts
444 * @param {FormData} formData
445 * @returns {Promise<unknown>}
446 */
447 async function bridgeImportMultipart(bridgeUrl, fetchOpts, formData) {
448 const headers = { Accept: 'application/json' };
449 if (fetchOpts.token) headers['Authorization'] = `Bearer ${fetchOpts.token}`;
450 if (fetchOpts.vaultId) headers['X-Vault-Id'] = fetchOpts.vaultId;
451 if (fetchOpts.userId) headers['X-User-Id'] = fetchOpts.userId;
452 const res = await fetch(`${bridgeUrl}/api/v1/import`, {
453 method: 'POST',
454 headers,
455 body: formData,
456 });
457 const text = await res.text().catch(() => '');
458 if (!res.ok) {
459 throw new Error(`Upstream ${res.status}: ${text.slice(0, 200)}`);
460 }
461 try {
462 return text ? JSON.parse(text) : {};
463 } catch {
464 return { raw: text };
465 }
466 }
467
468 /**
469 * Create a hosted McpServer instance scoped to one user's session.
470 *
471 * @param {{
472 * userId: string,
473 * canisterUserId?: string,
474 * vaultId: string,
475 * role: 'viewer' | 'editor' | 'admin' | 'evaluator',
476 * token: string,
477 * canisterUrl: string,
478 * canisterAuthSecret?: string,
479 * bridgeUrl: string,
480 * gatewayApiBaseUrl: (string|undefined) — public gateway base (e.g. HUB_BASE_URL); enables hub_create_proposal (POST /api/v1/proposals).
481 * scope?: Record<string, unknown>,
482 * }} ctx
483 * @returns {McpServer}
484 */
485 export function createHostedMcpServer(ctx) {
486 const {
487 userId,
488 vaultId,
489 role,
490 token,
491 canisterUrl,
492 canisterAuthSecret,
493 bridgeUrl,
494 gatewayApiBaseUrl: gatewayApiBaseUrlRaw,
495 scope = {},
496 } = ctx;
497 const gatewayApiBaseUrl =
498 typeof gatewayApiBaseUrlRaw === 'string' && gatewayApiBaseUrlRaw.trim() !== ''
499 ? gatewayApiBaseUrlRaw.trim().replace(/\/$/, '')
500 : '';
501 const canisterUserId =
502 typeof ctx.canisterUserId === 'string' && ctx.canisterUserId.trim() !== '' ? ctx.canisterUserId.trim() : userId;
503 const server = new McpServer(
504 { name: 'knowtation-hosted', version: '0.1.0' },
505 { capabilities: { logging: {} } }
506 );
507 const fetchOpts = { token, vaultId };
508 /** Same as Hub proxy: bridge resolves `effectiveCanisterUid` from JWT; header aids debugging. */
509 const bridgeFetchOpts = { token, vaultId, userId };
510 /** Canister `X-User-Id` matches Hub gateway: `effective_canister_user_id` when MCP session supplies it. */
511 const canisterFetchOpts = { ...fetchOpts, userId: canisterUserId, canisterAuthSecret: canisterAuthSecret || '' };
512
513 /**
514 * Shared multipart body for bridge `POST /api/v1/import` (hosted `import` and `transcribe` tools).
515 * @param {{ source_type: string, file_base64?: string, filename?: string, spreadsheet_id?: string, sheets_range?: string, project?: string, output_dir?: string, tags?: string|string[] }} args
516 */
517 async function hostedBridgeImportFromBase64Args(args) {
518 if (args.source_type === 'google-sheets') {
519 const sid = args.spreadsheet_id != null ? String(args.spreadsheet_id).trim() : '';
520 if (!sid) {
521 const err = new Error('spreadsheet_id is required for source_type google-sheets');
522 /** @type {any} */ (err).code = 'INVALID';
523 throw err;
524 }
525 const form = new FormData();
526 form.set('source_type', 'google-sheets');
527 form.set('spreadsheet_id', sid);
528 if (args.sheets_range != null && String(args.sheets_range).trim() !== '') {
529 form.set('sheets_range', String(args.sheets_range).trim());
530 }
531 if (args.project != null && args.project !== '') form.set('project', String(args.project));
532 if (args.output_dir != null && args.output_dir !== '') form.set('output_dir', String(args.output_dir));
533 if (args.tags != null) {
534 const tagsStr = Array.isArray(args.tags)
535 ? args.tags.map((t) => String(t).trim()).filter(Boolean).join(',')
536 : String(args.tags);
537 if (tagsStr) form.set('tags', tagsStr);
538 }
539 return bridgeImportMultipart(bridgeUrl, bridgeFetchOpts, form);
540 }
541
542 let fileBuffer;
543 try {
544 fileBuffer = Buffer.from(/** @type {string} */ (args.file_base64), 'base64');
545 } catch {
546 const err = new Error('file_base64 is not valid base64');
547 /** @type {any} */ (err).code = 'INVALID';
548 throw err;
549 }
550 if (!fileBuffer.length) {
551 const err = new Error('Decoded file is empty');
552 /** @type {any} */ (err).code = 'INVALID';
553 throw err;
554 }
555 if (fileBuffer.length > BRIDGE_IMPORT_MAX_BYTES) {
556 const err = new Error(`Decoded file exceeds ${BRIDGE_IMPORT_MAX_BYTES} bytes`);
557 /** @type {any} */ (err).code = 'INVALID';
558 throw err;
559 }
560 const form = new FormData();
561 form.set('source_type', args.source_type);
562 const blob = new Blob([fileBuffer]);
563 form.set('file', blob, args.filename);
564 if (args.project != null && args.project !== '') form.set('project', args.project);
565 if (args.output_dir != null && args.output_dir !== '') form.set('output_dir', args.output_dir);
566 if (args.tags != null) {
567 const tagsStr = Array.isArray(args.tags)
568 ? args.tags.map((t) => String(t).trim()).filter(Boolean).join(',')
569 : String(args.tags);
570 if (tagsStr) form.set('tags', tagsStr);
571 }
572 return bridgeImportMultipart(bridgeUrl, bridgeFetchOpts, form);
573 }
574
575 if (isToolAllowed('search', role)) {
576 server.registerTool(
577 'search',
578 {
579 description:
580 'Search the hosted vault: semantic (vector similarity, default) or keyword (substring / all-terms). Same filters as list-notes where applicable.',
581 inputSchema: {
582 query: z.string().describe('Search query'),
583 mode: z.enum(['semantic', 'keyword']).optional().describe('semantic = meaning (indexed); keyword = literal text'),
584 match: z.enum(['phrase', 'all_terms']).optional().describe('Keyword only: phrase = whole query substring; all_terms = every token must appear (AND)'),
585 limit: z.number().optional().describe('Max results (default 10)'),
586 fields: z.enum(['path', 'path+snippet', 'full']).optional().describe('Result shape (default path+snippet)'),
587 snippet_chars: z.number().optional().describe('Max snippet length (default 300)'),
588 count_only: z.boolean().optional().describe('Return count only, no results array'),
589 folder: z.string().optional().describe('Filter by folder path prefix'),
590 project: z.string().optional().describe('Filter by project slug'),
591 tag: z.string().optional().describe('Filter by tag'),
592 since: z.string().optional().describe('Filter by date (YYYY-MM-DD)'),
593 until: z.string().optional().describe('Filter by date (YYYY-MM-DD)'),
594 order: z.enum(['date', 'date-asc']).optional(),
595 chain: z.string().optional().describe('Causal chain filter'),
596 entity: z.string().optional().describe('Entity filter'),
597 episode: z.string().optional().describe('Episode filter'),
598 content_scope: z.enum(['all', 'notes', 'approval_logs']).optional().describe('Restrict to note files vs approval logs'),
599 },
600 },
601 async (args) => {
602 try {
603 const body = { query: args.query };
604 if (args.mode != null) body.mode = args.mode;
605 if (args.match != null) body.match = args.match;
606 if (args.limit != null) body.limit = args.limit;
607 if (args.fields != null) body.fields = args.fields;
608 if (args.snippet_chars != null) body.snippetChars = args.snippet_chars;
609 if (args.count_only != null) body.count_only = args.count_only;
610 if (args.folder != null) body.folder = args.folder;
611 if (args.project != null) body.project = args.project;
612 if (args.tag != null) body.tag = args.tag;
613 if (args.since != null) body.since = args.since;
614 if (args.until != null) body.until = args.until;
615 if (args.order != null) body.order = args.order;
616 if (args.chain != null) body.chain = args.chain;
617 if (args.entity != null) body.entity = args.entity;
618 if (args.episode != null) body.episode = args.episode;
619 if (args.content_scope != null) body.content_scope = args.content_scope;
620 const data = await upstreamFetch(`${bridgeUrl}/api/v1/search`, {
621 ...bridgeFetchOpts,
622 method: 'POST',
623 body,
624 });
625 return jsonResponse(data);
626 } catch (e) {
627 return jsonError(e.message || String(e), 'UPSTREAM_ERROR');
628 }
629 }
630 );
631 }
632
633 /**
634 * Hosted `relate` — parity with local `runRelate` (`lib/relate.mjs`) without a filesystem vault.
635 *
636 * Upstream (verified in `hub/bridge/server.mjs`):
637 * - **Source note:** `GET {canisterUrl}/api/v1/notes/:path` with the same headers as `get_note`
638 * (`Authorization`, `X-Vault-Id`, `X-User-Id`, `X-Gateway-Auth`).
639 * - **Neighbors:** `POST {bridgeUrl}/api/v1/search` with JSON body
640 * `{ query, mode: "semantic", limit, snippetChars: 200, project? }`; bridge embeds `query` with
641 * `voyageInputType: "query"`. Only paths that return 200 from canister `GET …/notes/:path` are
642 * returned (stale vector paths omitted).
643 */
644 if (isToolAllowed('relate', role)) {
645 server.registerTool(
646 'relate',
647 {
648 description:
649 'Find semantically related notes for a vault-relative path: read the source note from the canister, semantic search on the bridge index (excludes the source path), titles from the canister. Neighbors that do not exist on the canister (stale index) are omitted.',
650 inputSchema: {
651 path: z.string().describe('Vault-relative path to the source note (.md)'),
652 limit: z.number().optional().describe('Max related notes (default 5, max 20)'),
653 project: z.string().optional().describe('Filter neighbors by project slug'),
654 },
655 },
656 async (args) => {
657 try {
658 const note = await upstreamFetch(
659 `${canisterUrl}/api/v1/notes/${encodeURIComponent(args.path)}`,
660 canisterFetchOpts
661 );
662 const titleFm = titleFromCanisterFrontmatter(note.frontmatter) ?? '';
663 const body = note.body != null ? String(note.body) : '';
664 const embedText = `${titleFm ? `${titleFm}\n` : ''}${body}`.slice(0, RELATE_BODY_SLICE);
665 if (!embedText.trim()) {
666 return jsonError('Source note has no title or body to embed; cannot relate.', 'INVALID');
667 }
668 const srcKey = vaultPathKey(note.path ?? args.path);
669
670 const want = Math.max(1, Math.min(Number(args.limit) || 5, 20));
671 const searchLimit = Math.min(100, Math.max(want + 15, want * 12));
672
673 const searchBody = {
674 query: embedText,
675 mode: 'semantic',
676 limit: searchLimit,
677 snippetChars: 200,
678 };
679 if (args.project != null && String(args.project).trim() !== '') {
680 searchBody.project = normalizeSlug(String(args.project));
681 }
682
683 const data = await upstreamFetch(`${bridgeUrl}/api/v1/search`, {
684 ...bridgeFetchOpts,
685 method: 'POST',
686 body: searchBody,
687 });
688 const rows = Array.isArray(data.results) ? data.results : [];
689 const seen = new Set();
690 const related = [];
691 for (const h of rows) {
692 if (related.length >= want) break;
693 const p = vaultPathKey(h.path);
694 if (!p || p === srcKey) continue;
695 if (seen.has(p)) continue;
696 seen.add(p);
697 try {
698 const rn = await upstreamFetch(
699 `${canisterUrl}/api/v1/notes/${encodeURIComponent(p)}`,
700 canisterFetchOpts
701 );
702 related.push({
703 path: p,
704 score: scoreFromBridgeSearchHit(/** @type {Record<string, unknown>} */ (h)),
705 title: displayTitleFromHostedNote(rn),
706 snippet: relateSnippet(h.snippet ?? h.text),
707 });
708 } catch (_) {
709 // Omit stale vector hits (path not on canister).
710 }
711 }
712
713 await Promise.all(
714 related.map(async (r) => {
715 const pathFallback = titleFromPathStem(r.path);
716 try {
717 const rn = await upstreamFetch(
718 `${canisterUrl}/api/v1/notes/${encodeURIComponent(r.path)}`,
719 canisterFetchOpts
720 );
721 const noteForTitle = { ...rn, path: (rn && rn.path) || r.path };
722 r.title = displayTitleFromHostedNote(noteForTitle) ?? pathFallback;
723 } catch {
724 r.title = pathFallback;
725 }
726 })
727 );
728
729 return jsonResponse({ path: srcKey, related });
730 } catch (e) {
731 return jsonError(e.message || String(e), 'UPSTREAM_ERROR');
732 }
733 }
734 );
735 }
736
737 /**
738 * Hosted `backlinks` — parity with local `runBacklinks` (`lib/backlinks.mjs`) without a filesystem vault.
739 *
740 * Contract: paginate `GET {canisterUrl}/api/v1/notes?limit=&offset=` (same headers as `list_notes`),
741 * skip the target path, `GET` each candidate note for full body (list rows may omit body), scan with
742 * `findFirstWikilinkToTargetInBody` (`lib/wikilink.mjs`) using `vaultBasenameTargetKey(target)` as in local.
743 * Stops after **HOSTED_BACKLINKS_MAX_NOTES** candidates examined; response includes `backlinks_truncated` and
744 * `backlinks_notes_scanned` so callers know coverage.
745 */
746 if (isToolAllowed('backlinks', role)) {
747 server.registerTool(
748 'backlinks',
749 {
750 description:
751 'Notes that wikilink to a target path (`[[target]]` / `[[folder/target]]`, Obsidian-style). Scans the hosted vault via canister list + per-note reads (capped at 2000 notes examined; see backlinks_truncated in the JSON).',
752 inputSchema: {
753 path: z.string().describe('Vault-relative path of the target note (.md)'),
754 },
755 },
756 async (args) => {
757 try {
758 await upstreamFetch(
759 `${canisterUrl}/api/v1/notes/${encodeURIComponent(args.path)}`,
760 canisterFetchOpts
761 );
762 } catch (e) {
763 return jsonError(e.message || String(e), 'UPSTREAM_ERROR');
764 }
765 const srcKey = vaultPathKey(args.path);
766 const targetKey = vaultBasenameTargetKey(srcKey);
767 const backlinks = [];
768 let offset = 0;
769 let scanned = 0;
770 while (scanned < HOSTED_BACKLINKS_MAX_NOTES) {
771 const remain = HOSTED_BACKLINKS_MAX_NOTES - scanned;
772 const pageSize = Math.min(HOSTED_BACKLINKS_PAGE_SIZE, Math.max(1, remain));
773 const list = await upstreamFetch(
774 `${canisterUrl}/api/v1/notes?limit=${pageSize}&offset=${offset}`,
775 canisterFetchOpts
776 );
777 const rows = Array.isArray(list.notes) ? list.notes : [];
778 if (rows.length === 0) break;
779 for (const row of rows) {
780 if (scanned >= HOSTED_BACKLINKS_MAX_NOTES) break;
781 scanned += 1;
782 const p = vaultPathKey(row.path);
783 if (!p || p === srcKey) continue;
784 let full;
785 try {
786 full = await upstreamFetch(
787 `${canisterUrl}/api/v1/notes/${encodeURIComponent(p)}`,
788 canisterFetchOpts
789 );
790 } catch {
791 continue;
792 }
793 const body = full.body != null ? String(full.body) : '';
794 const context = findFirstWikilinkToTargetInBody(body, targetKey);
795 if (context == null) continue;
796 const pathFb = titleFromPathStem(p);
797 backlinks.push({
798 path: p,
799 title: displayTitleFromHostedNote(full) ?? titleFromCanisterFrontmatter(full.frontmatter) ?? pathFb,
800 context,
801 });
802 }
803 offset += rows.length;
804 if (rows.length < pageSize) break;
805 }
806 return jsonResponse({
807 path: srcKey,
808 backlinks,
809 backlinks_truncated: scanned >= HOSTED_BACKLINKS_MAX_NOTES,
810 backlinks_notes_scanned: scanned,
811 });
812 }
813 );
814 }
815
816 /**
817 * Hosted `extract_tasks` — parity with local `runExtractTasks` (`lib/extract-tasks.mjs`) without a filesystem vault.
818 *
819 * Upstream: paginate `GET {canisterUrl}/api/v1/notes` with the same query keys as hosted `list_notes`
820 * (`folder`, `project`, `tag`, `since`, `until`, `limit`, `offset`). The ICP canister in this repo returns full
821 * bodies on list rows; when a row has an empty body, the handler falls back to `GET …/notes/:path`.
822 * Client-side folder/project/tag/date filters mirror local `runExtractTasks` (canister list query params are not
823 * relied on for correctness). Stops after **HOSTED_EXTRACT_TASKS_MAX_NOTES** list rows processed.
824 */
825 if (isToolAllowed('extract_tasks', role)) {
826 server.registerTool(
827 'extract_tasks',
828 {
829 description:
830 'Extract markdown checkbox tasks (`- [ ]` / `- [x]`) from the hosted vault. Uses canister note list + bodies (GET per note only when list body is empty). Optional folder/project/tag/since/until match hosted list_notes query shapes; filters are applied client-side like local extract_tasks. Max 2000 notes scanned per call (see extract_tasks_truncated in the JSON).',
831 inputSchema: {
832 folder: z.string().optional().describe('Restrict to notes under this vault-relative folder prefix'),
833 project: z.string().optional().describe('Filter by project slug'),
834 tag: z.string().optional().describe('Filter by tag'),
835 since: z.string().optional().describe('Include only notes with date/updated on or after YYYY-MM-DD'),
836 until: z.string().optional().describe('Include only notes with date/updated on or before YYYY-MM-DD'),
837 status: z.enum(['open', 'done', 'all']).optional().describe('Task checkbox filter (default all)'),
838 },
839 },
840 async (args) => {
841 try {
842 const statusArg = args.status ?? 'all';
843 const filter = {
844 folder: args.folder,
845 project: args.project,
846 tag: args.tag,
847 since: args.since,
848 until: args.until,
849 };
850 const tasks = [];
851 let offset = 0;
852 let scanned = 0;
853 while (scanned < HOSTED_EXTRACT_TASKS_MAX_NOTES) {
854 const remain = HOSTED_EXTRACT_TASKS_MAX_NOTES - scanned;
855 const pageSize = Math.min(HOSTED_EXTRACT_TASKS_PAGE_SIZE, Math.max(1, remain));
856 const params = new URLSearchParams();
857 if (args.folder) params.set('folder', args.folder);
858 if (args.project) params.set('project', args.project);
859 if (args.tag) params.set('tag', args.tag);
860 if (args.since) params.set('since', args.since);
861 if (args.until) params.set('until', args.until);
862 params.set('limit', String(pageSize));
863 params.set('offset', String(offset));
864 const list = await upstreamFetch(`${canisterUrl}/api/v1/notes?${params}`, canisterFetchOpts);
865 const rows = Array.isArray(list.notes) ? list.notes : [];
866 if (rows.length === 0) break;
867 for (const row of rows) {
868 if (scanned >= HOSTED_EXTRACT_TASKS_MAX_NOTES) break;
869 scanned += 1;
870 const p = vaultPathKey(row.path);
871 if (!p) continue;
872 if (!hostedNotePassesExtractFilters(p, row.frontmatter, filter)) continue;
873 let body = row.body != null ? String(row.body) : '';
874 if (!body.trim()) {
875 try {
876 const full = await upstreamFetch(
877 `${canisterUrl}/api/v1/notes/${encodeURIComponent(p)}`,
878 canisterFetchOpts
879 );
880 body = full.body != null ? String(full.body) : '';
881 } catch {
882 continue;
883 }
884 }
885 for (const t of extractCheckboxTasksFromBody(body, { path: p, status: statusArg })) {
886 tasks.push(t);
887 }
888 }
889 offset += rows.length;
890 if (rows.length < pageSize) break;
891 }
892 return jsonResponse({
893 tasks,
894 extract_tasks_truncated: scanned >= HOSTED_EXTRACT_TASKS_MAX_NOTES,
895 extract_tasks_notes_scanned: scanned,
896 });
897 } catch (e) {
898 return jsonError(e.message || String(e), 'UPSTREAM_ERROR');
899 }
900 }
901 );
902 }
903
904 /**
905 * Hosted `cluster` — parity with local `runCluster` (`lib/cluster-semantic.mjs`) without a filesystem vault.
906 *
907 * Upstream (verified in `hub/bridge/server.mjs`):
908 * - **Note text:** paginate `GET {canisterUrl}/api/v1/notes` (same query keys as hosted `list_notes`); optional
909 * `GET …/notes/:path` when a list row has an empty body. Client-side `folder` / `project` filters match
910 * `hostedNotePassesExtractFilters` (same intent as local path + project filter).
911 * - **Embeddings:** `POST {bridgeUrl}/api/v1/embed` with JSON `{ texts: string[] }`; same JWT + `X-Vault-Id` +
912 * `resolveHostedBridgeContext` as `POST /api/v1/search`; uses `getVectorsDirForUser` + `getBridgeStoreConfig` +
913 * `embedWithUsage` with `voyageInputType: "document"` like `POST /api/v1/index` chunk batches.
914 * - **Grouping:** `kmeans` from `lib/kmeans.mjs` on returned vectors (max {@link HOSTED_CLUSTER_MAX_NOTES} notes;
915 * `n_clusters` default 5, clamped 2–15).
916 */
917 if (isToolAllowed('cluster', role)) {
918 server.registerTool(
919 'cluster',
920 {
921 description:
922 'Semantic k-means clusters over hosted note text (title + body slice). Loads notes from the canister (list + optional per-note GET), embeds up to 200 notes via the bridge POST /api/v1/embed, then clusters in-process. Optional folder/project filters match list_notes shapes (client-side).',
923 inputSchema: {
924 folder: z.string().optional().describe('Restrict to notes under this vault-relative folder prefix'),
925 project: z.string().optional().describe('Filter by project slug'),
926 n_clusters: z
927 .number()
928 .int()
929 .optional()
930 .describe('Number of clusters (default 5, clamped between 2 and 15)'),
931 },
932 },
933 async (args) => {
934 try {
935 const k = Math.max(2, Math.min(Number(args.n_clusters) || 5, 15));
936 const filter = { folder: args.folder, project: args.project };
937 const texts = [];
938 const pathFor = [];
939 let offset = 0;
940 let scanned = 0;
941 while (pathFor.length < HOSTED_CLUSTER_MAX_NOTES && scanned < HOSTED_CLUSTER_MAX_LIST_ROWS) {
942 const remain = HOSTED_CLUSTER_MAX_LIST_ROWS - scanned;
943 const pageSize = Math.min(HOSTED_CLUSTER_PAGE_SIZE, Math.max(1, remain));
944 const params = new URLSearchParams();
945 if (args.folder) params.set('folder', args.folder);
946 if (args.project) params.set('project', args.project);
947 params.set('limit', String(pageSize));
948 params.set('offset', String(offset));
949 const list = await upstreamFetch(`${canisterUrl}/api/v1/notes?${params}`, canisterFetchOpts);
950 const rows = Array.isArray(list.notes) ? list.notes : [];
951 if (rows.length === 0) break;
952 for (const row of rows) {
953 if (pathFor.length >= HOSTED_CLUSTER_MAX_NOTES) break;
954 if (scanned >= HOSTED_CLUSTER_MAX_LIST_ROWS) break;
955 scanned += 1;
956 const p = vaultPathKey(row.path);
957 if (!p) continue;
958 if (!hostedNotePassesExtractFilters(p, row.frontmatter, filter)) continue;
959 let body = row.body != null ? String(row.body) : '';
960 if (!body.trim()) {
961 try {
962 const full = await upstreamFetch(
963 `${canisterUrl}/api/v1/notes/${encodeURIComponent(p)}`,
964 canisterFetchOpts
965 );
966 body = full.body != null ? String(full.body) : '';
967 } catch {
968 continue;
969 }
970 }
971 const t = hostedClusterEmbedText(row.frontmatter, body);
972 if (!t) continue;
973 texts.push(t);
974 pathFor.push(p);
975 }
976 offset += rows.length;
977 if (rows.length < pageSize) break;
978 }
979
980 if (texts.length < k) {
981 return jsonResponse({
982 clusters: [],
983 notes_sampled: texts.length,
984 max_notes: HOSTED_CLUSTER_MAX_NOTES,
985 note: `Not enough notes (${texts.length}) for k=${k}. Add notes or lower n_clusters.`,
986 cluster_list_rows_scanned: scanned,
987 cluster_truncated: scanned >= HOSTED_CLUSTER_MAX_LIST_ROWS,
988 });
989 }
990
991 const embedRes = await upstreamFetch(`${bridgeUrl}/api/v1/embed`, {
992 ...bridgeFetchOpts,
993 method: 'POST',
994 body: { texts },
995 });
996 const vectorsRaw = embedRes && typeof embedRes === 'object' ? embedRes.vectors : null;
997 if (!Array.isArray(vectorsRaw) || vectorsRaw.length !== texts.length) {
998 return jsonError('Bridge embed returned an unexpected vectors array', 'UPSTREAM_ERROR');
999 }
1000
1001 const points = [];
1002 for (let i = 0; i < pathFor.length; i++) {
1003 const v = vectorsRaw[i];
1004 if (!v || !Array.isArray(v) || !v.length) continue;
1005 points.push({
1006 id: pathFor[i],
1007 vector: /** @type {number[]} */ (v),
1008 path: pathFor[i],
1009 text: texts[i],
1010 });
1011 }
1012 if (points.length < k) {
1013 return jsonResponse({
1014 clusters: [],
1015 notes_sampled: points.length,
1016 max_notes: HOSTED_CLUSTER_MAX_NOTES,
1017 note: 'Embedding failed for some notes.',
1018 cluster_list_rows_scanned: scanned,
1019 cluster_truncated: scanned >= HOSTED_CLUSTER_MAX_LIST_ROWS,
1020 });
1021 }
1022
1023 const { labels } = kmeans(
1024 points.map((pt) => ({ id: pt.id, vector: pt.vector })),
1025 k
1026 );
1027
1028 const clusters = [];
1029 for (let c = 0; c < k; c++) {
1030 const members = [];
1031 for (let i = 0; i < points.length; i++) {
1032 if (labels[i] === c) members.push(points[i]);
1033 }
1034 if (!members.length) continue;
1035 const centroidSnippet = (members[0].text || '').slice(0, 120).replace(/\s+/g, ' ').trim();
1036 const pathsIn = [...new Set(members.map((m) => m.path))];
1037 clusters.push({
1038 label: `cluster_${c + 1}`,
1039 centroid_snippet: centroidSnippet,
1040 paths: pathsIn,
1041 });
1042 }
1043
1044 return jsonResponse({
1045 clusters,
1046 notes_sampled: points.length,
1047 max_notes: HOSTED_CLUSTER_MAX_NOTES,
1048 cluster_list_rows_scanned: scanned,
1049 cluster_truncated: scanned >= HOSTED_CLUSTER_MAX_LIST_ROWS,
1050 });
1051 } catch (e) {
1052 return jsonError(e.message || String(e), 'UPSTREAM_ERROR');
1053 }
1054 }
1055 );
1056 }
1057
1058 /**
1059 * Hosted `tag_suggest` — parity with local `runTagSuggest` (`lib/tag-suggest.mjs`) without a filesystem vault.
1060 *
1061 * Upstream (verified in `hub/bridge/server.mjs`):
1062 * - **Source note (path):** `GET {canisterUrl}/api/v1/notes/:path` with the same headers as `get_note`
1063 * (`Authorization`, `X-Vault-Id`, `X-User-Id`, `X-Gateway-Auth`).
1064 * - **Source text (body-only):** optional `body` argument, trimmed to {@link TAG_SUGGEST_TEXT_SLICE} chars (same cap as local).
1065 * - **Semantic neighbors:** `POST {bridgeUrl}/api/v1/search` with JSON
1066 * `{ query, mode: "semantic", limit: <neighbor_limit>, snippetChars: 200 }` (default {@link TAG_SUGGEST_NEIGHBOR_LIMIT_DEFAULT}, max {@link TAG_SUGGEST_NEIGHBOR_LIMIT_MAX}); same JWT + `X-Vault-Id` + `resolveHostedBridgeContext` as
1067 * `relate` / `POST /api/v1/search` (bridge `userIdFromJwt` + hosted context). Search results include `tags` per row when the
1068 * vector store exposes them (`results` map in `hub/bridge/server.mjs`). If a hit has no tags, `GET …/notes/:path` on the
1069 * canister supplies frontmatter tags (`tagsFromFm` + `materializeListFrontmatter`), analogous to local `readNote` fallback.
1070 * - **Embedding type:** the bridge embeds `query` with `voyageInputType: "query"` for semantic search; local `runTagSuggest`
1071 * uses **document** embedding for the source string — same intentional hosted vs local tradeoff as `relate` vs `lib/relate.mjs`.
1072 */
1073 if (isToolAllowed('tag_suggest', role)) {
1074 server.registerTool(
1075 'tag_suggest',
1076 {
1077 description:
1078 'Suggest tags from semantically similar notes on the hosted index. Pass vault-relative path (loads title+body from the canister) or raw body text; at least one is required. Uses bridge semantic search (indexed vault) and aggregates tags from neighbors (up to 12 suggestions). Optional neighbor_limit (5–80) increases how many semantic neighbors are considered (default 40).',
1079 inputSchema: {
1080 path: z.string().optional().describe('Vault-relative path to the note (.md); loaded from the canister when set'),
1081 body: z.string().optional().describe('Raw markdown/text when no path; combined with path is invalid — path wins if both are sent'),
1082 neighbor_limit: z
1083 .number()
1084 .optional()
1085 .describe('Semantic neighbor count for bridge search (clamped 5–80; default 40). Higher values can improve recall on larger vaults at the cost of latency.'),
1086 },
1087 },
1088 async (args) => {
1089 try {
1090 const hasPath = args.path != null && String(args.path).trim() !== '';
1091 const hasBody = args.body != null && String(args.body).trim() !== '';
1092 if (!hasPath && !hasBody) {
1093 return jsonError('Provide path or body (at least one).', 'INVALID');
1094 }
1095
1096 let embedText = '';
1097 /** @type {string[]} */
1098 let existing = [];
1099 /** @type {string | null} */
1100 let srcKey = null;
1101
1102 if (hasPath) {
1103 const note = await upstreamFetch(
1104 `${canisterUrl}/api/v1/notes/${encodeURIComponent(args.path)}`,
1105 canisterFetchOpts
1106 );
1107 const titleFm = titleFromCanisterFrontmatter(note.frontmatter) ?? '';
1108 const body = note.body != null ? String(note.body) : '';
1109 embedText = `${titleFm ? `${titleFm}\n` : ''}${body}`.slice(0, TAG_SUGGEST_TEXT_SLICE);
1110 existing = hostedExistingTagsFromCanisterNote(/** @type {Record<string, unknown>} */ (note));
1111 srcKey = vaultPathKey(note.path ?? args.path);
1112 } else {
1113 embedText = String(args.body).slice(0, TAG_SUGGEST_TEXT_SLICE);
1114 }
1115
1116 if (!embedText.trim()) {
1117 return jsonError('No title or body text to match; cannot suggest tags.', 'INVALID');
1118 }
1119
1120 const rawNeighbor = Number(args.neighbor_limit);
1121 const neighborLimit = Number.isFinite(rawNeighbor)
1122 ? Math.max(5, Math.min(Math.floor(rawNeighbor), TAG_SUGGEST_NEIGHBOR_LIMIT_MAX))
1123 : TAG_SUGGEST_NEIGHBOR_LIMIT_DEFAULT;
1124
1125 const searchBody = {
1126 query: embedText,
1127 mode: 'semantic',
1128 limit: neighborLimit,
1129 snippetChars: 200,
1130 };
1131 const data = await upstreamFetch(`${bridgeUrl}/api/v1/search`, {
1132 ...bridgeFetchOpts,
1133 method: 'POST',
1134 body: searchBody,
1135 });
1136 const rows = Array.isArray(data.results) ? data.results : [];
1137 const existingSet = new Set(existing.map((t) => normalizeSlug(String(t))).filter(Boolean));
1138 const tagCounts = new Map();
1139
1140 for (const h of rows) {
1141 if (!h || typeof h !== 'object') continue;
1142 const p = vaultPathKey(h.path);
1143 if (!p || (srcKey != null && p === srcKey)) continue;
1144
1145 let tagsRaw = h.tags;
1146 /** @type {Record<string, unknown> | undefined} */
1147 let noteForTags;
1148 if (!Array.isArray(tagsRaw) || tagsRaw.length === 0) {
1149 try {
1150 noteForTags = await upstreamFetch(
1151 `${canisterUrl}/api/v1/notes/${encodeURIComponent(p)}`,
1152 canisterFetchOpts
1153 );
1154 } catch {
1155 continue;
1156 }
1157 }
1158 const tagList = hostedTagsFromHitOrNote(tagsRaw, noteForTags);
1159 for (const slug of tagList) {
1160 if (!slug || existingSet.has(slug)) continue;
1161 tagCounts.set(slug, (tagCounts.get(slug) || 0) + 1);
1162 }
1163 }
1164
1165 const suggested_tags = [...tagCounts.entries()]
1166 .sort((a, b) => b[1] - a[1])
1167 .map(([name]) => name)
1168 .slice(0, 12);
1169
1170 return jsonResponse({ suggested_tags, existing_tags: existing });
1171 } catch (e) {
1172 return jsonError(e.message || String(e), 'UPSTREAM_ERROR');
1173 }
1174 }
1175 );
1176 }
1177
1178 if (isToolAllowed('get_note', role)) {
1179 server.registerTool(
1180 'get_note',
1181 {
1182 description: 'Retrieve a single note by vault-relative path.',
1183 inputSchema: {
1184 path: z.string().describe('Vault-relative note path'),
1185 },
1186 },
1187 async (args) => {
1188 try {
1189 const data = await upstreamFetch(
1190 `${canisterUrl}/api/v1/notes/${encodeURIComponent(args.path)}`,
1191 canisterFetchOpts
1192 );
1193 return jsonResponse(data);
1194 } catch (e) {
1195 return jsonError(e.message || String(e), 'UPSTREAM_ERROR');
1196 }
1197 }
1198 );
1199 }
1200
1201 if (isToolAllowed('get_note_outline', role)) {
1202 server.registerTool(
1203 'get_note_outline',
1204 {
1205 description: 'Return a derived Markdown heading outline for one hosted note without body text.',
1206 inputSchema: {
1207 path: z.string().describe('Vault-relative note path'),
1208 },
1209 },
1210 async (args) => {
1211 try {
1212 const data = await upstreamFetch(
1213 `${canisterUrl}/api/v1/notes/${encodeURIComponent(args.path)}`,
1214 canisterFetchOpts
1215 );
1216 return jsonResponse(
1217 buildNoteOutline({
1218 path: String(args.path),
1219 frontmatter: parseCanisterFrontmatter(data?.frontmatter) || {},
1220 body: data?.body != null ? String(data.body) : '',
1221 })
1222 );
1223 } catch (e) {
1224 return jsonError(e.message || String(e), 'UPSTREAM_ERROR');
1225 }
1226 }
1227 );
1228 }
1229
1230 if (isToolAllowed('get_document_tree', role)) {
1231 server.registerTool(
1232 'get_document_tree',
1233 {
1234 description: 'Return a derived nested Markdown heading tree for one hosted note without body text.',
1235 inputSchema: {
1236 path: z.string().describe('Vault-relative note path'),
1237 },
1238 },
1239 async (args) => {
1240 try {
1241 const requestedPath = normalizeHostedDocumentTreePath(args.path);
1242 const data = await upstreamFetch(
1243 `${canisterUrl}/api/v1/notes/${encodeURIComponent(requestedPath)}`,
1244 canisterFetchOpts
1245 );
1246 return jsonResponse(
1247 buildDocumentTree({
1248 path: requestedPath,
1249 frontmatter: parseCanisterFrontmatter(data?.frontmatter) || {},
1250 body: data?.body != null ? String(data.body) : '',
1251 })
1252 );
1253 } catch (e) {
1254 return jsonError(e.message || String(e), 'UPSTREAM_ERROR');
1255 }
1256 }
1257 );
1258 }
1259
1260 if (isToolAllowed('get_metadata_facets', role)) {
1261 server.registerTool(
1262 'get_metadata_facets',
1263 {
1264 description: 'Return bounded body-free MetadataFacets v0 for one hosted note.',
1265 inputSchema: {
1266 path: z.string().describe('Vault-relative note path'),
1267 },
1268 },
1269 async (args) => {
1270 try {
1271 const requestedPath = normalizeHostedDocumentTreePath(args.path);
1272 const data = await upstreamFetch(
1273 `${canisterUrl}/api/v1/notes/${encodeURIComponent(requestedPath)}`,
1274 canisterFetchOpts
1275 );
1276 return jsonResponse(
1277 normalizeMetadataFacets(
1278 requestedPath,
1279 parseCanisterFrontmatter(data?.frontmatter) || {}
1280 )
1281 );
1282 } catch (e) {
1283 return jsonError(e.message || String(e), 'UPSTREAM_ERROR');
1284 }
1285 }
1286 );
1287 }
1288
1289 if (isToolAllowed('get_section_source', role)) {
1290 server.registerTool(
1291 'get_section_source',
1292 {
1293 description: 'Return body-free SectionSource v0 metadata for one hosted note.',
1294 inputSchema: {
1295 path: z.string().describe('Vault-relative note path'),
1296 },
1297 },
1298 async (args) => {
1299 try {
1300 const requestedPath = normalizeHostedDocumentTreePath(args.path);
1301 const data = await upstreamFetch(
1302 `${canisterUrl}/api/v1/notes/${encodeURIComponent(requestedPath)}`,
1303 canisterFetchOpts
1304 );
1305 return jsonResponse(
1306 buildSectionSource({
1307 path: requestedPath,
1308 frontmatter: parseCanisterFrontmatter(data?.frontmatter) || {},
1309 body: data?.body != null ? String(data.body) : '',
1310 })
1311 );
1312 } catch (e) {
1313 return jsonError(sanitizeHostedSectionSourceError(e), 'UPSTREAM_ERROR');
1314 }
1315 }
1316 );
1317 }
1318
1319 if (isToolAllowed('list_notes', role)) {
1320 server.registerTool(
1321 'list_notes',
1322 {
1323 description: 'List notes with filters.',
1324 inputSchema: {
1325 folder: z.string().optional(),
1326 project: z.string().optional(),
1327 tag: z.string().optional(),
1328 since: z.string().optional(),
1329 until: z.string().optional(),
1330 limit: z.number().optional(),
1331 offset: z.number().optional(),
1332 },
1333 },
1334 async (args) => {
1335 try {
1336 const params = new URLSearchParams();
1337 if (args.folder) params.set('folder', args.folder);
1338 if (args.project) params.set('project', args.project);
1339 if (args.tag) params.set('tag', args.tag);
1340 if (args.since) params.set('since', args.since);
1341 if (args.until) params.set('until', args.until);
1342 if (args.limit) params.set('limit', String(args.limit));
1343 if (args.offset) params.set('offset', String(args.offset));
1344 const data = await upstreamFetch(`${canisterUrl}/api/v1/notes?${params}`, canisterFetchOpts);
1345 return jsonResponse(data);
1346 } catch (e) {
1347 return jsonError(e.message || String(e), 'UPSTREAM_ERROR');
1348 }
1349 }
1350 );
1351 }
1352
1353 if (isToolAllowed('write', role)) {
1354 server.registerTool(
1355 'write',
1356 {
1357 description: 'Write or update a note in the vault.',
1358 inputSchema: {
1359 path: z.string().describe('Vault-relative path'),
1360 body: z.string().describe('Markdown body'),
1361 // Open-ended record(value: unknown) breaks Zod v4 JSON Schema export and makes tools/list fail (no tools in clients).
1362 frontmatter: z.record(z.string(), z.unknown()).optional(),
1363 },
1364 },
1365 async (args) => {
1366 try {
1367 const data = await upstreamFetch(`${canisterUrl}/api/v1/notes`, {
1368 ...canisterFetchOpts,
1369 method: 'POST',
1370 body: { path: args.path, body: args.body, frontmatter: args.frontmatter },
1371 });
1372 return jsonResponse(data);
1373 } catch (e) {
1374 return jsonError(e.message || String(e), 'UPSTREAM_ERROR');
1375 }
1376 }
1377 );
1378 }
1379
1380 if (isToolAllowed('hub_create_proposal', role) && gatewayApiBaseUrl) {
1381 server.registerTool(
1382 'hub_create_proposal',
1383 {
1384 description:
1385 'Create a Hub proposal (review-before-commit): POST /api/v1/proposals on this gateway with your JWT and X-Vault-Id (same contract as Hub UI and stdio hub_create_proposal). Editor, evaluator, or admin. Body: docs/HUB-API.md §3.4.',
1386 inputSchema: {
1387 path: z.string().min(1).describe('Vault-relative note path (required)'),
1388 body: z.string().optional().describe('Proposed Markdown body (default empty)'),
1389 frontmatter: z.record(z.string(), z.unknown()).optional().describe('Proposed frontmatter object'),
1390 intent: z.string().optional().describe('Reason for the change'),
1391 base_state_id: z.string().optional().describe('kn1_… optimistic concurrency id from note state'),
1392 external_ref: z.string().optional().describe('Optional cross-system reference (e.g. Muse)'),
1393 labels: z.array(z.string()).optional(),
1394 source: z.string().optional().describe('e.g. agent | human | import'),
1395 },
1396 },
1397 async (args) => {
1398 try {
1399 /** @type {Record<string, unknown>} */
1400 const payload = {
1401 path: args.path,
1402 body: args.body ?? '',
1403 frontmatter: args.frontmatter ?? {},
1404 };
1405 if (args.intent !== undefined) payload.intent = args.intent;
1406 if (args.base_state_id !== undefined) payload.base_state_id = args.base_state_id;
1407 if (args.external_ref !== undefined) payload.external_ref = args.external_ref;
1408 if (args.labels !== undefined) payload.labels = args.labels;
1409 if (args.source !== undefined) payload.source = args.source;
1410 const data = await gatewayHubPostJson(gatewayApiBaseUrl, { token, vaultId }, payload);
1411 return jsonResponse(data);
1412 } catch (e) {
1413 return jsonHubUpstreamError(e);
1414 }
1415 }
1416 );
1417 }
1418
1419 /**
1420 * Hosted `capture` — parity with local `runCaptureInbox` / `buildCaptureInboxWritePayload` (`lib/capture-inbox.mjs`).
1421 * Upstream: `POST {canisterUrl}/api/v1/notes` with the same headers as `write` (JWT, `X-Vault-Id`, `X-User-Id` =
1422 * `canisterUserId`, `X-Gateway-Auth`). Hub `POST /api/v1/capture` is webhook-only (`X-Webhook-Secret`); hosted MCP
1423 * does not proxy that route for capture.
1424 */
1425 if (isToolAllowed('capture', role)) {
1426 server.registerTool(
1427 'capture',
1428 {
1429 description:
1430 'Fast inbox capture: creates a new note under inbox/ (or projects/{project}/inbox/) with inbox frontmatter (source, date, inbox). Same path and metadata rules as local MCP capture; no AIR. Uses the canister notes API like write.',
1431 inputSchema: {
1432 text: z.string().min(1).describe('Note body text'),
1433 source: z.string().optional().describe('Source label (default mcp-capture)'),
1434 project: z.string().optional().describe('Optional project slug for project inbox path'),
1435 tags: z.array(z.string()).optional().describe('Optional tags (normalized like local capture)'),
1436 },
1437 },
1438 async (args) => {
1439 try {
1440 const { path, body, frontmatter } = buildCaptureInboxWritePayload(args.text, {
1441 source: args.source,
1442 project: args.project,
1443 tags: args.tags,
1444 });
1445 const data = await upstreamFetch(`${canisterUrl}/api/v1/notes`, {
1446 ...canisterFetchOpts,
1447 method: 'POST',
1448 body: { path, body, frontmatter },
1449 });
1450 return jsonResponse(data);
1451 } catch (e) {
1452 return jsonError(e.message || String(e), 'UPSTREAM_ERROR');
1453 }
1454 }
1455 );
1456 }
1457
1458 /**
1459 * Hosted `transcribe` — same upstream as Hub / bridge **`POST /api/v1/import`** with **`source_type`** **`audio`** or **`video`**
1460 * (Whisper via `lib/transcribe.mjs` on the bridge). Local MCP `transcribe` reads a disk path; hosted accepts **base64**
1461 * bytes like the **`import`** tool. Requires bridge env (**`OPENAI_API_KEY`**, optional ffmpeg for transcode) as for self-hosted import.
1462 */
1463 if (isToolAllowed('transcribe', role)) {
1464 server.registerTool(
1465 'transcribe',
1466 {
1467 description:
1468 'Transcribe audio or video (OpenAI Whisper on the bridge) into the hosted vault: multipart POST /api/v1/import with source_type audio or video, same contract as Hub import. Provide base64 file bytes and filename; optional project, output_dir, tags.',
1469 inputSchema: {
1470 source_type: z.enum(['audio', 'video']).describe('Importer id: audio or video (Whisper)'),
1471 file_base64: z.string().min(1).describe('Media file content as standard base64 (decoded size max 100 MiB)'),
1472 filename: z.string().min(1).describe('Original filename with extension (e.g. meeting.m4a)'),
1473 project: z.string().optional().describe('Optional project slug'),
1474 output_dir: z.string().optional().describe('Optional vault-relative output folder'),
1475 tags: z
1476 .union([z.string(), z.array(z.string())])
1477 .optional()
1478 .describe('Optional tags: comma-separated string or array of strings'),
1479 },
1480 },
1481 async (args) => {
1482 try {
1483 const data = await hostedBridgeImportFromBase64Args(args);
1484 return jsonResponse(data);
1485 } catch (e) {
1486 const code = /** @type {any} */ (e).code === 'INVALID' ? 'INVALID' : 'UPSTREAM_ERROR';
1487 return jsonError(e.message || String(e), code);
1488 }
1489 }
1490 );
1491 }
1492
1493 if (isToolAllowed('index', role)) {
1494 server.registerTool(
1495 'index',
1496 {
1497 description: 'Trigger re-indexing of the hosted vault.',
1498 },
1499 async () => {
1500 try {
1501 const data = await upstreamFetch(`${bridgeUrl}/api/v1/index`, {
1502 ...bridgeFetchOpts,
1503 method: 'POST',
1504 });
1505 return jsonResponse(data);
1506 } catch (e) {
1507 return jsonError(e.message || String(e), 'UPSTREAM_ERROR');
1508 }
1509 }
1510 );
1511 }
1512
1513 if (isToolAllowed('vault_sync', role)) {
1514 server.registerTool(
1515 'vault_sync',
1516 {
1517 description:
1518 'Back up the hosted vault to GitHub (same as Hub “Back up now”): exports notes and proposals via the bridge and pushes to the connected repo. Requires GitHub connected on the bridge; optional repo overrides owner/name.',
1519 inputSchema: {
1520 repo: z
1521 .string()
1522 .optional()
1523 .describe('GitHub repository as owner/name (optional if a repo is already stored after Connect GitHub)'),
1524 },
1525 },
1526 async (args) => {
1527 try {
1528 const body =
1529 args.repo != null && String(args.repo).trim() !== ''
1530 ? { repo: String(args.repo).trim() }
1531 : {};
1532 const data = await upstreamFetch(`${bridgeUrl}/api/v1/vault/sync`, {
1533 ...bridgeFetchOpts,
1534 method: 'POST',
1535 body,
1536 });
1537 return jsonResponse(data);
1538 } catch (e) {
1539 return jsonError(e.message || String(e), 'UPSTREAM_ERROR');
1540 }
1541 }
1542 );
1543 }
1544
1545 if (isToolAllowed('import', role)) {
1546 server.registerTool(
1547 'import',
1548 {
1549 description:
1550 'Import into the hosted vault via the bridge (Hub POST /api/v1/import). For most source types: base64 file, filename, source_type. For google-sheets only: source_type, spreadsheet_id (id from the URL; no file); optional sheets_range (A1 range). The bridge process must have Google service account env for google-sheets.',
1551 inputSchema: {
1552 source_type: z
1553 .enum(IMPORT_SOURCE_ENUM)
1554 .describe(`Importer id (same as Hub import). Allowed: ${IMPORT_SOURCE_TYPES.join(', ')}`),
1555 file_base64: z
1556 .string()
1557 .optional()
1558 .describe('File as base64 (max 100 MiB decoded). Omit when source_type is google-sheets.'),
1559 filename: z
1560 .string()
1561 .optional()
1562 .describe('Original filename. Omit when source_type is google-sheets.'),
1563 spreadsheet_id: z
1564 .string()
1565 .optional()
1566 .describe('Required when source_type is google-sheets: spreadsheet id from the Google Sheets URL.'),
1567 sheets_range: z
1568 .string()
1569 .optional()
1570 .describe(
1571 'Optional for google-sheets: A1 notation (e.g. Sheet1!A1:E100 or \'My Tab\'!A:Z). Omit to use the first tab from A1.'
1572 ),
1573 project: z.string().optional().describe('Optional project slug'),
1574 output_dir: z.string().optional().describe('Optional vault-relative output folder'),
1575 tags: z
1576 .union([z.string(), z.array(z.string())])
1577 .optional()
1578 .describe('Optional tags: comma-separated string or array of strings'),
1579 },
1580 },
1581 async (args) => {
1582 try {
1583 if (args.source_type === 'google-sheets') {
1584 if (!args.spreadsheet_id || !String(args.spreadsheet_id).trim()) {
1585 return jsonError('spreadsheet_id is required for source_type google-sheets', 'INVALID');
1586 }
1587 if (args.file_base64 || args.filename) {
1588 return jsonError('For google-sheets, omit file_base64 and filename; use spreadsheet_id only', 'INVALID');
1589 }
1590 } else {
1591 if (!args.file_base64 || !args.filename) {
1592 return jsonError('file_base64 and filename are required for this source_type', 'INVALID');
1593 }
1594 }
1595 const data = await hostedBridgeImportFromBase64Args(args);
1596 return jsonResponse(data);
1597 } catch (e) {
1598 const code = /** @type {any} */ (e).code === 'INVALID' ? 'INVALID' : 'UPSTREAM_ERROR';
1599 return jsonError(e.message || String(e), code);
1600 }
1601 }
1602 );
1603 }
1604
1605 if (isToolAllowed('import_url', role)) {
1606 server.registerTool(
1607 'import_url',
1608 {
1609 description:
1610 'Import a public https URL into the hosted vault (same as Hub Import from URL). Fetches server-side with SSRF protections; uses article extraction when possible. Requires bridge.',
1611 inputSchema: {
1612 url: z
1613 .string()
1614 .min(1)
1615 .describe('Full https URL (e.g. https://example.com/article)'),
1616 mode: z
1617 .enum(['auto', 'bookmark', 'extract'])
1618 .optional()
1619 .describe('auto = extract when possible else bookmark; bookmark = link only; extract = require readable HTML'),
1620 project: z.string().optional().describe('Optional project slug'),
1621 output_dir: z.string().optional().describe('Optional vault-relative output folder'),
1622 tags: z
1623 .union([z.string(), z.array(z.string())])
1624 .optional()
1625 .describe('Optional tags: comma-separated string or array of strings'),
1626 },
1627 },
1628 async (args) => {
1629 try {
1630 const u = String(args.url || '').trim();
1631 if (!u.startsWith('https://')) {
1632 return jsonError('url must start with https://', 'INVALID');
1633 }
1634 const body = {
1635 url: u,
1636 mode: args.mode === 'bookmark' || args.mode === 'extract' || args.mode === 'auto' ? args.mode : 'auto',
1637 };
1638 if (args.project != null && String(args.project).trim() !== '') body.project = String(args.project).trim();
1639 if (args.output_dir != null && String(args.output_dir).trim() !== '') body.output_dir = String(args.output_dir).trim();
1640 if (args.tags != null) {
1641 body.tags = Array.isArray(args.tags)
1642 ? args.tags.map((t) => String(t).trim()).filter(Boolean)
1643 : String(args.tags);
1644 }
1645 const data = await upstreamFetch(`${bridgeUrl}/api/v1/import-url`, {
1646 ...bridgeFetchOpts,
1647 method: 'POST',
1648 body,
1649 });
1650 return jsonResponse(data);
1651 } catch (e) {
1652 return jsonError(e.message || String(e), 'UPSTREAM_ERROR');
1653 }
1654 }
1655 );
1656 }
1657
1658 if (isToolAllowed('export', role)) {
1659 server.registerTool(
1660 'export',
1661 {
1662 description:
1663 'Admin-only: vault notes JSON from the hub canister (GET /api/v1/export). Returns { notes: [...] } when the response is under an MCP-only byte cap; if EXPORT_TOO_LARGE, use the Hub or vault_sync for a full export without this MCP limit.',
1664 },
1665 async () => {
1666 try {
1667 const data = await canisterGetJsonWithByteLimit(
1668 `${canisterUrl}/api/v1/export`,
1669 canisterFetchOpts,
1670 HOSTED_MCP_EXPORT_MAX_RESPONSE_BYTES
1671 );
1672 return jsonResponse(data);
1673 } catch (e) {
1674 const code = /** @type {any} */ (e).code === 'EXPORT_TOO_LARGE' ? 'EXPORT_TOO_LARGE' : 'UPSTREAM_ERROR';
1675 return jsonError(e.message || String(e), code);
1676 }
1677 }
1678 );
1679 }
1680
1681 if (isToolAllowed('summarize', role)) {
1682 server.registerTool(
1683 'summarize',
1684 {
1685 description: 'Summarize notes via the client LLM (sampling) or server fallback.',
1686 inputSchema: {
1687 path: z.string().optional(),
1688 paths: z.array(z.string()).optional(),
1689 style: z.enum(['brief', 'detailed', 'bullets']).optional(),
1690 },
1691 },
1692 async (args) => {
1693 try {
1694 const paths = [];
1695 if (args.path) paths.push(args.path);
1696 if (args.paths) paths.push(...args.paths);
1697 if (!paths.length) return jsonError('Provide path or paths', 'INVALID');
1698
1699 const bodies = [];
1700 for (const p of paths.slice(0, 10)) {
1701 try {
1702 const note = await upstreamFetch(
1703 `${canisterUrl}/api/v1/notes/${encodeURIComponent(p)}`,
1704 canisterFetchOpts
1705 );
1706 bodies.push(`## ${p}\n${note.body || ''}`);
1707 } catch (_) {}
1708 }
1709
1710 const combined = bodies.join('\n\n').slice(0, 48000);
1711 const style = args.style || 'brief';
1712 const maxWords = style === 'detailed' ? 400 : style === 'bullets' ? 300 : 150;
1713 const system = `You summarize vault notes faithfully. Output style: ${style}. Max approximately ${maxWords} words.`;
1714
1715 const { trySampling } = await import('../../mcp/sampling.mjs');
1716 let summary = await trySampling(server, { system, user: combined, maxTokens: Math.min(1024, maxWords * 2) });
1717 if (!summary) {
1718 summary = `(Sampling unavailable — summarize tool requires a client that supports MCP sampling for hosted mode.)`;
1719 }
1720 return jsonResponse({ summary, source_paths: paths });
1721 } catch (e) {
1722 return jsonError(e.message || String(e), 'UPSTREAM_ERROR');
1723 }
1724 }
1725 );
1726 }
1727
1728 /**
1729 * Hosted MCP prompts (Track B1–B2): same bridge/canister HTTP paths as tools; no local vault reads.
1730 * Each prompt registers only when {@link isPromptAllowed} and the upstream tools it needs are allowed.
1731 */
1732 if (isPromptAllowed('daily-brief', role) && isToolAllowed('list_notes', role)) {
1733 server.registerPrompt(
1734 'daily-brief',
1735 {
1736 title: 'Daily brief',
1737 description: 'Notes since a date (default today UTC) with snippets; assistant prefill for summarizing.',
1738 argsSchema: {
1739 date: z.string().optional().describe('YYYY-MM-DD; default today (UTC)'),
1740 project: z.string().optional().describe('Project slug'),
1741 },
1742 },
1743 async (args) => {
1744 const since = (args.date && String(args.date).trim()) || new Date().toISOString().slice(0, 10);
1745 const params = new URLSearchParams();
1746 params.set('since', since);
1747 if (args.project != null && String(args.project).trim() !== '') {
1748 params.set('project', normalizeSlug(String(args.project)));
1749 }
1750 params.set('limit', '80');
1751 params.set('offset', '0');
1752 try {
1753 const data = await upstreamFetch(`${canisterUrl}/api/v1/notes?${params}`, canisterFetchOpts);
1754 const notes = Array.isArray(data.notes) ? data.notes : [];
1755 const lines = notes.length
1756 ? notes.map((n, i) => {
1757 const row = /** @type {{ path?: string, frontmatter?: unknown, body?: unknown }} */ (n);
1758 const title = displayTitleFromHostedNote(row) ?? row.path;
1759 const fm = materializeListFrontmatter(row.frontmatter);
1760 const d = dateKeyFromHostedFrontmatter(fm) || '';
1761 const body = row.body != null ? String(row.body) : '';
1762 return `${i + 1}. **${title}** (${row.path}, ${d})\n ${snippet(body, 240)}`;
1763 })
1764 : ['(No notes in range.)'];
1765 return {
1766 description: `Daily brief for notes since ${since}`,
1767 messages: [
1768 {
1769 role: 'user',
1770 content: textContent(
1771 'You are a personal knowledge assistant. Below are notes captured in the selected range. Summarize themes, decisions, and open threads.'
1772 ),
1773 },
1774 { role: 'user', content: textContent(lines.join('\n\n')) },
1775 { role: 'assistant', content: textContent('Here is your daily brief:') },
1776 ],
1777 };
1778 } catch (e) {
1779 return {
1780 description: 'Daily brief',
1781 messages: [{ role: 'user', content: textContent(`Error loading notes: ${e.message || String(e)}`) }],
1782 };
1783 }
1784 }
1785 );
1786 }
1787
1788 if (
1789 isPromptAllowed('search-and-synthesize', role) &&
1790 isToolAllowed('search', role) &&
1791 isToolAllowed('get_note', role)
1792 ) {
1793 server.registerPrompt(
1794 'search-and-synthesize',
1795 {
1796 title: 'Search and synthesize',
1797 description: 'Semantic search then embed top notes for synthesis.',
1798 argsSchema: {
1799 query: z.string().describe('Search query'),
1800 project: z.string().optional().describe('Project slug'),
1801 limit: z.string().optional().describe('Max notes (default 10)'),
1802 },
1803 },
1804 async (args) => {
1805 const limit = Math.min(20, Math.max(1, parseIntSafe(args.limit, 10)));
1806 const searchBody = { query: String(args.query || ''), mode: 'semantic', limit, fields: 'path' };
1807 if (args.project != null && String(args.project).trim() !== '') {
1808 searchBody.project = normalizeSlug(String(args.project));
1809 }
1810 try {
1811 const searchOut = await upstreamFetch(`${bridgeUrl}/api/v1/search`, {
1812 ...bridgeFetchOpts,
1813 method: 'POST',
1814 body: searchBody,
1815 });
1816 const paths = (Array.isArray(searchOut.results) ? searchOut.results : [])
1817 .map((r) => r.path)
1818 .filter(Boolean)
1819 .slice(0, MAX_EMBEDDED_NOTES);
1820 const messages = [
1821 {
1822 role: 'user',
1823 content: textContent(
1824 `You have ${paths.length} top-matching vault notes below (semantic search for: "${String(args.query)}"). Synthesize key themes, agreements, and gaps. Cite paths when specific.`
1825 ),
1826 },
1827 ];
1828 for (const p of paths) {
1829 try {
1830 const note = await upstreamFetch(
1831 `${canisterUrl}/api/v1/notes/${encodeURIComponent(p)}`,
1832 canisterFetchOpts
1833 );
1834 const uri = `knowtation://hosted/note/${String(p).replace(/^\/+/, '')}`;
1835 messages.push({
1836 role: 'user',
1837 content: {
1838 type: 'resource',
1839 resource: {
1840 uri,
1841 mimeType: 'text/markdown',
1842 text: noteToMarkdown({
1843 path: note.path ?? p,
1844 frontmatter: note.frontmatter || {},
1845 body: note.body != null ? String(note.body) : '',
1846 }),
1847 },
1848 },
1849 });
1850 } catch (_) {}
1851 }
1852 return await maybeAppendSamplingPrefill(server, {
1853 description: 'Search results embedded as resources',
1854 messages,
1855 });
1856 } catch (e) {
1857 return {
1858 messages: [{ role: 'user', content: textContent(`Error: ${e.message || String(e)}`) }],
1859 };
1860 }
1861 }
1862 );
1863 }
1864
1865 if (
1866 isPromptAllowed('project-summary', role) &&
1867 isToolAllowed('list_notes', role) &&
1868 isToolAllowed('get_note', role)
1869 ) {
1870 server.registerPrompt(
1871 'project-summary',
1872 {
1873 title: 'Project summary',
1874 description: 'Recent project notes embedded for executive-style summary.',
1875 argsSchema: {
1876 project: z.string().describe('Project slug'),
1877 since: z.string().optional().describe('YYYY-MM-DD'),
1878 format: z.enum(['brief', 'detailed', 'stakeholder']).optional().describe('Summary style'),
1879 },
1880 },
1881 async (args) => {
1882 const project = normalizeSlug(String(args.project || ''));
1883 if (!project) {
1884 return {
1885 messages: [{ role: 'user', content: textContent('Error: project argument is required.') }],
1886 };
1887 }
1888 const fmt = args.format || 'brief';
1889 const params = new URLSearchParams();
1890 params.set('project', project);
1891 if (args.since != null && String(args.since).trim() !== '') params.set('since', String(args.since).trim());
1892 params.set('limit', String(PROJECT_SUMMARY_NOTES));
1893 params.set('offset', '0');
1894 try {
1895 const out = await upstreamFetch(`${canisterUrl}/api/v1/notes?${params}`, canisterFetchOpts);
1896 const notes = Array.isArray(out.notes) ? out.notes : [];
1897 const total = typeof out.total === 'number' ? out.total : notes.length;
1898 const messages = [
1899 {
1900 role: 'user',
1901 content: textContent(
1902 `Produce a ${fmt} executive summary for project "${project}" using the embedded notes. Note count (sample): ${notes.length} of ${total} total matching filters.`
1903 ),
1904 },
1905 ];
1906 for (const n of notes.slice(0, MAX_EMBEDDED_NOTES)) {
1907 const p = n.path;
1908 if (!p) continue;
1909 try {
1910 const note = await upstreamFetch(
1911 `${canisterUrl}/api/v1/notes/${encodeURIComponent(p)}`,
1912 canisterFetchOpts
1913 );
1914 const uri = `knowtation://hosted/note/${String(p).replace(/^\/+/, '')}`;
1915 messages.push({
1916 role: 'user',
1917 content: {
1918 type: 'resource',
1919 resource: {
1920 uri,
1921 mimeType: 'text/markdown',
1922 text: noteToMarkdown({
1923 path: note.path ?? p,
1924 frontmatter: note.frontmatter || {},
1925 body: note.body != null ? String(note.body) : '',
1926 }),
1927 },
1928 },
1929 });
1930 } catch (_) {}
1931 }
1932 return await maybeAppendSamplingPrefill(server, {
1933 description: `Project summary (${project})`,
1934 messages,
1935 });
1936 } catch (e) {
1937 return {
1938 messages: [{ role: 'user', content: textContent(`Error: ${e.message || String(e)}`) }],
1939 };
1940 }
1941 }
1942 );
1943 }
1944
1945 if (isPromptAllowed('temporal-summary', role) && isToolAllowed('list_notes', role)) {
1946 server.registerPrompt(
1947 'temporal-summary',
1948 {
1949 title: 'Temporal summary',
1950 description: 'Notes between two dates; optional semantic topic filter.',
1951 argsSchema: {
1952 since: z.string().describe('YYYY-MM-DD start'),
1953 until: z.string().describe('YYYY-MM-DD end'),
1954 topic: z.string().optional().describe('Optional semantic filter; runs search then intersects dates'),
1955 project: z.string().optional().describe('Project slug'),
1956 },
1957 },
1958 async (args) => {
1959 const since = String(args.since || '').slice(0, 10);
1960 const until = String(args.until || '').slice(0, 10);
1961 /** @type {Set<string> | null} */
1962 let pathSet = null;
1963 if (args.topic && String(args.topic).trim()) {
1964 if (!isToolAllowed('search', role)) {
1965 return {
1966 description: 'Temporal summary',
1967 messages: [
1968 {
1969 role: 'user',
1970 content: textContent(
1971 'A topic filter was requested but this session does not allow the search tool; omit topic or use list_notes manually.'
1972 ),
1973 },
1974 ],
1975 };
1976 }
1977 try {
1978 const so = await upstreamFetch(`${bridgeUrl}/api/v1/search`, {
1979 ...bridgeFetchOpts,
1980 method: 'POST',
1981 body: {
1982 query: String(args.topic),
1983 mode: 'semantic',
1984 limit: 80,
1985 fields: 'path',
1986 ...(args.project != null && String(args.project).trim() !== ''
1987 ? { project: normalizeSlug(String(args.project)) }
1988 : {}),
1989 },
1990 });
1991 pathSet = new Set((Array.isArray(so.results) ? so.results : []).map((r) => r.path).filter(Boolean));
1992 } catch (e) {
1993 return {
1994 description: 'Temporal summary',
1995 messages: [{ role: 'user', content: textContent(`Topic search failed: ${e.message || String(e)}`) }],
1996 };
1997 }
1998 }
1999 const params = new URLSearchParams();
2000 params.set('since', since);
2001 params.set('until', until);
2002 if (args.project != null && String(args.project).trim() !== '') {
2003 params.set('project', normalizeSlug(String(args.project)));
2004 }
2005 params.set('limit', '100');
2006 params.set('offset', '0');
2007 try {
2008 const out = await upstreamFetch(`${canisterUrl}/api/v1/notes?${params}`, canisterFetchOpts);
2009 let notes = Array.isArray(out.notes) ? out.notes : [];
2010 if (pathSet) {
2011 notes = notes.filter((n) => n.path && pathSet.has(n.path));
2012 }
2013 notes = [...notes].sort((a, b) => {
2014 const da = dateKeyFromHostedFrontmatter(materializeListFrontmatter(a.frontmatter));
2015 const db = dateKeyFromHostedFrontmatter(materializeListFrontmatter(b.frontmatter));
2016 return da.localeCompare(db);
2017 });
2018 const lines = notes.map((n, i) => {
2019 const row = /** @type {{ path?: string, frontmatter?: unknown }} */ (n);
2020 const fm = materializeListFrontmatter(row.frontmatter);
2021 const t = displayTitleFromHostedNote(/** @type {any} */ (row)) ?? row.path;
2022 const d = dateKeyFromHostedFrontmatter(fm) || '';
2023 const tg = tagsFromFm(fm);
2024 return `${i + 1}. ${t} (${row.path}, ${d})${tg.length ? ` tags: ${tg.join(',')}` : ''}`;
2025 });
2026 return {
2027 description: `Temporal view ${since} … ${until}`,
2028 messages: [
2029 {
2030 role: 'user',
2031 content: textContent(
2032 `What happened between ${since} and ${until}? What decisions were made? What changed? Use the note list below${args.topic ? ' (filtered by topic search)' : ''}.\n\n${lines.join('\n') || '(No notes in range.)'}`
2033 ),
2034 },
2035 ],
2036 };
2037 } catch (e) {
2038 return {
2039 messages: [{ role: 'user', content: textContent(`Error: ${e.message || String(e)}`) }],
2040 };
2041 }
2042 }
2043 );
2044 }
2045
2046 if (
2047 isPromptAllowed('content-plan', role) &&
2048 isToolAllowed('list_notes', role) &&
2049 isToolAllowed('get_note', role)
2050 ) {
2051 server.registerPrompt(
2052 'content-plan',
2053 {
2054 title: 'Content plan',
2055 description: 'Content calendar / plan from recent project notes.',
2056 argsSchema: {
2057 project: z.string().describe('Project slug'),
2058 format: z.enum(['blog', 'podcast', 'newsletter', 'thread']).optional(),
2059 tone: z.string().optional(),
2060 },
2061 },
2062 async (args) => {
2063 const project = normalizeSlug(String(args.project || ''));
2064 if (!project) {
2065 return {
2066 messages: [{ role: 'user', content: textContent('Error: project argument is required.') }],
2067 };
2068 }
2069 const fmt = args.format || 'blog';
2070 const tone = args.tone || 'clear, authoritative';
2071 const params = new URLSearchParams();
2072 params.set('project', project);
2073 params.set('limit', String(CONTENT_PLAN_NOTES));
2074 params.set('offset', '0');
2075 try {
2076 const out = await upstreamFetch(`${canisterUrl}/api/v1/notes?${params}`, canisterFetchOpts);
2077 const notes = Array.isArray(out.notes) ? out.notes : [];
2078 const messages = [
2079 {
2080 role: 'user',
2081 content: textContent(
2082 `Create a ${fmt} content plan for project "${project}". Tone: ${tone}. Topics, order, angles, and what to write next. Ground in the embedded notes.`
2083 ),
2084 },
2085 ];
2086 for (const n of notes.slice(0, MAX_EMBEDDED_NOTES)) {
2087 const p = n.path;
2088 if (!p) continue;
2089 try {
2090 const note = await upstreamFetch(
2091 `${canisterUrl}/api/v1/notes/${encodeURIComponent(p)}`,
2092 canisterFetchOpts
2093 );
2094 const uri = `knowtation://hosted/note/${String(p).replace(/^\/+/, '')}`;
2095 messages.push({
2096 role: 'user',
2097 content: {
2098 type: 'resource',
2099 resource: {
2100 uri,
2101 mimeType: 'text/markdown',
2102 text: noteToMarkdown({
2103 path: note.path ?? p,
2104 frontmatter: note.frontmatter || {},
2105 body: note.body != null ? String(note.body) : '',
2106 }),
2107 },
2108 },
2109 });
2110 } catch (_) {}
2111 }
2112 return await maybeAppendSamplingPrefill(server, {
2113 description: `Content plan (${project})`,
2114 messages,
2115 });
2116 } catch (e) {
2117 return {
2118 messages: [{ role: 'user', content: textContent(`Error: ${e.message || String(e)}`) }],
2119 };
2120 }
2121 }
2122 );
2123 }
2124
2125 /**
2126 * Hosted MCP prompts (Track B2): meeting-notes, knowledge-gap, causal-chain, extract-entities, write-from-capture.
2127 * Same upstreams as tools; no local vault or capture template files (write-from-capture is text-only instructions).
2128 */
2129 if (isPromptAllowed('meeting-notes', role)) {
2130 server.registerPrompt(
2131 'meeting-notes',
2132 {
2133 title: 'Meeting notes',
2134 description: 'Transcript → structured meeting note instructions.',
2135 argsSchema: {
2136 transcript: z.string().describe('Raw transcript'),
2137 attendees: z.string().optional().describe('Comma-separated names'),
2138 project: z.string().optional(),
2139 date: z.string().optional().describe('YYYY-MM-DD'),
2140 },
2141 },
2142 async (args) => {
2143 const attendees = String(args.attendees || '')
2144 .split(',')
2145 .map((s) => s.trim())
2146 .filter(Boolean);
2147 const project = args.project != null && String(args.project).trim() !== '' ? normalizeSlug(String(args.project)) : null;
2148 const date = (args.date && String(args.date).trim().slice(0, 10)) || new Date().toISOString().slice(0, 10);
2149 const t = String(args.transcript || '').slice(0, 100_000);
2150 const suggestedPath = project
2151 ? `projects/${project}/inbox/meeting-${date}.md`
2152 : `inbox/meeting-${date}.md`;
2153 return {
2154 description: 'Meeting note draft prompt',
2155 messages: [
2156 {
2157 role: 'user',
2158 content: textContent(
2159 `Convert the transcript into a vault meeting note with YAML frontmatter: title, date: ${date}, attendees: [${attendees.map((a) => `"${a}"`).join(', ')}]${project ? `, project: "${project}"` : ''}, tags. Body: agenda summary, decisions, action items (owners), follow-ups. Suggested path for the write tool: ${suggestedPath}`
2160 ),
2161 },
2162 { role: 'user', content: textContent(`--- Transcript ---\n${t}`) },
2163 ],
2164 };
2165 }
2166 );
2167 }
2168
2169 if (isPromptAllowed('knowledge-gap', role) && isToolAllowed('search', role)) {
2170 server.registerPrompt(
2171 'knowledge-gap',
2172 {
2173 title: 'Knowledge gap',
2174 description: 'Given search hits, ask what is missing and what to capture next.',
2175 argsSchema: {
2176 query: z.string().describe('Topic / question'),
2177 project: z.string().optional(),
2178 },
2179 },
2180 async (args) => {
2181 const searchBody = {
2182 query: String(args.query || ''),
2183 mode: 'semantic',
2184 limit: 15,
2185 fields: 'path+snippet',
2186 snippetChars: 200,
2187 };
2188 if (args.project != null && String(args.project).trim() !== '') {
2189 searchBody.project = normalizeSlug(String(args.project));
2190 }
2191 try {
2192 const so = await upstreamFetch(`${bridgeUrl}/api/v1/search`, {
2193 ...bridgeFetchOpts,
2194 method: 'POST',
2195 body: searchBody,
2196 });
2197 const lines = (Array.isArray(so.results) ? so.results : []).map((r, i) => {
2198 const row = /** @type {{ path?: string, snippet?: unknown }} */ (r);
2199 const sn = row.snippet != null ? snippet(String(row.snippet), 200) : '';
2200 return `${i + 1}. ${row.path}${sn ? `\n ${sn}` : ''}`;
2201 });
2202 return await maybeAppendSamplingPrefill(server, {
2203 description: 'Knowledge gap analysis',
2204 messages: [
2205 {
2206 role: 'user',
2207 content: textContent(
2208 `Given these vault search results for "${String(args.query)}", what is missing? What questions remain unanswered? What should I capture next?\n\n${lines.join('\n\n') || '(No results.)'}`
2209 ),
2210 },
2211 ],
2212 });
2213 } catch (e) {
2214 return {
2215 description: 'Knowledge gap',
2216 messages: [{ role: 'user', content: textContent(`Error: ${e.message || String(e)}`) }],
2217 };
2218 }
2219 }
2220 );
2221 }
2222
2223 if (
2224 isPromptAllowed('causal-chain', role) &&
2225 isToolAllowed('search', role) &&
2226 isToolAllowed('get_note', role)
2227 ) {
2228 server.registerPrompt(
2229 'causal-chain',
2230 {
2231 title: 'Causal chain',
2232 description:
2233 'Notes in a causal_chain_id: bridge semantic search with chain filter, then full notes from the canister (sorted by date). Differs from local graph order when the index omits notes or hits the search limit.',
2234 argsSchema: {
2235 chain_id: z.string().describe('Causal chain id / slug'),
2236 include_summaries: z.string().optional().describe('true to emphasize summarizes edges'),
2237 },
2238 },
2239 async (args) => {
2240 const chainSlug = normalizeSlug(String(args.chain_id || ''));
2241 if (!chainSlug) {
2242 return {
2243 messages: [{ role: 'user', content: textContent('Error: chain_id is required.') }],
2244 };
2245 }
2246 const inc = String(args.include_summaries || '').toLowerCase() === 'true';
2247 try {
2248 const searchOut = await upstreamFetch(`${bridgeUrl}/api/v1/search`, {
2249 ...bridgeFetchOpts,
2250 method: 'POST',
2251 body: {
2252 query: chainSlug,
2253 mode: 'semantic',
2254 limit: 80,
2255 fields: 'path',
2256 chain: chainSlug,
2257 },
2258 });
2259 const seen = new Set();
2260 const orderedPaths = [];
2261 for (const r of Array.isArray(searchOut.results) ? searchOut.results : []) {
2262 const p = r.path != null ? vaultPathKey(String(r.path)) : '';
2263 if (!p || seen.has(p)) continue;
2264 seen.add(p);
2265 orderedPaths.push(p);
2266 }
2267 /** @type {{ path: string, note: Record<string, unknown> }[]} */
2268 const loaded = [];
2269 for (const p of orderedPaths) {
2270 try {
2271 const note = await upstreamFetch(
2272 `${canisterUrl}/api/v1/notes/${encodeURIComponent(p)}`,
2273 canisterFetchOpts
2274 );
2275 loaded.push({ path: p, note: /** @type {Record<string, unknown>} */ (note) });
2276 } catch (_) {}
2277 }
2278 loaded.sort((a, b) => {
2279 const fa = materializeListFrontmatter(a.note.frontmatter);
2280 const fb = materializeListFrontmatter(b.note.frontmatter);
2281 const da = dateKeyFromHostedFrontmatter(fa) || '';
2282 const db = dateKeyFromHostedFrontmatter(fb) || '';
2283 const c = da.localeCompare(db);
2284 if (c !== 0) return c;
2285 return vaultPathKey(a.path).localeCompare(vaultPathKey(b.path));
2286 });
2287 const messages = [
2288 {
2289 role: 'user',
2290 content: textContent(
2291 `Narrate the causal sequence for chain "${chainSlug}". Use follows / summarizes in frontmatter where present.${inc ? ' Pay special attention to summarization relationships.' : ''} Notes are ordered by date then path (hosted: bridge search with chain filter + canister reads; not identical to local filesystem graph ordering).`
2292 ),
2293 },
2294 ];
2295 for (const { path: p, note } of loaded.slice(0, MAX_EMBEDDED_NOTES)) {
2296 const uri = `knowtation://hosted/note/${String(p).replace(/^\/+/, '')}`;
2297 messages.push({
2298 role: 'user',
2299 content: {
2300 type: 'resource',
2301 resource: {
2302 uri,
2303 mimeType: 'text/markdown',
2304 text: noteToMarkdown({
2305 path: note.path ?? p,
2306 frontmatter: note.frontmatter || {},
2307 body: note.body != null ? String(note.body) : '',
2308 }),
2309 },
2310 },
2311 });
2312 }
2313 if (loaded.length === 0) {
2314 messages.push({
2315 role: 'user',
2316 content: textContent(
2317 '(No notes found for this causal_chain_id in the hosted index, or search returned no paths. Confirm frontmatter causal_chain_id matches and the vault is indexed.)'
2318 ),
2319 });
2320 }
2321 return { description: `Causal chain ${chainSlug}`, messages };
2322 } catch (e) {
2323 return {
2324 description: 'Causal chain',
2325 messages: [{ role: 'user', content: textContent(`Error: ${e.message || String(e)}`) }],
2326 };
2327 }
2328 }
2329 );
2330 }
2331
2332 if (
2333 isPromptAllowed('extract-entities', role) &&
2334 isToolAllowed('list_notes', role) &&
2335 isToolAllowed('get_note', role)
2336 ) {
2337 server.registerPrompt(
2338 'extract-entities',
2339 {
2340 title: 'Extract entities',
2341 description: 'Structured JSON extraction prompt over vault notes in scope.',
2342 argsSchema: {
2343 folder: z.string().optional(),
2344 project: z.string().optional(),
2345 entity_types: z.enum(['people', 'places', 'decisions', 'goals', 'all']).optional(),
2346 },
2347 },
2348 async (args) => {
2349 const types = args.entity_types || 'all';
2350 const params = new URLSearchParams();
2351 if (args.folder != null && String(args.folder).trim() !== '') params.set('folder', String(args.folder).trim());
2352 if (args.project != null && String(args.project).trim() !== '') {
2353 params.set('project', normalizeSlug(String(args.project)));
2354 }
2355 params.set('limit', String(MAX_ENTITY_NOTES));
2356 params.set('offset', '0');
2357 try {
2358 const out = await upstreamFetch(`${canisterUrl}/api/v1/notes?${params}`, canisterFetchOpts);
2359 const notes = Array.isArray(out.notes) ? out.notes : [];
2360 const messages = [
2361 {
2362 role: 'user',
2363 content: textContent(
2364 `Extract entities from the embedded notes. Output a single JSON object: { "people": [], "places": [], "decisions": [], "goals": [] } with short strings. Entity focus: ${types}. If a category is empty, use [].`
2365 ),
2366 },
2367 ];
2368 for (const n of notes.slice(0, MAX_EMBEDDED_NOTES)) {
2369 const p = n.path;
2370 if (!p) continue;
2371 try {
2372 const note = await upstreamFetch(
2373 `${canisterUrl}/api/v1/notes/${encodeURIComponent(p)}`,
2374 canisterFetchOpts
2375 );
2376 const uri = `knowtation://hosted/note/${String(p).replace(/^\/+/, '')}`;
2377 messages.push({
2378 role: 'user',
2379 content: {
2380 type: 'resource',
2381 resource: {
2382 uri,
2383 mimeType: 'text/markdown',
2384 text: noteToMarkdown({
2385 path: note.path ?? p,
2386 frontmatter: note.frontmatter || {},
2387 body: note.body != null ? String(note.body) : '',
2388 }),
2389 },
2390 },
2391 });
2392 } catch (_) {}
2393 }
2394 return { description: 'Entity extraction', messages };
2395 } catch (e) {
2396 return {
2397 messages: [{ role: 'user', content: textContent(`Error: ${e.message || String(e)}`) }],
2398 };
2399 }
2400 }
2401 );
2402 }
2403
2404 if (isPromptAllowed('write-from-capture', role)) {
2405 server.registerPrompt(
2406 'write-from-capture',
2407 {
2408 title: 'Write from capture',
2409 description:
2410 'Format raw capture text into a proper vault note (YAML frontmatter). Hosted: no local capture.md template; use capture or write tool after drafting.',
2411 argsSchema: {
2412 raw_text: z.string().describe('Raw pasted text'),
2413 source: z.string().describe('e.g. telegram, whatsapp, email'),
2414 project: z.string().optional().describe('Project slug'),
2415 },
2416 },
2417 async (args) => {
2418 const raw = String(args.raw_text ?? '');
2419 const source = String(args.source ?? 'unknown');
2420 const project =
2421 args.project != null && String(args.project).trim() !== '' ? normalizeSlug(String(args.project)) : null;
2422 return {
2423 description: 'Capture → vault note',
2424 messages: [
2425 {
2426 role: 'user',
2427 content: textContent(
2428 `Format the following raw capture into a Knowtation markdown note with YAML frontmatter: title, date (today if missing), source: "${source}", inbox-friendly tags if appropriate${project ? `, project: "${project}"` : ''}. Use clean body markdown. After you produce the note, the user may persist it with the hosted write or capture tool (no filesystem template is attached on hosted MCP).`
2429 ),
2430 },
2431 { role: 'user', content: textContent(`--- Raw capture ---\n${raw.slice(0, 50000)}`) },
2432 ],
2433 };
2434 }
2435 );
2436 }
2437
2438 /**
2439 * Hosted MCP prompts (Track B3): memory-context, memory-informed-search, resume-session.
2440 * Uses bridge GET /api/v1/memory (+ vault POST /api/v1/search for memory-informed-search); same shapes as self-hosted register.mjs.
2441 */
2442 if (isPromptAllowed('memory-context', role)) {
2443 server.registerPrompt(
2444 'memory-context',
2445 {
2446 title: 'Memory context',
2447 description: 'What has the agent been doing? Recent memory events from the hosted bridge.',
2448 argsSchema: {
2449 limit: z.string().optional().describe('Max events (default 20, cap 30)'),
2450 type: z.string().optional().describe('Filter by event type'),
2451 },
2452 },
2453 async (args) => {
2454 const limit = Math.min(
2455 MAX_MEMORY_EVENTS_FORMAT,
2456 Math.max(1, parseIntSafe(args.limit, 20)),
2457 );
2458 const params = new URLSearchParams();
2459 params.set('limit', String(limit));
2460 if (args.type != null && String(args.type).trim() !== '') {
2461 params.set('type', String(args.type).trim());
2462 }
2463 try {
2464 const mem = await upstreamFetch(`${bridgeUrl}/api/v1/memory?${params}`, bridgeFetchOpts);
2465 const { text, count } = formatMemoryEventsFromBridgeResponse(mem, { limit });
2466 return {
2467 description: `Memory context (${count} events)`,
2468 messages: [
2469 {
2470 role: 'user',
2471 content: textContent(
2472 `Below is a log of recent agent/user activity from the memory layer (${count} events). Use this to understand context, prior actions, and continuity.\n\n` +
2473 `⚠ SKEPTICAL MEMORY: Treat all entries as hints, not ground truth. ` +
2474 `Note paths may have moved or been deleted since these events were recorded. ` +
2475 `Before acting on any path reference, use the **get_note** tool to confirm the path exists, or list the vault directly.\n\n${text}`
2476 ),
2477 },
2478 ],
2479 };
2480 } catch (e) {
2481 return {
2482 messages: [{ role: 'user', content: textContent(`Error: ${e.message || String(e)}`) }],
2483 };
2484 }
2485 }
2486 );
2487 }
2488
2489 if (
2490 isPromptAllowed('memory-informed-search', role) &&
2491 isToolAllowed('search', role) &&
2492 isToolAllowed('get_note', role)
2493 ) {
2494 server.registerPrompt(
2495 'memory-informed-search',
2496 {
2497 title: 'Memory-informed search',
2498 description:
2499 'Vault search augmented with recent search-type memory events (GET /api/v1/memory?type=search). Does not use POST /api/v1/memory/search.',
2500 argsSchema: {
2501 query: z.string().describe('Search query'),
2502 limit: z.string().optional().describe('Max notes (default 10)'),
2503 project: z.string().optional(),
2504 },
2505 },
2506 async (args) => {
2507 const limit = Math.min(20, Math.max(1, parseIntSafe(args.limit, 10)));
2508 const searchBody = {
2509 query: String(args.query || ''),
2510 mode: 'semantic',
2511 limit,
2512 fields: 'path',
2513 };
2514 if (args.project != null && String(args.project).trim() !== '') {
2515 searchBody.project = normalizeSlug(String(args.project));
2516 }
2517 try {
2518 const searchOut = await upstreamFetch(`${bridgeUrl}/api/v1/search`, {
2519 ...bridgeFetchOpts,
2520 method: 'POST',
2521 body: searchBody,
2522 });
2523 const paths = (Array.isArray(searchOut.results) ? searchOut.results : [])
2524 .map((r) => /** @type {{ path?: string }} */ (r).path)
2525 .filter(Boolean)
2526 .slice(0, MAX_EMBEDDED_NOTES);
2527 const memParams = new URLSearchParams();
2528 memParams.set('type', 'search');
2529 memParams.set('limit', '10');
2530 const memJson = await upstreamFetch(`${bridgeUrl}/api/v1/memory?${memParams}`, bridgeFetchOpts);
2531 const { text: memText, count: memCount } = formatMemoryEventsFromBridgeResponse(memJson, { limit: 10 });
2532 const messages = [
2533 {
2534 role: 'user',
2535 content: textContent(
2536 `Search query: "${String(args.query)}"\n\n**Previous searches from memory** (${memCount} recent):\n${memText}\n\n**Current search results** (${paths.length} notes embedded below). Compare with past searches — highlight what is new or changed, and synthesize findings.\n\n` +
2537 `⚠ SKEPTICAL MEMORY: Treat memory lines as hints; confirm paths with **get_note** before acting.`
2538 ),
2539 },
2540 ];
2541 for (const p of paths) {
2542 try {
2543 const note = await upstreamFetch(
2544 `${canisterUrl}/api/v1/notes/${encodeURIComponent(p)}`,
2545 canisterFetchOpts
2546 );
2547 const uri = `knowtation://hosted/note/${String(p).replace(/^\/+/, '')}`;
2548 messages.push({
2549 role: 'user',
2550 content: {
2551 type: 'resource',
2552 resource: {
2553 uri,
2554 mimeType: 'text/markdown',
2555 text: noteToMarkdown({
2556 path: note.path ?? p,
2557 frontmatter: note.frontmatter || {},
2558 body: note.body != null ? String(note.body) : '',
2559 }),
2560 },
2561 },
2562 });
2563 } catch (_) {}
2564 }
2565 return await maybeAppendSamplingPrefill(server, {
2566 description: 'Memory-informed search',
2567 messages,
2568 });
2569 } catch (e) {
2570 return {
2571 description: 'Memory-informed search',
2572 messages: [{ role: 'user', content: textContent(`Error: ${e.message || String(e)}`) }],
2573 };
2574 }
2575 }
2576 );
2577 }
2578
2579 if (isPromptAllowed('resume-session', role)) {
2580 server.registerPrompt(
2581 'resume-session',
2582 {
2583 title: 'Resume session',
2584 description: 'Pick up where you left off — recent memory events and session summaries (hosted bridge).',
2585 argsSchema: {
2586 since: z.string().optional().describe('YYYY-MM-DD (default: last 24 hours UTC date)'),
2587 },
2588 },
2589 async (args) => {
2590 const since =
2591 (args.since && String(args.since).trim().slice(0, 10)) ||
2592 new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
2593 try {
2594 const paramsAll = new URLSearchParams();
2595 paramsAll.set('since', since);
2596 paramsAll.set('limit', '30');
2597 const memAll = await upstreamFetch(`${bridgeUrl}/api/v1/memory?${paramsAll}`, bridgeFetchOpts);
2598 const { text: allText, count: allCount } = formatMemoryEventsFromBridgeResponse(memAll, { limit: 30 });
2599
2600 const paramsSum = new URLSearchParams();
2601 paramsSum.set('type', 'session_summary');
2602 paramsSum.set('since', since);
2603 paramsSum.set('limit', '5');
2604 const memSum = await upstreamFetch(`${bridgeUrl}/api/v1/memory?${paramsSum}`, bridgeFetchOpts);
2605 const { text: summaryText, count: summaryCount } = formatMemoryEventsFromBridgeResponse(memSum, {
2606 limit: 5,
2607 });
2608
2609 const parts = [];
2610 if (summaryCount > 0) {
2611 parts.push(`**Session summaries** (${summaryCount}):\n${summaryText}`);
2612 }
2613 parts.push(`**Recent activity** (${allCount} events since ${since}):\n${allText}`);
2614 return {
2615 description: `Resume session (since ${since})`,
2616 messages: [
2617 {
2618 role: 'user',
2619 content: textContent(
2620 `Help me pick up where I left off. Below is my recent activity log and any session summaries. Summarize what was happening, what was accomplished, and suggest next steps.\n\n` +
2621 `⚠ SKEPTICAL MEMORY: Treat all memory entries as hints, not ground truth. ` +
2622 `Vault paths referenced in past events may have moved or been deleted. ` +
2623 `Use **get_note** to confirm path references before acting, and check the vault directly for current state.\n\n` +
2624 `${parts.join('\n\n')}`
2625 ),
2626 },
2627 ],
2628 };
2629 } catch (e) {
2630 return {
2631 messages: [{ role: 'user', content: textContent(`Error: ${e.message || String(e)}`) }],
2632 };
2633 }
2634 }
2635 );
2636 }
2637
2638 if (isToolAllowed('enrich', role)) {
2639 server.registerTool(
2640 'enrich',
2641 {
2642 description: 'Auto-categorize a note (suggest project, tags, title) via sampling.',
2643 inputSchema: {
2644 path: z.string().describe('Vault-relative note path'),
2645 },
2646 },
2647 async (args) => {
2648 try {
2649 const note = await upstreamFetch(
2650 `${canisterUrl}/api/v1/notes/${encodeURIComponent(args.path)}`,
2651 canisterFetchOpts
2652 );
2653 const body = (note.body || '').slice(0, 32000);
2654 const existingFm = note.frontmatter || {};
2655
2656 const { trySamplingJson } = await import('../../mcp/sampling.mjs');
2657 const system = `You are a knowledge management assistant. Given a note's content, suggest metadata. Return ONLY a JSON object with: "title" (string), "project" (lowercase-kebab-case string or null), "tags" (array of up to 5 lowercase strings).`;
2658 const result = await trySamplingJson(server, {
2659 system,
2660 user: `Existing frontmatter: ${JSON.stringify(existingFm)}\n\n${body}`,
2661 maxTokens: 512,
2662 });
2663
2664 return jsonResponse({
2665 path: args.path,
2666 suggestions: result || { title: null, project: null, tags: [] },
2667 source: result ? 'sampling' : 'unavailable',
2668 });
2669 } catch (e) {
2670 return jsonError(e.message || String(e), 'UPSTREAM_ERROR');
2671 }
2672 }
2673 );
2674 }
2675
2676 /**
2677 * R1 + R2 hosted resources: one `ResourceTemplate` for note reads (same upstream as `get_note`)
2678 * and folder JSON listings (same upstream as `list_notes` with `folder`).
2679 *
2680 * When `list_notes` is allowed, a `list` callback is set so the MCP SDK merges concrete URIs into
2681 * `resources/list` (see `@modelcontextprotocol/sdk` McpServer `setResourceRequestHandlers`). Cursor’s
2682 * “N resources” UI counts that list; templates without `list` only appear under `resourceTemplates/list`.
2683 */
2684 if (isToolAllowed('get_note', role)) {
2685 /**
2686 * R3 embedded image fetch (shared). Some MCP clients match `knowtation://hosted/vault/{+path}` with a greedy
2687 * `{+path}` so `…/note.md/image/0` is **not** routed to the narrower image template — `path` then does not end
2688 * in `.md` and was mis-handled as a folder listing. We also handle that shape in `hosted-vault-note` below.
2689 */
2690 async function hostedReadVaultEmbeddedImage(uri, notePath, idx) {
2691 if (notePath.includes('..') || !notePath.endsWith('.md')) {
2692 throw new McpError(ErrorCode.InvalidParams, 'Invalid note path');
2693 }
2694 if (isNaN(idx) || idx < 0) {
2695 throw new McpError(ErrorCode.InvalidParams, 'Invalid image index');
2696 }
2697 try {
2698 const data = await upstreamFetch(
2699 `${canisterUrl}/api/v1/notes/${encodeURIComponent(notePath)}`,
2700 canisterFetchOpts
2701 );
2702 const body = data.body != null ? String(data.body) : '';
2703 const images = extractImageUrls(body);
2704 if (idx >= images.length) {
2705 throw new McpError(
2706 ErrorCode.InvalidParams,
2707 `Image index ${idx} out of range (note has ${images.length} embedded images)`,
2708 );
2709 }
2710 const img = images[idx];
2711 const result = await fetchImageAsBase64(img.url, {
2712 maxBytes: 5 * 1024 * 1024,
2713 timeoutMs: 10000,
2714 });
2715 return {
2716 contents: [
2717 {
2718 uri: uri.toString(),
2719 mimeType: result.mimeType,
2720 blob: result.blob,
2721 },
2722 ],
2723 };
2724 } catch (e) {
2725 if (e instanceof McpError) throw e;
2726 throw new McpError(ErrorCode.InternalError, e.message || String(e));
2727 }
2728 }
2729
2730 /**
2731 * R3: embedded images — use **`vault-image`** (not `vault/.../image/…`) so the URI does not share a prefix with
2732 * `knowtation://hosted/vault/{+path}`; some MCP clients fail template match or treat reads as “not found” when
2733 * the same scheme/host overlaps the generic vault template. Legacy `…/vault/…/note.md/image/n` is still read
2734 * via the `hosted-vault-note` handler (regex branch).
2735 * Video URLs stay in markdown only (no binary video resource; hosted MVP product choice).
2736 */
2737 const hostedNoteImageTemplate = new ResourceTemplate('knowtation://hosted/vault-image/{+notePath}/{index}', {
2738 list:
2739 isToolAllowed('list_notes', role) ?
2740 async () => {
2741 const resources = [];
2742 let offset = 0;
2743 let notesScanned = 0;
2744 while (
2745 resources.length < HOSTED_IMAGE_RESOURCE_LIST_MAX &&
2746 notesScanned < HOSTED_IMAGE_LIST_MAX_NOTES_SCANNED
2747 ) {
2748 const params = new URLSearchParams();
2749 params.set('limit', String(HOSTED_IMAGE_LIST_NOTES_PAGE_SIZE));
2750 params.set('offset', String(offset));
2751 const data = await upstreamFetch(`${canisterUrl}/api/v1/notes?${params}`, canisterFetchOpts);
2752 const notes = Array.isArray(data?.notes) ? data.notes : [];
2753 if (notes.length === 0) break;
2754 for (const n of notes) {
2755 if (notesScanned >= HOSTED_IMAGE_LIST_MAX_NOTES_SCANNED) break;
2756 notesScanned += 1;
2757 const p = n?.path != null ? String(n.path) : '';
2758 if (!p || !p.endsWith('.md')) continue;
2759 let body = n.body != null ? String(n.body) : '';
2760 if (!body.trim()) {
2761 try {
2762 const full = await upstreamFetch(
2763 `${canisterUrl}/api/v1/notes/${encodeURIComponent(p)}`,
2764 canisterFetchOpts
2765 );
2766 body = full.body != null ? String(full.body) : '';
2767 } catch (_) {
2768 continue;
2769 }
2770 }
2771 const images = extractImageUrls(body);
2772 for (let i = 0; i < images.length; i++) {
2773 if (resources.length >= HOSTED_IMAGE_RESOURCE_LIST_MAX) break;
2774 const img = images[i];
2775 const name = img.alt || img.url.split('/').pop().split('?')[0] || `image-${i}`;
2776 resources.push({
2777 uri: `knowtation://hosted/vault-image/${p}/${i}`,
2778 name,
2779 mimeType: img.mimeType,
2780 description: `Image in ${p}`,
2781 });
2782 }
2783 if (resources.length >= HOSTED_IMAGE_RESOURCE_LIST_MAX) break;
2784 }
2785 offset += notes.length;
2786 if (notes.length < HOSTED_IMAGE_LIST_NOTES_PAGE_SIZE) break;
2787 }
2788 return { resources };
2789 }
2790 : async () => ({ resources: [] }),
2791 });
2792 server.registerResource(
2793 'hosted-vault-note-image',
2794 hostedNoteImageTemplate,
2795 {
2796 title: 'Hosted note embedded image',
2797 description:
2798 'Image URL in note markdown (![](url)), fetched with SSRF-safe HTTPS-only rules (mcp/resources/image-fetch.mjs).',
2799 },
2800 async (uri, variables) => {
2801 let notePath = variables.notePath;
2802 if (Array.isArray(notePath)) notePath = notePath[0];
2803 notePath = decodeURIComponent(String(notePath || '').replace(/\\/g, '/'));
2804 let idx = variables.index;
2805 if (Array.isArray(idx)) idx = idx[0];
2806 idx = parseInt(String(idx), 10);
2807 return hostedReadVaultEmbeddedImage(uri, notePath, idx);
2808 }
2809 );
2810
2811 const templateCallbacks =
2812 isToolAllowed('list_notes', role) ?
2813 {
2814 list: async () => {
2815 const params = new URLSearchParams();
2816 params.set('limit', String(HOSTED_VAULT_RESOURCE_LIST_MAX));
2817 params.set('offset', '0');
2818 const data = await upstreamFetch(`${canisterUrl}/api/v1/notes?${params}`, canisterFetchOpts);
2819 const notes = Array.isArray(data?.notes) ? data.notes : [];
2820 const resources = [];
2821 for (const n of notes) {
2822 const p = n?.path != null ? String(n.path) : '';
2823 if (!p || !p.endsWith('.md')) continue;
2824 const uri = `knowtation://hosted/vault/${p}`;
2825 const fm = materializeListFrontmatter(n.frontmatter);
2826 const bodyStr = n.body != null ? String(n.body) : '';
2827 const title = displayTitleFromHostedNote({ path: p, frontmatter: fm, body: bodyStr }) || p.split('/').pop() || p;
2828 const description = bodyStr.slice(0, 160).replace(/\s+/g, ' ').trim();
2829 resources.push({
2830 uri,
2831 name: title,
2832 mimeType: 'text/markdown',
2833 description: description || undefined,
2834 });
2835 }
2836 return { resources };
2837 },
2838 }
2839 : {};
2840 const hostedVaultNoteTemplate = new ResourceTemplate('knowtation://hosted/vault/{+path}', templateCallbacks);
2841 server.registerResource(
2842 'hosted-vault-note',
2843 hostedVaultNoteTemplate,
2844 {
2845 title: 'Hosted vault note or folder',
2846 description:
2847 'Markdown note if path ends with .md (same canister GET as get_note); otherwise JSON folder listing (GET /api/v1/notes?folder=…, same as list_notes).',
2848 },
2849 async (uri, variables) => {
2850 let rel = variables.path;
2851 if (Array.isArray(rel)) rel = rel[0];
2852 rel = decodeURIComponent(String(rel || '').replace(/\\/g, '/')).trim();
2853 if (rel.includes('..')) {
2854 throw new McpError(ErrorCode.InvalidParams, 'Invalid path');
2855 }
2856 const embeddedImg = rel.match(/^(.+\.md)\/image\/(\d+)$/);
2857 if (embeddedImg) {
2858 const notePath = embeddedImg[1];
2859 const imageIdx = parseInt(embeddedImg[2], 10);
2860 return hostedReadVaultEmbeddedImage(uri, notePath, imageIdx);
2861 }
2862 const isNote = rel.endsWith('.md');
2863 if (isNote && !rel) {
2864 throw new McpError(ErrorCode.InvalidParams, 'Invalid path');
2865 }
2866 if (isNote) {
2867 try {
2868 const data = await upstreamFetch(
2869 `${canisterUrl}/api/v1/notes/${encodeURIComponent(rel)}`,
2870 canisterFetchOpts
2871 );
2872 const path = data.path != null ? String(data.path) : rel;
2873 const markdown = noteToMarkdown({
2874 path,
2875 frontmatter: data.frontmatter && typeof data.frontmatter === 'object' ? data.frontmatter : {},
2876 body: data.body != null ? String(data.body) : '',
2877 });
2878 const title = displayTitleFromHostedNote(data) || path.split('/').pop() || path;
2879 const desc = String(data.body || '').slice(0, 160).replace(/\s+/g, ' ').trim();
2880 return {
2881 contents: [
2882 {
2883 uri: uri.toString(),
2884 mimeType: 'text/markdown',
2885 text: markdown,
2886 _meta: { title, description: desc || undefined },
2887 },
2888 ],
2889 };
2890 } catch (e) {
2891 const msg = e.message || String(e);
2892 throw new McpError(ErrorCode.InternalError, msg);
2893 }
2894 }
2895
2896 if (!isToolAllowed('list_notes', role)) {
2897 throw new McpError(
2898 ErrorCode.InvalidParams,
2899 'Folder listing requires list_notes to be allowed for this session.',
2900 );
2901 }
2902 const folderNorm = rel.replace(/\/+$/, '');
2903 try {
2904 const params = new URLSearchParams();
2905 params.set('limit', String(HOSTED_VAULT_LISTING_RESOURCE_LIMIT));
2906 params.set('offset', '0');
2907 if (folderNorm) params.set('folder', folderNorm);
2908 const data = await upstreamFetch(`${canisterUrl}/api/v1/notes?${params}`, canisterFetchOpts);
2909 const notes = Array.isArray(data?.notes) ? data.notes : [];
2910 const total = typeof data?.total === 'number' ? data.total : notes.length;
2911 const limit = HOSTED_VAULT_LISTING_RESOURCE_LIMIT;
2912 const folderLabel = folderNorm ? `/${folderNorm.replace(/^\/+/, '')}` : '/';
2913 const payload = {
2914 folder: folderLabel,
2915 notes,
2916 total,
2917 limit,
2918 truncated: total > limit,
2919 };
2920 return {
2921 contents: [
2922 {
2923 uri: uri.toString(),
2924 mimeType: 'application/json',
2925 text: JSON.stringify(payload),
2926 },
2927 ],
2928 };
2929 } catch (e) {
2930 const msg = e.message || String(e);
2931 throw new McpError(ErrorCode.InternalError, msg);
2932 }
2933 }
2934 );
2935
2936 /**
2937 * R3: vault markdown templates under `templates/` (same canister reads as get_note; index via list_notes folder=).
2938 */
2939 if (isToolAllowed('list_notes', role)) {
2940 server.registerResource(
2941 'hosted-templates-index',
2942 'knowtation://hosted/templates-index',
2943 {
2944 title: 'Hosted vault template paths',
2945 description: `JSON listing of notes under templates/ (GET /api/v1/notes?folder=templates&limit=${HOSTED_TEMPLATES_LIST_LIMIT}).`,
2946 },
2947 async () => {
2948 const params = new URLSearchParams();
2949 params.set('limit', String(HOSTED_TEMPLATES_LIST_LIMIT));
2950 params.set('offset', '0');
2951 params.set('folder', 'templates');
2952 const data = await upstreamFetch(`${canisterUrl}/api/v1/notes?${params}`, canisterFetchOpts);
2953 const notes = Array.isArray(data?.notes) ? data.notes : [];
2954 const relPaths = notes
2955 .map((n) => (n?.path != null ? String(n.path) : ''))
2956 .filter((p) => p.startsWith('templates/') && p.endsWith('.md'));
2957 return {
2958 contents: [
2959 {
2960 uri: 'knowtation://hosted/templates-index',
2961 mimeType: 'application/json',
2962 text: JSON.stringify({ templates: relPaths, total: relPaths.length }),
2963 },
2964 ],
2965 };
2966 }
2967 );
2968
2969 const hostedTemplateFileTemplate = new ResourceTemplate('knowtation://hosted/template/{+name}', {
2970 list: async () => {
2971 const params = new URLSearchParams();
2972 params.set('limit', String(HOSTED_TEMPLATES_LIST_LIMIT));
2973 params.set('offset', '0');
2974 params.set('folder', 'templates');
2975 const data = await upstreamFetch(`${canisterUrl}/api/v1/notes?${params}`, canisterFetchOpts);
2976 const notes = Array.isArray(data?.notes) ? data.notes : [];
2977 const resources = [];
2978 for (const n of notes) {
2979 const p = n?.path != null ? String(n.path) : '';
2980 if (!p.startsWith('templates/') || !p.endsWith('.md')) continue;
2981 const name = p.replace(/^templates\//, '');
2982 resources.push({
2983 uri: `knowtation://hosted/template/${name}`,
2984 name: name.split('/').pop() || name,
2985 mimeType: 'text/markdown',
2986 description: `Template: ${name}`,
2987 });
2988 }
2989 return { resources };
2990 },
2991 });
2992 server.registerResource(
2993 'hosted-template-file',
2994 hostedTemplateFileTemplate,
2995 {
2996 title: 'Hosted vault template',
2997 description: 'Markdown file under vault templates/ (same bytes as get_note).',
2998 },
2999 async (uri, variables) => {
3000 let name = variables.name;
3001 if (Array.isArray(name)) name = name[0];
3002 name = decodeURIComponent(String(name || '').replace(/\\/g, '/'));
3003 if (!name || name.includes('..')) {
3004 throw new McpError(ErrorCode.InvalidParams, 'Invalid template name');
3005 }
3006 let rel = `templates/${name}`;
3007 if (!rel.endsWith('.md')) rel = `${rel}.md`;
3008 try {
3009 const data = await upstreamFetch(
3010 `${canisterUrl}/api/v1/notes/${encodeURIComponent(rel)}`,
3011 canisterFetchOpts
3012 );
3013 const path = data.path != null ? String(data.path) : rel;
3014 const markdown = noteToMarkdown({
3015 path,
3016 frontmatter: data.frontmatter && typeof data.frontmatter === 'object' ? data.frontmatter : {},
3017 body: data.body != null ? String(data.body) : '',
3018 });
3019 return {
3020 contents: [
3021 {
3022 uri: uri.toString(),
3023 mimeType: 'text/markdown',
3024 text: markdown,
3025 },
3026 ],
3027 };
3028 } catch (e) {
3029 const msg = e.message || String(e);
3030 throw new McpError(ErrorCode.InternalError, msg);
3031 }
3032 }
3033 );
3034 }
3035 }
3036
3037 /**
3038 * R2 (initial): static JSON listing resource — first page only; filters/pagination remain on `list_notes`.
3039 */
3040 if (isToolAllowed('list_notes', role)) {
3041 server.registerResource(
3042 'hosted-vault-listing',
3043 'knowtation://hosted/vault-listing',
3044 {
3045 title: 'Hosted vault listing (first page)',
3046 description: `JSON from GET /api/v1/notes?limit=${HOSTED_VAULT_LISTING_RESOURCE_LIMIT}&offset=0 (same upstream as list_notes).`,
3047 },
3048 async () => {
3049 const params = new URLSearchParams();
3050 params.set('limit', String(HOSTED_VAULT_LISTING_RESOURCE_LIMIT));
3051 params.set('offset', '0');
3052 const data = await upstreamFetch(`${canisterUrl}/api/v1/notes?${params}`, canisterFetchOpts);
3053 return {
3054 contents: [
3055 {
3056 uri: 'knowtation://hosted/vault-listing',
3057 mimeType: 'application/json',
3058 text: JSON.stringify(data),
3059 },
3060 ],
3061 };
3062 }
3063 );
3064 }
3065
3066 server.registerResource(
3067 'vault-info',
3068 'knowtation://hosted/vault-info',
3069 { description: 'Current vault context (user, vault, role, scope)' },
3070 async () => ({
3071 contents: [{
3072 uri: 'knowtation://hosted/vault-info',
3073 mimeType: 'application/json',
3074 text: JSON.stringify({ userId, canisterUserId, vaultId, role, scope }),
3075 }],
3076 })
3077 );
3078
3079 server.registerResource(
3080 'hosted-prime',
3081 'knowtation://hosted/prime',
3082 {
3083 title: 'Hosted MCP bootstrap (prime)',
3084 description:
3085 'Compact JSON after auth: vault partition, role, MCP prompt names registered for this session, and suggested resource URIs. No secrets.',
3086 },
3087 async () => {
3088 const mcp_prompts_registered_for_role = [...allowedPromptsForRole(role)].sort();
3089 const payload = {
3090 schema: 'knowtation.prime/v1',
3091 surface: 'hosted',
3092 prime_uri: 'knowtation://hosted/prime',
3093 session: { userId, canisterUserId, vaultId, role, scope },
3094 mcp_prompts_registered_for_role,
3095 suggested_next_resources: [
3096 'knowtation://hosted/vault-info',
3097 'knowtation://hosted/vault-listing',
3098 ],
3099 docs: {
3100 why_knowtation: 'docs/TOKEN-SAVINGS.md',
3101 agent_integration: 'docs/AGENT-INTEGRATION.md',
3102 parity_matrix: 'docs/PARITY-MATRIX-HOSTED.md',
3103 },
3104 token_layers: {
3105 vault_retrieval:
3106 'Vault MCP tools (search, list_notes, get_note, …) pull snippets with limits — primary token savings.',
3107 terminal_tooling:
3108 'Terminal log compaction is optional on your dev host; Knowtation does not execute shell hooks inside hosted canisters.',
3109 },
3110 };
3111 return {
3112 contents: [
3113 {
3114 uri: 'knowtation://hosted/prime',
3115 mimeType: 'application/json',
3116 text: JSON.stringify(payload, null, 2),
3117 },
3118 ],
3119 };
3120 }
3121 );
3122
3123 /**
3124 * R3: memory topic JSON — same event shapes as `GET /api/v1/memory`, filtered by `extractTopicFromEvent`
3125 * (parity with self-hosted `knowtation://memory/topic/{slug}`). Topic list is derived from the latest bridge window only.
3126 * Hosted product guardrail: no video **file** resource; video stays as URLs in note bodies (§1b).
3127 */
3128 if (isPromptAllowed('memory-context', role)) {
3129 const memoryTopicTemplate = new ResourceTemplate('knowtation://hosted/memory/topic/{slug}', {
3130 list: async () => {
3131 const params = new URLSearchParams();
3132 params.set('limit', String(HOSTED_MEMORY_TOPIC_BRIDGE_LIMIT));
3133 const memJson = await upstreamFetch(`${bridgeUrl}/api/v1/memory?${params}`, bridgeFetchOpts);
3134 const raw = Array.isArray(memJson?.events) ? memJson.events : [];
3135 const topics = uniqueHostedMemoryTopicSlugs(raw);
3136 return {
3137 resources: topics.map((t) => ({
3138 uri: `knowtation://hosted/memory/topic/${encodeURIComponent(t)}`,
3139 name: t,
3140 mimeType: 'application/json',
3141 description: `Memory events for topic: ${t}`,
3142 })),
3143 };
3144 },
3145 });
3146 server.registerResource(
3147 'hosted-memory-topic',
3148 memoryTopicTemplate,
3149 {
3150 title: 'Hosted memory topic',
3151 description:
3152 'Memory events for a topic slug (heuristic partition). Upstream: GET /api/v1/memory with client-side filter; window size follows bridge limit.',
3153 },
3154 async (uri, variables) => {
3155 let slug = variables.slug;
3156 if (Array.isArray(slug)) slug = slug[0];
3157 slug = decodeURIComponent(String(slug || ''));
3158 if (!slug || slug.includes('..')) {
3159 throw new McpError(ErrorCode.InvalidParams, 'Invalid topic slug');
3160 }
3161 try {
3162 const params = new URLSearchParams();
3163 params.set('limit', String(HOSTED_MEMORY_TOPIC_BRIDGE_LIMIT));
3164 const memJson = await upstreamFetch(`${bridgeUrl}/api/v1/memory?${params}`, bridgeFetchOpts);
3165 const raw = Array.isArray(memJson?.events) ? memJson.events : [];
3166 const events = filterHostedMemoryEventsByTopic(raw, slug);
3167 const payload = {
3168 topic: slugify(slug),
3169 events,
3170 count: events.length,
3171 window_limit: HOSTED_MEMORY_TOPIC_BRIDGE_LIMIT,
3172 note:
3173 'Topics use the same slug rules as self-hosted MemoryManager.extractTopicFromEvent. Events are the subset of the latest bridge list (max 100) matching this slug.',
3174 };
3175 return {
3176 contents: [
3177 {
3178 uri: uri.toString(),
3179 mimeType: 'application/json',
3180 text: JSON.stringify(payload, null, 2),
3181 },
3182 ],
3183 };
3184 } catch (e) {
3185 const msg = e.message || String(e);
3186 throw new McpError(ErrorCode.InternalError, msg);
3187 }
3188 }
3189 );
3190 }
3191
3192 return server;
3193 }
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