mcp-hosted-server.mjs
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 (), 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