diff --git a/README.md b/README.md index 767ea11003..e3177a2780 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ already have: - **Local first.** One small harness server on `127.0.0.1` owns every agent process. Transcripts, keys, and events live in `~/.openmausbot`, not a cloud. - **Agents with hands.** Each bot can get a real computer β€” a cloud Linux desktop it drives while you watch - live, or your own Mac β€” plus 500+ apps through Composio Connect. + live, or your own Mac β€” plus custom tools through remote MCP servers (HTTP or SSE). ## Features @@ -91,12 +91,12 @@ permission broker turns every risky action into a decision you make, for cloud a -### πŸ”Œ Connected apps +### πŸ”Œ Custom remote MCP servers -A one-click marketplace over Composio Connect: Gmail, Slack, GitHub, Notion, Linear and hundreds more. -OAuth once, and every bot can use them as tools. +Add your own HTTP or SSE MCP servers in the Plugins panel. Point at any Model Context Protocol server β€” +Notion, GitHub, custom APIs β€” and every Claude bot can use those tools. Composio is optional. -Connected apps marketplace +Remote MCP servers panel @@ -162,7 +162,7 @@ flowchart LR REG --> CL & CX & GR CL & CX & GR -- "permission requests" --> BROKER server -- "Box API" --> BOX[("Cloud computer
box.ascii.dev")] - server -- "Composio Connect" --> APPS[("Gmail Β· Slack Β· GitHub Β· …")] + server -- "Custom MCP servers" --> APPS[("Your HTTP/SSE MCP tools")] ``` | Layer | Where | What it does | @@ -215,7 +215,7 @@ pnpm package:linux # Ubuntu x64: .deb + AppImage; no Swift required | Capability | macOS | Ubuntu 24.04 Xorg | Ubuntu 24.04 Wayland | |---|---|---|---| | Packaged app, embedded harness, local agent CLIs | Supported | Beta | Beta | -| Composio and Box/cloud computers | Supported | Beta | Beta | +| Remote MCP servers and Box/cloud computers | Supported | Beta | Beta | | Local screen preview and computer control | Supported | Planned | Planned after compositor validation | | Native on-device dictation | Supported | Planned | Planned | @@ -228,13 +228,32 @@ in the sidebar footer) when you want to enable its integration: | Credential | What it enables | Where to get it | |---|---|---| -| Composio Connect key (`ck_…`) | Connect Gmail, GitHub, Slack, Notion, and other apps to your bots | [Composio Connect setup guide](https://docs.composio.dev/docs/composio-connect) | -| Composio API key (`ak_…`) | Browse the full app catalog with official names and logos | [Composio project API key guide](https://docs.composio.dev/reference/authenticating-to-composio/project-api-key-permissions) | | Box API key | Give bots an isolated remote Linux computer with a desktop and terminal | [Box API key guide](https://docs.ascii.dev/box/api-keys) | | ElevenLabs key | Read replies aloud, and call your bots | [ElevenLabs API keys](https://elevenlabs.io/app/settings/api-keys) | - -Composio and Box are third-party services with their own accounts and terms. Box is a paid service after -its trial, and using a cloud computer may incur charges. +| Composio Connect key (`ck_…`) (optional) | Use Composio's connected apps marketplace instead of custom MCP servers | [Composio Connect setup guide](https://docs.composio.dev/docs/composio-connect) | + +**Custom MCP servers** are configured in the Plugins panel (puzzle icon in the chat header). No account +or API key required β€” just point at your HTTP or SSE MCP server URL. See the [MCP servers +directory](https://github.com/modelcontextprotocol/servers) for examples. + +### Adding a custom remote MCP server + +1. Click the puzzle icon (🧩) in the chat header to open the Plugins panel +2. Click "Add Server" +3. Fill in: + - **Name**: A unique identifier (lowercase, alphanumeric, dash, underscore) β€” used as `mcp__` in tool allowlists + - **Transport**: HTTP (streamable HTTP) or SSE (Server-Sent Events) + - **URL**: Your MCP server endpoint + - **Headers** (optional): Add auth headers, API keys, etc. These are stored securely and never echoed back +4. Click "Save" + +Your Claude bots can now use tools from that server. Example custom servers: +- **Notion**: Read and write pages, databases +- **GitHub**: Issues, PRs, code search +- **APIs.guru**: Browse and test public APIs +- **Custom APIs**: Your own internal tools + +MCP servers can be enabled/disabled per server without losing their configuration. ```sh pnpm typecheck # app + server diff --git a/server/config.ts b/server/config.ts index 96feb51a4e..6cfe015b08 100644 --- a/server/config.ts +++ b/server/config.ts @@ -8,6 +8,16 @@ import { join } from "node:path"; import { writeFileAtomic } from "./atomic.ts"; import type { InstanceConfigMap } from "./contracts.ts"; +export interface McpServer { + name: string; + transport: "http" | "sse"; + url: string; + /** Optional headers (e.g. Authorization, API keys) β€” stored on the harness, + * never echoed back in GET /api/config (same write-only rule as other secrets). */ + headers?: Record; + enabled?: boolean; +} + export interface AppConfig { xai?: { key?: string; url?: string }; /** key = ck_… Connect consumer key (connections + agent tools); @@ -21,6 +31,9 @@ export interface AppConfig { /** The person using the app (collected in onboarding, shown in the * sidebar). Not a secret β€” echoed back by GET /api/config. */ profile?: { name?: string; email?: string }; + /** Custom remote MCP servers: user-configured HTTP or SSE servers. Persisted + * in ~/.openmausbot/config.json; headers are write-only like other secrets. */ + mcpServers?: McpServer[]; instances?: InstanceConfigMap; } @@ -72,6 +85,10 @@ export function saveConfig(patch: Partial): void { disk[key] = { ...(disk[key] as object), ...patch[key] }; } } + // mcpServers is an array, not an object to merge β€” replace wholesale + if (Array.isArray(patch.mcpServers)) { + disk.mcpServers = patch.mcpServers; + } mkdirSync(DATA_DIR, { recursive: true }); writeFileAtomic(p, JSON.stringify(disk, null, 2)); } diff --git a/server/contracts.ts b/server/contracts.ts index 0257904399..7c4673a015 100644 --- a/server/contracts.ts +++ b/server/contracts.ts @@ -108,6 +108,14 @@ export interface SendTurnInput { * through the harness so this bot can message other bots. The harness * owns turns, permissions, and recursion limits; the proxy only forwards. */ agents?: { command: string; args: string[]; env: Record }; + /** Custom remote MCP servers: user-configured HTTP or SSE servers. */ + mcpServers?: Array<{ + name: string; + transport: "http" | "sse"; + url: string; + headers?: Record; + enabled?: boolean; + }>; }; cwd?: string; } diff --git a/server/drivers/claude.test.ts b/server/drivers/claude.test.ts index 38ba927175..ecd67e968e 100644 --- a/server/drivers/claude.test.ts +++ b/server/drivers/claude.test.ts @@ -164,6 +164,100 @@ describe("ClaudeDriver turns (fake CLI)", () => { expect(allowed).toContain("mcp__agents"); }); + it("mounts custom remote MCP servers (HTTP/SSE) and pre-allows their tools", async () => { + await create(); + const dump = join(scratch, "dump.json"); + process.env.FAKE_CLAUDE_DUMP = dump; + + await instance.adapter.sendTurn({ + threadId: "t-custom-mcp", + text: "hi", + integrations: { + mcpServers: [ + { + name: "notion", + transport: "http", + url: "https://api.example.com/mcp/notion", + headers: { Authorization: "Bearer secret-token" }, + enabled: true, + }, + { + name: "deepwiki", + transport: "sse", + url: "https://api.example.com/mcp/deepwiki", + enabled: true, + }, + { + name: "disabled-server", + transport: "http", + url: "https://disabled.example.com/mcp", + enabled: false, + }, + ], + }, + }); + await recorder.until((e) => e.type === "turn.completed"); + + const seen = JSON.parse(readFileSync(dump, "utf8")); + const mcpConfig = JSON.parse(seen.argv[seen.argv.indexOf("--mcp-config") + 1]); + + // Enabled servers are mounted + expect(mcpConfig.mcpServers.notion).toMatchObject({ + type: "http", + url: "https://api.example.com/mcp/notion", + headers: { Authorization: "Bearer secret-token" }, + }); + expect(mcpConfig.mcpServers.deepwiki).toMatchObject({ + type: "sse", + url: "https://api.example.com/mcp/deepwiki", + }); + + // Disabled server is not mounted + expect(mcpConfig.mcpServers["disabled-server"]).toBeUndefined(); + + // Tools are pre-allowed + const allowed = seen.argv[seen.argv.indexOf("--allowedTools") + 1]; + expect(allowed).toContain("mcp__notion"); + expect(allowed).toContain("mcp__deepwiki"); + expect(allowed).not.toContain("mcp__disabled-server"); + }); + + it("mounts Composio alongside custom MCP servers when both are present", async () => { + await create(); + const dump = join(scratch, "dump.json"); + process.env.FAKE_CLAUDE_DUMP = dump; + + await instance.adapter.sendTurn({ + threadId: "t-both", + text: "hi", + integrations: { + composio: { key: "ck_test123" }, + mcpServers: [ + { name: "custom", transport: "http", url: "https://custom.example.com/mcp", enabled: true }, + ], + }, + }); + await recorder.until((e) => e.type === "turn.completed"); + + const seen = JSON.parse(readFileSync(dump, "utf8")); + const mcpConfig = JSON.parse(seen.argv[seen.argv.indexOf("--mcp-config") + 1]); + + // Both are mounted + expect(mcpConfig.mcpServers.custom).toMatchObject({ + type: "http", + url: "https://custom.example.com/mcp", + }); + expect(mcpConfig.mcpServers.composio).toMatchObject({ + type: "http", + url: "https://connect.composio.dev/mcp", + headers: { "x-consumer-api-key": "ck_test123" }, + }); + + const allowed = seen.argv[seen.argv.indexOf("--allowedTools") + 1]; + expect(allowed).toContain("mcp__custom"); + expect(allowed).toContain("mcp__composio"); + }); + it("resumes with --resume when a cursor exists and reports that session id", async () => { await create(); const dump = join(scratch, "dump.json"); diff --git a/server/drivers/claude.ts b/server/drivers/claude.ts index 798d0e1903..fd9f4238fd 100644 --- a/server/drivers/claude.ts +++ b/server/drivers/claude.ts @@ -262,6 +262,22 @@ export const ClaudeDriver: ProviderDriver = { // acceptEdits run silently denies anything unlisted) const mcpServers: Record = {}; const allowed: string[] = []; + + // Custom remote MCP servers (user-configured HTTP/SSE) + if (turn.integrations?.mcpServers) { + for (const server of turn.integrations.mcpServers) { + if (!server.enabled) continue; + const headers = server.headers ?? {}; + mcpServers[server.name] = { + type: server.transport, + url: server.url, + ...(Object.keys(headers).length ? { headers } : {}), + }; + allowed.push(`mcp__${server.name}`); + } + } + + // Composio (optional preset) if (turn.integrations?.composio?.key) { mcpServers.composio = { type: "http", diff --git a/server/index.test.ts b/server/index.test.ts index d8ffab4401..406f63eb8b 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -277,6 +277,56 @@ describe("harness HTTP API", () => { expect(after.body.profile).toEqual({ name: "Ada Lovelace", email: "Ada@Example.com" }); }); + it("saves custom MCP servers and never echoes headers back", async () => { + const servers = [ + { + name: "notion", + transport: "http", + url: "https://api.example.com/mcp/notion", + headers: { Authorization: "Bearer secret-token-123" }, + enabled: true, + }, + { + name: "deepwiki", + transport: "sse", + url: "https://api.example.com/mcp/deepwiki", + enabled: false, + }, + ]; + + const put = await api("PUT", "/api/config", { mcpServers: servers }); + expect(put.status).toBe(200); + expect(put.body.mcpServers).toHaveLength(2); + expect(put.body.mcpServers[0]).toMatchObject({ + name: "notion", + transport: "http", + url: "https://api.example.com/mcp/notion", + enabled: true, + hasHeaders: true, + }); + expect(put.body.mcpServers[0].headers).toBeUndefined(); + expect(JSON.stringify(put.body)).not.toContain("secret-token-123"); + expect(JSON.stringify(put.body)).not.toContain("Bearer"); + + const after = await api("GET", "/api/config"); + expect(after.body.mcpServers).toHaveLength(2); + expect(after.body.mcpServers[0]).toMatchObject({ + name: "notion", + transport: "http", + url: "https://api.example.com/mcp/notion", + enabled: true, + hasHeaders: true, + }); + expect(after.body.mcpServers[1]).toMatchObject({ + name: "deepwiki", + transport: "sse", + url: "https://api.example.com/mcp/deepwiki", + enabled: false, + hasHeaders: false, + }); + expect(JSON.stringify(after.body)).not.toContain("secret-token-123"); + }); + it("404s unknown routes with the route in the error", async () => { const res = await api("GET", "/api/definitely-not-a-route"); expect(res.status).toBe(404); diff --git a/server/index.ts b/server/index.ts index 70c7daf5e5..c734f7e642 100644 --- a/server/index.ts +++ b/server/index.ts @@ -523,6 +523,10 @@ async function startTurn( try { const integrations: NonNullable[0]["integrations"]> = {}; if (cfg.composio?.key) integrations.composio = { key: cfg.composio.key, url: cfg.composio.url }; + // Custom remote MCP servers (enabled servers only) + if (cfg.mcpServers?.length) { + integrations.mcpServers = cfg.mcpServers.filter((s) => s.enabled !== false); + } const wants = opts?.runOn === "cloud" ? "cloud" : bot.computer; // cloud routine overrides the MAUS default const mountsComputerMcp = instance.adapter.capabilities.computerMcp === true; const mountsCloudComputer = mountsComputerMcp || instance.driverKind === "boxAgent"; @@ -864,6 +868,15 @@ function configStatus() { tts: tts.describeVoice(cfg), // not a secret β€” the sidebar shows it profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "" }, + // custom MCP servers: names, urls, and enabled state are echoed back; + // headers are write-only like other secrets + mcpServers: (cfg.mcpServers ?? []).map((s) => ({ + name: s.name, + transport: s.transport, + url: s.url, + enabled: s.enabled ?? true, + hasHeaders: Boolean(s.headers && Object.keys(s.headers).length), + })), }; } @@ -1496,6 +1509,10 @@ const server = createServer(async (req, res) => { for (const key of ["xai", "composio", "box", "tts", "profile"] as const) { if (body[key] && typeof body[key] === "object") patch[key] = body[key]; } + // mcpServers is an array, not an object + if (Array.isArray(body.mcpServers)) { + patch.mcpServers = body.mcpServers; + } if (!Object.keys(patch).length) return json(res, 400, { error: "nothing to save" }); // check a box token against the provider before storing it: a // rejected token used to save happily and only surface as a 401 in diff --git a/src/components/PluginsPanel.tsx b/src/components/PluginsPanel.tsx index ade459cce1..2fb898fedf 100644 --- a/src/components/PluginsPanel.tsx +++ b/src/components/PluginsPanel.tsx @@ -1,114 +1,173 @@ -// Connected apps marketplace, backed by Composio Connect. Catalog comes -// from /api/connectors/catalog β€” the full toolkit list with logos when a -// Composio API key is configured, a curated set otherwise. Icons resolve -// logo β†’ favicon β†’ monogram. +// Custom remote MCP servers panel. Users can add their own HTTP/SSE MCP +// servers with optional auth headers. Composio Connect shows as one optional +// preset when a key is configured, not the primary path. import { useCallback, useEffect, useState } from "react"; -import { Loader2, RefreshCw, X } from "lucide-react"; -import { api, useStore } from "@/state/store"; +import { ExternalLink, Loader2, Plus, RefreshCw, Settings2, Trash2, X } from "lucide-react"; +import { api, useStore, type ConfigStatus } from "@/state/store"; import { cn } from "@/lib/cn"; -interface ToolkitCard { - slug: string; - label: string; - blurb: string; - logo: string | null; - domain: string | null; +interface McpServer { + name: string; + transport: "http" | "sse"; + url: string; + enabled: boolean; + hasHeaders: boolean; } -function ServiceIcon({ card }: { card: ToolkitCard }) { - // 0 = official logo, 1 = favicon by domain, 2 = monogram - const [stage, setStage] = useState(card.logo ? 0 : card.domain ? 1 : 2); - if (stage === 0 && card.logo) { - return setStage(1)} />; - } - if (stage === 1 && card.domain) { - return ( - setStage(2)} - /> - ); - } - return ( -
- {card.label.slice(0, 1).toUpperCase()} -
- ); +interface EditingServer extends Omit { + headers: Array<{ key: string; value: string }>; } export function PluginsPanel() { const { dispatch } = useStore(); - const [cards, setCards] = useState(null); - const [source, setSource] = useState<"api" | "curated">("curated"); - const [configured, setConfigured] = useState(true); - const [status, setStatus] = useState>({}); - const [busySlug, setBusySlug] = useState(null); - const [refreshing, setRefreshing] = useState(false); + const [servers, setServers] = useState([]); + const [composioConfigured, setComposioConfigured] = useState(false); + const [editing, setEditing] = useState(null); + const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const [search, setSearch] = useState(""); - - const refreshStatus = useCallback((slugs: string[]): Promise> => { - if (!slugs.length) return Promise.resolve({}); - setRefreshing(true); - return api(`/api/connectors?services=${slugs.join(",")}`) - .then((r) => { - const services: Record = r.services ?? {}; - setStatus(services); - return services; + + const loadServers = useCallback(() => { + api("/api/config") + .then((cfg: ConfigStatus) => { + setServers(cfg.mcpServers ?? []); + setComposioConfigured(cfg.composio.configured); }) - .catch(() => ({})) - .finally(() => setRefreshing(false)); + .catch((e) => setError(e.message)); }, []); useEffect(() => { - let alive = true; - api("/api/connectors/catalog") - .then((r) => { - if (!alive) return; - setCards(r.cards ?? []); - setSource(r.source ?? "curated"); - setConfigured(Boolean(r.configured)); - if (r.configured) void refreshStatus((r.cards ?? []).map((c: ToolkitCard) => c.slug).slice(0, 40)); - }) - .catch((e) => alive && setError(e.message)); - return () => { - alive = false; - }; - }, [refreshStatus]); - - const connect = (slug: string) => { - setBusySlug(slug); + loadServers(); + }, [loadServers]); + + const openEditor = (server?: McpServer) => { + if (server) { + setEditing({ + name: server.name, + transport: server.transport, + url: server.url, + enabled: server.enabled, + headers: [], + }); + } else { + setEditing({ + name: "", + transport: "http", + url: "", + enabled: true, + headers: [], + }); + } setError(null); - api(`/api/connectors/${slug}/authorize`, { method: "POST" }) - .then(({ url }) => { - window.open(url); - // the user finishes OAuth in the browser; poll a few times to catch it. - // check the freshly-fetched result, not the `status` captured in this - // closure β€” that snapshot never updates, so it would always poll 6Γ— - let tries = 0; - const timer = setInterval(() => { - void refreshStatus([slug]).then((s) => { - if (++tries >= 6 || s[slug]?.connected) clearInterval(timer); - }); - }, 5000); - }) - .catch((e) => setError(e.message)) - .finally(() => setBusySlug(null)); }; - const disconnect = (slug: string) => { - setBusySlug(slug); - api(`/api/connectors/${slug}`, { method: "DELETE" }) - .then(() => refreshStatus([slug])) - .catch((e) => setError(e.message)) - .finally(() => setBusySlug(null)); + const closeEditor = () => { + setEditing(null); + setError(null); }; - const visible = (cards ?? []).filter( - (c) => !search || `${c.label} ${c.slug} ${c.blurb}`.toLowerCase().includes(search.toLowerCase()), - ); + const saveServer = async () => { + if (!editing) return; + if (!editing.name.trim() || !editing.url.trim()) { + setError("Name and URL are required"); + return; + } + // Validate name: alphanumeric + dash/underscore only + if (!/^[\w-]+$/.test(editing.name.trim())) { + setError("Name must contain only letters, numbers, dash, and underscore"); + return; + } + + setSaving(true); + setError(null); + try { + // Build the server object with headers only if they exist + const headers: Record = {}; + for (const h of editing.headers) { + if (h.key.trim() && h.value.trim()) { + headers[h.key.trim()] = h.value.trim(); + } + } + + const updatedServers = [...servers]; + const existingIndex = updatedServers.findIndex((s) => s.name === editing.name); + const newServer: McpServer = { + name: editing.name.trim(), + transport: editing.transport, + url: editing.url.trim(), + enabled: editing.enabled, + hasHeaders: Object.keys(headers).length > 0, + }; + + if (existingIndex >= 0) { + updatedServers[existingIndex] = newServer; + } else { + updatedServers.push(newServer); + } + + // Save to backend (with headers in the payload, but they won't be echoed back) + const serverPayload = { + ...newServer, + headers: Object.keys(headers).length ? headers : undefined, + }; + delete (serverPayload as any).hasHeaders; + + const updatedPayload = updatedServers.map((s) => { + if (s.name === newServer.name) return serverPayload; + // For other servers, keep their existing state (we don't have their headers) + const { hasHeaders, ...rest } = s; + return rest; + }); + + await api("/api/config", { + method: "PUT", + body: JSON.stringify({ mcpServers: updatedPayload }), + }); + + loadServers(); + closeEditor(); + } catch (e: any) { + setError(e.message); + } finally { + setSaving(false); + } + }; + + const deleteServer = async (name: string) => { + if (!confirm(`Delete MCP server "${name}"?`)) return; + setSaving(true); + setError(null); + try { + const updated = servers.filter((s) => s.name !== name); + const payload = updated.map(({ hasHeaders, ...rest }) => rest); + await api("/api/config", { + method: "PUT", + body: JSON.stringify({ mcpServers: payload }), + }); + loadServers(); + } catch (e: any) { + setError(e.message); + } finally { + setSaving(false); + } + }; + + const toggleEnabled = async (name: string) => { + setSaving(true); + setError(null); + try { + const updated = servers.map((s) => (s.name === name ? { ...s, enabled: !s.enabled } : s)); + const payload = updated.map(({ hasHeaders, ...rest }) => rest); + await api("/api/config", { + method: "PUT", + body: JSON.stringify({ mcpServers: payload }), + }); + loadServers(); + } catch (e: any) { + setError(e.message); + } finally { + setSaving(false); + } + }; return (
dispatch({ type: "togglePlugins", open: false })} >
e.stopPropagation()} >
-
Connected apps
+
Remote MCP Servers
- Apps your bots can use through Composio Connect. + Connect your own remote MCP servers (HTTP or SSE). Works with Claude.
- {!configured && ( -
- No Composio Connect key yet β€”{" "} - {" "} - to connect apps. -
- )} - {configured && source === "curated" && ( -
- Showing a curated set.{" "} - {" "} - to browse the full catalog. -
- )} - {error &&
{error}
} - - setSearch(e.target.value)} - placeholder="Search apps" - className="mt-3 w-full rounded-lg border border-hairline/40 bg-inset px-3 py-2 text-[13px] text-ink placeholder:text-ink-secondary focus:border-hairline focus:outline-none" - /> - -
- {cards === null ? ( -
- Loading catalog… + {error &&
{error}
} + + {!editing && ( + <> +
+
Your Servers
+
- ) : ( - visible.map((card, i) => { - const connected = status[card.slug]?.connected; - const busy = busySlug === card.slug; - return ( -
0 && "border-t border-hairline/40", - )} - > - -
-
- {card.label} - {connected && } + +
+ {servers.length === 0 ? ( +
+
No MCP servers configured yet
+
+ Add your own HTTP or SSE MCP servers to give your bots access to custom tools. +
+
+ ) : ( + servers.map((server, i) => ( +
0 && "border-t border-hairline/40")} + > +
+
+ {server.name} + {server.enabled && } + {!server.enabled && (disabled)} +
+
+ {server.transport.toUpperCase()} Β· {server.url} + {server.hasHeaders && " Β· has headers"} +
+
+ + + +
+ )) + )} +
+ + {composioConfigured && ( +
+
+
+
Composio Connect
+
+ Connected apps (Slack, GitHub, Gmail, etc.) via Composio
-
{card.blurb}
- + Manage + +
- ); - }) - )} - {cards !== null && visible.length === 0 && ( -
No apps match.
- )} -
+
+ )} + +
+ Need an MCP server? Check out{" "} + + MCP servers directory + + . +
+ + )} + + {editing && ( +
+
+ {servers.find((s) => s.name === editing.name) ? "Edit" : "Add"} MCP Server +
+ +
+ + setEditing({ ...editing, name: e.target.value })} + placeholder="my-mcp-server" + disabled={servers.some((s) => s.name === editing.name)} + className="mt-1 w-full rounded-lg border border-hairline/40 bg-inset px-3 py-2 text-[13px] text-ink placeholder:text-ink-secondary focus:border-hairline focus:outline-none disabled:opacity-50" + /> +
+ Lowercase letters, numbers, dash, and underscore only. Used as the MCP server identifier. +
+
+ +
+ + +
+ +
+ + setEditing({ ...editing, url: e.target.value })} + placeholder="https://api.example.com/mcp" + className="mt-1 w-full rounded-lg border border-hairline/40 bg-inset px-3 py-2 text-[13px] text-ink placeholder:text-ink-secondary focus:border-hairline focus:outline-none" + /> +
+ +
+ +
+ {editing.headers.map((header, i) => ( +
+ { + const updated = [...editing.headers]; + updated[i] = { ...updated[i], key: e.target.value }; + setEditing({ ...editing, headers: updated }); + }} + placeholder="Authorization" + className="flex-1 rounded-lg border border-hairline/40 bg-inset px-3 py-2 text-[12px] text-ink placeholder:text-ink-secondary focus:border-hairline focus:outline-none" + /> + { + const updated = [...editing.headers]; + updated[i] = { ...updated[i], value: e.target.value }; + setEditing({ ...editing, headers: updated }); + }} + placeholder="Bearer token..." + className="flex-1 rounded-lg border border-hairline/40 bg-inset px-3 py-2 text-[12px] text-ink placeholder:text-ink-secondary focus:border-hairline focus:outline-none" + /> + +
+ ))} + +
+
+ Headers are stored securely and never echoed back. Use for API keys, auth tokens, etc. +
+
+ +
+ setEditing({ ...editing, enabled: e.target.checked })} + className="size-4 rounded border-hairline/40" + /> + +
+ +
+ + +
+
+ )}
); diff --git a/src/state/store.tsx b/src/state/store.tsx index 175851bf13..4b09f9976c 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -166,6 +166,14 @@ export interface ConfigStatus { tts?: { configured: boolean; ready: boolean; voice: string }; /** who's using the app β€” collected in onboarding, shown in the sidebar */ profile?: { name: string; email: string }; + /** Custom remote MCP servers: names, urls, and enabled state. Headers are write-only. */ + mcpServers?: Array<{ + name: string; + transport: "http" | "sse"; + url: string; + enabled: boolean; + hasHeaders: boolean; + }>; } /** How an engine gets installed β€” declared by its driver, mirrors