281 lines
8.1 KiB
JavaScript
281 lines
8.1 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 { requireVaultPath } from './vault-path.js'
|
|
|
|
const VAULT_PATH = requireVaultPath()
|
|
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: entity types, total note count, top-level folders, and 20 most recently modified notes.',
|
|
inputSchema: { type: 'object', properties: {} },
|
|
},
|
|
{
|
|
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")' },
|
|
},
|
|
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' },
|
|
},
|
|
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' },
|
|
},
|
|
},
|
|
},
|
|
]
|
|
|
|
const TOOL_HANDLERS = {
|
|
search_notes: handleSearchNotes,
|
|
get_vault_context: handleVaultContext,
|
|
get_note: handleGetNote,
|
|
open_note: handleOpenNote,
|
|
highlight_editor: handleHighlightEditor,
|
|
refresh_vault: handleRefreshVault,
|
|
}
|
|
|
|
async function handleSearchNotes(args) {
|
|
const results = await searchNotes(VAULT_PATH, args.query, args.limit)
|
|
const text = results.length === 0
|
|
? 'No matching notes found.'
|
|
: results.map(r => `**${r.title}** (${r.path})\n${r.snippet}`).join('\n\n')
|
|
return { content: [{ type: 'text', text }] }
|
|
}
|
|
|
|
async function handleVaultContext() {
|
|
const ctx = await vaultContext(VAULT_PATH)
|
|
return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] }
|
|
}
|
|
|
|
async function handleGetNote(args) {
|
|
const note = await getNote(VAULT_PATH, args.path)
|
|
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.
|
|
broadcastUiAction('vault_changed', { path: args.path })
|
|
broadcastUiAction('open_tab', { path: args.path })
|
|
return { content: [{ type: 'text', text: `Opening ${args.path} 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: args?.path })
|
|
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
|
|
}
|
|
|
|
// --- 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
|
|
const handler = TOOL_HANDLERS[name]
|
|
if (!handler) {
|
|
throw new Error(`Unknown tool: ${name}`)
|
|
}
|
|
try {
|
|
return await handler(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 (vault: ${VAULT_PATH})`)
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error)
|
|
void shutdown(1)
|
|
})
|