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:
24
mcp-server/package-lock.json
generated
24
mcp-server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
79
mcp-server/ws-bridge.js
Normal 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
113
src/hooks/useMcpBridge.ts
Normal 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 }
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user