All files / src/hooks useMcpBridge.ts

0% Statements 0/48
0% Branches 0/11
0% Functions 0/14
0% Lines 0/47

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114                                                                                                                                                                                                                                   
/**
 * 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 }
}