Files
tolaria/mcp-server/ws-bridge.js
Test cc08055e0a feat: MCP server foundation with full tool surface and auto-registration
- 14 MCP tools: vault CRUD, search, list, context, link, frontmatter edit, and 4 UI actions
- Auto-registers Laputa in ~/.claude/mcp.json and ~/.cursor/mcp.json on startup
- WebSocket bridge spawned on app start (port 9710 tools, 9711 UI actions)
- Frontend useMcpRegistration hook for vault-aware registration
- Comprehensive tests: 26 vault.js tests covering all 9 exported functions
- Added gray-matter dependency for frontmatter parsing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:18:21 +01:00

122 lines
4.2 KiB
JavaScript

#!/usr/bin/env node
/**
* WebSocket bridge for Laputa MCP tools.
*
* Exposes vault operations over WebSocket so the Laputa app frontend
* can invoke MCP tools in real-time without going through stdio.
*
* Port 9710: Tool bridge — Claude/AI clients call vault tools here.
* Port 9711: UI bridge — Frontend listens for UI action broadcasts.
*
* Usage:
* VAULT_PATH=/path/to/vault WS_PORT=9710 WS_UI_PORT=9711 node ws-bridge.js
*
* Protocol (tool bridge):
* Client sends: { "id": "req-1", "tool": "search_notes", "args": { "query": "test" } }
* Server sends: { "id": "req-1", "result": { ... } }
* On error: { "id": "req-1", "error": "message" }
*
* Protocol (UI bridge):
* Server broadcasts: { "type": "ui_action", "action": "open_note", "path": "..." }
*/
import { WebSocketServer } from 'ws'
import {
readNote, createNote, searchNotes, appendToNote,
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
} from './vault.js'
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
/** @type {WebSocketServer | null} */
let uiBridge = null
function broadcastUiAction(action, payload) {
if (!uiBridge) return
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
for (const client of uiBridge.clients) {
if (client.readyState === 1) client.send(msg)
}
}
function buildFrontmatter(args) {
const fm = {}
if (args.is_a) fm.is_a = args.is_a
return fm
}
const TOOL_HANDLERS = {
open_note: (args) => readNote(VAULT_PATH, args.path).then(text => ({ content: text })),
read_note: (args) => readNote(VAULT_PATH, args.path).then(text => ({ content: text })),
create_note: (args) => createNote(VAULT_PATH, args.path, args.title, buildFrontmatter(args)),
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
append_to_note: (args) => appendToNote(VAULT_PATH, args.path, args.text).then(() => ({ ok: true })),
edit_note_frontmatter: (args) => editNoteFrontmatter(VAULT_PATH, args.path, args.patch),
delete_note: (args) => deleteNote(VAULT_PATH, args.path).then(() => ({ ok: true })),
link_notes: (args) => linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title),
list_notes: (args) => listNotes(VAULT_PATH, args.type_filter, args.sort),
vault_context: () => vaultContext(VAULT_PATH),
ui_open_note: (args) => { broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
ui_open_tab: (args) => { broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
ui_set_filter: (args) => { broadcastUiAction('set_filter', { type: args.type }); return { ok: true } },
}
async function handleMessage(data) {
const msg = JSON.parse(data)
const { id, tool, args } = msg
const handler = TOOL_HANDLERS[tool]
if (!handler) {
return { id, error: `Unknown tool: ${tool}` }
}
try {
const result = await handler(args || {})
return { id, result }
} catch (err) {
return { id, error: err.message }
}
}
export function startUiBridge(port = WS_UI_PORT) {
uiBridge = new WebSocketServer({ port })
uiBridge.on('connection', () => {
console.error(`[ws-bridge] UI client connected on port ${port}`)
})
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)
return uiBridge
}
export function startBridge(port = WS_PORT) {
const wss = new WebSocketServer({ port })
wss.on('connection', (ws) => {
console.error(`[ws-bridge] Client connected (vault: ${VAULT_PATH})`)
ws.on('message', async (raw) => {
try {
const response = await handleMessage(raw.toString())
ws.send(JSON.stringify(response))
} catch (err) {
ws.send(JSON.stringify({ error: `Parse error: ${err.message}` }))
}
})
ws.on('close', () => console.error('[ws-bridge] Client disconnected'))
})
console.error(`[ws-bridge] Listening on ws://localhost:${port}`)
return wss
}
// Run directly if invoked as main module
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
if (isMain) {
startUiBridge()
startBridge()
}