Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0da55e6
feat: improve Composio tool description with service categories and u…
sidneyswift Jan 12, 2026
29c7fb3
refactor: address PR review feedback
sidneyswift Jan 15, 2026
43a470c
Merge origin/test into composio-tool-router: resolve conflict in lib/…
sidneyswift Jan 19, 2026
50f75a6
refactor: use Zod schema validation for /api/connectors/authorize end…
sidneyswift Jan 19, 2026
b3a8f31
security: verify connector ownership before disconnect
sidneyswift Jan 19, 2026
376aab8
fix: add runtime validation for Composio session.tools() return value
sidneyswift Jan 19, 2026
1cc260a
refactor: use proper Vercel AI SDK types for Tool Router
sidneyswift Jan 19, 2026
90796fb
fix: support OAuth callbacks on Vercel preview deployments
sidneyswift Jan 19, 2026
9808865
fix: add test branch support for frontend URL
sidneyswift Jan 19, 2026
47cf4bf
fix: use correct test frontend URL pattern
sidneyswift Jan 19, 2026
d351e3c
refactor: simplify registerComposioTools per KISS principle
sidneyswift Jan 19, 2026
b74b074
refactor: use getCallToolResult directly per KISS principle
sidneyswift Jan 19, 2026
1cc3ec5
fix: use correct frontend port 3000 for local development
sidneyswift Jan 19, 2026
453f1bd
refactor: remove verbose comments from getFrontendBaseUrl
sidneyswift Jan 19, 2026
5a9d55e
refactor: simplify registerComposioTools to pass raw results
sidneyswift Jan 19, 2026
7bc18e0
docs: update composio tool description to mention Google Sheets
sidneyswift Jan 19, 2026
bdd98b7
feat: limit Composio Tool Router to Google Sheets only
sidneyswift Jan 19, 2026
b0cedbc
fix: get accountId from auth context instead of tool schema
sidneyswift Jan 19, 2026
9b9db79
docs: clarify accountId should come from auth, not input schemas
sidneyswift Jan 19, 2026
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
39 changes: 39 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,45 @@ export async function selectTableName({
- All API routes should have JSDoc comments
- Run `pnpm lint` before committing

## Authentication

**Never use `account_id` in request bodies or tool schemas.** Always derive the account ID from authentication:

- **API routes**: Use `x-api-key` header via `getApiKeyAccountId()`
- **MCP tools**: Use `extra.authInfo` via `resolveAccountId()`

Both API keys and Privy access tokens resolve to an `accountId`. Never accept `account_id` as user input.

### API Routes

```typescript
import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId";

const accountIdOrError = await getApiKeyAccountId(request);
if (accountIdOrError instanceof NextResponse) {
return accountIdOrError;
}
const accountId = accountIdOrError;
```

### MCP Tools

```typescript
import { resolveAccountId } from "@/lib/mcp/resolveAccountId";
import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey";

const authInfo = extra.authInfo as McpAuthInfo | undefined;
const { accountId, error } = await resolveAccountId({
authInfo,
accountIdOverride: undefined,
});
```

This ensures:
- Callers cannot impersonate other accounts
- Authentication is always enforced
- Account ID is derived from validated credentials

## Input Validation

All API endpoints should use a **validate function** for input parsing. Use Zod for schema validation.
Expand Down
67 changes: 67 additions & 0 deletions app/api/connectors/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { authorizeConnector } from "@/lib/composio/connectors";
import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId";
import { validateAuthorizeConnectorBody } from "@/lib/composio/connectors/validateAuthorizeConnectorBody";

/**
* OPTIONS handler for CORS preflight requests.
*/
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: getCorsHeaders(),
});
}

/**
* POST /api/connectors/authorize
*
* Generate an OAuth authorization URL for a specific connector.
*
* Authentication: x-api-key header required.
* The account ID is inferred from the API key.
*
* Request body:
* - connector: The connector slug, e.g., "googlesheets" (required)
* - callback_url: Optional custom callback URL after OAuth
*
* @returns The redirect URL for OAuth authorization
*/
export async function POST(request: NextRequest): Promise<NextResponse> {
const headers = getCorsHeaders();

try {
const accountIdOrError = await getApiKeyAccountId(request);
if (accountIdOrError instanceof NextResponse) {
return accountIdOrError;
}

const accountId = accountIdOrError;
const body = await request.json();

const validated = validateAuthorizeConnectorBody(body);
if (validated instanceof NextResponse) {
return validated;
}

const { connector, callback_url } = validated;
const result = await authorizeConnector(accountId, connector, callback_url);

return NextResponse.json(
{
success: true,
data: {
connector: result.connector,
redirectUrl: result.redirectUrl,
},
},
{ status: 200, headers },
);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to authorize connector";
return NextResponse.json({ error: message }, { status: 500, headers });
}
}
109 changes: 109 additions & 0 deletions app/api/connectors/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { getConnectors } from "@/lib/composio/connectors";
import { disconnectConnector } from "@/lib/composio/connectors/disconnectConnector";
import { validateDisconnectConnectorBody } from "@/lib/composio/connectors/validateDisconnectConnectorBody";
import { verifyConnectorOwnership } from "@/lib/composio/connectors/verifyConnectorOwnership";
import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId";

/**
* OPTIONS handler for CORS preflight requests.
*/
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: getCorsHeaders(),
});
}

/**
* GET /api/connectors
*
* List all available connectors and their connection status for a user.
*
* Authentication: x-api-key header required.
*
* @returns List of connectors with connection status
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
const headers = getCorsHeaders();

try {
const accountIdOrError = await getApiKeyAccountId(request);
if (accountIdOrError instanceof NextResponse) {
return accountIdOrError;
}
Comment on lines 32 to 36
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Missing Zod schema validation per coding guidelines.

As per the coding guidelines, all API endpoints should use a validate function for input parsing with Zod schema validation. The current manual query parameter validation should be replaced with a Zod schema.

♻️ Suggested implementation with Zod
 import type { NextRequest } from "next/server";
 import { NextResponse } from "next/server";
 import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
 import { getConnectors } from "@/lib/composio/connectors";
+import { z } from "zod";
+
+const getConnectorsQuerySchema = z.object({
+  account_id: z.string().min(1, "account_id query parameter is required"),
+});

Then update the GET handler:

   try {
-    const accountId = request.nextUrl.searchParams.get("account_id");
-
-    if (!accountId) {
-      return NextResponse.json(
-        { error: "account_id query parameter is required" },
-        { status: 400, headers },
-      );
-    }
+    const queryParams = {
+      account_id: request.nextUrl.searchParams.get("account_id"),
+    };
+    const parseResult = getConnectorsQuerySchema.safeParse(queryParams);
+
+    if (!parseResult.success) {
+      return NextResponse.json(
+        { error: parseResult.error.issues[0]?.message ?? "Invalid query parameters" },
+        { status: 400, headers },
+      );
+    }
+
+    const { account_id: accountId } = parseResult.data;

Based on coding guidelines, all API endpoints should use a validate function for input parsing with Zod schema validation.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const accountId = request.nextUrl.searchParams.get("account_id");
if (!accountId) {
return NextResponse.json(
{ error: "account_id query parameter is required" },
{ status: 400, headers },
);
}
try {
const queryParams = {
account_id: request.nextUrl.searchParams.get("account_id"),
};
const parseResult = getConnectorsQuerySchema.safeParse(queryParams);
if (!parseResult.success) {
return NextResponse.json(
{ error: parseResult.error.issues[0]?.message ?? "Invalid query parameters" },
{ status: 400, headers },
);
}
const { account_id: accountId } = parseResult.data;
🤖 Prompt for AI Agents
In @app/api/connectors/route.ts around lines 29 - 37, Replace the manual query
param check for account_id with Zod-based validation: define an
AccountQuerySchema (e.g., z.object({ account_id: z.string().min(1) })) and call
the existing validate function inside the GET handler to parse
request.nextUrl/searchParams instead of using
request.nextUrl.searchParams.get("account_id"); assign the validated value to
accountId from the validate result and remove the manual if (!accountId) branch
so errors are handled by validate. Ensure you import z and validate, name the
schema AccountQuerySchema, and use the validated account_id where the code
currently references accountId.


const accountId = accountIdOrError;

const connectors = await getConnectors(accountId);

return NextResponse.json(
{
success: true,
data: {
connectors,
},
},
{ status: 200, headers },
);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to fetch connectors";
return NextResponse.json({ error: message }, { status: 500, headers });
}
}

/**
* DELETE /api/connectors
*
* Disconnect a connected account from Composio.
*
* Authentication: x-api-key header required.
*
* Body: { connected_account_id: string }
*/
export async function DELETE(request: NextRequest): Promise<NextResponse> {
const headers = getCorsHeaders();

try {
const accountIdOrError = await getApiKeyAccountId(request);
if (accountIdOrError instanceof NextResponse) {
return accountIdOrError;
}

const accountId = accountIdOrError;
const body = await request.json();

const validated = validateDisconnectConnectorBody(body);
if (validated instanceof NextResponse) {
return validated;
}

const { connected_account_id } = validated;

// Verify the connected account belongs to the authenticated user
const isOwner = await verifyConnectorOwnership(accountId, connected_account_id);
if (!isOwner) {
return NextResponse.json(
{ error: "Connected account not found or does not belong to this user" },
{ status: 403, headers }
);
}

const result = await disconnectConnector(connected_account_id);

return NextResponse.json(
{
success: true,
data: result,
},
{ status: 200, headers },
);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to disconnect connector";
return NextResponse.json({ error: message }, { status: 500, headers });
}
}
45 changes: 45 additions & 0 deletions lib/composio/connectors/authorizeConnector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { getComposioClient } from "../client";
import { getCallbackUrl } from "../getCallbackUrl";

/**
* Result of authorizing a connector.
*/
export interface AuthorizeResult {
connector: string;
redirectUrl: string;
}

/**
* Generate an OAuth authorization URL for a connector.
*
* Why: Used by the /api/connectors/authorize endpoint to let users
* connect from the settings page (not in-chat).
*
* @param userId - The user's account ID
* @param connector - The connector slug (e.g., "googlesheets", "gmail")
* @param customCallbackUrl - Optional custom callback URL after OAuth
* @returns The redirect URL for OAuth
*/
export async function authorizeConnector(
userId: string,
connector: string,
customCallbackUrl?: string,
): Promise<AuthorizeResult> {
const composio = getComposioClient();

const callbackUrl =
customCallbackUrl || getCallbackUrl({ destination: "connectors" });

const session = await composio.create(userId, {
manageConnections: {
callbackUrl,
},
});

const connectionRequest = await session.authorize(connector);

return {
connector,
redirectUrl: connectionRequest.redirectUrl,
};
}
36 changes: 36 additions & 0 deletions lib/composio/connectors/disconnectConnector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { getComposioApiKey } from "../getComposioApiKey";

/**
* Disconnect a connected account from Composio.
*
* Why: Composio's Tool Router SDK doesn't expose a disconnect method,
* so we call the REST API directly to delete the connection.
*
* @param connectedAccountId - The ID of the connected account to disconnect
* @returns Success status
*/
export async function disconnectConnector(
connectedAccountId: string,
): Promise<{ success: boolean }> {
const apiKey = getComposioApiKey();

// Composio v3 API uses DELETE method
const url = `https://backend.composio.dev/api/v3/connected_accounts/${connectedAccountId}`;

const response = await fetch(url, {
method: "DELETE",
headers: {
"x-api-key": apiKey,
"Content-Type": "application/json",
},
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Failed to disconnect (${response.status}): ${errorText}`,
);
}

return { success: true };
}
30 changes: 30 additions & 0 deletions lib/composio/connectors/getConnectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { getComposioClient } from "../client";

/**
* Connector info returned by Composio.
*/
export interface ConnectorInfo {
slug: string;
name: string;
isConnected: boolean;
connectedAccountId?: string;
}

/**
* Get all connectors and their connection status for a user.
*
* @param userId - The user's account ID
* @returns List of connectors with connection status
*/
export async function getConnectors(userId: string): Promise<ConnectorInfo[]> {
const composio = getComposioClient();
const session = await composio.create(userId);
const toolkits = await session.toolkits();

return toolkits.items.map((toolkit) => ({
slug: toolkit.slug,
name: toolkit.name,
isConnected: toolkit.connection?.isActive ?? false,
connectedAccountId: toolkit.connection?.connectedAccount?.id,
}));
}
3 changes: 3 additions & 0 deletions lib/composio/connectors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { getConnectors, type ConnectorInfo } from "./getConnectors";
export { authorizeConnector, type AuthorizeResult } from "./authorizeConnector";
export { disconnectConnector } from "./disconnectConnector";
41 changes: 41 additions & 0 deletions lib/composio/connectors/validateAuthorizeConnectorBody.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { z } from "zod";

export const authorizeConnectorBodySchema = z.object({
connector: z
.string({ message: "connector is required" })
.min(1, "connector cannot be empty (e.g., 'googlesheets', 'gmail')"),
callback_url: z.string().url("callback_url must be a valid URL").optional(),
});

export type AuthorizeConnectorBody = z.infer<
typeof authorizeConnectorBodySchema
>;

/**
* Validates request body for POST /api/connectors/authorize.
*
* @param body - The request body
* @returns A NextResponse with an error if validation fails, or the validated body if validation passes.
*/
export function validateAuthorizeConnectorBody(
body: unknown
): NextResponse | AuthorizeConnectorBody {
const result = authorizeConnectorBodySchema.safeParse(body);

if (!result.success) {
const firstError = result.error.issues[0];
return NextResponse.json(
{
error: firstError.message,
},
{
status: 400,
headers: getCorsHeaders(),
}
);
}

return result.data;
}
Loading