diff --git a/packages/core/src/chat/chat-controller.ts b/packages/core/src/chat/chat-controller.ts index 02ac610..6b6c1ce 100644 --- a/packages/core/src/chat/chat-controller.ts +++ b/packages/core/src/chat/chat-controller.ts @@ -55,6 +55,10 @@ export class ChatController { this.#runtime.setProviderConfig(config); } + reloadMcpTools() { + return this.#runtime.reloadMcpTools(); + } + clearMessages() { this.#runtime.clearMessages(); } diff --git a/packages/core/src/chat/settings-panel.svelte b/packages/core/src/chat/settings-panel.svelte index abf8ff0..ebbab61 100644 --- a/packages/core/src/chat/settings-panel.svelte +++ b/packages/core/src/chat/settings-panel.svelte @@ -7,10 +7,13 @@ listFetchProviders, listImageSearchProviders, listSearchProviders, + loadMcpConfig, loadOAuthCredentials, loadSavedConfig, loadWebConfig, + type McpServerConfig, OAUTH_PROVIDERS, + saveMcpConfig, removeOAuthCredentials, saveConfig, saveOAuthCredentials, @@ -63,6 +66,40 @@ let exaApiKey = $state(savedWeb.apiKeys.exa || ""); let showAdvancedWebKeys = $state(false); + let mcpServers = $state(loadMcpConfig(ns).servers ?? []); + let mcpStatus = $state(""); + let mcpBusy = $state(false); + + function addMcpServer() { + mcpServers.push({ name: "", url: "", enabled: true }); + } + + function removeMcpServer(index: number) { + mcpServers.splice(index, 1); + } + + async function saveMcp() { + mcpBusy = true; + mcpStatus = ""; + try { + const servers = mcpServers + .filter((s) => s.name.trim() && s.url.trim()) + .map((s) => ({ + name: s.name.trim(), + url: s.url.trim(), + enabled: s.enabled !== false, + })); + saveMcpConfig(ns, { servers }); + const count = await chat.reloadMcpTools(); + const active = servers.filter((s) => s.enabled).length; + mcpStatus = `Loaded ${count} tool${count === 1 ? "" : "s"} from ${active} server${active === 1 ? "" : "s"}.`; + } catch (error) { + mcpStatus = `Error: ${error instanceof Error ? error.message : String(error)}`; + } finally { + mcpBusy = false; + } + } + let oauthFlow = $state( saved?.authMethod === "oauth" ? loadOAuthCredentials(ns, saved.provider) @@ -882,6 +919,87 @@ /> +
+
+ mcp servers +
+ +
+ {#if mcpServers.length > 0} +
+ {#each mcpServers as server, i (i)} +
+
+ + + +
+ +
+ {/each} +
+ {:else} +

No MCP servers configured

+ {/if} + +
+ + +
+ + {#if mcpStatus} +

{mcpStatus}

+ {/if} +

+ Tools from each enabled MCP server are added to the agent. For an http:// + server, point the URL at a localhost proxy (browsers block http from an + https page except on localhost). +

+
+
+
about diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index ba38e1a..346f032 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -34,6 +34,14 @@ export { removeOAuthCredentials, saveOAuthCredentials, } from "./oauth"; +// MCP (Model Context Protocol) +export { + loadMcpConfig, + loadMcpTools, + type McpConfig, + type McpServerConfig, + saveMcpConfig, +} from "./mcp"; export { loadPdfDocument } from "./pdf"; // Provider config export { diff --git a/packages/sdk/src/mcp/index.ts b/packages/sdk/src/mcp/index.ts new file mode 100644 index 0000000..b2a2515 --- /dev/null +++ b/packages/sdk/src/mcp/index.ts @@ -0,0 +1,235 @@ +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import type { StorageNamespace } from "../context"; + +// Minimal Model Context Protocol (MCP) client + tool loader. +// Speaks the Streamable HTTP transport (JSON-RPC over POST, SSE responses). +// Browser-only: uses fetch. For http:// servers behind mixed-content, point the +// url at a localhost proxy (Chromium treats http://localhost as trustworthy). + +export interface McpServerConfig { + name: string; + url: string; + enabled?: boolean; + headers?: Record; +} + +export interface McpConfig { + servers: McpServerConfig[]; +} + +interface McpToolDef { + name: string; + description?: string; + inputSchema?: Record; +} + +interface McpContentBlock { + type: string; + text?: string; + data?: string; + mimeType?: string; + [k: string]: unknown; +} + +interface McpCallResult { + content?: McpContentBlock[]; + structuredContent?: unknown; + isError?: boolean; +} + +const PROTOCOL_VERSION = "2025-06-18"; + +function mcpStorageKey(ns: StorageNamespace): string { + return `${ns.localStoragePrefix}-mcp-config`; +} + +export function loadMcpConfig(ns: StorageNamespace): McpConfig { + try { + const raw = localStorage.getItem(mcpStorageKey(ns)); + if (!raw) return { servers: [] }; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed?.servers)) return { servers: parsed.servers }; + return { servers: [] }; + } catch { + return { servers: [] }; + } +} + +export function saveMcpConfig(ns: StorageNamespace, config: McpConfig): void { + localStorage.setItem(mcpStorageKey(ns), JSON.stringify(config)); +} + +function parseSseForId(text: string, id: number): unknown { + let last: unknown; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.startsWith("data:") ? line.slice(5).trim() : ""; + if (!trimmed) continue; + try { + const msg = JSON.parse(trimmed) as { id?: number }; + if (msg && msg.id === id) return msg; + last = msg; + } catch { + // ignore non-JSON data lines (e.g. keep-alives) + } + } + return last; +} + +class McpClient { + private url: string; + private headers: Record; + private sessionId: string | null = null; + private nextId = 1; + + constructor(url: string, headers?: Record) { + this.url = url; + this.headers = headers ?? {}; + } + + private async rpc( + method: string, + params: Record | undefined, + notification: boolean, + ): Promise { + const headers: Record = { + "content-type": "application/json", + accept: "application/json, text/event-stream", + ...this.headers, + }; + if (this.sessionId) headers["mcp-session-id"] = this.sessionId; + + const id = notification ? undefined : this.nextId++; + const body: Record = { jsonrpc: "2.0", method }; + if (params !== undefined) body.params = params; + if (id !== undefined) body.id = id; + + const res = await fetch(this.url, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + const sid = res.headers.get("mcp-session-id"); + if (sid) this.sessionId = sid; + + if (notification) return undefined; + if (!res.ok) { + throw new Error(`MCP HTTP ${res.status}: ${await res.text()}`); + } + + const contentType = res.headers.get("content-type") ?? ""; + let message: { result?: unknown; error?: { message?: string } } | undefined; + if (contentType.includes("text/event-stream")) { + message = parseSseForId(await res.text(), id as number) as typeof message; + } else { + message = (await res.json()) as typeof message; + } + if (message?.error) { + throw new Error(message.error.message ?? "MCP error"); + } + return message?.result; + } + + async initialize(): Promise { + await this.rpc( + "initialize", + { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "office-agents", version: "1" }, + }, + false, + ); + await this.rpc("notifications/initialized", undefined, true); + } + + async listTools(): Promise { + const result = (await this.rpc("tools/list", {}, false)) as { + tools?: McpToolDef[]; + }; + return result?.tools ?? []; + } + + async callTool( + name: string, + args: Record, + ): Promise { + return (await this.rpc( + "tools/call", + { name, arguments: args }, + false, + )) as McpCallResult; + } +} + +function sanitizeToolName(server: string, tool: string): string { + const clean = `${server}_${tool}`.replace(/[^a-zA-Z0-9_-]/g, "_"); + return clean.slice(0, 64); +} + +function wrapMcpTool( + client: McpClient, + server: McpServerConfig, + tool: McpToolDef, +): AgentTool { + const definition = { + name: sanitizeToolName(server.name, tool.name), + label: tool.name, + description: tool.description ?? `MCP tool ${tool.name} (${server.name})`, + parameters: tool.inputSchema ?? { type: "object", properties: {} }, + execute: async (_toolCallId: string, params: Record) => { + try { + const result = await client.callTool(tool.name, params ?? {}); + const blocks = (result?.content ?? []).map((block) => { + if (block.type === "text") { + return { type: "text" as const, text: block.text ?? "" }; + } + if (block.type === "image" && block.data) { + return { + type: "image" as const, + data: block.data, + mimeType: block.mimeType ?? "image/png", + }; + } + return { type: "text" as const, text: JSON.stringify(block) }; + }); + if (blocks.length === 0) { + blocks.push({ + type: "text" as const, + text: JSON.stringify(result?.structuredContent ?? { ok: true }), + }); + } + return { content: blocks, details: undefined }; + } catch (error) { + const message = + error instanceof Error ? error.message : "MCP tool call failed"; + return { + content: [{ type: "text" as const, text: `Error: ${message}` }], + details: undefined, + }; + } + }, + }; + return definition as unknown as AgentTool; +} + +// Connect to every enabled MCP server, list its tools, and return them wrapped +// as AgentTools. A failing server is logged and skipped (never throws). +export async function loadMcpTools(ns: StorageNamespace): Promise { + const config = loadMcpConfig(ns); + const tools: AgentTool[] = []; + for (const server of config.servers) { + if (server.enabled === false || !server.url) continue; + try { + const client = new McpClient(server.url, server.headers); + await client.initialize(); + const mcpTools = await client.listTools(); + for (const tool of mcpTools) { + tools.push(wrapMcpTool(client, server, tool)); + } + } catch (error) { + console.warn(`[mcp] server "${server.name}" failed to load:`, error); + } + } + return tools; +} diff --git a/packages/sdk/src/runtime.ts b/packages/sdk/src/runtime.ts index cb12521..8e9c7e4 100644 --- a/packages/sdk/src/runtime.ts +++ b/packages/sdk/src/runtime.ts @@ -22,6 +22,7 @@ import { generateId, type SessionStats, } from "./message-utils"; +import { loadMcpTools } from "./mcp"; import { loadOAuthCredentials, refreshOAuthToken, @@ -120,10 +121,14 @@ export class AgentRuntime { return this.context.namespace; } + private mcpTools: AgentTool[] = []; + private get tools(): AgentTool[] { - return typeof this.adapter.tools === "function" - ? this.adapter.tools(this.context) - : this.adapter.tools; + const base = + typeof this.adapter.tools === "function" + ? this.adapter.tools(this.context) + : this.adapter.tools; + return [...base, ...this.mcpTools]; } constructor(adapter: RuntimeAdapter, context: AgentContext) { @@ -700,6 +705,20 @@ export class AgentRuntime { } } + // Reconnect to configured MCP servers and rebuild the agent so the refreshed + // tool list takes effect without a full page reload. + async reloadMcpTools(): Promise { + try { + this.mcpTools = await loadMcpTools(this.ns); + } catch { + this.mcpTools = []; + } + if (this.config) { + this.applyConfig(this.config); + } + return this.mcpTools.length; + } + async init() { if (this.sessionLoaded) return; this.sessionLoaded = true; @@ -721,6 +740,14 @@ export class AgentRuntime { this.skills = skills; await syncSkillsToVfs(this.ns, this.context); + // Load MCP tools before applyConfig so they are included when the agent + // is built. A failure here must never block startup. + try { + this.mcpTools = await loadMcpTools(this.ns); + } catch { + this.mcpTools = []; + } + const saved = loadSavedConfig(this.ns); if (saved?.provider && saved?.apiKey && saved?.model) { this.applyConfig(saved);