#!/usr/bin/env node /** * Tolaria MCP Server — lightweight vault tools for AI agents. * * These MCP tools provide Tolaria-specific capabilities alongside each * app-managed agent's own Safe / Power User permission profile: * * - search_notes: full-text search across vault notes * - get_vault_context: vault structure overview (types, note count, folders) * - get_note: parsed frontmatter + content (convenience over raw cat) * - open_note: signal Tolaria UI to open a note as a tab * - highlight_editor: visually highlight a UI element (editor, tab, etc.) * - refresh_vault: trigger vault rescan so new/modified files appear */ import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js' import WebSocket from 'ws' import { searchNotes, getNote, vaultContext } from './vault.js' import { requireVaultPaths } from './vault-path.js' import path from 'node:path' const VAULT_PATHS = requireVaultPaths() const PRIMARY_VAULT_PATH = VAULT_PATHS[0] const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10) const WS_UI_URL = `ws://localhost:${WS_UI_PORT}` // Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js). // The bridge relays messages to all other clients (the React frontend). let uiSocket = null let reconnectTimer = null let shutdownStarted = false const RECONNECT_INTERVAL_MS = 3000 function connectUiBridge() { if (shutdownStarted) return try { const ws = new WebSocket(WS_UI_URL) uiSocket = ws ws.on('open', () => { if (shutdownStarted) { closeUiSocket() return } console.error(`[mcp] Connected to UI bridge at ${WS_UI_URL}`) }) ws.on('close', () => { if (uiSocket === ws) uiSocket = null scheduleUiReconnect() }) ws.on('error', () => { // Silent — bridge may not be running yet, will retry }) } catch { scheduleUiReconnect() } } function scheduleUiReconnect() { if (shutdownStarted) return clearUiReconnectTimer() reconnectTimer = setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS) reconnectTimer.unref?.() } function clearUiReconnectTimer() { if (!reconnectTimer) return clearTimeout(reconnectTimer) reconnectTimer = null } function closeUiSocket() { const socket = uiSocket uiSocket = null if (!socket) return socket.removeAllListeners() socket.on('error', () => {}) if (socket.readyState === WebSocket.CONNECTING) { socket.terminate?.() return } try { socket.close() } catch { // Ignore close races during process teardown. } socket.terminate?.() } function broadcastUiAction(action, payload) { if (!uiSocket || uiSocket.readyState !== WebSocket.OPEN) return uiSocket.send(JSON.stringify({ type: 'ui_action', action, ...payload })) } const TOOLS = [ { name: 'search_notes', description: 'Full-text search across vault notes by title or content. Returns matching paths, titles, and snippets.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query string' }, limit: { type: 'number', description: 'Maximum number of results (default: 10)' }, }, required: ['query'], }, }, { name: 'get_vault_context', description: 'Get vault orientation for the active Tolaria vaults: entity types, note count, folders, and recent notes.', inputSchema: { type: 'object', properties: { vaultPath: { type: 'string', description: 'Optional target vault root. Omit to inspect all active vaults.' }, }, }, }, { name: 'get_note', description: 'Read a note with parsed YAML frontmatter and markdown content. Returns {path, frontmatter, content}.', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Relative path to the note (e.g. "project/my-project.md")' }, vaultPath: { type: 'string', description: 'Optional target vault root when multiple vaults are active.' }, }, required: ['path'], }, }, { name: 'open_note', description: 'Open a note in the Tolaria UI as a new tab. Use after creating or editing a note so the user can see it.', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Relative path to the note' }, vaultPath: { type: 'string', description: 'Optional target vault root when opening a note outside the default vault.' }, }, required: ['path'], }, }, { name: 'highlight_editor', description: 'Visually highlight a UI element in Tolaria (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.', inputSchema: { type: 'object', properties: { element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'Which UI element to highlight' }, path: { type: 'string', description: 'Optional note path to associate with the highlight' }, }, required: ['element'], }, }, { name: 'refresh_vault', description: 'Trigger a vault rescan so new or modified files appear immediately in the Tolaria note list.', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Optional specific note path that changed' }, vaultPath: { type: 'string', description: 'Optional target vault root when refreshing a note outside the default vault.' }, }, }, }, ] function requestedVaultPath(args = {}) { const requested = typeof args.vaultPath === 'string' ? args.vaultPath.trim() : '' if (!requested) return null if (!VAULT_PATHS.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] : VAULT_PATHS 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 VAULT_PATHS) { 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) { if (targetVaultPath) return vaultContext(targetVaultPath) if (VAULT_PATHS.length === 1) return vaultContext(PRIMARY_VAULT_PATH) return { vaults: await Promise.all(VAULT_PATHS.map(vaultContext)), } } function uiPath(args = {}) { const notePath = typeof args.path === 'string' ? args.path : '' if (path.isAbsolute(notePath)) return notePath const vaultPath = requestedVaultPath(args) ?? (VAULT_PATHS.length === 1 ? PRIMARY_VAULT_PATH : '') return vaultPath ? path.join(vaultPath, notePath) : notePath } async function handleSearchNotes(args) { const results = await searchActiveVaults(args.query, args.limit) const text = results.length === 0 ? 'No matching notes found.' : results.map(r => `**${r.title}** (${r.vaultLabel} / ${r.path})\n${r.snippet}`).join('\n\n') return { content: [{ type: 'text', text }] } } async function handleVaultContext(args = {}) { const ctx = await activeVaultContext(requestedVaultPath(args)) return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] } } async function handleGetNote(args) { const note = await getNoteFromActiveVaults(args.path, requestedVaultPath(args)) return { content: [{ type: 'text', text: JSON.stringify(note, null, 2) }] } } 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 }) return { content: [{ type: 'text', text: `Opening ${targetPath} in Tolaria` }] } } function handleHighlightEditor(args) { broadcastUiAction('highlight', { element: args.element, path: args.path }) return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] } } function handleRefreshVault(args) { broadcastUiAction('vault_changed', { path: uiPath(args) }) return { content: [{ type: 'text', text: 'Vault refresh triggered' }] } } function callToolHandler(name, args) { switch (name) { case 'search_notes': return handleSearchNotes(args) case 'get_vault_context': return handleVaultContext(args) case 'get_note': return handleGetNote(args) case 'open_note': return handleOpenNote(args) case 'highlight_editor': return handleHighlightEditor(args) case 'refresh_vault': return handleRefreshVault(args) default: throw new Error(`Unknown tool: ${name}`) } } // --- Server setup --- const server = new Server( { name: 'tolaria-mcp-server', version: '0.3.0' }, { capabilities: { tools: {} } }, ) server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS, })) server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params try { return await callToolHandler(name, args) } catch (error) { return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true, } } }) async function shutdown(exitCode = 0) { if (shutdownStarted) return shutdownStarted = true clearUiReconnectTimer() closeUiSocket() try { await server.close() } catch (error) { console.error(`[mcp] Error while closing server: ${error.message}`) } process.exitCode = exitCode setImmediate(() => process.exit(exitCode)) } async function main() { const transport = new StdioServerTransport() server.onclose = () => { void shutdown(0) } process.stdin.once('end', () => { void shutdown(0) }) process.stdin.once('close', () => { void shutdown(0) }) process.once('SIGINT', () => { void shutdown(0) }) process.once('SIGTERM', () => { void shutdown(0) }) connectUiBridge() await server.connect(transport) console.error(`Tolaria MCP server running (vaults: ${VAULT_PATHS.join(', ')})`) } main().catch((error) => { console.error(error) void shutdown(1) })