From 42d7a3ac44a271e6449e8b1eea65da8bae3bbe72 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 6 Jun 2026 02:27:19 +0200 Subject: [PATCH] refactor: share mcp tool orchestration --- docs/ABSTRACTIONS.md | 2 +- docs/ARCHITECTURE.md | 8 +- mcp-server/index.js | 163 ++---------------------- mcp-server/package-lock.json | 20 +-- mcp-server/package.json | 7 +- mcp-server/tool-service.js | 213 ++++++++++++++++++++++++++++++++ mcp-server/tool-service.test.js | 156 +++++++++++++++++++++++ mcp-server/ws-bridge.js | 161 +++--------------------- 8 files changed, 416 insertions(+), 314 deletions(-) create mode 100644 mcp-server/tool-service.js create mode 100644 mcp-server/tool-service.test.js diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 65e29c9e..e10f78a5 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -431,7 +431,7 @@ Renderer attachment paths are normalized through `src/utils/vaultAttachments.ts` UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. Plain-text paste reads the desktop clipboard through `read_text_from_clipboard` in Tauri so macOS WKWebView clipboard permissions do not block the command; browser/mock mode falls back to the Web Clipboard API or mock handlers. None of those actions mutate vault contents or bypass the backend write boundary. -The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with the active mounted workspace set in `VAULT_PATHS`, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints accept explicit `VAULT_PATH`/`VAULT_PATHS` for app-owned or legacy launches; durable external registrations omit vault env and resolve the current mounted workspace set from Tolaria's `vaults.json` at tool-call time. Manual MCP config export uses the same packaged `mcp-server/` resolver as registration and app-managed AI agents, including Windows executable-adjacent installs under `%LOCALAPPDATA%\Tolaria`, so the copied snippet stays durable across active-workspace changes without writing third-party config files. Vault context checks each active workspace root for `AGENTS.md` and returns those instructions alongside note counts, folders, and recent notes. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind. +The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with the active mounted workspace set in `VAULT_PATHS`, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints accept explicit `VAULT_PATH`/`VAULT_PATHS` for app-owned or legacy launches; durable external registrations omit vault env and resolve the current mounted workspace set from Tolaria's `vaults.json` at tool-call time. `mcp-server/tool-service.js` owns the shared tool semantics for active-vault resolution, cross-vault lookup/search, note-creation defaults, vault listing, and UI action intents; `mcp-server/index.js` and `mcp-server/ws-bridge.js` remain transport adapters around that service. Manual MCP config export uses the same packaged `mcp-server/` resolver as registration and app-managed AI agents, including Windows executable-adjacent installs under `%LOCALAPPDATA%\Tolaria`, so the copied snippet stays durable across active-workspace changes without writing third-party config files. Vault context checks each active workspace root for `AGENTS.md` and returns those instructions alongside note counts, folders, and recent notes. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind. ### Vault Caching diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 184adb4f..32c5d10a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -354,6 +354,7 @@ Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existi ## MCP Server The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Gemini CLI, Cursor, or any MCP-compatible client). +The stdio entrypoint and desktop WebSocket bridge share `mcp-server/tool-service.js` for mounted-vault resolution, note lookup/search, note creation defaults, vault listing, and UI action intents; `index.js` and `ws-bridge.js` only adapt those semantics to their transport-specific request and response shapes. ### Tool Surface @@ -399,12 +400,15 @@ That setup is user-initiated through the status bar / command palette flow, not flowchart TD subgraph MCP["MCP Server (Node.js or Bun) — mounted-workspace scoped"] IDX["index.js"] + SVC["tool-service.js\n(vault resolution, note operations,\nUI action intents)"] VAULT["vault.js\n(findMarkdownFiles, readNote, createNote,\nsearchNotes, appendToNote, editNoteFrontmatter,\ndeleteNote, linkNotes, listNotes, vaultContext)"] WSB["ws-bridge.js"] IDX -->|"stdio transport"| STDIO["Claude Code / Cursor / Gemini / OpenCode"] - IDX --> VAULT - IDX --> WSB + IDX --> SVC + SVC --> VAULT + IDX -.->|"UI action WebSocket client"| WSB + WSB --> SVC WSB -->|"port 9710 — tool bridge"| AI["AI Clients\n(Claude Code, external)"] WSB -->|"port 9711 — UI bridge"| FE["Frontend\n(useAiActivity)"] end diff --git a/mcp-server/index.js b/mcp-server/index.js index 1803b6d3..a2e8b1ac 100644 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -20,10 +20,7 @@ import { ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js' import WebSocket from 'ws' -import { createNote, searchNotes, getNote } from './vault.js' -import { requireVaultPaths } from './vault-path.js' -import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js' -import path from 'node:path' +import { createMcpToolService } from './tool-service.js' const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10) const WS_UI_URL = `ws://localhost:${WS_UI_PORT}` @@ -47,10 +44,6 @@ let reconnectTimer = null let shutdownStarted = false const RECONNECT_INTERVAL_MS = 3000 -function activeVaultPaths() { - return requireVaultPaths() -} - function connectUiBridge() { if (shutdownStarted) return @@ -116,6 +109,8 @@ function broadcastUiAction(action, payload) { uiSocket.send(JSON.stringify({ type: 'ui_action', action, ...payload })) } +const toolService = createMcpToolService({ emitUiAction: broadcastUiAction }) + const TOOLS = [ { name: 'search_notes', @@ -220,125 +215,8 @@ const TOOLS = [ }, ] -function requestedVaultPath(args = {}) { - const requested = typeof args.vaultPath === 'string' ? args.vaultPath.trim() : '' - if (!requested) return null - if (!activeVaultPaths().includes(requested)) { - throw new Error(`Vault is not active in Tolaria: ${requested}`) - } - return requested -} - -function vaultLabel(vaultPath) { - return path.basename(vaultPath) || vaultPath -} - -function withVaultMetadata(note, vaultPath) { - return { - ...note, - vaultPath, - vaultLabel: vaultLabel(vaultPath), - } -} - -async function getNoteFromActiveVaults(notePath, vaultPath = null) { - const candidates = vaultPath ? [vaultPath] : activeVaultPaths() - const matches = [] - const errors = [] - - for (const candidate of candidates) { - try { - matches.push(withVaultMetadata(await getNote(candidate, notePath), candidate)) - } catch (error) { - errors.push(error) - } - } - - if (matches.length === 1) return matches[0] - if (matches.length > 1) { - throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`) - } - throw errors[0] ?? new Error(`Note not found: ${notePath}`) -} - -async function searchActiveVaults(query, limit = 10) { - const requestedLimit = Number.isFinite(limit) && limit > 0 ? limit : 10 - const results = [] - - for (const vaultPath of activeVaultPaths()) { - const vaultResults = await searchNotes(vaultPath, query, requestedLimit) - results.push(...vaultResults.map((result) => withVaultMetadata(result, vaultPath))) - if (results.length >= requestedLimit) break - } - - return results.slice(0, requestedLimit) -} - -async function activeVaultContext(targetVaultPath = null) { - const roots = activeVaultPaths() - if (targetVaultPath) return vaultContextWithInstructions(targetVaultPath) - if (roots.length === 1) return vaultContextWithInstructions(roots[0]) - - return { - vaults: await Promise.all(roots.map(vaultContextWithInstructions)), - } -} - -function uiPath(args = {}) { - const notePath = typeof args.path === 'string' ? args.path : '' - if (path.isAbsolute(notePath)) return notePath - const roots = activeVaultPaths() - const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? roots[0] : '') - return vaultPath ? path.join(vaultPath, notePath) : notePath -} - -function isInsideVaultRoot(vaultPath, notePath) { - const relative = path.relative(vaultPath, notePath) - return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative) -} - -function notePathArg(args = {}) { - const notePath = typeof args.path === 'string' ? args.path.trim() : '' - if (!notePath) throw new Error('Note path is required') - return notePath -} - -function writableVaultPath(args = {}) { - const requested = requestedVaultPath(args) - if (requested) return requested - - const roots = activeVaultPaths() - const notePath = notePathArg(args) - if (path.isAbsolute(notePath)) { - const root = roots.find(vaultPath => isInsideVaultRoot(vaultPath, notePath)) - if (root) return root - } - if (roots.length === 1) return roots[0] - throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`) -} - -function yamlScalar(value) { - return JSON.stringify(value) -} - -function fallbackCreateNoteContent(args = {}) { - const title = typeof args.title === 'string' && args.title.trim() ? args.title.trim() : path.basename(notePathArg(args), '.md') - const type = typeof args.type === 'string' && args.type.trim() - ? args.type.trim() - : typeof args.is_a === 'string' && args.is_a.trim() - ? args.is_a.trim() - : 'Note' - return `---\ntype: ${yamlScalar(type)}\n---\n\n# ${title}\n` -} - -function createNoteContent(args = {}) { - return typeof args.content === 'string' && args.content.trim() - ? args.content - : fallbackCreateNoteContent(args) -} - async function handleSearchNotes(args) { - const results = await searchActiveVaults(args.query, args.limit) + const results = await toolService.searchNotes(args) const text = results.length === 0 ? 'No matching notes found.' : results.map(r => `**${r.title}** (${r.vaultLabel} / ${r.path})\n${r.snippet}`).join('\n\n') @@ -346,40 +224,25 @@ async function handleSearchNotes(args) { } async function handleVaultContext(args = {}) { - const ctx = await activeVaultContext(requestedVaultPath(args)) + const ctx = await toolService.vaultContext(args) return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] } } async function handleListVaults() { - const vaults = await Promise.all(activeVaultPaths().map(async (vaultPath) => { - const agentInstructions = await readAgentInstructions(vaultPath) - return { - path: vaultPath, - label: vaultLabel(vaultPath), - agentInstructionsPath: agentInstructions?.path ?? null, - hasAgentInstructions: agentInstructions !== null, - } - })) - - return { content: [{ type: 'text', text: JSON.stringify({ vaults }, null, 2) }] } + return { content: [{ type: 'text', text: JSON.stringify(await toolService.listVaults(), null, 2) }] } } async function handleGetNote(args) { - const note = await getNoteFromActiveVaults(args.path, requestedVaultPath(args)) + const note = await toolService.readNote(args) return { content: [{ type: 'text', text: JSON.stringify(note, null, 2) }] } } async function handleCreateNote(args = {}) { - const notePath = notePathArg(args) - const vaultPath = writableVaultPath(args) - const note = await createNote(vaultPath, notePath, createNoteContent(args)) - const targetPath = uiPath({ ...args, path: note.path, vaultPath }) - broadcastUiAction('vault_changed', { path: targetPath }) - broadcastUiAction('open_tab', { path: targetPath }) + const note = await toolService.createNote(args) return { content: [{ type: 'text', - text: JSON.stringify({ path: note.path, absolutePath: note.absolutePath, vaultPath }, null, 2), + text: JSON.stringify(note, null, 2), }], } } @@ -387,19 +250,17 @@ async function handleCreateNote(args = {}) { function handleOpenNote(args) { // Refresh vault first so the new/modified note appears in the note list, // then signal the UI to open it in a tab. - const targetPath = uiPath(args) - broadcastUiAction('vault_changed', { path: targetPath }) - broadcastUiAction('open_tab', { path: targetPath }) + const { targetPath } = toolService.openNoteAsTab(args) return { content: [{ type: 'text', text: `Opening ${targetPath} in Tolaria` }] } } function handleHighlightEditor(args) { - broadcastUiAction('highlight', { element: args.element, path: args.path }) + toolService.highlightEditor(args) return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] } } function handleRefreshVault(args) { - broadcastUiAction('vault_changed', { path: uiPath(args) }) + toolService.refreshVault(args) return { content: [{ type: 'text', text: 'Vault refresh triggered' }] } } diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index b666522c..fa053714 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", "gray-matter": "^4.0.3", - "ws": "^8.19.0" + "ws": "^8.20.1" } }, "node_modules/@hono/node-server": { @@ -619,9 +619,9 @@ } }, "node_modules/hono": { - "version": "4.12.18", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", - "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "version": "4.12.21", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz", + "integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -914,9 +914,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -1227,9 +1227,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/mcp-server/package.json b/mcp-server/package.json index 11c50b90..73699578 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -6,18 +6,19 @@ "main": "index.js", "scripts": { "start": "node index.js", - "test": "node --test test.js" + "test": "node --test test.js tool-service.test.js" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", "gray-matter": "^4.0.3", - "ws": "^8.19.0" + "ws": "^8.20.1" }, "overrides": { "@hono/node-server": "1.19.13", "express-rate-limit": "8.2.2", "fast-uri": "3.1.2", - "hono": "4.12.18", + "hono": "4.12.21", + "qs": "6.15.2", "ip-address": "10.1.1", "path-to-regexp": "8.4.0" } diff --git a/mcp-server/tool-service.js b/mcp-server/tool-service.js new file mode 100644 index 00000000..f533d8ba --- /dev/null +++ b/mcp-server/tool-service.js @@ -0,0 +1,213 @@ +import path from 'node:path' +import { + createNote as createVaultNote, + getNote, + searchNotes as searchVaultNotes, +} from './vault.js' +import { requireVaultPaths } from './vault-path.js' +import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js' + +export function createMcpToolService({ + resolveVaultPaths = () => requireVaultPaths(), + emitUiAction = () => {}, +} = {}) { + function activeVaultPaths() { + return resolveVaultPaths() + } + + function requestedVaultPath(args = {}) { + const requested = typeof args.vaultPath === 'string' ? args.vaultPath.trim() : '' + if (!requested) return null + if (!activeVaultPaths().includes(requested)) { + throw new Error(`Vault is not active in Tolaria: ${requested}`) + } + return requested + } + + function resolveUiPath(args = {}) { + const notePath = typeof args.path === 'string' ? args.path : '' + if (path.isAbsolute(notePath)) return notePath + const roots = activeVaultPaths() + const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? roots[0] : '') + return vaultPath ? path.join(vaultPath, notePath) : notePath + } + + async function readNote(args = {}) { + return getNoteFromActiveVaults(notePathArg(args), requestedVaultPath(args)) + } + + async function searchNotes(args = {}) { + const requestedLimit = Number.isFinite(args.limit) && args.limit > 0 ? args.limit : 10 + const results = [] + + for (const vaultPath of activeVaultPaths()) { + const vaultResults = await searchVaultNotes(vaultPath, args.query, requestedLimit) + results.push(...vaultResults.map((result) => withVaultMetadata(result, vaultPath))) + if (results.length >= requestedLimit) break + } + + return results.slice(0, requestedLimit) + } + + async function vaultContext(args = {}) { + const targetVaultPath = requestedVaultPath(args) + const roots = activeVaultPaths() + if (targetVaultPath) return vaultContextWithInstructions(targetVaultPath) + if (roots.length === 1) return vaultContextWithInstructions(roots[0]) + + return { + vaults: await Promise.all(roots.map(vaultContextWithInstructions)), + } + } + + async function listVaults() { + const vaults = await Promise.all(activeVaultPaths().map(async (vaultPath) => { + const agentInstructions = await readAgentInstructions(vaultPath) + return { + path: vaultPath, + label: vaultLabel(vaultPath), + agentInstructionsPath: agentInstructions?.path ?? null, + hasAgentInstructions: agentInstructions !== null, + } + })) + + return { vaults } + } + + async function createNote(args = {}) { + const vaultPath = writableVaultPath(args) + const notePath = writableNotePath(args, vaultPath) + const note = await createVaultNote(vaultPath, notePath, createNoteContent(args)) + const targetPath = resolveUiPath({ ...args, path: note.path, vaultPath }) + emitUiAction('vault_changed', { path: targetPath }) + emitUiAction('open_tab', { path: targetPath }) + return { path: note.path, absolutePath: note.absolutePath, vaultPath } + } + + function openNoteAsTab(args = {}) { + const targetPath = resolveUiPath(args) + emitUiAction('vault_changed', { path: targetPath }) + emitUiAction('open_tab', { path: targetPath }) + return { targetPath } + } + + function openNoteInEditor(args = {}) { + const targetPath = resolveUiPath(args) + emitUiAction('vault_changed', { path: targetPath }) + emitUiAction('open_note', { path: targetPath }) + return { targetPath } + } + + function highlightEditor(args = {}) { + emitUiAction('highlight', { element: args.element, path: args.path }) + } + + function setFilter(args = {}) { + emitUiAction('set_filter', { filterType: args.type }) + } + + function refreshVault(args = {}) { + emitUiAction('vault_changed', { path: resolveUiPath(args) }) + } + + async function getNoteFromActiveVaults(notePath, vaultPath = null) { + const candidates = vaultPath ? [vaultPath] : activeVaultPaths() + const matches = [] + const errors = [] + + for (const candidate of candidates) { + try { + matches.push(withVaultMetadata(await getNote(candidate, notePath), candidate)) + } catch (error) { + errors.push(error) + } + } + + if (matches.length === 1) return matches[0] + if (matches.length > 1) { + throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`) + } + throw errors[0] ?? new Error(`Note not found: ${notePath}`) + } + + function writableVaultPath(args = {}) { + const requested = requestedVaultPath(args) + if (requested) return requested + + const roots = activeVaultPaths() + const notePath = notePathArg(args) + if (path.isAbsolute(notePath)) { + const root = roots.find(vaultPath => isInsideVaultRoot(vaultPath, notePath)) + if (root) return root + } + if (roots.length === 1) return roots[0] + throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`) + } + + return { + activeVaultPaths, + createNote, + highlightEditor, + listVaults, + openNoteAsTab, + openNoteInEditor, + readNote, + refreshVault, + requestedVaultPath, + resolveUiPath, + searchNotes, + setFilter, + vaultContext, + } +} + +function writableNotePath(args, vaultPath) { + const notePath = notePathArg(args) + if (!path.isAbsolute(notePath) || !isInsideVaultRoot(vaultPath, notePath)) return notePath + return path.relative(vaultPath, notePath) +} + +function withVaultMetadata(note, vaultPath) { + return { + ...note, + vaultPath, + vaultLabel: vaultLabel(vaultPath), + } +} + +function vaultLabel(vaultPath) { + return path.basename(vaultPath) || vaultPath +} + +function isInsideVaultRoot(vaultPath, notePath) { + const relative = path.relative(vaultPath, notePath) + return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative) +} + +function notePathArg(args = {}) { + const notePath = typeof args.path === 'string' ? args.path.trim() : '' + if (!notePath) throw new Error('Note path is required') + return notePath +} + +function yamlScalar(value) { + return JSON.stringify(value) +} + +function fallbackCreateNoteContent(args = {}) { + const title = typeof args.title === 'string' && args.title.trim() + ? args.title.trim() + : path.basename(notePathArg(args), '.md') + const type = typeof args.type === 'string' && args.type.trim() + ? args.type.trim() + : typeof args.is_a === 'string' && args.is_a.trim() + ? args.is_a.trim() + : 'Note' + return `---\ntype: ${yamlScalar(type)}\n---\n\n# ${title}\n` +} + +function createNoteContent(args = {}) { + return typeof args.content === 'string' && args.content.trim() + ? args.content + : fallbackCreateNoteContent(args) +} diff --git a/mcp-server/tool-service.test.js b/mcp-server/tool-service.test.js new file mode 100644 index 00000000..ff1c7096 --- /dev/null +++ b/mcp-server/tool-service.test.js @@ -0,0 +1,156 @@ +import { describe, it, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { createMcpToolService } from './tool-service.js' + +let tmpDir +let firstVault +let secondVault + +beforeEach(async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-service-')) + firstVault = path.join(tmpDir, 'First Vault') + secondVault = path.join(tmpDir, 'Second Vault') + + await seedVault(firstVault, { + 'note/shared.md': noteFixture('Shared Note', 'Shared content from the first vault.'), + 'note/alpha.md': noteFixture('Alpha Project', 'Project planning in the first vault.'), + }) + await seedVault(secondVault, { + 'AGENTS.md': '# Second Vault Rules\n', + 'note/shared.md': noteFixture('Shared Note', 'Shared content from the second vault.'), + 'note/beta.md': noteFixture('Beta Project', 'Project planning in the second vault.'), + }) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +describe('createMcpToolService', () => { + it('requires vaultPath when reading an ambiguous note path', async () => { + const service = makeService() + + await assert.rejects( + () => service.readNote({ path: 'note/shared.md' }), + /Note path is ambiguous across active vaults/, + ) + + const note = await service.readNote({ + path: 'note/shared.md', + vaultPath: secondVault, + }) + + assert.equal(note.vaultPath, secondVault) + assert.equal(note.vaultLabel, 'Second Vault') + assert.match(note.content, /second vault/) + }) + + it('creates notes with fallback markdown and emits refresh and tab actions', async () => { + const emittedActions = [] + const service = makeService({ emittedActions }) + const absolutePath = path.join(secondVault, 'note/created.md') + + const note = await service.createNote({ + path: absolutePath, + title: 'Created From MCP', + type: 'Project', + }) + + assert.equal(note.path, 'note/created.md') + assert.equal(note.vaultPath, secondVault) + assert.equal(path.basename(note.absolutePath), 'created.md') + assert.equal( + await readFile(note.absolutePath, 'utf-8'), + '---\ntype: "Project"\n---\n\n# Created From MCP\n', + ) + assert.deepEqual(emittedActions, [ + { action: 'vault_changed', payload: { path: absolutePath } }, + { action: 'open_tab', payload: { path: absolutePath } }, + ]) + }) + + it('searches active vaults with consistent vault metadata', async () => { + const service = makeService() + + const results = await service.searchNotes({ query: 'Project', limit: 2 }) + + assert.equal(results.length, 2) + assert.deepEqual( + results.map(({ path: notePath, vaultPath, vaultLabel }) => ({ + notePath, + vaultPath, + vaultLabel, + })), + [ + { notePath: 'note/alpha.md', vaultPath: firstVault, vaultLabel: 'First Vault' }, + { notePath: 'note/beta.md', vaultPath: secondVault, vaultLabel: 'Second Vault' }, + ], + ) + }) + + it('lists active vaults with agent-instruction metadata', async () => { + const service = makeService() + + assert.deepEqual(await service.listVaults(), { + vaults: [ + { + path: firstVault, + label: 'First Vault', + agentInstructionsPath: null, + hasAgentInstructions: false, + }, + { + path: secondVault, + label: 'Second Vault', + agentInstructionsPath: path.join(secondVault, 'AGENTS.md'), + hasAgentInstructions: true, + }, + ], + }) + }) + + it('emits transport-neutral UI intents for note opening and filters', () => { + const emittedActions = [] + const service = makeService({ emittedActions }) + + service.openNoteAsTab({ path: 'note/beta.md', vaultPath: secondVault }) + service.openNoteInEditor({ path: 'note/beta.md', vaultPath: secondVault }) + service.highlightEditor({ element: 'editor', path: 'note/beta.md' }) + service.setFilter({ type: 'Project' }) + service.refreshVault({ path: 'note/beta.md', vaultPath: secondVault }) + + assert.deepEqual(emittedActions, [ + { action: 'vault_changed', payload: { path: path.join(secondVault, 'note/beta.md') } }, + { action: 'open_tab', payload: { path: path.join(secondVault, 'note/beta.md') } }, + { action: 'vault_changed', payload: { path: path.join(secondVault, 'note/beta.md') } }, + { action: 'open_note', payload: { path: path.join(secondVault, 'note/beta.md') } }, + { action: 'highlight', payload: { element: 'editor', path: 'note/beta.md' } }, + { action: 'set_filter', payload: { filterType: 'Project' } }, + { action: 'vault_changed', payload: { path: path.join(secondVault, 'note/beta.md') } }, + ]) + }) +}) + +function makeService({ emittedActions = [] } = {}) { + return createMcpToolService({ + resolveVaultPaths: () => [firstVault, secondVault], + emitUiAction: (action, payload) => { + emittedActions.push({ action, payload }) + }, + }) +} + +async function seedVault(vaultPath, files) { + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(vaultPath, relativePath) + await mkdir(path.dirname(filePath), { recursive: true }) + await writeFile(filePath, content, 'utf-8') + } +} + +function noteFixture(title, body) { + return `---\ntitle: ${JSON.stringify(title)}\ntype: Note\n---\n\n# ${title}\n\n${body}\n` +} diff --git a/mcp-server/ws-bridge.js b/mcp-server/ws-bridge.js index 9d1b76ed..50b2a44c 100644 --- a/mcp-server/ws-bridge.js +++ b/mcp-server/ws-bridge.js @@ -21,12 +21,7 @@ */ import { createServer } from 'node:http' import { WebSocketServer } from 'ws' -import { - createNote, getNote, searchNotes, -} from './vault.js' -import { requireVaultPaths } from './vault-path.js' -import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js' -import path from 'node:path' +import { createMcpToolService } from './tool-service.js' const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10) const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10) @@ -41,111 +36,6 @@ const TRUSTED_UI_ORIGINS = new Set([ let uiBridge = null const UNKNOWN_TOOL = Symbol('unknown tool') -function activeVaultPaths() { - return requireVaultPaths() -} - -function requestedVaultPath(args = {}) { - const requested = typeof args.vaultPath === 'string' ? args.vaultPath.trim() : '' - if (!requested) return null - if (!activeVaultPaths().includes(requested)) { - throw new Error(`Vault is not active in Tolaria: ${requested}`) - } - return requested -} - -function uiPath(args = {}) { - const notePath = typeof args.path === 'string' ? args.path : '' - if (path.isAbsolute(notePath)) return notePath - const roots = activeVaultPaths() - const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? roots[0] : '') - return vaultPath ? path.join(vaultPath, notePath) : notePath -} - -function isInsideVaultRoot(vaultPath, notePath) { - const relative = path.relative(vaultPath, notePath) - return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative) -} - -function notePathArg(args = {}) { - const notePath = typeof args.path === 'string' ? args.path.trim() : '' - if (!notePath) throw new Error('Note path is required') - return notePath -} - -function writableVaultPath(args = {}) { - const requested = requestedVaultPath(args) - if (requested) return requested - - const roots = activeVaultPaths() - const notePath = notePathArg(args) - if (path.isAbsolute(notePath)) { - const root = roots.find(vaultPath => isInsideVaultRoot(vaultPath, notePath)) - if (root) return root - } - if (roots.length === 1) return roots[0] - throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`) -} - -function yamlScalar(value) { - return JSON.stringify(value) -} - -function fallbackCreateNoteContent(args = {}) { - const title = typeof args.title === 'string' && args.title.trim() ? args.title.trim() : path.basename(notePathArg(args), '.md') - const type = typeof args.type === 'string' && args.type.trim() - ? args.type.trim() - : typeof args.is_a === 'string' && args.is_a.trim() - ? args.is_a.trim() - : 'Note' - return `---\ntype: ${yamlScalar(type)}\n---\n\n# ${title}\n` -} - -function createNoteContent(args = {}) { - return typeof args.content === 'string' && args.content.trim() - ? args.content - : fallbackCreateNoteContent(args) -} - -async function getNoteFromActiveVaults(notePath, vaultPath = null) { - const candidates = vaultPath ? [vaultPath] : activeVaultPaths() - const matches = [] - const errors = [] - - for (const candidate of candidates) { - try { - matches.push({ ...(await getNote(candidate, notePath)), vaultPath: candidate }) - } catch (error) { - errors.push(error) - } - } - - if (matches.length === 1) return matches[0] - if (matches.length > 1) { - throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`) - } - throw errors[0] ?? new Error(`Note not found: ${notePath}`) -} - -async function searchActiveVaults(query, limit = 10) { - const requestedLimit = Number.isFinite(limit) && limit > 0 ? limit : 10 - const results = [] - - for (const vaultPath of activeVaultPaths()) { - const vaultResults = await searchNotes(vaultPath, query, requestedLimit) - results.push(...vaultResults.map((result) => ({ ...result, vaultPath }))) - if (results.length >= requestedLimit) break - } - - return results.slice(0, requestedLimit) -} - -async function activeVaultContext() { - const roots = activeVaultPaths() - if (roots.length === 1) return vaultContextWithInstructions(roots[0]) - return { vaults: await Promise.all(roots.map(vaultContextWithInstructions)) } -} - function broadcastUiAction(action, payload) { if (!uiBridge) return const msg = JSON.stringify({ type: 'ui_action', action, ...payload }) @@ -154,72 +44,49 @@ function broadcastUiAction(action, payload) { } } +const toolService = createMcpToolService({ emitUiAction: broadcastUiAction }) async function readNoteTool(args) { - const note = await getNoteFromActiveVaults(args.path, requestedVaultPath(args)) + const note = await toolService.readNote(args) return { content: note.content, frontmatter: note.frontmatter } } function uiOpenNoteTool(args) { - const targetPath = uiPath(args) - broadcastUiAction('vault_changed', { path: targetPath }) - broadcastUiAction('open_note', { path: targetPath }) + toolService.openNoteInEditor(args) return { ok: true } } function uiOpenTabTool(args) { - const targetPath = uiPath(args) - broadcastUiAction('vault_changed', { path: targetPath }) - broadcastUiAction('open_tab', { path: targetPath }) + toolService.openNoteAsTab(args) return { ok: true } } async function createNoteTool(args = {}) { - const notePath = notePathArg(args) - const vaultPath = writableVaultPath(args) - const note = await createNote(vaultPath, notePath, createNoteContent(args)) - const targetPath = uiPath({ ...args, path: note.path, vaultPath }) - broadcastUiAction('vault_changed', { path: targetPath }) - broadcastUiAction('open_tab', { path: targetPath }) - return { ok: true, path: note.path, absolutePath: note.absolutePath, vaultPath } + return { ok: true, ...(await toolService.createNote(args)) } } function highlightTool(args) { - broadcastUiAction('highlight', { element: args.element, path: args.path }) + toolService.highlightEditor(args) return { ok: true } } function uiSetFilterTool(args) { - broadcastUiAction('set_filter', { filterType: args.type }) + toolService.setFilter(args) return { ok: true } } function refreshVaultTool(args) { - broadcastUiAction('vault_changed', { path: uiPath(args) }) + toolService.refreshVault(args) return { ok: true } } -async function listVaultsTool() { - return { - vaults: await Promise.all(activeVaultPaths().map(async (vaultPath) => { - const agentInstructions = await readAgentInstructions(vaultPath) - return { - path: vaultPath, - label: path.basename(vaultPath) || vaultPath, - agentInstructionsPath: agentInstructions?.path ?? null, - hasAgentInstructions: agentInstructions !== null, - } - })), - } -} - const TOOL_EXECUTORS = [ ['open_note', readNoteTool], ['read_note', readNoteTool], ['create_note', createNoteTool], - ['search_notes', (args) => searchActiveVaults(args.query, args.limit)], - ['vault_context', () => activeVaultContext()], - ['list_vaults', () => listVaultsTool()], + ['search_notes', (args) => toolService.searchNotes(args)], + ['vault_context', (args) => toolService.vaultContext(args)], + ['list_vaults', () => toolService.listVaults()], ['ui_open_note', uiOpenNoteTool], ['ui_open_tab', uiOpenTabTool], ['ui_highlight', highlightTool], @@ -335,7 +202,7 @@ export function startUiBridge(port = WS_UI_PORT) { } export function startBridge(port = WS_PORT) { - const currentVaultPaths = activeVaultPaths() + const currentVaultPaths = toolService.activeVaultPaths() const wss = new WebSocketServer({ port, host: LOOPBACK_HOST, @@ -365,7 +232,7 @@ export function startBridge(port = WS_PORT) { const isMain = process.argv[1]?.endsWith('ws-bridge.js') if (isMain) { try { - activeVaultPaths() + toolService.activeVaultPaths() startUiBridge().then(() => startBridge()) } catch (err) { console.error(`[ws-bridge] ${err.message}`)