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,