From 1c56c78f76e9b53937fd952ab1541cf861d04ea3 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Sat, 5 Sep 2026 00:26:24 +0300 Subject: [PATCH] feat(composio): key-gated Composio toolkit access over MCP Composio is a hosted catalogue of ~1500 SaaS toolkits (Gmail, Slack, Notion, Linear, Jira, ...) that also brokers each app's OAuth. This wires it in as one more MCP server rather than as a bespoke integration: a tool-router session yields a Streamable-HTTP MCP endpoint authenticated by a static `x-api-key` header, which is exactly the transport src/mcp/ already speaks. No SDK. `@composio/core` would drag a transitive dependency tree into a project that ships a single-file SEA binary, and would duplicate lifecycle, retry, approval and status machinery the MCP client already owns. The whole integration needs one HTTP call. The session model keeps the prompt cost flat: instead of ~1500 toolkits' worth of schemas, four meta-tools land in the registry (SEARCH_TOOLS, GET_TOOL_SCHEMAS, MANAGE_CONNECTIONS, MULTI_EXECUTE_TOOL) and the agent discovers real tools at runtime. Nothing is user-visible yet -- the key-entry UI is the Integrations hub in a follow-up. Set COMPOSIO_API_KEY in /.env to try it. Design notes: - The key is the only gate. No key (or `composio.enabled: false`) and no server is appended, so no Composio tool is ever registered and the model cannot reach one. - The key never enters config.json -- it lives in /.env, matching TELEGRAM_BOT_TOKEN and the web.search.*.apiKeyEnv precedent. Config carries only the switch, the env-var name, and cached session ids. - Failure is soft. Composio being down must not stop the agent from booting; it logs a warning and continues with no server. - `userId` is a minted UUID, persisted, never an email: Composio scopes connected accounts to it, so regenerating it would orphan every app the operator had already authorised. - The remote workbench is disabled explicitly -- it duplicates os.shell.run and would route the operator's shell work through a third-party sandbox. - Trust stays approval_gated. Discovery is still unprompted because mcp-tool-adapter exempts tools annotated readOnlyHint, which is how Composio tags its two discovery tools, while the two mutating tools are tagged destructive and keep hitting the approval gate. Config v50: additive `composio` block; older files inherit defaults that mount nothing. Verified against a live Composio key: server connects `up` with 4 tools registered as mcp.composio.*, the session is cached and reused on the next boot, the key does not appear in config.json, and with no key the runtime reports zero MCP servers and zero mcp.* tools. --- AGENTS.md | 17 +++ .../build-composio-server-config.test.ts | 40 +++++ src/composio/build-composio-server-config.ts | 57 +++++++ src/composio/composio-api.test.ts | 142 ++++++++++++++++++ src/composio/composio-api.ts | 141 +++++++++++++++++ src/composio/ensure-composio-session.test.ts | 119 +++++++++++++++ src/composio/ensure-composio-session.ts | 93 ++++++++++++ src/composio/index.ts | 42 ++++++ src/composio/persist-composio-session.ts | 61 ++++++++ src/composio/resolve-composio-key.test.ts | 53 +++++++ src/composio/resolve-composio-key.ts | 41 +++++ src/composio/resolve-composio-server.test.ts | 142 ++++++++++++++++++ src/composio/resolve-composio-server.ts | 79 ++++++++++ src/config/config-schema.test.ts | 49 ++++++ src/config/config-schema.ts | 99 +++++++++++- src/config/load-config.ts | 7 + src/runtime/bootstrap.ts | 17 ++- 17 files changed, 1197 insertions(+), 2 deletions(-) create mode 100644 src/composio/build-composio-server-config.test.ts create mode 100644 src/composio/build-composio-server-config.ts create mode 100644 src/composio/composio-api.test.ts create mode 100644 src/composio/composio-api.ts create mode 100644 src/composio/ensure-composio-session.test.ts create mode 100644 src/composio/ensure-composio-session.ts create mode 100644 src/composio/index.ts create mode 100644 src/composio/persist-composio-session.ts create mode 100644 src/composio/resolve-composio-key.test.ts create mode 100644 src/composio/resolve-composio-key.ts create mode 100644 src/composio/resolve-composio-server.test.ts create mode 100644 src/composio/resolve-composio-server.ts diff --git a/AGENTS.md b/AGENTS.md index d5bad7d7..9d17b555 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1766,6 +1766,23 @@ Locked invariants (pinned by [src/tui/mcp/mcp-reducer.test.ts](src/tui/mcp/mcp-r 4. **The editor is disabled on the MCP tab while a modal is open.** `mcpTabBusy` in `app-key-bindings.ts` covers both `addModal !== null` (lets the `MultiLineEditor` capture every keystroke) and `removeConfirm !== null` (claims the `y`/`n` confirmation keys against the global nav cycler). 5. **Variant γ surface is opt-in but on by default.** Restarting the runtime is no longer required after add/remove — the prompt's `### tools` catalog and GBNF grammar are rebuilt on the next step. KV-cache for in-flight sessions is invalidated once per add/remove (the persona stays byte-stable; only the rendered tools block changes). +## Composio (hosted toolkits) + +[Composio](https://composio.dev) is a hosted catalogue of ~1500 SaaS toolkits (Gmail, Slack, Notion, Linear, Jira, …) that also brokers each app's OAuth. atomic-agent consumes it as **one more MCP server** rather than as a bespoke integration: a tool-router session yields a Streamable-HTTP MCP endpoint authenticated by a static `x-api-key` header, which is exactly the transport [src/mcp/](src/mcp/) already speaks. Code lives in [src/composio/](src/composio/); the cold-path wiring is a single `await resolveComposioServerConfig(...)` in [src/runtime/bootstrap.ts](src/runtime/bootstrap.ts) that appends at most one entry to the server list before `McpManager` is constructed. + +### Why an MCP server and not an SDK + +`@composio/core` would drag a transitive dependency tree into a project that ships a single-file SEA binary, and would duplicate lifecycle, retry, approval and status machinery `src/mcp/` already owns. The whole integration needs exactly one HTTP call. Composio's session model does the rest: instead of loading ~1500 toolkits' worth of schemas, the session exposes four **meta-tools** — `COMPOSIO_SEARCH_TOOLS` (find a tool by use-case), `COMPOSIO_GET_TOOL_SCHEMAS`, `COMPOSIO_MANAGE_CONNECTIONS` (returns an OAuth Connect Link mid-conversation), `COMPOSIO_MULTI_EXECUTE_TOOL` — so the stable-prefix cost is four tools, flat, regardless of catalogue size. + +Locked invariants (pinned by [src/composio/resolve-composio-server.test.ts](src/composio/resolve-composio-server.test.ts), [src/composio/ensure-composio-session.test.ts](src/composio/ensure-composio-session.test.ts), [src/composio/build-composio-server-config.test.ts](src/composio/build-composio-server-config.test.ts)): + +1. **The API key is the only gate.** With no key resolvable (or `composio.enabled: false`), `resolveComposioServerConfig` returns `undefined`, no server is appended, no tool is registered, and the model cannot see or call anything Composio-related. There is no second switch and no partial state. +2. **The key never enters `config.json`.** It lives in `/.env` under the name in `composio.apiKeyEnv` (default `COMPOSIO_API_KEY`), written 0600 through `setDotenvKey` — the same split as `TELEGRAM_BOT_TOKEN` and the `web.search.*.apiKeyEnv` precedent. `config.composio` carries only the switch, the env-var *name*, and cached session ids. +3. **Failure is soft.** Composio unreachable, rate-limiting, or rejecting a stale key logs a warning and boots without it. A third-party SaaS broker must never stand between the operator and their own shell, files and browser. +4. **`userId` is a minted UUID, persisted, and never an email.** Composio scopes connected accounts to it, so regenerating it silently orphans every app the operator has already authorised. An email would also hand PII to a third party for no benefit. +5. **The workbench stays disabled.** `createComposioSession` always posts `workbench: { enable: false }`, dropping `COMPOSIO_REMOTE_WORKBENCH` / `COMPOSIO_REMOTE_BASH_TOOL`. They duplicate `os.shell.run` and would quietly route the operator's shell work through a third-party sandbox. +6. **Trust stays `approval_gated`.** Discovery is still unprompted, because `mcp-tool-adapter.ts` exempts tools annotated `readOnlyHint === true` and Composio tags `COMPOSIO_SEARCH_TOOLS` / `COMPOSIO_GET_TOOL_SCHEMAS` exactly that way, while tagging `COMPOSIO_MULTI_EXECUTE_TOOL` / `COMPOSIO_MANAGE_CONNECTIONS` destructive. Every write to a real SaaS account therefore hits the approval gate, and the seamlessness costs nothing in consent. Loosening the server's trust to `pure_read` would un-gate the writes too — do not. + ## Project path resolution (`os.fs.locate_project`) Issue #77: users mention projects by name ("check my raylib project") instead of full paths. The read-only tool `os.fs.locate_project { name, limit? }` ([src/tools/os/fs-locate-project.ts](src/tools/os/fs-locate-project.ts) + [fs-locate-project-sources.ts](src/tools/os/fs-locate-project-sources.ts), registered with the other OS tools in `registerOsTools` — bootstrap constructs the `SessionStore` first and supplies the column-only projection through `RegisterOsToolsOptions.listRecentSessionDirs`) first takes a **direct-path fast path** — a pasted absolute path (`e:/_raylib`, `~/dev/app`) that exists as a directory is returned immediately (source `direct-path`) — and otherwise resolves the mention against three bounded sources, in priority order. The `name` argument is a short folder-name segment (the descriptor + examples teach the model to pass `raylib`, never the whole sentence — matching is per-basename, not phrase-tokenized): diff --git a/src/composio/build-composio-server-config.test.ts b/src/composio/build-composio-server-config.test.ts new file mode 100644 index 00000000..b0b01517 --- /dev/null +++ b/src/composio/build-composio-server-config.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { + COMPOSIO_SERVER_NAME, + buildComposioServerConfig, +} from "./build-composio-server-config.js"; +import { MCP_SERVER_NAME_RE } from "../mcp/mcp-types.js"; + +const SESSION = { + mcpUrl: "https://backend.composio.dev/tool_router/trs_abc/mcp", +}; + +describe("buildComposioServerConfig", () => { + it("produces a streamable-http server carrying the key as x-api-key", () => { + const cfg = buildComposioServerConfig({ session: SESSION, apiKey: "ak_9" }); + expect(cfg.name).toBe(COMPOSIO_SERVER_NAME); + expect(cfg.enabled).toBe(true); + expect(cfg.transport).toEqual({ + kind: "streamable_http", + url: SESSION.mcpUrl, + headers: { "x-api-key": "ak_9" }, + }); + }); + + it("stays at the fail-closed approval_gated trust level", () => { + // Discovery still flows without prompting: the adapter skips the + // gate for tools the server annotates readOnlyHint, which is how + // Composio tags COMPOSIO_SEARCH_TOOLS / GET_TOOL_SCHEMAS. Loosening + // trust here would also un-gate MULTI_EXECUTE and MANAGE_CONNECTIONS. + expect( + buildComposioServerConfig({ session: SESSION, apiKey: "ak" }).trust, + ).toBe("approval_gated"); + }); + + it("uses a server name the MCP namespace accepts", () => { + // Tools are addressed as mcp.composio.; an invalid name would + // break tool dispatch and the GBNF string literal alike. + expect(MCP_SERVER_NAME_RE.test(COMPOSIO_SERVER_NAME)).toBe(true); + }); +}); diff --git a/src/composio/build-composio-server-config.ts b/src/composio/build-composio-server-config.ts new file mode 100644 index 00000000..67784049 --- /dev/null +++ b/src/composio/build-composio-server-config.ts @@ -0,0 +1,57 @@ +/** + * Project a Composio session onto the neutral `McpServerConfig` the + * existing MCP manager already knows how to run. + * + * This is the whole integration seam: Composio's hosted tool router + * speaks Streamable HTTP MCP and authenticates with a static header, + * which is exactly the transport `src/mcp/` already supports. No new + * tool code, no new transport, no OAuth client — the agent treats + * Composio as one more MCP server. + */ + +import { + COMPOSIO_API_KEY_HEADER, + type ComposioSession, +} from "./composio-api.js"; +import type { McpServerConfig } from "../mcp/mcp-types.js"; + +/** + * Reserved server name. Tools land as `mcp.composio.`; the name + * satisfies `MCP_SERVER_NAME_RE` and is stable so cached approvals and + * transcripts keep resolving across restarts. + */ +export const COMPOSIO_SERVER_NAME = "composio"; + +export interface BuildComposioServerConfigOptions { + session: Pick; + apiKey: string; +} + +/** + * Build the synthetic server entry. + * + * `trust` stays `approval_gated` — the fail-closed default for any + * third party. That is not the same as "every call prompts": the + * adapter in `mcp-tool-adapter.ts` skips the gate for tools the + * server annotates `readOnlyHint: true`, and Composio tags its two + * discovery tools (`COMPOSIO_SEARCH_TOOLS`, `COMPOSIO_GET_TOOL_SCHEMAS`) + * exactly that way while tagging `COMPOSIO_MULTI_EXECUTE_TOOL` and + * `COMPOSIO_MANAGE_CONNECTIONS` destructive. Discovery therefore flows + * silently and every write to a real account still hits the approval + * gate — the seamlessness the feature is for, without loosening trust. + */ +export function buildComposioServerConfig( + opts: BuildComposioServerConfigOptions, +): McpServerConfig { + return { + name: COMPOSIO_SERVER_NAME, + description: "Composio hosted toolkits (1500+ SaaS apps)", + enabled: true, + trust: "approval_gated", + transport: { + kind: "streamable_http", + url: opts.session.mcpUrl, + headers: { [COMPOSIO_API_KEY_HEADER]: opts.apiKey }, + }, + }; +} diff --git a/src/composio/composio-api.test.ts b/src/composio/composio-api.test.ts new file mode 100644 index 00000000..c07bc336 --- /dev/null +++ b/src/composio/composio-api.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + COMPOSIO_API_KEY_HEADER, + ComposioApiError, + createComposioSession, + parseSessionResponse, +} from "./composio-api.js"; + +/** Shape of a real 201 body, trimmed to the fields we read. */ +const OK_BODY = { + session_id: "trs_abc123", + mcp: { + type: "http", + url: "https://backend.composio.dev/tool_router/trs_abc123/mcp", + }, + tool_router_tools: [ + "COMPOSIO_SEARCH_TOOLS", + "COMPOSIO_GET_TOOL_SCHEMAS", + "COMPOSIO_MANAGE_CONNECTIONS", + "COMPOSIO_MULTI_EXECUTE_TOOL", + ], + experimental: { assistive_prompt: "use the meta-tools" }, +}; + +function jsonResponse(body: unknown, status = 201): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("parseSessionResponse", () => { + it("reads the session id, mcp url, tool names and assistive prompt", () => { + const session = parseSessionResponse(OK_BODY); + expect(session.sessionId).toBe("trs_abc123"); + expect(session.mcpUrl).toBe( + "https://backend.composio.dev/tool_router/trs_abc123/mcp", + ); + expect(session.toolNames).toHaveLength(4); + expect(session.assistivePrompt).toBe("use the meta-tools"); + }); + + it("omits the assistive prompt when the API does not send one", () => { + const { experimental: _drop, ...rest } = OK_BODY; + expect(parseSessionResponse(rest).assistivePrompt).toBeUndefined(); + }); + + it("rejects a body with no session id", () => { + expect(() => parseSessionResponse({ mcp: { url: "x" } })).toThrow( + ComposioApiError, + ); + }); + + it("rejects a body with no mcp url", () => { + expect(() => parseSessionResponse({ session_id: "trs_x" })).toThrow( + ComposioApiError, + ); + }); + + it("rejects a non-object body", () => { + expect(() => parseSessionResponse("nope")).toThrow(ComposioApiError); + }); +}); + +describe("createComposioSession", () => { + it("posts the user id, disables the workbench, and sends x-api-key", async () => { + const fetchMock = vi.fn(async () => jsonResponse(OK_BODY)); + vi.stubGlobal("fetch", fetchMock); + + const session = await createComposioSession({ + apiKey: "ak_test", + userId: "11111111-2222-3333-4444-555555555555", + baseUrl: "https://example.test/api/v3.1", + }); + + expect(session.sessionId).toBe("trs_abc123"); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://example.test/api/v3.1/tool_router/session"); + expect(init.method).toBe("POST"); + expect( + (init.headers as Record)[COMPOSIO_API_KEY_HEADER], + ).toBe("ak_test"); + // The remote workbench duplicates os.shell.run and would route the + // operator's shell work through a third party — it stays off. + expect(JSON.parse(init.body as string)).toEqual({ + user_id: "11111111-2222-3333-4444-555555555555", + workbench: { enable: false }, + }); + }); + + it("reports a rejected key distinctly from other failures", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({}, 401))); + await expect( + createComposioSession({ apiKey: "bad", userId: "u" }), + ).rejects.toMatchObject({ name: "ComposioApiError", status: 401 }); + await expect( + createComposioSession({ apiKey: "bad", userId: "u" }), + ).rejects.toThrow(/rejected the API key/); + }); + + it("surfaces a server-side failure with its status", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({}, 503))); + await expect( + createComposioSession({ apiKey: "ak", userId: "u" }), + ).rejects.toMatchObject({ name: "ComposioApiError", status: 503 }); + }); + + it("translates a transport failure into a ComposioApiError", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }), + ); + await expect( + createComposioSession({ apiKey: "ak", userId: "u" }), + ).rejects.toThrow(/Could not reach Composio/); + }); + + it("lets a caller-side abort through untranslated", async () => { + const controller = new AbortController(); + controller.abort(); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("The operation was aborted"); + }), + ); + await expect( + createComposioSession({ + apiKey: "ak", + userId: "u", + signal: controller.signal, + }), + ).rejects.not.toBeInstanceOf(ComposioApiError); + }); +}); diff --git a/src/composio/composio-api.ts b/src/composio/composio-api.ts new file mode 100644 index 00000000..f0650c59 --- /dev/null +++ b/src/composio/composio-api.ts @@ -0,0 +1,141 @@ +/** + * Minimal Composio REST client — the only file that talks to + * `backend.composio.dev` over HTTP. + * + * atomic-agent deliberately does **not** depend on `@composio/core`. + * The whole integration needs exactly one call (create a tool-router + * session), and the SDK would drag a transitive dependency tree into + * a project that ships a single-file SEA binary. Everything past this + * module speaks the neutral shapes declared here. + * + * The session endpoint returns a hosted MCP URL; from that point on + * the existing MCP client in `src/mcp/` does all the work — see + * `buildComposioServerConfig`. + */ + +/** Public Composio API root. Overridable per call so tests never go out. */ +export const COMPOSIO_API_BASE = "https://backend.composio.dev/api/v3.1"; + +/** Header Composio authenticates with. Not `Authorization: Bearer`. */ +export const COMPOSIO_API_KEY_HEADER = "x-api-key"; + +/** + * A created tool-router session: a durable, user-scoped handle whose + * `mcpUrl` speaks Streamable HTTP MCP. + */ +export interface ComposioSession { + /** Composio's session handle, `trs_…`. Cached in config for reuse. */ + sessionId: string; + /** Streamable-HTTP MCP endpoint for this session. */ + mcpUrl: string; + /** Meta-tool names the session exposes (4 with the workbench off). */ + toolNames: readonly string[]; + /** + * Composio's own guidance on driving the meta-tools, returned under + * `experimental.assistive_prompt`. Absent on older API revisions — + * `src/prompt/` falls back to its own copy when this is undefined. + */ + assistivePrompt?: string; +} + +export class ComposioApiError extends Error { + readonly status: number; + constructor(message: string, status: number) { + super(message); + this.name = "ComposioApiError"; + this.status = status; + } +} + +export interface CreateSessionOptions { + apiKey: string; + /** Stable per-install id. Scopes connected accounts; never an email. */ + userId: string; + baseUrl?: string; + signal?: AbortSignal; + timeoutMs?: number; +} + +/** + * Create a tool-router session and return its MCP endpoint. + * + * The workbench is disabled explicitly: Composio would otherwise + * expose `COMPOSIO_REMOTE_WORKBENCH` / `COMPOSIO_REMOTE_BASH_TOOL`, + * a remote sandbox that duplicates `os.shell.run` and would quietly + * route the operator's shell work through a third party. + */ +export async function createComposioSession( + opts: CreateSessionOptions, +): Promise { + const base = opts.baseUrl ?? COMPOSIO_API_BASE; + const timeout = AbortSignal.timeout(opts.timeoutMs ?? 20_000); + let res: Response; + try { + res = await fetch(`${base}/tool_router/session`, { + method: "POST", + headers: { + [COMPOSIO_API_KEY_HEADER]: opts.apiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + user_id: opts.userId, + workbench: { enable: false }, + }), + signal: opts.signal + ? AbortSignal.any([opts.signal, timeout]) + : timeout, + }); + } catch (err) { + // A caller-side cancel is not a Composio outage — let it through + // untranslated so the pane that cancelled can tell them apart. + if (opts.signal?.aborted) throw err; + throw new ComposioApiError( + `Could not reach Composio: ${err instanceof Error ? err.message : String(err)}`, + 0, + ); + } + if (res.status === 401 || res.status === 403) { + throw new ComposioApiError( + `Composio rejected the API key (HTTP ${res.status}). Check the key in the Integrations tab.`, + res.status, + ); + } + if (!res.ok) { + throw new ComposioApiError( + `Composio returned HTTP ${res.status} ${res.statusText}.`, + res.status, + ); + } + return parseSessionResponse(await res.json()); +} + +/** Narrow the untyped JSON body into `ComposioSession`. */ +export function parseSessionResponse(body: unknown): ComposioSession { + if (typeof body !== "object" || body === null) { + throw new ComposioApiError("Composio returned a non-object session body.", 0); + } + const obj = body as Record; + const sessionId = obj.session_id; + if (typeof sessionId !== "string" || sessionId.length === 0) { + throw new ComposioApiError("Composio session response carried no session_id.", 0); + } + const mcp = obj.mcp as Record | undefined; + const mcpUrl = mcp?.url; + if (typeof mcpUrl !== "string" || mcpUrl.length === 0) { + throw new ComposioApiError("Composio session response carried no mcp.url.", 0); + } + const rawTools = obj.tool_router_tools; + const toolNames = Array.isArray(rawTools) + ? rawTools.filter((t): t is string => typeof t === "string") + : []; + const experimental = obj.experimental as Record | undefined; + const assistivePrompt = experimental?.assistive_prompt; + return { + sessionId, + mcpUrl, + toolNames, + ...(typeof assistivePrompt === "string" && assistivePrompt.length > 0 + ? { assistivePrompt } + : {}), + }; +} diff --git a/src/composio/ensure-composio-session.test.ts b/src/composio/ensure-composio-session.test.ts new file mode 100644 index 00000000..857f694e --- /dev/null +++ b/src/composio/ensure-composio-session.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ensureComposioSession } from "./ensure-composio-session.js"; +import type { ComposioSession } from "./composio-api.js"; + +const FRESH: ComposioSession = { + sessionId: "trs_new", + mcpUrl: "https://backend.composio.dev/tool_router/trs_new/mcp", + toolNames: ["COMPOSIO_SEARCH_TOOLS"], +}; + +const EMPTY_CACHE = { userId: null, sessionId: null, mcpUrl: null }; + +describe("ensureComposioSession", () => { + it("reuses a cached session without calling the API", async () => { + const createSession = vi.fn(); + const persist = vi.fn(); + const out = await ensureComposioSession({ + apiKey: "ak", + cache: { + userId: "user-1", + sessionId: "trs_cached", + mcpUrl: "https://example.test/mcp", + }, + persist, + createSession, + }); + expect(createSession).not.toHaveBeenCalled(); + expect(persist).not.toHaveBeenCalled(); + expect(out).toMatchObject({ + sessionId: "trs_cached", + mcpUrl: "https://example.test/mcp", + userId: "user-1", + created: false, + }); + }); + + it("mints a UUID user id on first use and persists it", async () => { + const createSession = vi.fn(async () => FRESH); + const persist = vi.fn(); + const out = await ensureComposioSession({ + apiKey: "ak", + cache: EMPTY_CACHE, + persist, + createSession, + }); + expect(out.created).toBe(true); + expect(out.userId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + expect(createSession).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: "ak", userId: out.userId }), + ); + expect(persist).toHaveBeenCalledWith({ + userId: out.userId, + sessionId: "trs_new", + mcpUrl: FRESH.mcpUrl, + }); + }); + + it("keeps an existing user id when only the session is missing", async () => { + // Connected accounts hang off the user id — regenerating it would + // silently orphan every app the operator has already authorised. + const createSession = vi.fn(async () => FRESH); + const out = await ensureComposioSession({ + apiKey: "ak", + cache: { userId: "user-keep", sessionId: null, mcpUrl: null }, + persist: vi.fn(), + createSession, + }); + expect(out.userId).toBe("user-keep"); + expect(createSession).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-keep" }), + ); + }); + + it("re-creates the session when forceRefresh is set", async () => { + const createSession = vi.fn(async () => FRESH); + const out = await ensureComposioSession({ + apiKey: "ak", + cache: { + userId: "user-1", + sessionId: "trs_stale", + mcpUrl: "https://example.test/mcp", + }, + persist: vi.fn(), + forceRefresh: true, + createSession, + }); + expect(createSession).toHaveBeenCalledOnce(); + expect(out.sessionId).toBe("trs_new"); + expect(out.userId).toBe("user-1"); + }); + + it("treats a half-written cache as a miss", async () => { + // A sessionId with no url (or vice versa) cannot be mounted; minting + // a fresh session beats booting with an unusable transport. + const createSession = vi.fn(async () => FRESH); + await ensureComposioSession({ + apiKey: "ak", + cache: { userId: "u", sessionId: "trs_x", mcpUrl: null }, + persist: vi.fn(), + createSession, + }); + expect(createSession).toHaveBeenCalledOnce(); + }); + + it("treats blank cached strings as absent", async () => { + const createSession = vi.fn(async () => FRESH); + const out = await ensureComposioSession({ + apiKey: "ak", + cache: { userId: " ", sessionId: " ", mcpUrl: " " }, + persist: vi.fn(), + createSession, + }); + expect(createSession).toHaveBeenCalledOnce(); + expect(out.userId).not.toBe(" "); + }); +}); diff --git a/src/composio/ensure-composio-session.ts b/src/composio/ensure-composio-session.ts new file mode 100644 index 00000000..7eae2996 --- /dev/null +++ b/src/composio/ensure-composio-session.ts @@ -0,0 +1,93 @@ +/** + * Reuse-or-create the Composio tool-router session. + * + * Two things must stay stable across restarts: + * + * - the **user id**, because Composio scopes connected accounts to it. + * Mint it once and keep it, or every restart would ask the operator + * to re-authorise Gmail. It is a random UUID, never the operator's + * email — Composio's own docs advise against emails as user ids, and + * an email is PII this integration has no reason to hand over. + * - the **session id**, so a normal boot costs zero API calls. + * + * A cached session that Composio has since dropped surfaces as a failed + * MCP connect, not as an error here; `forceRefresh` is how the + * Integrations pane asks for a new one. + */ + +import { randomUUID } from "node:crypto"; + +import { + createComposioSession, + type ComposioSession, +} from "./composio-api.js"; + +/** The three values persisted in `config.composio`. */ +export interface ComposioSessionCache { + userId: string | null; + sessionId: string | null; + mcpUrl: string | null; +} + +export interface EnsuredComposioSession extends ComposioSession { + userId: string; + /** False when the cache was reused and no API call was made. */ + created: boolean; +} + +export interface EnsureComposioSessionOptions { + apiKey: string; + cache: ComposioSessionCache; + /** Persist a newly minted id/session. Not called on a cache hit. */ + persist: (next: { + userId: string; + sessionId: string; + mcpUrl: string; + }) => Promise | void; + /** Discard the cached session and create a fresh one. */ + forceRefresh?: boolean; + baseUrl?: string; + signal?: AbortSignal; + timeoutMs?: number; + /** Seam for tests; defaults to the real HTTP call. */ + createSession?: typeof createComposioSession; +} + +export async function ensureComposioSession( + opts: EnsureComposioSessionOptions, +): Promise { + const userId = normalizeId(opts.cache.userId) ?? randomUUID(); + const cachedSessionId = normalizeId(opts.cache.sessionId); + const cachedUrl = normalizeId(opts.cache.mcpUrl); + + if (!opts.forceRefresh && cachedSessionId && cachedUrl) { + return { + sessionId: cachedSessionId, + mcpUrl: cachedUrl, + toolNames: [], + userId, + created: false, + }; + } + + const create = opts.createSession ?? createComposioSession; + const session = await create({ + apiKey: opts.apiKey, + userId, + ...(opts.baseUrl === undefined ? {} : { baseUrl: opts.baseUrl }), + ...(opts.signal === undefined ? {} : { signal: opts.signal }), + ...(opts.timeoutMs === undefined ? {} : { timeoutMs: opts.timeoutMs }), + }); + await opts.persist({ + userId, + sessionId: session.sessionId, + mcpUrl: session.mcpUrl, + }); + return { ...session, userId, created: true }; +} + +function normalizeId(value: string | null): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} diff --git a/src/composio/index.ts b/src/composio/index.ts new file mode 100644 index 00000000..04babd8f --- /dev/null +++ b/src/composio/index.ts @@ -0,0 +1,42 @@ +/** + * Composio integration — hosted access to 1500+ SaaS toolkits, mounted + * through the existing MCP client. See `AGENTS.md` §"Composio". + */ + +export { + COMPOSIO_API_BASE, + COMPOSIO_API_KEY_HEADER, + ComposioApiError, + createComposioSession, + parseSessionResponse, +} from "./composio-api.js"; +export type { + ComposioSession, + CreateSessionOptions, +} from "./composio-api.js"; +export { + COMPOSIO_SERVER_NAME, + buildComposioServerConfig, +} from "./build-composio-server-config.js"; +export type { BuildComposioServerConfigOptions } from "./build-composio-server-config.js"; +export { + COMPOSIO_API_KEY_ENV, + resolveComposioApiKey, +} from "./resolve-composio-key.js"; +export type { ResolveComposioKeyOptions } from "./resolve-composio-key.js"; +export { ensureComposioSession } from "./ensure-composio-session.js"; +export type { + ComposioSessionCache, + EnsuredComposioSession, + EnsureComposioSessionOptions, +} from "./ensure-composio-session.js"; +export { + persistComposioSession, + clearComposioSession, +} from "./persist-composio-session.js"; +export type { ComposioSessionRecord } from "./persist-composio-session.js"; +export { resolveComposioServerConfig } from "./resolve-composio-server.js"; +export type { + ComposioResolveLogger, + ResolveComposioServerOptions, +} from "./resolve-composio-server.js"; diff --git a/src/composio/persist-composio-session.ts b/src/composio/persist-composio-session.ts new file mode 100644 index 00000000..5f8b6fae --- /dev/null +++ b/src/composio/persist-composio-session.ts @@ -0,0 +1,61 @@ +/** + * Write the minted user id and cached tool-router session back to + * `/config.json`. + * + * Follows `persistMcpServer`: re-validate the whole file through + * `parseUserConfigFile` before writing, so a stale on-disk schema + * cannot smuggle invalid state in on the back of this edit, then + * drop the config cache so the next `getConfig()` sees the change. + * + * The API key is never touched here — it lives only in + * `/.env`. + */ + +import { + ensureUserConfigFileSync, + parseUserConfigFile, + resetConfigCache, + writeUserConfigFileSync, +} from "../config/index.js"; + +export interface ComposioSessionRecord { + userId: string; + sessionId: string; + mcpUrl: string; +} + +/** Persist the session triple. Returns the path written. */ +export function persistComposioSession( + configPath: string, + record: ComposioSessionRecord, +): string { + const prev = ensureUserConfigFileSync(configPath); + const draft = { + ...prev, + composio: { + ...prev.composio, + userId: record.userId, + sessionId: record.sessionId, + mcpUrl: record.mcpUrl, + }, + }; + const validated = parseUserConfigFile(draft); + writeUserConfigFileSync(configPath, validated); + resetConfigCache(); + return configPath; +} + +/** + * Forget the cached session (but keep the user id, which is what + * connected accounts hang off). Used when the operator clears or + * replaces the key: the old session belonged to the old key. + */ +export function clearComposioSession(configPath: string): void { + const prev = ensureUserConfigFileSync(configPath); + const draft = { + ...prev, + composio: { ...prev.composio, sessionId: null, mcpUrl: null }, + }; + writeUserConfigFileSync(configPath, parseUserConfigFile(draft)); + resetConfigCache(); +} diff --git a/src/composio/resolve-composio-key.test.ts b/src/composio/resolve-composio-key.test.ts new file mode 100644 index 00000000..e1ce5e5e --- /dev/null +++ b/src/composio/resolve-composio-key.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { + COMPOSIO_API_KEY_ENV, + resolveComposioApiKey, +} from "./resolve-composio-key.js"; + +describe("resolveComposioApiKey", () => { + it("reads the default env var", () => { + expect( + resolveComposioApiKey({ env: { [COMPOSIO_API_KEY_ENV]: "ak_1" } }), + ).toBe("ak_1"); + }); + + it("honours a custom env var name from config", () => { + expect( + resolveComposioApiKey({ + apiKeyEnv: "MY_COMPOSIO", + env: { MY_COMPOSIO: "ak_2", [COMPOSIO_API_KEY_ENV]: "ak_wrong" }, + }), + ).toBe("ak_2"); + }); + + it("trims surrounding whitespace from a pasted key", () => { + expect( + resolveComposioApiKey({ env: { [COMPOSIO_API_KEY_ENV]: " ak_3\n" } }), + ).toBe("ak_3"); + }); + + it("treats an unset var as unconfigured", () => { + expect(resolveComposioApiKey({ env: {} })).toBeUndefined(); + }); + + it("treats an empty or whitespace-only var as unconfigured", () => { + // A blank value is how clearing the key lands in .env; it must read + // as "no key" rather than as an empty key that fails at connect. + expect( + resolveComposioApiKey({ env: { [COMPOSIO_API_KEY_ENV]: "" } }), + ).toBeUndefined(); + expect( + resolveComposioApiKey({ env: { [COMPOSIO_API_KEY_ENV]: " " } }), + ).toBeUndefined(); + }); + + it("falls back to the default name when config passes an empty one", () => { + expect( + resolveComposioApiKey({ + apiKeyEnv: "", + env: { [COMPOSIO_API_KEY_ENV]: "ak_4" }, + }), + ).toBe("ak_4"); + }); +}); diff --git a/src/composio/resolve-composio-key.ts b/src/composio/resolve-composio-key.ts new file mode 100644 index 00000000..745949b1 --- /dev/null +++ b/src/composio/resolve-composio-key.ts @@ -0,0 +1,41 @@ +/** + * Resolve the Composio API key. + * + * Mirrors `resolveLlmProviderApiKey` and the Telegram bot token: the + * secret never lives in `config.json`, only in `/.env` + * (0600), which `loadDotenvFromStateDir` has already folded into + * `process.env` by the time bootstrap runs. Config carries the *name* + * of the variable, following the `web.search.exa.apiKeyEnv` + * precedent, so an operator can point at their own variable. + */ + +/** Default env var the Integrations tab writes to. */ +export const COMPOSIO_API_KEY_ENV = "COMPOSIO_API_KEY"; + +export interface ResolveComposioKeyOptions { + /** Env var name from `config.composio.apiKeyEnv`. */ + apiKeyEnv?: string; + /** Injectable for tests; defaults to `process.env`. */ + env?: NodeJS.ProcessEnv; +} + +/** + * Return the configured key, or `undefined` when unset. + * + * `undefined` is the integration's only gate: bootstrap mounts no + * Composio MCP server without it, so no Composio tool is ever + * registered and none can be called. + */ +export function resolveComposioApiKey( + opts: ResolveComposioKeyOptions = {}, +): string | undefined { + const env = opts.env ?? process.env; + const name = + opts.apiKeyEnv && opts.apiKeyEnv.length > 0 + ? opts.apiKeyEnv + : COMPOSIO_API_KEY_ENV; + const value = env[name]; + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} diff --git a/src/composio/resolve-composio-server.test.ts b/src/composio/resolve-composio-server.test.ts new file mode 100644 index 00000000..0acf9cd3 --- /dev/null +++ b/src/composio/resolve-composio-server.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { resolveComposioServerConfig } from "./resolve-composio-server.js"; +import type { ComposioConfig } from "../config/config-schema.js"; + +// The gate must be decidable without touching /config.json. +const persistMock = vi.hoisted(() => vi.fn()); +vi.mock("./persist-composio-session.js", () => ({ + persistComposioSession: persistMock, + clearComposioSession: vi.fn(), +})); + +const CACHED: ComposioConfig = { + enabled: true, + apiKeyEnv: "COMPOSIO_API_KEY", + userId: "user-1", + sessionId: "trs_cached", + mcpUrl: "https://backend.composio.dev/tool_router/trs_cached/mcp", +}; + +function logger() { + return { warn: vi.fn(), info: vi.fn() }; +} + +beforeEach(() => { + persistMock.mockClear(); + vi.unstubAllGlobals(); +}); + +describe("resolveComposioServerConfig", () => { + it("mounts nothing when no key is configured", async () => { + // This is the whole product requirement: with no key there is no + // server, so no Composio tool is ever registered and the model + // cannot reach one. + await expect( + resolveComposioServerConfig({ + composio: CACHED, + userConfigFile: "/nonexistent/config.json", + env: {}, + }), + ).resolves.toBeUndefined(); + }); + + it("mounts nothing when the key is present but the block is disabled", async () => { + await expect( + resolveComposioServerConfig({ + composio: { ...CACHED, enabled: false }, + userConfigFile: "/nonexistent/config.json", + env: { COMPOSIO_API_KEY: "ak_live" }, + }), + ).resolves.toBeUndefined(); + }); + + it("mounts the cached session when a key is present", async () => { + const cfg = await resolveComposioServerConfig({ + composio: CACHED, + userConfigFile: "/nonexistent/config.json", + env: { COMPOSIO_API_KEY: "ak_live" }, + }); + expect(cfg?.name).toBe("composio"); + expect(cfg?.transport).toMatchObject({ + kind: "streamable_http", + url: CACHED.mcpUrl, + headers: { "x-api-key": "ak_live" }, + }); + expect(persistMock).not.toHaveBeenCalled(); + }); + + it("degrades to no server, not a boot failure, when Composio is down", async () => { + // A third-party SaaS broker being unreachable has nothing to do with + // the operator's shell, files or browser — the agent must still boot. + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }), + ); + const log = logger(); + await expect( + resolveComposioServerConfig({ + composio: { ...CACHED, sessionId: null, mcpUrl: null }, + userConfigFile: "/nonexistent/config.json", + env: { COMPOSIO_API_KEY: "ak_live" }, + logger: log, + }), + ).resolves.toBeUndefined(); + expect(log.warn).toHaveBeenCalledWith( + "composio unavailable; continuing without it", + expect.objectContaining({ error: expect.stringContaining("ECONNREFUSED") }), + ); + }); + + it("degrades to no server when the key is rejected", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("{}", { status: 401 })), + ); + const log = logger(); + await expect( + resolveComposioServerConfig({ + composio: { ...CACHED, sessionId: null, mcpUrl: null }, + userConfigFile: "/nonexistent/config.json", + env: { COMPOSIO_API_KEY: "ak_bad" }, + logger: log, + }), + ).resolves.toBeUndefined(); + expect(log.warn).toHaveBeenCalled(); + }); + + it("creates and persists a session when the cache is empty", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + session_id: "trs_new", + mcp: { url: "https://example.test/tool_router/trs_new/mcp" }, + tool_router_tools: ["COMPOSIO_SEARCH_TOOLS"], + }), + { status: 201 }, + ), + ), + ); + const log = logger(); + const cfg = await resolveComposioServerConfig({ + composio: { ...CACHED, sessionId: null, mcpUrl: null }, + userConfigFile: "/tmp/config.json", + env: { COMPOSIO_API_KEY: "ak_live" }, + logger: log, + }); + expect(cfg?.transport).toMatchObject({ + url: "https://example.test/tool_router/trs_new/mcp", + }); + expect(persistMock).toHaveBeenCalledWith("/tmp/config.json", { + userId: "user-1", + sessionId: "trs_new", + mcpUrl: "https://example.test/tool_router/trs_new/mcp", + }); + expect(log.info).toHaveBeenCalled(); + }); +}); diff --git a/src/composio/resolve-composio-server.ts b/src/composio/resolve-composio-server.ts new file mode 100644 index 00000000..39ee3e19 --- /dev/null +++ b/src/composio/resolve-composio-server.ts @@ -0,0 +1,79 @@ +/** + * Decide whether this boot mounts Composio, and with what server config. + * + * The whole gate lives here: no key (or `composio.enabled: false`) and + * the function returns `undefined`, bootstrap adds no server, and not + * one Composio tool is ever registered — the model cannot see or call + * something that was never mounted. + * + * Failure is **soft** by design. Composio being unreachable, rate + * limiting, or rejecting a stale key must never stop the agent from + * starting: the operator's shell, files and browser have nothing to do + * with a third-party SaaS broker. A failure logs a warning and the boot + * continues exactly as it would with no key at all. + */ + +import { buildComposioServerConfig } from "./build-composio-server-config.js"; +import { ensureComposioSession } from "./ensure-composio-session.js"; +import { persistComposioSession } from "./persist-composio-session.js"; +import { resolveComposioApiKey } from "./resolve-composio-key.js"; +import type { ComposioConfig } from "../config/config-schema.js"; +import type { McpServerConfig } from "../mcp/mcp-types.js"; + +/** Minimal logger shape — avoids importing the tracing module here. */ +export interface ComposioResolveLogger { + warn(message: string, fields?: Record): void; + info(message: string, fields?: Record): void; +} + +export interface ResolveComposioServerOptions { + composio: ComposioConfig; + /** Absolute path of `/config.json`, for the session cache. */ + userConfigFile: string; + logger?: ComposioResolveLogger; + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; + /** Skip the cached session and mint a new one. */ + forceRefresh?: boolean; +} + +export async function resolveComposioServerConfig( + opts: ResolveComposioServerOptions, +): Promise { + if (!opts.composio.enabled) return undefined; + const apiKey = resolveComposioApiKey({ + apiKeyEnv: opts.composio.apiKeyEnv, + ...(opts.env === undefined ? {} : { env: opts.env }), + }); + if (apiKey === undefined) return undefined; + + try { + const session = await ensureComposioSession({ + apiKey, + cache: { + userId: opts.composio.userId, + sessionId: opts.composio.sessionId, + mcpUrl: opts.composio.mcpUrl, + }, + persist: (next) => { + persistComposioSession(opts.userConfigFile, next); + }, + ...(opts.forceRefresh === undefined + ? {} + : { forceRefresh: opts.forceRefresh }), + ...(opts.signal === undefined ? {} : { signal: opts.signal }), + }); + if (session.created) { + opts.logger?.info("composio session created", { + sessionId: session.sessionId, + tools: session.toolNames.length, + }); + } + return buildComposioServerConfig({ session, apiKey }); + } catch (err) { + opts.logger?.warn("composio unavailable; continuing without it", { + error: err instanceof Error ? err.message : String(err), + }); + return undefined; + } +} diff --git a/src/config/config-schema.test.ts b/src/config/config-schema.test.ts index b5cc3336..82ceb3a9 100644 --- a/src/config/config-schema.test.ts +++ b/src/config/config-schema.test.ts @@ -996,6 +996,55 @@ describe("parseUserConfigFile", () => { ).toThrow(/skills.taps/); }); + it("applies composio defaults when the section is absent", () => { + const parsed = parseUserConfigFile({ version: USER_CONFIG_VERSION }); + expect(parsed.composio).toEqual(USER_CONFIG_DEFAULTS.composio); + }); + + it("accepts a v49 file and fills in composio.* defaults transparently", () => { + const parsed = parseUserConfigFile({ version: 49 }); + expect(parsed.version).toBe(USER_CONFIG_VERSION); + expect(parsed.composio).toEqual(USER_CONFIG_DEFAULTS.composio); + // Defaults must leave the integration inert: no cached session and + // no key means bootstrap mounts nothing. + expect(parsed.composio.sessionId).toBeNull(); + expect(parsed.composio.mcpUrl).toBeNull(); + expect(parsed.composio.userId).toBeNull(); + }); + + it("round-trips a populated composio block", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + composio: { + enabled: true, + apiKeyEnv: "MY_COMPOSIO_KEY", + userId: "11111111-2222-3333-4444-555555555555", + sessionId: "trs_abc", + mcpUrl: "https://backend.composio.dev/tool_router/trs_abc/mcp", + }, + }); + expect(parsed.composio.apiKeyEnv).toBe("MY_COMPOSIO_KEY"); + expect(parsed.composio.sessionId).toBe("trs_abc"); + }); + + it("normalises blank composio cache entries back to null", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + composio: { sessionId: " ", mcpUrl: "" }, + }); + expect(parsed.composio.sessionId).toBeNull(); + expect(parsed.composio.mcpUrl).toBeNull(); + }); + + it("rejects a non-string composio.sessionId", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + composio: { sessionId: 42 }, + }), + ).toThrow(/composio.sessionId/); + }); + it("applies telegram defaults when the section is absent", () => { const parsed = parseUserConfigFile({ version: USER_CONFIG_VERSION }); expect(parsed.telegram).toEqual(USER_CONFIG_DEFAULTS.telegram); diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index 1de6b5ee..de42f03d 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -808,6 +808,11 @@ export interface AtomicAgentConfig { * the master kill switch and the single-operator owner id. */ telegram: TelegramConfig; + /** + * Composio integration. Mirrors `UserConfigFile.composio`. The API + * key is not stored here — see `ComposioConfig`. + */ + composio: ComposioConfig; /** * MCP (Model Context Protocol) client configuration. Mirrors * `UserConfigFile.mcp`. Each entry in `servers[]` becomes a @@ -925,6 +930,42 @@ export type TelegramParseMode = "plain" | "html"; * messages whose `from.id` matches `ownerUserId` are dispatched into * the agent loop. Group chats are dropped unconditionally. */ +/** + * Composio integration. Composio is a hosted catalogue of 1500+ SaaS + * toolkits (Gmail, Slack, Notion, Linear, …) that also brokers each + * app's OAuth. The agent reaches it as an ordinary MCP server: a + * tool-router session yields a Streamable-HTTP MCP endpoint carrying + * four meta-tools, and `src/mcp/` does the rest. + * + * As with `TelegramConfig`, the API key is **not** stored here — it + * lives in `/.env` under the name in `apiKeyEnv` and is + * loaded at bootstrap by `loadDotenvFromStateDir`. A missing key is + * the integration's real gate: no key, no MCP server, no Composio + * tool in the registry. + */ +export interface ComposioConfig { + /** + * Master kill switch. `false` keeps the integration dormant even + * when a key is present — the escape hatch for an operator who + * wants the key on disk but the toolkits off. + */ + enabled: boolean; + /** Name of the env var holding the API key. */ + apiKeyEnv: string; + /** + * Stable anonymous install id scoping Composio connected accounts. + * Minted once as a random UUID and never derived from the operator's + * email: Composio's docs advise against emails as user ids, and an + * email is PII the integration has no reason to disclose. Losing it + * means re-authorising every connected app, so it is persisted. + */ + userId: string | null; + /** Cached tool-router session id (`trs_…`), so a boot costs no API call. */ + sessionId: string | null; + /** Cached MCP endpoint for `sessionId`. */ + mcpUrl: string | null; +} + export interface TelegramConfig { /** Master kill switch. When `false`, the channel is constructed but never started. */ enabled: boolean; @@ -1596,6 +1637,12 @@ export interface UserConfigFile { * here — see `TelegramConfig` for rationale. */ telegram: TelegramConfig; + /** + * Composio integration. Added in config v50. Older files are + * transparently upgraded with the defaults below, which leave the + * integration inert until a key is written to `/.env`. + */ + composio: ComposioConfig; /** * MCP client servers. Added in config v23. Each entry declares one * external MCP server the runtime will connect to at bootstrap and @@ -1698,7 +1745,12 @@ export interface UserConfigFile { // taken: a declined offer must not come back on a re-run after a reset. // Additive: an older file parses with it `null`, which reads as "never // offered", the same answer that file has always implied. -export const USER_CONFIG_VERSION = 49; +// v50: new `composio` block wiring the Composio toolkit catalogue in as +// an MCP server. Additive and inert by default — the block carries a +// switch, an env-var *name*, and cached session ids, never the key +// itself, and an older file inherits defaults that mount nothing until +// a key is written to `/.env`. +export const USER_CONFIG_VERSION = 50; /** * Config v21+ flips the full memory-v2 fabric on by default. Upgrades @@ -1836,6 +1888,7 @@ const SUPPORTED_INPUT_VERSIONS: readonly number[] = [ 46, 47, 48, + 49, USER_CONFIG_VERSION, ]; @@ -2106,6 +2159,16 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = { parseMode: "html", progressIndicator: true, }, + composio: { + // Added in v50. `enabled: true` is safe because the key, not this + // flag, is what actually mounts anything: with no key in the env + // the runtime opens no connection and registers no tool. + enabled: true, + apiKeyEnv: "COMPOSIO_API_KEY", + userId: null, + sessionId: null, + mcpUrl: null, + }, mcp: { // Added in v23. Empty by default — the operator declares MCP // servers explicitly. The runtime opens no connections when the @@ -2509,6 +2572,26 @@ export function parseNonEmptyString(raw: unknown, field: string): string { ); } +/** + * Parse an optional string that is meaningfully absent. `undefined` + * (key missing) and `null` (explicitly cleared) both read as `null`, + * so a cleared cache entry and a never-written one behave alike. + */ +export function parseNullableString( + raw: unknown, + field: string, +): string | null { + if (raw === undefined || raw === null) return null; + if (typeof raw === "string") { + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : null; + } + throw new ConfigValidationError( + field, + `expected string or null, got ${JSON.stringify(raw)}`, + ); +} + export function parseHttpApprovalMode( raw: unknown, field: string, @@ -3234,6 +3317,7 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { const vision = (obj.vision as Record | undefined) ?? {}; const skills = (obj.skills as Record | undefined) ?? {}; const telegram = (obj.telegram as Record | undefined) ?? {}; + const composio = (obj.composio as Record | undefined) ?? {}; const tui = (obj.tui as Record | undefined) ?? {}; const analytics = (obj.analytics as Record | undefined) ?? {}; @@ -4021,6 +4105,19 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { "telegram.progressIndicator", ), }, + composio: { + enabled: parseBool( + composio.enabled ?? USER_CONFIG_DEFAULTS.composio.enabled, + "composio.enabled", + ), + apiKeyEnv: parseNonEmptyString( + composio.apiKeyEnv ?? USER_CONFIG_DEFAULTS.composio.apiKeyEnv, + "composio.apiKeyEnv", + ), + userId: parseNullableString(composio.userId, "composio.userId"), + sessionId: parseNullableString(composio.sessionId, "composio.sessionId"), + mcpUrl: parseNullableString(composio.mcpUrl, "composio.mcpUrl"), + }, mcp: { servers: parseMcpServers(mcp.servers, "mcp.servers"), }, diff --git a/src/config/load-config.ts b/src/config/load-config.ts index a602d16b..51bf5b6f 100644 --- a/src/config/load-config.ts +++ b/src/config/load-config.ts @@ -520,6 +520,13 @@ export function loadConfig(): AtomicAgentConfig { parseMode: user.telegram.parseMode, progressIndicator: user.telegram.progressIndicator, }, + composio: { + enabled: user.composio.enabled, + apiKeyEnv: user.composio.apiKeyEnv, + userId: user.composio.userId, + sessionId: user.composio.sessionId, + mcpUrl: user.composio.mcpUrl, + }, mcp: { // Servers are owned by the user-config file. Deep clone the // array so downstream mutations (e.g. TUI enable/disable diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index 742a4bc4..83394cf8 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -176,6 +176,7 @@ import { TaskRunner, TaskStore } from "../tasks/index.js"; import { Scheduler } from "../scheduler/index.js"; import { WebhookSessionStore } from "../http/webhook-session-store.js"; +import { resolveComposioServerConfig } from "../composio/index.js"; import { StructuredLogger } from "../tracing/structured-logger.js"; import type { LogSink } from "../tracing/structured-logger.js"; import { MetricsCollector } from "../tracing/metrics-collector.js"; @@ -1543,7 +1544,21 @@ export async function createAgentRuntime( // blocks bootstrap. Catalog growth after this point (hot-add // server) requires a rebuild of the stable prefix / grammar // (currently a runtime restart — see AGENTS.md §"MCP client"). - const mcpServerConfigs = config.mcp?.servers ?? []; + // Composio rides the same rails: when a key is configured we mint (or + // reuse) a tool-router session and mount its hosted endpoint as one + // more MCP server. With no key `resolveComposioServerConfig` returns + // `undefined` and this line is the only trace of the integration — + // no server, no tools, nothing for the model to reach for. Failure is + // soft: an unreachable Composio must not stop the agent from booting. + const composioServerConfig = await resolveComposioServerConfig({ + composio: config.composio, + userConfigFile: config.paths.userConfigFile, + logger, + }); + const mcpServerConfigs = [ + ...(config.mcp?.servers ?? []), + ...(composioServerConfig ? [composioServerConfig] : []), + ]; const mcpEnabled = mcpServerConfigs.length > 0; const mcpManager = new McpManager(mcpServerConfigs, { toolRegistry,