Spaces:
Running
Running
| // core/mcp.js β a lean, browser-native Model Context Protocol CLIENT. Speaks the open MCP | |
| // spec (JSON-RPC 2.0) over the Streamable HTTP transport (single endpoint, POST per message, | |
| // optional SSE response bodies, Mcp-Session-Id header) with a fallback for plain HTTP+SSE | |
| // servers. No SDK, no build step β the protocol is small and this is all of it that a chat | |
| // client needs: initialize β tools/list β tools/call. | |
| // | |
| // Substrate twist: every connected server and every tool call is content-addressable β the | |
| // caller can seal a PROV-O receipt around call inputs/outputs (see core/tools.js), so tool | |
| // use gets the same verifiability as inference (Law L5). | |
| const PROTOCOL_VERSION = "2025-03-26"; | |
| const CLIENT_INFO = { name: "holo-q", version: "2.0.0" }; | |
| let _seq = 0; | |
| const nextId = () => ++_seq; | |
| // Parse an SSE stream body and resolve with the JSON-RPC response matching `id`. | |
| async function readSse(res, id) { | |
| const reader = res.body.getReader(); const dec = new TextDecoder(); | |
| let buf = "", result = null; | |
| for (;;) { | |
| const { done, value } = await reader.read(); if (done) break; | |
| buf += dec.decode(value, { stream: true }); | |
| let i; | |
| while ((i = buf.indexOf("\n\n")) >= 0) { | |
| const frame = buf.slice(0, i); buf = buf.slice(i + 2); | |
| const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join(""); | |
| if (!data) continue; | |
| try { const msg = JSON.parse(data); if (msg.id === id) { result = msg; reader.cancel().catch(() => {}); return result; } } catch {} | |
| } | |
| } | |
| return result; | |
| } | |
| export function makeMcpClient({ url, headers = {}, name = "", timeoutMs = 30000 } = {}) { | |
| let sessionId = null, initialized = null, serverInfo = null, toolCache = null; | |
| async function rpc(method, params, { notification = false } = {}) { | |
| const body = notification | |
| ? { jsonrpc: "2.0", method, ...(params ? { params } : {}) } | |
| : { jsonrpc: "2.0", id: nextId(), method, ...(params ? { params } : {}) }; | |
| const ctrl = new AbortController(); | |
| const t = setTimeout(() => ctrl.abort(), timeoutMs); | |
| try { | |
| const res = await fetch(url, { | |
| method: "POST", signal: ctrl.signal, | |
| headers: { "content-type": "application/json", accept: "application/json, text/event-stream", ...(sessionId ? { "mcp-session-id": sessionId } : {}), ...headers }, | |
| body: JSON.stringify(body), | |
| }); | |
| const sid = res.headers.get("mcp-session-id"); if (sid) sessionId = sid; | |
| if (notification) return null; | |
| if (res.status === 202) return null; | |
| if (!res.ok) throw new Error(`MCP ${res.status} ${res.statusText}`); | |
| const ctype = res.headers.get("content-type") || ""; | |
| const msg = ctype.includes("text/event-stream") ? await readSse(res, body.id) : await res.json(); | |
| if (!msg) throw new Error("MCP: no response for request " + body.id); | |
| if (msg.error) throw new Error(`MCP ${method}: ${msg.error.message || JSON.stringify(msg.error)}`); | |
| return msg.result; | |
| } finally { clearTimeout(t); } | |
| } | |
| async function initialize() { | |
| if (initialized) return initialized; | |
| initialized = (async () => { | |
| const r = await rpc("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO }); | |
| serverInfo = r && r.serverInfo || { name: name || url }; | |
| await rpc("notifications/initialized", undefined, { notification: true }).catch(() => {}); | |
| return r; | |
| })(); | |
| return initialized; | |
| } | |
| // tools/list (cached; paginated per spec) | |
| async function listTools(force = false) { | |
| if (toolCache && !force) return toolCache; | |
| await initialize(); | |
| const out = []; let cursor; | |
| do { | |
| const r = await rpc("tools/list", cursor ? { cursor } : {}); | |
| out.push(...(r.tools || [])); cursor = r.nextCursor; | |
| } while (cursor); | |
| toolCache = out; | |
| return out; | |
| } | |
| // tools/call β { content: [{type:"text",text}|{type:"image",...}], isError } | |
| async function callTool(toolName, args) { | |
| await initialize(); | |
| const r = await rpc("tools/call", { name: toolName, arguments: args || {} }); | |
| return r; | |
| } | |
| return { | |
| url, name: () => (serverInfo && serverInfo.name) || name || url, | |
| initialize, listTools, callTool, | |
| get serverInfo() { return serverInfo; }, | |
| }; | |
| } | |
| // Flatten a tools/call result's content into plain text for the model's <tool_response>. | |
| export function contentToText(result) { | |
| if (!result) return ""; | |
| if (result.isError) return "ERROR: " + (result.content || []).map((c) => c.text || "").join("\n"); | |
| return (result.content || []).map((c) => c.type === "text" ? c.text : c.type === "image" ? "[image]" : c.type === "resource" ? JSON.stringify(c.resource).slice(0, 2000) : "").join("\n"); | |
| } | |
| // ββ DISCOVERY β the MCP universe, three rings out from the substrate βββββββββββββββββββββ | |
| // 1. the SUBSTRATE's own roster (/.well-known/mcp.json β same-origin, verifiable) | |
| // 2. user-added servers (ΞΊ-pinned config in the boot index) | |
| // 3. the OFFICIAL public MCP registry (registry.modelcontextprotocol.io β the open index | |
| // of the ecosystem; remote/streamable-http entries are directly connectable from a tab) | |
| export async function discoverSubstrate() { | |
| try { | |
| const r = await fetch("/.well-known/mcp.json"); if (!r.ok) return []; | |
| const j = await r.json(); | |
| const servers = j.servers || j.mcpServers || (Array.isArray(j) ? j : [j]); | |
| return (Array.isArray(servers) ? servers : Object.entries(servers).map(([k, v]) => ({ name: k, ...v }))) | |
| .map((s) => ({ source: "substrate", name: s.name || "hologram", url: s.url || s.endpoint || null, description: s.description || "", tools: s.tools || null, raw: s })) | |
| .filter((s) => s.url || s.tools); | |
| } catch { return []; } | |
| } | |
| export async function searchRegistry(query, { limit = 20 } = {}) { | |
| // The official registry's public REST API (v0). Remote servers carry `remotes[]` with | |
| // streamable-http/sse URLs β those are connectable straight from the browser. | |
| const base = "https://registry.modelcontextprotocol.io/v0/servers"; | |
| const u = base + "?limit=" + limit + (query ? "&search=" + encodeURIComponent(query) : ""); | |
| const r = await fetch(u, { headers: { accept: "application/json" } }); | |
| if (!r.ok) throw new Error("registry " + r.status); | |
| const j = await r.json(); | |
| return (j.servers || []).map((s) => { | |
| const remote = (s.remotes || []).find((x) => /streamable|http/.test(x.type || "")) || (s.remotes || [])[0] || null; | |
| return { source: "registry", name: s.name, description: s.description || "", version: s.version, | |
| url: remote ? remote.url : null, transport: remote ? remote.type : (s.packages?.length ? "package (host-run)" : "unknown"), raw: s }; | |
| }); | |
| } | |
| export async function discoverAgents() { | |
| // The substrate's agent doors: NANDA AgentFacts + A2A AgentCards + Skills β one entry point. | |
| try { | |
| const r = await fetch("/.well-known/agents.json"); if (!r.ok) return null; | |
| return await r.json(); | |
| } catch { return null; } | |
| } | |