Files
PaperClipAI/server/src/services/composio.ts
T
DottaandPaperclip 6244e4cf32 feat(apps): add Composio and Gmail connectors (#12342)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - App connections need both direct providers and managed provider
hubs.
> - The grant layer now defines safe credential ownership.
> - Composio needs parent and child connection lifecycle rules, and
Gmail needs governed setup.
> - This pull request adds both connector families on the grant
foundation.
> - The benefit is broader app access without weakening credential
isolation.

## Linked Issues or Issue Description

Refs #11965

This is stack 4 of 11. It depends on stack 3 and replaces another
reviewable part of #11965.

## What Changed

- Add Composio parent and child connection support.
- Add Gmail connection setup and governance.
- Preserve credential paths and remove duplicate binding declarations.
- Cascade Composio pause and restore actions to child connections.

## Verification

- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/tool-access-service.test.ts`
- Result: 164 tests passed.
- `pnpm build`

## Risks

- Parent lifecycle changes can affect every Composio child.
- The service restores only children whose provider accounts remain
active.
- Credential binding paths are normalized before secret resolution.

> I checked `ROADMAP.md`. This stack continues the existing app
connection work from #11965 and does not duplicate another planned item.

## Model Used

OpenAI Codex, GPT-5. The runtime model ID and context window were not
exposed. The model used reasoning, tool use, and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-29 12:08:33 -05:00

217 lines
7.8 KiB
TypeScript

const DEFAULT_COMPOSIO_API_BASE_URL = "https://backend.composio.dev/api/v3.1";
export class ComposioApiError extends Error {
constructor(message: string, readonly status: number) {
super(message);
this.name = "ComposioApiError";
}
}
export type ComposioPage<T> = {
items: T[];
next_cursor?: string | null;
total_pages?: number;
current_page?: number;
total_items?: number;
};
export type ComposioToolkit = {
slug: string;
name: string;
type?: string;
auth_schemes?: string[];
composio_managed_auth_schemes?: string[];
no_auth?: boolean;
auth_guide_url?: string | null;
meta?: {
description?: string | null;
logo?: string | null;
app_url?: string | null;
tools_count?: number;
triggers_count?: number;
version?: string;
[key: string]: unknown;
};
[key: string]: unknown;
};
export type ComposioAuthConfig = {
id: string;
uuid?: string;
name?: string;
auth_scheme: string;
is_composio_managed: boolean;
status?: string;
toolkit: { slug: string; logo?: string | null; auth_guide_url?: string | null; auth_hint_url?: string | null };
[key: string]: unknown;
};
export type ComposioConnectLink = {
link_token: string;
redirect_url: string;
expires_at: string;
connected_account_id?: string | null;
[key: string]: unknown;
};
export type ComposioConnectedAccount = {
id: string;
user_id: string;
status: string;
status_reason?: string | null;
is_disabled?: boolean;
toolkit: { slug: string };
auth_config: { id: string; auth_scheme: string; is_composio_managed: boolean; is_disabled?: boolean };
[key: string]: unknown;
};
export type ComposioSession = {
session_id: string;
mcp: { type?: string; url: string; headers?: Record<string, string> };
config?: Record<string, unknown>;
config_version?: number;
warnings?: Array<{ code: string; message: string }>;
[key: string]: unknown;
};
export type ComposioListOptions = { cursor?: string; limit?: number };
export type ComposioSessionOptions = {
mcp: true;
toolkits?: string[];
tools?: Record<string, { enable: string[] }>;
authConfigs?: Record<string, string>;
connectedAccounts?: Record<string, string[]>;
};
export interface ComposioClient {
validateApiKey(): Promise<void>;
listToolkits(options?: ComposioListOptions): Promise<ComposioPage<ComposioToolkit>>;
listAuthConfigs(options?: ComposioListOptions & { toolkitSlugs?: string[]; showDisabled?: boolean }): Promise<ComposioPage<ComposioAuthConfig>>;
createConnectLink(input: { authConfigId: string; userId: string; alias?: string; callbackUrl?: string }): Promise<ComposioConnectLink>;
listConnectedAccounts(options?: ComposioListOptions & { toolkitSlugs?: string[]; statuses?: string[]; userIds?: string[]; authConfigIds?: string[] }): Promise<ComposioPage<ComposioConnectedAccount>>;
deleteConnectedAccount(connectedAccountId: string): Promise<void>;
createSession(userId: string, options: ComposioSessionOptions): Promise<ComposioSession>;
resumeSession(sessionId: string, options: { mcp: true }): Promise<ComposioSession>;
}
type ComposioClientOptions = { apiKey: string; baseUrl?: string; fetch?: typeof globalThis.fetch };
function appendQuery(url: URL, key: string, value: string | number | boolean | undefined) {
if (value !== undefined) url.searchParams.append(key, String(value));
}
function appendQueryList(url: URL, key: string, values: string[] | undefined) {
for (const value of values ?? []) url.searchParams.append(key, value);
}
function errorMessage(status: number): string {
if (status === 401 || status === 403) return "Composio rejected the API key.";
if (status === 429) return "Composio rate-limited the request. Try again shortly.";
return `Composio request failed with HTTP ${status}.`;
}
export function createComposioClient(options: ComposioClientOptions): ComposioClient {
const apiKey = options.apiKey.trim();
if (!apiKey) throw new Error("A Composio API key is required.");
const baseUrl = (options.baseUrl ?? DEFAULT_COMPOSIO_API_BASE_URL).replace(/\/+$/, "");
const fetchImpl = options.fetch ?? globalThis.fetch;
async function request<T>(path: string, init: RequestInit = {}, query?: (url: URL) => void): Promise<T> {
const url = new URL(`${baseUrl}/${path.replace(/^\/+/, "")}`);
query?.(url);
let response: Response;
try {
response = await fetchImpl(url, {
...init,
headers: {
accept: "application/json",
"x-api-key": apiKey,
...(init.body ? { "content-type": "application/json" } : {}),
...init.headers,
},
});
} catch {
throw new ComposioApiError("Could not reach Composio.", 0);
}
if (!response.ok) throw new ComposioApiError(errorMessage(response.status), response.status);
try {
return await response.json() as T;
} catch {
throw new ComposioApiError("Composio returned an invalid response.", response.status);
}
}
return {
async validateApiKey() {
await this.listToolkits({ limit: 1 });
},
listToolkits(options = {}) {
return request<ComposioPage<ComposioToolkit>>("toolkits", {}, (url) => {
appendQuery(url, "cursor", options.cursor);
appendQuery(url, "limit", options.limit);
});
},
listAuthConfigs(options = {}) {
return request<ComposioPage<ComposioAuthConfig>>("auth_configs", {}, (url) => {
appendQuery(url, "cursor", options.cursor);
appendQuery(url, "limit", options.limit);
appendQuery(url, "show_disabled", options.showDisabled);
appendQuery(url, "toolkit_slug", options.toolkitSlugs?.join(","));
});
},
createConnectLink(input) {
return request<ComposioConnectLink>("connected_accounts/link", {
method: "POST",
body: JSON.stringify({
auth_config_id: input.authConfigId,
user_id: input.userId,
...(input.alias ? { alias: input.alias } : {}),
...(input.callbackUrl ? { callback_url: input.callbackUrl } : {}),
}),
});
},
listConnectedAccounts(options = {}) {
return request<ComposioPage<ComposioConnectedAccount>>("connected_accounts", {}, (url) => {
appendQuery(url, "cursor", options.cursor);
appendQuery(url, "limit", options.limit);
appendQueryList(url, "toolkit_slugs", options.toolkitSlugs);
appendQueryList(url, "statuses", options.statuses);
appendQueryList(url, "user_ids", options.userIds);
appendQueryList(url, "auth_config_ids", options.authConfigIds);
});
},
async deleteConnectedAccount(connectedAccountId) {
const url = new URL(`${baseUrl}/connected_accounts/${encodeURIComponent(connectedAccountId)}`);
let response: Response;
try {
response = await fetchImpl(url, {
method: "DELETE",
headers: { accept: "application/json", "x-api-key": apiKey },
});
} catch {
throw new ComposioApiError("Could not reach Composio.", 0);
}
if (!response.ok) throw new ComposioApiError(errorMessage(response.status), response.status);
},
createSession(userId, options) {
return request<ComposioSession>("tool_router/session", {
method: "POST",
body: JSON.stringify({
user_id: userId,
mcp: options.mcp,
...(options.toolkits ? { toolkits: { enabled: options.toolkits } } : {}),
...(options.tools ? { tools: options.tools } : {}),
...(options.authConfigs ? { auth_configs: options.authConfigs } : {}),
...(options.connectedAccounts ? { connected_accounts: options.connectedAccounts } : {}),
}),
});
},
resumeSession(sessionId, _options) {
return request<ComposioSession>(`tool_router/session/${encodeURIComponent(sessionId)}/attach`, {
method: "POST",
body: JSON.stringify({}),
});
},
};
}