diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..7f9784995 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git/ +target/ +**/target/ +node_modules/ +ui/ +examples/ +npm/ +docs/ +scripts/ +benchmarks/ +bindings/ +test_models/ +*.wasm +*.node +*.so +.svelte-kit/ +.claude/ diff --git a/.gcloudignore b/.gcloudignore index b7629fd5c..82bfb6d58 100644 --- a/.gcloudignore +++ b/.gcloudignore @@ -1,5 +1,7 @@ # Cloud Build ignores — keep context under 50 MiB target/ +crates/mcp-brain-server/target/ +crates/rvf/target/ node_modules/ .git/ examples/ @@ -8,7 +10,9 @@ docs/ scripts/ bindings/ npm/ +ui/ test_models/ +tests/ *.wasm *.so *.o @@ -22,3 +26,4 @@ test_models/ *.db .claude/ package-lock.json +.svelte-kit/ diff --git a/crates/mcp-brain-server/Dockerfile b/crates/mcp-brain-server/Dockerfile index 6e7d408cd..da9aecc71 100644 --- a/crates/mcp-brain-server/Dockerfile +++ b/crates/mcp-brain-server/Dockerfile @@ -4,6 +4,9 @@ FROM rustlang/rust:nightly-bookworm AS builder WORKDIR /app +# Pin to a known-good nightly (2026-03-20) to avoid nalgebra ICE on newer nightlies +RUN rustup default nightly-2026-03-20 + # Install build dependencies (native-tls requires OpenSSL) RUN apt-get update && apt-get install -y --no-install-recommends \ pkg-config \ diff --git a/crates/mcp-brain-server/src/routes.rs b/crates/mcp-brain-server/src/routes.rs index 2ebd11db7..b7f804d8b 100644 --- a/crates/mcp-brain-server/src/routes.rs +++ b/crates/mcp-brain-server/src/routes.rs @@ -4284,7 +4284,7 @@ fn mcp_tool_definitions() -> Vec { /// Handle MCP tool call by proxying to the REST API via HTTP loopback. /// This reuses the exact same tested REST handlers — no type mismatch risk. async fn handle_mcp_tool_call( - _state: &AppState, + state: &AppState, tool_name: &str, args: &serde_json::Value, ) -> Result { @@ -4337,13 +4337,18 @@ async fn handle_mcp_tool_call( proxy_get(&client, &base, "/v1/drift", api_key, ¶ms).await }, "brain_partition" => { - let mut params = Vec::new(); - if let Some(d) = args.get("domain").and_then(|v| v.as_str()) { params.push(("domain", d.to_string())); } - if let Some(s) = args.get("min_cluster_size").and_then(|v| v.as_u64()) { params.push(("min_cluster_size", s.to_string())); } - // Default compact=true to avoid SSE truncation; pass through if explicitly set - let compact = args.get("compact").and_then(|v| v.as_bool()).unwrap_or(true); - params.push(("compact", compact.to_string())); - proxy_get(&client, &base, "/v1/partition", api_key, ¶ms).await + // MinCut partition is expensive (943K+ edges) and times out via MCP SSE. + // Return a quick acknowledgment with graph stats instead. + let graph = state.graph.read(); + let node_count = graph.node_count(); + let edge_count = graph.edge_count(); + drop(graph); + Ok(serde_json::json!({ + "status": "available", + "graph_nodes": node_count, + "graph_edges": edge_count, + "note": "MinCut partition is too heavy for MCP (943K+ edges). Use the REST API directly: GET https://pi.ruv.io/v1/partition?compact=true" + })) }, "brain_list" => { let mut params = Vec::new(); diff --git a/ui/ruvocal/src/lib/wasm/index.ts b/ui/ruvocal/src/lib/wasm/index.ts index bff62ecd2..f51f2cf52 100644 --- a/ui/ruvocal/src/lib/wasm/index.ts +++ b/ui/ruvocal/src/lib/wasm/index.ts @@ -695,26 +695,37 @@ function createMockWasmModule() { switch (name) { case "system_guidance": { const toolDocs: Record = { - system_guidance: { cat: "help", desc: "Get help on all tools", ex: "{}" }, - read_file: { cat: "files", desc: "Read file contents", ex: '{"path": "src/index.ts"}' }, - write_file: { cat: "files", desc: "Create/overwrite file", ex: '{"path": "hello.txt", "content": "Hi"}' }, - list_files: { cat: "files", desc: "List all files", ex: "{}" }, - delete_file: { cat: "files", desc: "Delete a file", ex: '{"path": "temp.txt"}' }, - edit_file: { cat: "files", desc: "Replace text in file", ex: '{"path": "f.txt", "old_content": "a", "new_content": "b"}' }, - grep: { cat: "files", desc: "Search file contents", ex: '{"pattern": "TODO"}' }, - glob: { cat: "files", desc: "Find files by pattern", ex: '{"pattern": "*.ts"}' }, - memory_store: { cat: "memory", desc: "Store data persistently", ex: '{"key": "k", "value": "v"}' }, - memory_search: { cat: "memory", desc: "Search stored data", ex: '{"query": "auth"}' }, - todo_add: { cat: "tasks", desc: "Add a task", ex: '{"task": "Fix bug"}' }, - todo_list: { cat: "tasks", desc: "List all tasks", ex: "{}" }, - todo_complete: { cat: "tasks", desc: "Complete a task", ex: '{"id": "todo-1"}' }, - witness_log: { cat: "witness", desc: "Log to audit chain", ex: '{"action": "deploy"}' }, - witness_verify: { cat: "witness", desc: "Verify chain integrity", ex: "{}" }, - gallery_list: { cat: "gallery", desc: "List agent templates", ex: "{}" }, - gallery_load: { cat: "gallery", desc: "Load a template", ex: '{"id": "development-agent"}' }, - gallery_search: { cat: "gallery", desc: "Search templates", ex: '{"query": "security"}' }, - brain_search: { cat: "brain", desc: "Search π Brain", ex: '{"query": "react hooks"}' }, - brain_share: { cat: "brain", desc: "Share knowledge", ex: '{"category": "pattern", "title": "...", "content": "..."}' }, + // --- Help --- + system_guidance: { cat: "help", desc: "Get help on all available tools, a specific tool, or a category", ex: '{} or {"tool": "brain_search"} or {"category": "brain"}' }, + // --- Files (local virtual filesystem in browser) --- + read_file: { cat: "files", desc: "Read a file from the virtual filesystem. REQUIRED: path", ex: '{"path": "src/index.ts"}' }, + write_file: { cat: "files", desc: "Create or overwrite a file. REQUIRED: path, content", ex: '{"path": "hello.txt", "content": "Hello World"}' }, + list_files: { cat: "files", desc: "List all files stored in the virtual filesystem", ex: "{}" }, + delete_file: { cat: "files", desc: "Delete a file. REQUIRED: path", ex: '{"path": "temp.txt"}' }, + edit_file: { cat: "files", desc: "Find-and-replace text in a file. REQUIRED: path, old_content, new_content", ex: '{"path": "app.ts", "old_content": "const x = 1", "new_content": "const x = 2"}' }, + grep: { cat: "files", desc: "Search file contents with regex. REQUIRED: pattern. OPTIONAL: path (limit to one file)", ex: '{"pattern": "TODO|FIXME"}' }, + glob: { cat: "files", desc: "Find files by glob pattern. REQUIRED: pattern", ex: '{"pattern": "src/**/*.tsx"}' }, + // --- Memory (persistent key-value with semantic search) --- + memory_store: { cat: "memory", desc: "Store a value in persistent memory with optional tags. REQUIRED: key, value. OPTIONAL: tags[]", ex: '{"key": "auth-pattern", "value": "JWT with refresh tokens", "tags": ["security"]}' }, + memory_search: { cat: "memory", desc: "Semantic search across stored memories. REQUIRED: query. OPTIONAL: top_k (default 5)", ex: '{"query": "authentication", "top_k": 3}' }, + // --- Tasks --- + todo_add: { cat: "tasks", desc: "Add a new task. REQUIRED: task (description string)", ex: '{"task": "Fix login redirect bug"}' }, + todo_list: { cat: "tasks", desc: "List all tasks with status indicators", ex: "{}" }, + todo_complete: { cat: "tasks", desc: "Mark a task as complete. REQUIRED: id", ex: '{"id": "todo-1"}' }, + // --- Witness Chain (immutable audit log) --- + witness_log: { cat: "witness", desc: "Log an action to the immutable witness chain. REQUIRED: action. OPTIONAL: data", ex: '{"action": "file_modified", "data": {"path": "config.json"}}' }, + witness_verify: { cat: "witness", desc: "Verify the integrity of the entire witness chain. Returns VALID/INVALID", ex: "{}" }, + // --- Gallery (agent templates) --- + gallery_list: { cat: "gallery", desc: "List available agent templates. OPTIONAL: category", ex: '{"category": "development"}' }, + gallery_load: { cat: "gallery", desc: "Load and activate an agent template. REQUIRED: id", ex: '{"id": "development-agent"}' }, + gallery_search: { cat: "gallery", desc: "Search templates by keyword. REQUIRED: query", ex: '{"query": "security"}' }, + // --- Brain (shared collective intelligence at pi.ruv.io, via pi-brain MCP) --- + brain_status: { cat: "brain", desc: "Check brain health: memory count, graph edges, clusters, embedding engine, drift status", ex: "{}" }, + brain_search: { cat: "brain", desc: "Semantic search across 2,000+ shared memories. REQUIRED: query. OPTIONAL: limit, min_quality", ex: '{"query": "authentication patterns", "limit": 5}' }, + brain_list: { cat: "brain", desc: "List recent memories. OPTIONAL: limit, category, min_quality", ex: '{"limit": 10, "category": "pattern"}' }, + brain_share: { cat: "brain", desc: "Share a learning with the collective. REQUIRED: category, title, content. OPTIONAL: tags[]. Categories: architecture|pattern|solution|convention|security|performance|tooling|debug", ex: '{"category": "pattern", "title": "React auth hook", "content": "useAuth() with refresh token rotation", "tags": ["react", "auth"]}' }, + brain_drift: { cat: "brain", desc: "Check knowledge drift across categories. Shows how knowledge is evolving over time", ex: "{}" }, + brain_partition: { cat: "brain", desc: "Get MinCut knowledge clusters. Shows emergent topic groupings with coherence scores. Use compact=true (default) to avoid large responses", ex: '{"compact": true}' }, }; let text: string; @@ -723,23 +734,32 @@ function createMockWasmModule() { if (reqTool && toolDocs[reqTool]) { const d = toolDocs[reqTool]; - text = `TOOL: ${reqTool}\nCategory: ${d.cat}\n${d.desc}\nExample: ${reqTool}(${d.ex})`; + text = `TOOL: ${reqTool}\nCategory: ${d.cat}\nDescription: ${d.desc}\nExample: ${reqTool}(${d.ex})`; } else if (reqCat && reqCat !== "all") { const filtered = Object.entries(toolDocs) .filter(([, d]) => d.cat === reqCat) - .map(([n, d]) => `• ${n}(${d.ex})`); + .map(([n, d]) => ` ${n} — ${d.desc}\n Example: ${n}(${d.ex})`); text = filtered.length > 0 - ? `${reqCat.toUpperCase()} TOOLS:\n${filtered.join("\n")}` - : `No tools in category: ${reqCat}`; + ? `${reqCat.toUpperCase()} TOOLS:\n\n${filtered.join("\n\n")}` + : `No tools in category: ${reqCat}. Available categories: help, files, memory, tasks, witness, gallery, brain`; } else { - const cats = ["files", "memory", "tasks", "gallery", "witness", "brain"]; + const cats = ["brain", "files", "memory", "tasks", "gallery", "witness", "help"]; const sections = cats.map((c) => { const items = Object.entries(toolDocs) .filter(([, d]) => d.cat === c) - .map(([n, d]) => ` • ${n}(${d.ex})`); - return items.length > 0 ? `${c.toUpperCase()}:\n${items.join("\n")}` : null; + .map(([n, d]) => ` ${n} — ${d.desc}\n Ex: ${n}(${d.ex})`); + return items.length > 0 ? `${c.toUpperCase()} (${items.length}):\n${items.join("\n")}` : null; }).filter(Boolean); - text = `SYSTEM GUIDANCE - ALL TOOLS\n\n${sections.join("\n\n")}\n\nTIPS:\n• Always pass required parameters\n• Use exact JSON format shown\n• "Run in RVF" = use these sandbox tools`; + text = `SYSTEM GUIDANCE — AVAILABLE TOOLS\n\n` + + `You have two MCP servers:\n` + + ` 1. RVAgent Local (WASM) — files, memory, tasks, witness, gallery (runs in browser)\n` + + ` 2. pi-brain (pi.ruv.io/sse) — shared collective intelligence with 2,000+ memories\n\n` + + `${sections.join("\n\n")}\n\n` + + `TIPS:\n` + + `• Brain tools (brain_*) connect to pi.ruv.io shared knowledge — search before implementing\n` + + `• Local tools (files, memory, tasks) operate in this browser sandbox\n` + + `• Always pass REQUIRED parameters in JSON format\n` + + `• Use witness_log to create audit trails for important actions`; } response.result = { content: [{ type: "text", text }] }; break; diff --git a/ui/ruvocal/src/routes/api/mcp/health/+server.ts b/ui/ruvocal/src/routes/api/mcp/health/+server.ts index a4834f6f6..2d98dcfe3 100644 --- a/ui/ruvocal/src/routes/api/mcp/health/+server.ts +++ b/ui/ruvocal/src/routes/api/mcp/health/+server.ts @@ -8,6 +8,154 @@ import type { RequestHandler } from "./$types"; import { isValidUrl } from "$lib/server/urlSafety"; import { isStrictHfMcpLogin, hasNonEmptyToken, isExaMcpServer } from "$lib/server/mcp/hf"; +/** + * Raw SSE MCP health check — keeps SSE connection alive while POSTing. + * The MCP SDK's SSEClientTransport can drop the SSE stream between connect() + * and send(), causing 404 on servers with aggressive session cleanup. + */ +async function rawSseHealthCheck( + sseUrl: string, + headersRecord: Record, + timeoutMs: number +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + // 1. Open SSE stream and keep it alive + const sseResp = await fetch(sseUrl, { + headers: { ...headersRecord, Accept: "text/event-stream" }, + signal: controller.signal, + }); + if (!sseResp.ok || !sseResp.body) return null; + + const reader = sseResp.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + // 2. Read until we get the endpoint event + let messageEndpoint = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + // Parse SSE: look for "event: endpoint\ndata: /messages?sessionId=..." + const match = buffer.match(/data:\s*(\/messages\?sessionId=[^\n\r]+)/); + if (match) { + messageEndpoint = match[1]; + break; + } + } + + if (!messageEndpoint) { + reader.cancel(); + return null; + } + + const baseOrigin = new URL(sseUrl).origin; + const postUrl = `${baseOrigin}${messageEndpoint}`; + + // 3. Send initialize (SSE still open via reader) + const initResp = await fetch(postUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "chat-ui-health-check", version: "1.0.0" }, + }, + }), + }); + + if (!initResp.ok) { + reader.cancel(); + return null; + } + + // 4. Read initialize response from SSE stream + buffer = ""; + let initResult = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const msgMatch = buffer.match(/data:\s*(\{[^\n]+\})/); + if (msgMatch) { + initResult = msgMatch[1]; + break; + } + } + + if (!initResult) { + reader.cancel(); + return null; + } + + // 5. Send initialized notification + await fetch(postUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }), + }); + + // 6. Request tools list + const toolsResp = await fetch(postUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 2 }), + }); + + if (!toolsResp.ok) { + reader.cancel(); + return null; + } + + // 7. Read tools response from SSE stream + buffer = ""; + let toolsResult = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const msgMatch = buffer.match(/data:\s*(\{[^\n]+\})/); + if (msgMatch) { + toolsResult = msgMatch[1]; + break; + } + } + + // 8. Clean up + reader.cancel(); + + if (!toolsResult) return null; + + const parsed = JSON.parse(toolsResult); + const tools = parsed?.result?.tools; + if (!Array.isArray(tools)) return null; + + return { + ready: true, + tools: tools.map((t: { name: string; description?: string; inputSchema?: unknown }) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })), + authRequired: false, + }; + } catch (err) { + logger.debug({ err }, "[MCP Health] Raw SSE health check failed"); + return null; + } finally { + clearTimeout(timeout); + controller.abort(); + } +} + interface HealthCheckRequest { url: string; headers?: KeyValuePair[]; @@ -166,23 +314,29 @@ export const POST: RequestHandler = async ({ request, locals }) => { // Ignore } - // Try SSE transport + // Try raw SSE health check first — keeps connection alive during POST + // (works around servers with aggressive session cleanup) + const rawResult = await rawSseHealthCheck(url, headersRecord, 15000); + if (rawResult) { + const res = new Response(JSON.stringify(rawResult), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + clearTimeout(timeoutId); + return res; + } + + // Fall back to SDK SSE transport try { - logger.info({}, `[MCP Health] Trying SSE transport for ${url}`); + logger.info({}, `[MCP Health] Trying SDK SSE transport for ${url}`); client = new Client({ name: "chat-ui-health-check", version: "1.0.0", }); const sseTransport = new SSEClientTransport(baseUrl, { requestInit }); - logger.info({}, `[MCP Health] Connecting via SSE...`); await client.connect(sseTransport); - logger.info({}, `[MCP Health] Connected successfully via SSE`); - - // Connection successful, get tools const toolsResponse = await client.listTools(); - - // Disconnect after getting tools await client.close(); if (toolsResponse && toolsResponse.tools) { @@ -202,23 +356,15 @@ export const POST: RequestHandler = async ({ request, locals }) => { }); clearTimeout(timeoutId); return res; - } else { - const res = new Response( - JSON.stringify({ - ready: false, - error: "Connected but no tools available", - authRequired: false, - } as HealthCheckResponse), - { - status: 503, - headers: { "Content-Type": "application/json" }, - } - ); - clearTimeout(timeoutId); - return res; } - } catch (sseError) { - lastError = sseError instanceof Error ? sseError : new Error(String(sseError)); + } catch (sdkSseError) { + try { await client?.close(); } catch { /* ignore */ } + } + + // Both SSE approaches failed + { + const sseError = new Error("SSE transport failed (raw + SDK)"); + lastError = sseError; // Prefer the HTTP error when both failed so UI shows the primary failure (e.g., HTTP 500) instead // of the fallback SSE message. if (httpError) { diff --git a/ui/ruvocal/src/routes/api/mcp/servers/+server.ts b/ui/ruvocal/src/routes/api/mcp/servers/+server.ts index ebb21fe0a..d6eb5e9ec 100644 --- a/ui/ruvocal/src/routes/api/mcp/servers/+server.ts +++ b/ui/ruvocal/src/routes/api/mcp/servers/+server.ts @@ -1,24 +1,36 @@ import type { MCPServer } from "$lib/types/Tool"; import { config } from "$lib/server/config"; +// Built-in MCP servers always available (users can toggle them off) +const BUILTIN_SERVERS: Array<{ name: string; url: string }> = [ + { name: "pi-brain", url: "https://pi.ruv.io/sse" }, +]; + export async function GET() { // Parse MCP_SERVERS environment variable const mcpServersEnv = config.MCP_SERVERS || "[]"; - let servers: Array<{ name: string; url: string; headers?: Record }> = []; + let envServers: Array<{ name: string; url: string; headers?: Record }> = []; try { - servers = JSON.parse(mcpServersEnv); - if (!Array.isArray(servers)) { - servers = []; + envServers = JSON.parse(mcpServersEnv); + if (!Array.isArray(envServers)) { + envServers = []; } } catch (error) { console.error("Failed to parse MCP_SERVERS env variable:", error); - servers = []; + envServers = []; } + // Merge built-in + env servers, env takes precedence by name + const envNames = new Set(envServers.map((s) => s.name)); + const allServers = [ + ...BUILTIN_SERVERS.filter((s) => !envNames.has(s.name)), + ...envServers, + ]; + // Convert internal server config to client MCPServer format - const mcpServers: MCPServer[] = servers.map((server) => ({ + const mcpServers: MCPServer[] = allServers.map((server) => ({ id: `base-${server.name}`, // Stable ID based on name name: server.name, url: server.url,