Files
tolaria/mcp-server/index.js
2026-05-11 18:21:12 +02:00

368 lines
11 KiB
JavaScript

#!/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)
})