refactor: share mcp tool orchestration

This commit is contained in:
lucaronin
2026-06-06 02:27:19 +02:00
parent 3e349fa017
commit 42d7a3ac44
8 changed files with 416 additions and 314 deletions

View File

@@ -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. 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 ### Vault Caching

View File

@@ -354,6 +354,7 @@ Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existi
## MCP Server ## 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 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 ### Tool Surface
@@ -399,12 +400,15 @@ That setup is user-initiated through the status bar / command palette flow, not
flowchart TD flowchart TD
subgraph MCP["MCP Server (Node.js or Bun) — mounted-workspace scoped"] subgraph MCP["MCP Server (Node.js or Bun) — mounted-workspace scoped"]
IDX["index.js"] 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)"] VAULT["vault.js\n(findMarkdownFiles, readNote, createNote,\nsearchNotes, appendToNote, editNoteFrontmatter,\ndeleteNote, linkNotes, listNotes, vaultContext)"]
WSB["ws-bridge.js"] WSB["ws-bridge.js"]
IDX -->|"stdio transport"| STDIO["Claude Code / Cursor / Gemini / OpenCode"] IDX -->|"stdio transport"| STDIO["Claude Code / Cursor / Gemini / OpenCode"]
IDX --> VAULT IDX --> SVC
IDX --> WSB SVC --> VAULT
IDX -.->|"UI action WebSocket client"| WSB
WSB --> SVC
WSB -->|"port 9710 — tool bridge"| AI["AI Clients\n(Claude Code, external)"] WSB -->|"port 9710 — tool bridge"| AI["AI Clients\n(Claude Code, external)"]
WSB -->|"port 9711 — UI bridge"| FE["Frontend\n(useAiActivity)"] WSB -->|"port 9711 — UI bridge"| FE["Frontend\n(useAiActivity)"]
end end

View File

@@ -20,10 +20,7 @@ import {
ListToolsRequestSchema, ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js' } from '@modelcontextprotocol/sdk/types.js'
import WebSocket from 'ws' import WebSocket from 'ws'
import { createNote, searchNotes, getNote } from './vault.js' import { createMcpToolService } from './tool-service.js'
import { requireVaultPaths } from './vault-path.js'
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
import path from 'node:path'
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10) const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}` const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
@@ -47,10 +44,6 @@ let reconnectTimer = null
let shutdownStarted = false let shutdownStarted = false
const RECONNECT_INTERVAL_MS = 3000 const RECONNECT_INTERVAL_MS = 3000
function activeVaultPaths() {
return requireVaultPaths()
}
function connectUiBridge() { function connectUiBridge() {
if (shutdownStarted) return if (shutdownStarted) return
@@ -116,6 +109,8 @@ function broadcastUiAction(action, payload) {
uiSocket.send(JSON.stringify({ type: 'ui_action', action, ...payload })) uiSocket.send(JSON.stringify({ type: 'ui_action', action, ...payload }))
} }
const toolService = createMcpToolService({ emitUiAction: broadcastUiAction })
const TOOLS = [ const TOOLS = [
{ {
name: 'search_notes', 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) { async function handleSearchNotes(args) {
const results = await searchActiveVaults(args.query, args.limit) const results = await toolService.searchNotes(args)
const text = results.length === 0 const text = results.length === 0
? 'No matching notes found.' ? 'No matching notes found.'
: results.map(r => `**${r.title}** (${r.vaultLabel} / ${r.path})\n${r.snippet}`).join('\n\n') : 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 = {}) { 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) }] } return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] }
} }
async function handleListVaults() { async function handleListVaults() {
const vaults = await Promise.all(activeVaultPaths().map(async (vaultPath) => { return { content: [{ type: 'text', text: JSON.stringify(await toolService.listVaults(), null, 2) }] }
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) }] }
} }
async function handleGetNote(args) { 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) }] } return { content: [{ type: 'text', text: JSON.stringify(note, null, 2) }] }
} }
async function handleCreateNote(args = {}) { async function handleCreateNote(args = {}) {
const notePath = notePathArg(args) const note = await toolService.createNote(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 { return {
content: [{ content: [{
type: 'text', 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) { function handleOpenNote(args) {
// Refresh vault first so the new/modified note appears in the note list, // Refresh vault first so the new/modified note appears in the note list,
// then signal the UI to open it in a tab. // then signal the UI to open it in a tab.
const targetPath = uiPath(args) const { targetPath } = toolService.openNoteAsTab(args)
broadcastUiAction('vault_changed', { path: targetPath })
broadcastUiAction('open_tab', { path: targetPath })
return { content: [{ type: 'text', text: `Opening ${targetPath} in Tolaria` }] } return { content: [{ type: 'text', text: `Opening ${targetPath} in Tolaria` }] }
} }
function handleHighlightEditor(args) { function handleHighlightEditor(args) {
broadcastUiAction('highlight', { element: args.element, path: args.path }) toolService.highlightEditor(args)
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] } return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
} }
function handleRefreshVault(args) { function handleRefreshVault(args) {
broadcastUiAction('vault_changed', { path: uiPath(args) }) toolService.refreshVault(args)
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] } return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
} }

View File

@@ -10,7 +10,7 @@
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0", "@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"ws": "^8.19.0" "ws": "^8.20.1"
} }
}, },
"node_modules/@hono/node-server": { "node_modules/@hono/node-server": {
@@ -619,9 +619,9 @@
} }
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.12.18", "version": "4.12.21",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz",
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
@@ -914,9 +914,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.0", "version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "side-channel": "^1.1.0"
@@ -1227,9 +1227,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.19.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"

View File

@@ -6,18 +6,19 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "node index.js", "start": "node index.js",
"test": "node --test test.js" "test": "node --test test.js tool-service.test.js"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0", "@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"ws": "^8.19.0" "ws": "^8.20.1"
}, },
"overrides": { "overrides": {
"@hono/node-server": "1.19.13", "@hono/node-server": "1.19.13",
"express-rate-limit": "8.2.2", "express-rate-limit": "8.2.2",
"fast-uri": "3.1.2", "fast-uri": "3.1.2",
"hono": "4.12.18", "hono": "4.12.21",
"qs": "6.15.2",
"ip-address": "10.1.1", "ip-address": "10.1.1",
"path-to-regexp": "8.4.0" "path-to-regexp": "8.4.0"
} }

213
mcp-server/tool-service.js Normal file
View File

@@ -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)
}

View File

@@ -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`
}

View File

@@ -21,12 +21,7 @@
*/ */
import { createServer } from 'node:http' import { createServer } from 'node:http'
import { WebSocketServer } from 'ws' import { WebSocketServer } from 'ws'
import { import { createMcpToolService } from './tool-service.js'
createNote, getNote, searchNotes,
} from './vault.js'
import { requireVaultPaths } from './vault-path.js'
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
import path from 'node:path'
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10) const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 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 let uiBridge = null
const UNKNOWN_TOOL = Symbol('unknown tool') 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) { function broadcastUiAction(action, payload) {
if (!uiBridge) return if (!uiBridge) return
const msg = JSON.stringify({ type: 'ui_action', action, ...payload }) 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) { 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 } return { content: note.content, frontmatter: note.frontmatter }
} }
function uiOpenNoteTool(args) { function uiOpenNoteTool(args) {
const targetPath = uiPath(args) toolService.openNoteInEditor(args)
broadcastUiAction('vault_changed', { path: targetPath })
broadcastUiAction('open_note', { path: targetPath })
return { ok: true } return { ok: true }
} }
function uiOpenTabTool(args) { function uiOpenTabTool(args) {
const targetPath = uiPath(args) toolService.openNoteAsTab(args)
broadcastUiAction('vault_changed', { path: targetPath })
broadcastUiAction('open_tab', { path: targetPath })
return { ok: true } return { ok: true }
} }
async function createNoteTool(args = {}) { async function createNoteTool(args = {}) {
const notePath = notePathArg(args) return { ok: true, ...(await toolService.createNote(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 }
} }
function highlightTool(args) { function highlightTool(args) {
broadcastUiAction('highlight', { element: args.element, path: args.path }) toolService.highlightEditor(args)
return { ok: true } return { ok: true }
} }
function uiSetFilterTool(args) { function uiSetFilterTool(args) {
broadcastUiAction('set_filter', { filterType: args.type }) toolService.setFilter(args)
return { ok: true } return { ok: true }
} }
function refreshVaultTool(args) { function refreshVaultTool(args) {
broadcastUiAction('vault_changed', { path: uiPath(args) }) toolService.refreshVault(args)
return { ok: true } 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 = [ const TOOL_EXECUTORS = [
['open_note', readNoteTool], ['open_note', readNoteTool],
['read_note', readNoteTool], ['read_note', readNoteTool],
['create_note', createNoteTool], ['create_note', createNoteTool],
['search_notes', (args) => searchActiveVaults(args.query, args.limit)], ['search_notes', (args) => toolService.searchNotes(args)],
['vault_context', () => activeVaultContext()], ['vault_context', (args) => toolService.vaultContext(args)],
['list_vaults', () => listVaultsTool()], ['list_vaults', () => toolService.listVaults()],
['ui_open_note', uiOpenNoteTool], ['ui_open_note', uiOpenNoteTool],
['ui_open_tab', uiOpenTabTool], ['ui_open_tab', uiOpenTabTool],
['ui_highlight', highlightTool], ['ui_highlight', highlightTool],
@@ -335,7 +202,7 @@ export function startUiBridge(port = WS_UI_PORT) {
} }
export function startBridge(port = WS_PORT) { export function startBridge(port = WS_PORT) {
const currentVaultPaths = activeVaultPaths() const currentVaultPaths = toolService.activeVaultPaths()
const wss = new WebSocketServer({ const wss = new WebSocketServer({
port, port,
host: LOOPBACK_HOST, host: LOOPBACK_HOST,
@@ -365,7 +232,7 @@ export function startBridge(port = WS_PORT) {
const isMain = process.argv[1]?.endsWith('ws-bridge.js') const isMain = process.argv[1]?.endsWith('ws-bridge.js')
if (isMain) { if (isMain) {
try { try {
activeVaultPaths() toolService.activeVaultPaths()
startUiBridge().then(() => startBridge()) startUiBridge().then(() => startBridge())
} catch (err) { } catch (err) {
console.error(`[ws-bridge] ${err.message}`) console.error(`[ws-bridge] ${err.message}`)