From 7d235d723f7e1c5c1e4e871aeb62d38ef5b6b1b3 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 20 Feb 2026 22:59:00 +0100 Subject: [PATCH] feat(ai-chat): WebSocket bridge for MCP vault ops 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 --- mcp-server/package-lock.json | 24 +++++++- mcp-server/package.json | 3 +- mcp-server/ws-bridge.js | 79 ++++++++++++++++++++++++ src/hooks/useMcpBridge.ts | 113 +++++++++++++++++++++++++++++++++++ vite.config.ts | 19 +++++- 5 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 mcp-server/ws-bridge.js create mode 100644 src/hooks/useMcpBridge.ts diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index 4fe69fad..6c22da69 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -8,7 +8,8 @@ "name": "laputa-mcp-server", "version": "0.1.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0" + "@modelcontextprotocol/sdk": "^1.0.0", + "ws": "^8.19.0" } }, "node_modules/@hono/node-server": { @@ -1116,6 +1117,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/mcp-server/package.json b/mcp-server/package.json index af1692c1..a75e4a6e 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -9,6 +9,7 @@ "test": "node --test test.js" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0" + "@modelcontextprotocol/sdk": "^1.0.0", + "ws": "^8.19.0" } } diff --git a/mcp-server/ws-bridge.js b/mcp-server/ws-bridge.js new file mode 100644 index 00000000..54fbdafd --- /dev/null +++ b/mcp-server/ws-bridge.js @@ -0,0 +1,79 @@ +#!/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() +} diff --git a/src/hooks/useMcpBridge.ts b/src/hooks/useMcpBridge.ts new file mode 100644 index 00000000..2279b014 --- /dev/null +++ b/src/hooks/useMcpBridge.ts @@ -0,0 +1,113 @@ +/** + * Hook for communicating with the Laputa MCP WebSocket bridge. + * + * Provides typed tool invocations for vault operations: + * - readNote, createNote, searchNotes, appendToNote + * + * Connection is lazy — only opens when first tool is called. + */ +import { useCallback, useRef, useState } from 'react' + +const DEFAULT_WS_URL = 'ws://localhost:9710' + +interface PendingRequest { + resolve: (value: any) => void + reject: (reason: any) => void +} + +interface SearchResult { + path: string + title: string + snippet: string +} + +export function useMcpBridge(wsUrl = DEFAULT_WS_URL) { + const wsRef = useRef(null) + const pendingRef = useRef>(new Map()) + const idCounterRef = useRef(0) + const [connected, setConnected] = useState(false) + + const ensureConnection = useCallback((): Promise => { + const ws = wsRef.current + if (ws && ws.readyState === WebSocket.OPEN) return Promise.resolve(ws) + + return new Promise((resolve, reject) => { + const newWs = new WebSocket(wsUrl) + + newWs.onopen = () => { + wsRef.current = newWs + setConnected(true) + resolve(newWs) + } + + newWs.onmessage = (event) => { + try { + const msg = JSON.parse(event.data) + const pending = pendingRef.current.get(msg.id) + if (pending) { + pendingRef.current.delete(msg.id) + if (msg.error) { + pending.reject(new Error(msg.error)) + } else { + pending.resolve(msg.result) + } + } + } catch { + // ignore malformed messages + } + } + + newWs.onclose = () => { + wsRef.current = null + setConnected(false) + } + + newWs.onerror = () => { + reject(new Error('WebSocket connection failed')) + } + }) + }, [wsUrl]) + + const callTool = useCallback(async (tool: string, args: Record): Promise => { + const ws = await ensureConnection() + const id = `mcp-${++idCounterRef.current}` + + return new Promise((resolve, reject) => { + pendingRef.current.set(id, { resolve, reject }) + ws.send(JSON.stringify({ id, tool, args })) + + // Timeout after 30 seconds + setTimeout(() => { + if (pendingRef.current.has(id)) { + pendingRef.current.delete(id) + reject(new Error('MCP tool call timed out')) + } + }, 30_000) + }) + }, [ensureConnection]) + + const readNote = useCallback( + (path: string) => callTool<{ content: string }>('read_note', { path }), + [callTool], + ) + + const createNote = useCallback( + (path: string, title: string, isA?: string) => + callTool('create_note', { path, title, is_a: isA }), + [callTool], + ) + + const searchNotes = useCallback( + (query: string, limit?: number) => + callTool('search_notes', { query, limit }), + [callTool], + ) + + const appendToNote = useCallback( + (path: string, text: string) => + callTool<{ ok: boolean }>('append_to_note', { path, text }), + [callTool], + ) + + return { connected, readNote, createNote, searchNotes, appendToNote, callTool } +} diff --git a/vite.config.ts b/vite.config.ts index 6f95ebd0..12e740ea 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -310,9 +310,26 @@ function aiChatProxyPlugin(): Plugin { } } +/** WebSocket proxy info endpoint — tells the frontend where the MCP bridge is */ +function mcpBridgeInfoPlugin(): Plugin { + return { + name: 'mcp-bridge-info', + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (req.url !== '/api/mcp/info') return next() + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ + wsUrl: `ws://localhost:${process.env.MCP_WS_PORT || 9710}`, + available: true, + })) + }) + }, + } +} + // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), tailwindcss(), vaultApiPlugin(), aiChatProxyPlugin()], + plugins: [react(), tailwindcss(), vaultApiPlugin(), aiChatProxyPlugin(), mcpBridgeInfoPlugin()], resolve: { alias: {