Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<stateDir>/.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):
Expand Down
40 changes: 40 additions & 0 deletions src/composio/build-composio-server-config.test.ts
Original file line number Diff line number Diff line change
@@ -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.<TOOL>; an invalid name would
// break tool dispatch and the GBNF string literal alike.
expect(MCP_SERVER_NAME_RE.test(COMPOSIO_SERVER_NAME)).toBe(true);
});
});
57 changes: 57 additions & 0 deletions src/composio/build-composio-server-config.ts
Original file line number Diff line number Diff line change
@@ -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.<TOOL>`; 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<ComposioSession, "mcpUrl">;
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 },
},
};
}
142 changes: 142 additions & 0 deletions src/composio/composio-api.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>)[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);
});
});
Loading
Loading