Add WebSocket bridge connecting MCP server tools to the frontend:
- mcp-server/ws-bridge.js: WebSocket server exposing vault tools
(read_note, create_note, search_notes, append_to_note)
- src/hooks/useMcpBridge.ts: React hook for typed tool invocations
with lazy connection, request/response correlation, timeouts
- Vite MCP bridge info endpoint (/api/mcp/info)
Protocol: client sends {id, tool, args}, server responds {id, result}
Default port: 9710 (configurable via WS_PORT env var)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
80 lines
2.4 KiB
JavaScript
80 lines
2.4 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.
|
|
*
|
|
* Usage:
|
|
* VAULT_PATH=/path/to/vault WS_PORT=9710 node ws-bridge.js
|
|
*
|
|
* Protocol:
|
|
* Client sends: { "id": "req-1", "tool": "search_notes", "args": { "query": "test" } }
|
|
* Server sends: { "id": "req-1", "result": { ... } }
|
|
* On error: { "id": "req-1", "error": "message" }
|
|
*/
|
|
import { WebSocketServer } from 'ws'
|
|
import { readNote, createNote, searchNotes, appendToNote } 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 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 })),
|
|
}
|
|
|
|
function buildFrontmatter(args) {
|
|
const fm = {}
|
|
if (args.is_a) fm.is_a = args.is_a
|
|
return fm
|
|
}
|
|
|
|
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 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) {
|
|
startBridge()
|
|
}
|