fix: SSE session grace period, pi-brain default, partition timeout (#288)

* fix: SSE health check, pi-brain default server, partition timeout

- Add rawSseHealthCheck() that keeps SSE alive during MCP handshake
- Add pi-brain as built-in default MCP server in chat UI
- Return quick graph stats for brain_partition instead of expensive MinCut
- Improve system_guidance with all brain tools and better descriptions
- Add .dockerignore and update .gcloudignore for faster builds

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(brain): pin Rust nightly to 2026-03-20 to avoid nalgebra ICE

The latest nightly (2026-03-21+) has a compiler panic when building
nalgebra 0.32.6 with specialization_graph_of. Pin to known-good nightly.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
rUv 2026-03-23 19:11:20 -04:00 committed by GitHub
parent 4ae7b81e6b
commit 49545fe670
7 changed files with 274 additions and 66 deletions

17
.dockerignore Normal file
View file

@ -0,0 +1,17 @@
.git/
target/
**/target/
node_modules/
ui/
examples/
npm/
docs/
scripts/
benchmarks/
bindings/
test_models/
*.wasm
*.node
*.so
.svelte-kit/
.claude/

View file

@ -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/

View file

@ -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 \

View file

@ -4284,7 +4284,7 @@ fn mcp_tool_definitions() -> Vec<serde_json::Value> {
/// 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<serde_json::Value, String> {
@ -4337,13 +4337,18 @@ async fn handle_mcp_tool_call(
proxy_get(&client, &base, "/v1/drift", api_key, &params).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, &params).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();

View file

@ -695,26 +695,37 @@ function createMockWasmModule() {
switch (name) {
case "system_guidance": {
const toolDocs: Record<string, { cat: string; desc: string; ex: string }> = {
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;

View file

@ -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<string, string>,
timeoutMs: number
): Promise<HealthCheckResponse | null> {
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) {

View file

@ -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<string, string> }> = [];
let envServers: Array<{ name: string; url: string; headers?: Record<string, string> }> = [];
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,