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 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-20 22:59:00 +01:00
parent b61d29edd1
commit 7d235d723f
5 changed files with 235 additions and 3 deletions

View File

@@ -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",

View File

@@ -9,6 +9,7 @@
"test": "node --test test.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0"
"@modelcontextprotocol/sdk": "^1.0.0",
"ws": "^8.19.0"
}
}

79
mcp-server/ws-bridge.js Normal file
View File

@@ -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()
}

113
src/hooks/useMcpBridge.ts Normal file
View File

@@ -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<WebSocket | null>(null)
const pendingRef = useRef<Map<string, PendingRequest>>(new Map())
const idCounterRef = useRef(0)
const [connected, setConnected] = useState(false)
const ensureConnection = useCallback((): Promise<WebSocket> => {
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 <T>(tool: string, args: Record<string, any>): Promise<T> => {
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<string>('create_note', { path, title, is_a: isA }),
[callTool],
)
const searchNotes = useCallback(
(query: string, limit?: number) =>
callTool<SearchResult[]>('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 }
}

View File

@@ -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: {