From 0da55e69695efee3c2bb3710e0049249453cb28a Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:18:39 -0500 Subject: [PATCH 01/18] feat: improve Composio tool description with service categories and usage examples --- app/api/connectors/authorize/route.ts | 66 +++++++++++ app/api/connectors/disconnect/route.ts | 51 ++++++++ app/api/connectors/route.ts | 55 +++++++++ lib/composio/connectors/authorizeConnector.ts | 45 +++++++ .../connectors/disconnectConnector.ts | 36 ++++++ lib/composio/connectors/getConnectors.ts | 30 +++++ lib/composio/connectors/index.ts | 3 + lib/composio/getCallbackUrl.ts | 40 +++++++ lib/composio/toolRouter/createSession.ts | 29 +++++ lib/composio/toolRouter/getTools.ts | 35 ++++++ lib/composio/toolRouter/index.ts | 2 + lib/mcp/tools/composio/index.ts | 14 +++ .../tools/composio/registerComposioTools.ts | 111 ++++++++++++++++++ lib/mcp/tools/index.ts | 2 + package.json | 4 +- pnpm-lock.yaml | 36 +++--- 16 files changed, 539 insertions(+), 20 deletions(-) create mode 100644 app/api/connectors/authorize/route.ts create mode 100644 app/api/connectors/disconnect/route.ts create mode 100644 app/api/connectors/route.ts create mode 100644 lib/composio/connectors/authorizeConnector.ts create mode 100644 lib/composio/connectors/disconnectConnector.ts create mode 100644 lib/composio/connectors/getConnectors.ts create mode 100644 lib/composio/connectors/index.ts create mode 100644 lib/composio/getCallbackUrl.ts create mode 100644 lib/composio/toolRouter/createSession.ts create mode 100644 lib/composio/toolRouter/getTools.ts create mode 100644 lib/composio/toolRouter/index.ts create mode 100644 lib/mcp/tools/composio/index.ts create mode 100644 lib/mcp/tools/composio/registerComposioTools.ts diff --git a/app/api/connectors/authorize/route.ts b/app/api/connectors/authorize/route.ts new file mode 100644 index 00000000..13219477 --- /dev/null +++ b/app/api/connectors/authorize/route.ts @@ -0,0 +1,66 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { authorizeConnector } from "@/lib/composio/connectors"; + +/** + * 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. + * + * Request body: + * - account_id: The user's account ID (required) + * - 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 { + const headers = getCorsHeaders(); + + try { + const body = await request.json(); + const { account_id, connector, callback_url } = body; + + if (!account_id) { + return NextResponse.json( + { error: "account_id is required" }, + { status: 400, headers }, + ); + } + + if (!connector) { + return NextResponse.json( + { error: "connector is required (e.g., 'googlesheets', 'gmail')" }, + { status: 400, headers }, + ); + } + + const result = await authorizeConnector(account_id, 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 }); + } +} diff --git a/app/api/connectors/disconnect/route.ts b/app/api/connectors/disconnect/route.ts new file mode 100644 index 00000000..4bc09366 --- /dev/null +++ b/app/api/connectors/disconnect/route.ts @@ -0,0 +1,51 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { disconnectConnector } from "@/lib/composio/connectors/disconnectConnector"; + +/** + * OPTIONS handler for CORS preflight requests. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/connectors/disconnect + * + * Disconnect a connected account from Composio. + * + * Body: { connected_account_id: string } + */ +export async function POST(request: NextRequest): Promise { + const headers = getCorsHeaders(); + + try { + const body = await request.json(); + const { connected_account_id } = body; + + if (!connected_account_id) { + return NextResponse.json( + { error: "connected_account_id is required" }, + { status: 400, 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 }); + } +} diff --git a/app/api/connectors/route.ts b/app/api/connectors/route.ts new file mode 100644 index 00000000..c54b17e6 --- /dev/null +++ b/app/api/connectors/route.ts @@ -0,0 +1,55 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getConnectors } from "@/lib/composio/connectors"; + +/** + * 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. + * + * Query params: + * - account_id: The user's account ID (required) + * + * @returns List of connectors with connection status + */ +export async function GET(request: NextRequest): Promise { + const headers = getCorsHeaders(); + + 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 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 }); + } +} diff --git a/lib/composio/connectors/authorizeConnector.ts b/lib/composio/connectors/authorizeConnector.ts new file mode 100644 index 00000000..23a61134 --- /dev/null +++ b/lib/composio/connectors/authorizeConnector.ts @@ -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 { + 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, + }; +} diff --git a/lib/composio/connectors/disconnectConnector.ts b/lib/composio/connectors/disconnectConnector.ts new file mode 100644 index 00000000..3aff9b9e --- /dev/null +++ b/lib/composio/connectors/disconnectConnector.ts @@ -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 }; +} diff --git a/lib/composio/connectors/getConnectors.ts b/lib/composio/connectors/getConnectors.ts new file mode 100644 index 00000000..6f5ac127 --- /dev/null +++ b/lib/composio/connectors/getConnectors.ts @@ -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 { + 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, + })); +} diff --git a/lib/composio/connectors/index.ts b/lib/composio/connectors/index.ts new file mode 100644 index 00000000..fa866c66 --- /dev/null +++ b/lib/composio/connectors/index.ts @@ -0,0 +1,3 @@ +export { getConnectors, type ConnectorInfo } from "./getConnectors"; +export { authorizeConnector, type AuthorizeResult } from "./authorizeConnector"; +export { disconnectConnector } from "./disconnectConnector"; diff --git a/lib/composio/getCallbackUrl.ts b/lib/composio/getCallbackUrl.ts new file mode 100644 index 00000000..5aa3ee0e --- /dev/null +++ b/lib/composio/getCallbackUrl.ts @@ -0,0 +1,40 @@ +/** + * Build OAuth callback URL based on environment and destination. + * + * Why: Composio redirects users back after OAuth. We need different + * destinations depending on context (chat room vs settings page). + */ + +type CallbackDestination = "chat" | "connectors"; + +interface CallbackOptions { + destination: CallbackDestination; + roomId?: string; +} + +/** + * Get the base URL for the frontend based on environment. + */ +function getFrontendBaseUrl(): string { + const isProd = process.env.VERCEL_ENV === "production"; + return isProd ? "https://chat.recoupable.com" : "http://localhost:3001"; +} + +/** + * Build callback URL for OAuth redirects. + * + * @param options.destination - Where to redirect: "chat" or "connectors" + * @param options.roomId - For chat destination, the room ID to return to + * @returns Full callback URL with success indicator + */ +export function getCallbackUrl(options: CallbackOptions): string { + const baseUrl = getFrontendBaseUrl(); + + if (options.destination === "connectors") { + return `${baseUrl}/settings/connectors?connected=true`; + } + + // Chat destination + const path = options.roomId ? `/chat/${options.roomId}` : "/chat"; + return `${baseUrl}${path}?connected=true`; +} diff --git a/lib/composio/toolRouter/createSession.ts b/lib/composio/toolRouter/createSession.ts new file mode 100644 index 00000000..409408dc --- /dev/null +++ b/lib/composio/toolRouter/createSession.ts @@ -0,0 +1,29 @@ +import { getComposioClient } from "../client"; +import { getCallbackUrl } from "../getCallbackUrl"; + +/** + * Create a Composio Tool Router session for a user. + * + * Why: Tool Router provides meta-tools for searching, connecting, + * and executing 500+ connectors through a single session. + * + * @param userId - Unique identifier for the user (accountId) + * @param roomId - Optional chat room ID for OAuth redirect + * @returns Composio Tool Router session + */ +export async function createToolRouterSession(userId: string, roomId?: string) { + const composio = getComposioClient(); + + const callbackUrl = getCallbackUrl({ + destination: "chat", + roomId, + }); + + const session = await composio.create(userId, { + manageConnections: { + callbackUrl, + }, + }); + + return session; +} diff --git a/lib/composio/toolRouter/getTools.ts b/lib/composio/toolRouter/getTools.ts new file mode 100644 index 00000000..d064fc82 --- /dev/null +++ b/lib/composio/toolRouter/getTools.ts @@ -0,0 +1,35 @@ +import { createToolRouterSession } from "./createSession"; + +/** + * Tool returned by Composio Tool Router. + * Uses inputSchema (not parameters) which is MCP-compatible. + */ +export interface ComposioTool { + description: string; + inputSchema: unknown; + execute: (args: unknown) => Promise; +} + +/** + * Get Composio Tool Router tools for a user. + * + * Returns 6 meta-tools: + * - COMPOSIO_MANAGE_CONNECTIONS - OAuth/auth management + * - COMPOSIO_SEARCH_TOOLS - Find available connectors + * - COMPOSIO_GET_TOOL_SCHEMAS - Get parameter schemas + * - COMPOSIO_MULTI_EXECUTE_TOOL - Execute actions + * - COMPOSIO_REMOTE_BASH_TOOL - Remote bash + * - COMPOSIO_REMOTE_WORKBENCH - Workbench + * + * @param userId - Unique identifier for the user (accountId) + * @param roomId - Optional chat room ID for OAuth redirect + * @returns Record of tool name to tool definition + */ +export async function getComposioTools( + userId: string, + roomId?: string, +): Promise> { + const session = await createToolRouterSession(userId, roomId); + const tools = await session.tools(); + return tools as Record; +} diff --git a/lib/composio/toolRouter/index.ts b/lib/composio/toolRouter/index.ts new file mode 100644 index 00000000..1ed43eda --- /dev/null +++ b/lib/composio/toolRouter/index.ts @@ -0,0 +1,2 @@ +export { createToolRouterSession } from "./createSession"; +export { getComposioTools, type ComposioTool } from "./getTools"; diff --git a/lib/mcp/tools/composio/index.ts b/lib/mcp/tools/composio/index.ts new file mode 100644 index 00000000..de072863 --- /dev/null +++ b/lib/mcp/tools/composio/index.ts @@ -0,0 +1,14 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerComposioTools } from "./registerComposioTools"; + +/** + * Registers all Composio-related tools on the MCP server. + * + * Currently registers: + * - composio: Meta-tool for accessing Composio Tool Router + * + * @param server - The MCP server instance to register tools on. + */ +export function registerAllComposioTools(server: McpServer): void { + registerComposioTools(server); +} diff --git a/lib/mcp/tools/composio/registerComposioTools.ts b/lib/mcp/tools/composio/registerComposioTools.ts new file mode 100644 index 00000000..4a7ec112 --- /dev/null +++ b/lib/mcp/tools/composio/registerComposioTools.ts @@ -0,0 +1,111 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getComposioTools } from "@/lib/composio/toolRouter"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; + +/** + * Schema for the composio meta-tool. + * + * Since Tool Router tools require a userId to create a session, + * we wrap all Composio tools in a single MCP tool that accepts + * account_id and routes to the appropriate Composio tool. + */ +const composioToolSchema = z.object({ + account_id: z + .string() + .min(1) + .describe("The user's account ID (from system prompt)"), + room_id: z + .string() + .optional() + .describe("The current chat room ID for OAuth redirect (from URL path)"), + tool_name: z + .string() + .min(1) + .describe( + "The Composio tool to use. Options: COMPOSIO_SEARCH_TOOLS, COMPOSIO_MANAGE_CONNECTIONS, COMPOSIO_GET_TOOL_SCHEMAS, COMPOSIO_MULTI_EXECUTE_TOOL", + ), + tool_params: z + .record(z.string(), z.unknown()) + .optional() + .describe( + "Parameters for the Composio tool. " + + "For COMPOSIO_MANAGE_CONNECTIONS: {toolkits: ['googlesheets']} (lowercase). " + + "For COMPOSIO_SEARCH_TOOLS: {queries: [{query: 'read google sheets'}]}. " + + "For COMPOSIO_MULTI_EXECUTE_TOOL: {tool_calls: [{tool_slug: 'TOOL_NAME', parameters: {...}}]}.", + ), +}); + +type ComposioToolArgs = z.infer; + +/** + * Tool description that helps the LLM understand Composio's capabilities. + * Includes categories of available services and usage patterns. + */ +const COMPOSIO_TOOL_DESCRIPTION = ` +Access 500+ external services via Composio. Use this when the user wants to interact with: + +**GOOGLE SUITE**: Gmail (read/send emails), Google Sheets (create/read/update spreadsheets), Google Drive (files), Google Docs, Google Calendar (events) +**PRODUCTIVITY**: Slack (messages), Notion (pages/databases), Linear (issues), Jira, Airtable, Trello, Asana +**DEVELOPMENT**: GitHub (repos/issues/PRs), GitLab, Bitbucket +**CRM/SALES**: HubSpot, Salesforce, Pipedrive +**COMMUNICATION**: Outlook, Microsoft Teams, Discord, Zoom +**SOCIAL**: Twitter/X (posts), LinkedIn + +HOW TO USE: +1. First, CONNECT the user's account: tool_name='COMPOSIO_MANAGE_CONNECTIONS', tool_params={"toolkits":["gmail"]} +2. Search for specific actions: tool_name='COMPOSIO_SEARCH_TOOLS', tool_params={"queries":[{"query":"read emails"}]} +3. Execute actions: tool_name='COMPOSIO_MULTI_EXECUTE_TOOL', tool_params={"tool_calls":[{"tool_slug":"GMAIL_LIST_EMAILS","parameters":{}}]} + +EXAMPLES: +- Read emails: Connect 'gmail', then use GMAIL_LIST_EMAILS or GMAIL_GET_EMAIL +- Create spreadsheet: Connect 'googlesheets', then use GOOGLESHEETS_CREATE +- Send Slack message: Connect 'slack', then use SLACK_POST_MESSAGE +- Create GitHub issue: Connect 'github', then use GITHUB_CREATE_ISSUE + +IMPORTANT: toolkit names are LOWERCASE (gmail, googlesheets, slack). Always include account_id and room_id. +`.trim(); + +/** + * Registers the "composio" tool on the MCP server. + * + * This is a meta-tool that provides access to Composio's Tool Router, + * which enables Google Sheets, Google Drive, and 500+ other connectors. + * + * The AI should: + * 1. Use COMPOSIO_SEARCH_TOOLS to find available actions + * 2. Use COMPOSIO_MANAGE_CONNECTIONS if authentication is needed + * 3. Use COMPOSIO_MULTI_EXECUTE_TOOL to run actions + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerComposioTools(server: McpServer): void { + server.registerTool( + "composio", + { + description: COMPOSIO_TOOL_DESCRIPTION, + inputSchema: composioToolSchema, + }, + async (args: ComposioToolArgs) => { + try { + const tools = await getComposioTools(args.account_id, args.room_id); + const tool = tools[args.tool_name]; + + if (!tool) { + const availableTools = Object.keys(tools); + return getToolResultError( + `Tool "${args.tool_name}" not found. Available tools: ${availableTools.join(", ")}`, + ); + } + + const result = await tool.execute(args.tool_params || {}); + return getToolResultSuccess(result); + } catch (error) { + return getToolResultError( + error instanceof Error ? error.message : "Composio tool execution failed", + ); + } + }, + ); +} diff --git a/lib/mcp/tools/index.ts b/lib/mcp/tools/index.ts index 39261efa..3454ccb8 100644 --- a/lib/mcp/tools/index.ts +++ b/lib/mcp/tools/index.ts @@ -14,6 +14,7 @@ import { registerCreateSegmentsTool } from "./registerCreateSegmentsTool"; import { registerAllYouTubeTools } from "./youtube"; import { registerTranscribeTools } from "./transcribe"; import { registerSendEmailTool } from "./registerSendEmailTool"; +import { registerAllComposioTools } from "./composio"; /** * Registers all MCP tools on the server. @@ -24,6 +25,7 @@ import { registerSendEmailTool } from "./registerSendEmailTool"; export const registerAllTools = (server: McpServer): void => { registerAllArtistSocialsTools(server); registerAllCatalogTools(server); + registerAllComposioTools(server); registerAllFileTools(server); registerAllImageTools(server); registerAllSora2Tools(server); diff --git a/package.json b/package.json index ca86291d..e11c8a3f 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,8 @@ "@ai-sdk/mcp": "^0.0.12", "@coinbase/cdp-sdk": "^1.38.6", "@coinbase/x402": "^0.7.3", - "@composio/core": "^0.2.6", - "@composio/vercel": "^0.2.18", + "@composio/core": "^0.3.4", + "@composio/vercel": "^0.3.4", "@modelcontextprotocol/sdk": "^1.24.3", "@privy-io/node": "^0.6.2", "@supabase/supabase-js": "^2.86.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfd67bed..61d0593d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,11 +18,11 @@ importers: specifier: ^0.7.3 version: 0.7.3(@solana/sysvars@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3))(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.1))(@types/react@19.2.7)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(ioredis@5.8.2)(react@19.2.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@composio/core': - specifier: ^0.2.6 - version: 0.2.6(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) + specifier: ^0.3.4 + version: 0.3.4(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) '@composio/vercel': - specifier: ^0.2.18 - version: 0.2.18(@composio/core@0.2.6(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13))(ai@6.0.0-beta.122(zod@4.1.13)) + specifier: ^0.3.4 + version: 0.3.4(@composio/core@0.3.4(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13))(ai@6.0.0-beta.122(zod@4.1.13)) '@modelcontextprotocol/sdk': specifier: ^1.24.3 version: 1.24.3(zod@4.1.13) @@ -224,24 +224,24 @@ packages: '@coinbase/x402@0.7.3': resolution: {integrity: sha512-mSxxbPnDCvSLfq6ZZAB5P1HyYQfQNSdWyY01Cn7yjzTuGUFFgZ7onDkbZnZ1Yry0UnG467aqrmjs6AgoYgpzCQ==} - '@composio/client@0.1.0-alpha.40': - resolution: {integrity: sha512-xkd+LseCiuFLxiXXtjFgON0bO4M/1AsBQD4omhf2nl5tXJI0sXj72wCf1EXhT2jIu5pgJqJ4H64ZzwoM0fHDvg==} + '@composio/client@0.1.0-alpha.52': + resolution: {integrity: sha512-IFPa77R/v4JUX0QeRHRTEkJCBZ+OqMQBh1rIn4Nv5PIP1eP3sZqxlTYJUKPS4qLNnwBJRD+Vn7/jUs5J7pdv5g==} - '@composio/core@0.2.6': - resolution: {integrity: sha512-QQgZz9o0ikl+A38PhyE0/j5V1y5wDDl8z6yfYw/WYAkk7AqjOJI9eJ/eujwgkTWxKnMaxrkthVzsjtvg/KIoYw==} + '@composio/core@0.3.4': + resolution: {integrity: sha512-6jHKJ9kbuJcCPdW8k8jFN2UlszO5e1TJIQ9WkJWOxNO7Qw8wzZ5Ay5pqL5XtU3beDxzQS/YDwBYDVHFppjx2Rg==} peerDependencies: - zod: '>=3.25.76 <5' + zod: ^3.25 || ^4 '@composio/json-schema-to-zod@0.1.19': resolution: {integrity: sha512-OynnORVWjsqDv13EvFa4Bb+B1SzBqpkWGi6qXm4vpB3EG65o3T9FbhDCqWB3ZufnMmH1T/NYS526O0lnn2LoCQ==} peerDependencies: zod: '>=3.25.76 <4 || >=4.1 <5' - '@composio/vercel@0.2.18': - resolution: {integrity: sha512-UuswOOjD+PZcqZT0vw+gHolLKMM1UI8n1EmB2vTlBqpOTsA0eTXfUuYcSED5qsvPoB9r6yWyg271MF3MeAPSZQ==} + '@composio/vercel@0.3.4': + resolution: {integrity: sha512-uo4MsSnif6fghvaGb5h473DAf+mFLJRlfFZ/XL4Jmf2TrMz7Z4ErBngep5WfHJ8vLJhbsI06AJ0BJ6ti3V5Ijg==} peerDependencies: - '@composio/core': 0.2.6 - ai: ^5.0.44 + '@composio/core': 0.3.4 + ai: 5.0.52 '@crawlee/types@3.15.3': resolution: {integrity: sha512-RvgVPXrsQw4GQIUXrC1z1aNOedUPJnZ/U/8n+jZ0fu1Iw9moJVMuiuIxSI8q1P6BA84aWZdalyfDWBZ3FMjsiw==} @@ -5847,11 +5847,11 @@ snapshots: - utf-8-validate - ws - '@composio/client@0.1.0-alpha.40': {} + '@composio/client@0.1.0-alpha.52': {} - '@composio/core@0.2.6(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13)': + '@composio/core@0.3.4(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13)': dependencies: - '@composio/client': 0.1.0-alpha.40 + '@composio/client': 0.1.0-alpha.52 '@composio/json-schema-to-zod': 0.1.19(zod@4.1.13) '@types/json-schema': 7.0.15 chalk: 4.1.2 @@ -5868,9 +5868,9 @@ snapshots: dependencies: zod: 4.1.13 - '@composio/vercel@0.2.18(@composio/core@0.2.6(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13))(ai@6.0.0-beta.122(zod@4.1.13))': + '@composio/vercel@0.3.4(@composio/core@0.3.4(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13))(ai@6.0.0-beta.122(zod@4.1.13))': dependencies: - '@composio/core': 0.2.6(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) + '@composio/core': 0.3.4(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) ai: 6.0.0-beta.122(zod@4.1.13) '@crawlee/types@3.15.3': From 29c7fb3669fbdb349d571a3c5ca7940434440c4d Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:28:55 -0500 Subject: [PATCH 02/18] refactor: address PR review feedback - Use x-api-key auth instead of account_id in request bodies/params - Add Zod validation for disconnect connector endpoint - Move DELETE to /api/connectors (KISS - RESTful design) - Extract getFrontendBaseUrl to own file (SRP) - Simplify registerComposioTools to return raw tools - Filter Composio tools to allowed subset in getTools.ts - Update CLAUDE.md with auth guidelines --- CLAUDE.md | 19 +++++ app/api/connectors/authorize/route.ts | 22 +++--- app/api/connectors/disconnect/route.ts | 51 ------------- app/api/connectors/route.ts | 61 +++++++++++++--- .../validateDisconnectConnectorBody.ts | 34 +++++++++ lib/composio/getCallbackUrl.ts | 10 +-- lib/composio/getFrontendBaseUrl.ts | 12 ++++ lib/composio/toolRouter/getTools.ts | 28 ++++++-- .../tools/composio/registerComposioTools.ts | 72 +++++-------------- 9 files changed, 170 insertions(+), 139 deletions(-) delete mode 100644 app/api/connectors/disconnect/route.ts create mode 100644 lib/composio/connectors/validateDisconnectConnectorBody.ts create mode 100644 lib/composio/getFrontendBaseUrl.ts diff --git a/CLAUDE.md b/CLAUDE.md index 20dc37f2..f72bcde9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,25 @@ pnpm format:check # Check formatting - All API routes should have JSDoc comments - Run `pnpm lint` before committing +## Authentication + +**Never use `account_id` in request bodies.** Always derive the account ID from the `x-api-key` header using `getApiKeyAccountId()`: + +```typescript +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; + +const accountIdOrError = await getApiKeyAccountId(request); +if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; +} +const accountId = accountIdOrError; +``` + +This ensures: +- Callers cannot impersonate other accounts +- Authentication is always enforced +- Account ID is derived from a validated API key + ## Input Validation All API endpoints should use a **validate function** for input parsing. Use Zod for schema validation. diff --git a/app/api/connectors/authorize/route.ts b/app/api/connectors/authorize/route.ts index 13219477..ecf55b73 100644 --- a/app/api/connectors/authorize/route.ts +++ b/app/api/connectors/authorize/route.ts @@ -2,6 +2,7 @@ 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"; /** * OPTIONS handler for CORS preflight requests. @@ -18,8 +19,10 @@ export async function OPTIONS() { * * 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: - * - account_id: The user's account ID (required) * - connector: The connector slug, e.g., "googlesheets" (required) * - callback_url: Optional custom callback URL after OAuth * @@ -29,16 +32,15 @@ export async function POST(request: NextRequest): Promise { const headers = getCorsHeaders(); try { - const body = await request.json(); - const { account_id, connector, callback_url } = body; - - if (!account_id) { - return NextResponse.json( - { error: "account_id is required" }, - { status: 400, headers }, - ); + const accountIdOrError = await getApiKeyAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; } + const accountId = accountIdOrError; + const body = await request.json(); + const { connector, callback_url } = body; + if (!connector) { return NextResponse.json( { error: "connector is required (e.g., 'googlesheets', 'gmail')" }, @@ -46,7 +48,7 @@ export async function POST(request: NextRequest): Promise { ); } - const result = await authorizeConnector(account_id, connector, callback_url); + const result = await authorizeConnector(accountId, connector, callback_url); return NextResponse.json( { diff --git a/app/api/connectors/disconnect/route.ts b/app/api/connectors/disconnect/route.ts deleted file mode 100644 index 4bc09366..00000000 --- a/app/api/connectors/disconnect/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { disconnectConnector } from "@/lib/composio/connectors/disconnectConnector"; - -/** - * OPTIONS handler for CORS preflight requests. - */ -export async function OPTIONS() { - return new NextResponse(null, { - status: 200, - headers: getCorsHeaders(), - }); -} - -/** - * POST /api/connectors/disconnect - * - * Disconnect a connected account from Composio. - * - * Body: { connected_account_id: string } - */ -export async function POST(request: NextRequest): Promise { - const headers = getCorsHeaders(); - - try { - const body = await request.json(); - const { connected_account_id } = body; - - if (!connected_account_id) { - return NextResponse.json( - { error: "connected_account_id is required" }, - { status: 400, 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 }); - } -} diff --git a/app/api/connectors/route.ts b/app/api/connectors/route.ts index c54b17e6..3ba84b61 100644 --- a/app/api/connectors/route.ts +++ b/app/api/connectors/route.ts @@ -2,6 +2,9 @@ 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 { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; /** * OPTIONS handler for CORS preflight requests. @@ -18,8 +21,7 @@ export async function OPTIONS() { * * List all available connectors and their connection status for a user. * - * Query params: - * - account_id: The user's account ID (required) + * Authentication: x-api-key header required. * * @returns List of connectors with connection status */ @@ -27,15 +29,13 @@ export async function GET(request: NextRequest): Promise { const headers = getCorsHeaders(); 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 accountIdOrError = await getApiKeyAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; } + const accountId = accountIdOrError; + const connectors = await getConnectors(accountId); return NextResponse.json( @@ -53,3 +53,46 @@ export async function GET(request: NextRequest): Promise { 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 { + const headers = getCorsHeaders(); + + try { + const accountIdOrError = await getApiKeyAccountId(request); + if (accountIdOrError instanceof NextResponse) { + return accountIdOrError; + } + + const body = await request.json(); + + const validated = validateDisconnectConnectorBody(body); + if (validated instanceof NextResponse) { + return validated; + } + + const { connected_account_id } = validated; + + 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 }); + } +} diff --git a/lib/composio/connectors/validateDisconnectConnectorBody.ts b/lib/composio/connectors/validateDisconnectConnectorBody.ts new file mode 100644 index 00000000..70dfb150 --- /dev/null +++ b/lib/composio/connectors/validateDisconnectConnectorBody.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const disconnectConnectorBodySchema = z.object({ + connected_account_id: z.string().min(1, "connected_account_id is required"), +}); + +export type DisconnectConnectorBody = z.infer; + +/** + * Validates request body for POST /api/connectors/disconnect. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateDisconnectConnectorBody(body: unknown): NextResponse | DisconnectConnectorBody { + const result = disconnectConnectorBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/composio/getCallbackUrl.ts b/lib/composio/getCallbackUrl.ts index 5aa3ee0e..570c9251 100644 --- a/lib/composio/getCallbackUrl.ts +++ b/lib/composio/getCallbackUrl.ts @@ -1,3 +1,5 @@ +import { getFrontendBaseUrl } from "./getFrontendBaseUrl"; + /** * Build OAuth callback URL based on environment and destination. * @@ -12,14 +14,6 @@ interface CallbackOptions { roomId?: string; } -/** - * Get the base URL for the frontend based on environment. - */ -function getFrontendBaseUrl(): string { - const isProd = process.env.VERCEL_ENV === "production"; - return isProd ? "https://chat.recoupable.com" : "http://localhost:3001"; -} - /** * Build callback URL for OAuth redirects. * diff --git a/lib/composio/getFrontendBaseUrl.ts b/lib/composio/getFrontendBaseUrl.ts new file mode 100644 index 00000000..605dc96f --- /dev/null +++ b/lib/composio/getFrontendBaseUrl.ts @@ -0,0 +1,12 @@ +/** + * Get the base URL for the frontend based on environment. + * + * Why: Different environments (production vs local) need different URLs + * for OAuth callbacks and other frontend redirects. + * + * @returns The frontend base URL (e.g., "https://chat.recoupable.com" or "http://localhost:3001") + */ +export function getFrontendBaseUrl(): string { + const isProd = process.env.VERCEL_ENV === "production"; + return isProd ? "https://chat.recoupable.com" : "http://localhost:3001"; +} diff --git a/lib/composio/toolRouter/getTools.ts b/lib/composio/toolRouter/getTools.ts index d064fc82..8701f9dd 100644 --- a/lib/composio/toolRouter/getTools.ts +++ b/lib/composio/toolRouter/getTools.ts @@ -10,16 +10,25 @@ export interface ComposioTool { execute: (args: unknown) => Promise; } +/** + * Tools we want to expose from Composio Tool Router. + * Once we're ready to add all tools, remove this filter. + */ +const ALLOWED_TOOLS = [ + "COMPOSIO_MANAGE_CONNECTIONS", + "COMPOSIO_SEARCH_TOOLS", + "COMPOSIO_GET_TOOL_SCHEMAS", + "COMPOSIO_MULTI_EXECUTE_TOOL", +]; + /** * Get Composio Tool Router tools for a user. * - * Returns 6 meta-tools: + * Returns a filtered subset of meta-tools: * - COMPOSIO_MANAGE_CONNECTIONS - OAuth/auth management * - COMPOSIO_SEARCH_TOOLS - Find available connectors * - COMPOSIO_GET_TOOL_SCHEMAS - Get parameter schemas * - COMPOSIO_MULTI_EXECUTE_TOOL - Execute actions - * - COMPOSIO_REMOTE_BASH_TOOL - Remote bash - * - COMPOSIO_REMOTE_WORKBENCH - Workbench * * @param userId - Unique identifier for the user (accountId) * @param roomId - Optional chat room ID for OAuth redirect @@ -30,6 +39,15 @@ export async function getComposioTools( roomId?: string, ): Promise> { const session = await createToolRouterSession(userId, roomId); - const tools = await session.tools(); - return tools as Record; + const allTools = (await session.tools()) as Record; + + // Filter to only allowed tools + const filteredTools: Record = {}; + for (const toolName of ALLOWED_TOOLS) { + if (allTools[toolName]) { + filteredTools[toolName] = allTools[toolName]; + } + } + + return filteredTools; } diff --git a/lib/mcp/tools/composio/registerComposioTools.ts b/lib/mcp/tools/composio/registerComposioTools.ts index 4a7ec112..0515a8f5 100644 --- a/lib/mcp/tools/composio/registerComposioTools.ts +++ b/lib/mcp/tools/composio/registerComposioTools.ts @@ -5,11 +5,7 @@ import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; import { getToolResultError } from "@/lib/mcp/getToolResultError"; /** - * Schema for the composio meta-tool. - * - * Since Tool Router tools require a userId to create a session, - * we wrap all Composio tools in a single MCP tool that accepts - * account_id and routes to the appropriate Composio tool. + * Schema for the composio tool. */ const composioToolSchema = z.object({ account_id: z @@ -20,63 +16,37 @@ const composioToolSchema = z.object({ .string() .optional() .describe("The current chat room ID for OAuth redirect (from URL path)"), - tool_name: z - .string() - .min(1) - .describe( - "The Composio tool to use. Options: COMPOSIO_SEARCH_TOOLS, COMPOSIO_MANAGE_CONNECTIONS, COMPOSIO_GET_TOOL_SCHEMAS, COMPOSIO_MULTI_EXECUTE_TOOL", - ), - tool_params: z - .record(z.string(), z.unknown()) - .optional() - .describe( - "Parameters for the Composio tool. " + - "For COMPOSIO_MANAGE_CONNECTIONS: {toolkits: ['googlesheets']} (lowercase). " + - "For COMPOSIO_SEARCH_TOOLS: {queries: [{query: 'read google sheets'}]}. " + - "For COMPOSIO_MULTI_EXECUTE_TOOL: {tool_calls: [{tool_slug: 'TOOL_NAME', parameters: {...}}]}.", - ), }); type ComposioToolArgs = z.infer; /** * Tool description that helps the LLM understand Composio's capabilities. - * Includes categories of available services and usage patterns. */ const COMPOSIO_TOOL_DESCRIPTION = ` -Access 500+ external services via Composio. Use this when the user wants to interact with: +Get available Composio tools for accessing 500+ external services. -**GOOGLE SUITE**: Gmail (read/send emails), Google Sheets (create/read/update spreadsheets), Google Drive (files), Google Docs, Google Calendar (events) -**PRODUCTIVITY**: Slack (messages), Notion (pages/databases), Linear (issues), Jira, Airtable, Trello, Asana -**DEVELOPMENT**: GitHub (repos/issues/PRs), GitLab, Bitbucket +**GOOGLE SUITE**: Gmail, Google Sheets, Google Drive, Google Docs, Google Calendar +**PRODUCTIVITY**: Slack, Notion, Linear, Jira, Airtable, Trello, Asana +**DEVELOPMENT**: GitHub, GitLab, Bitbucket **CRM/SALES**: HubSpot, Salesforce, Pipedrive **COMMUNICATION**: Outlook, Microsoft Teams, Discord, Zoom -**SOCIAL**: Twitter/X (posts), LinkedIn +**SOCIAL**: Twitter/X, LinkedIn -HOW TO USE: -1. First, CONNECT the user's account: tool_name='COMPOSIO_MANAGE_CONNECTIONS', tool_params={"toolkits":["gmail"]} -2. Search for specific actions: tool_name='COMPOSIO_SEARCH_TOOLS', tool_params={"queries":[{"query":"read emails"}]} -3. Execute actions: tool_name='COMPOSIO_MULTI_EXECUTE_TOOL', tool_params={"tool_calls":[{"tool_slug":"GMAIL_LIST_EMAILS","parameters":{}}]} +Returns available tools: +- COMPOSIO_MANAGE_CONNECTIONS - Connect user accounts (OAuth) +- COMPOSIO_SEARCH_TOOLS - Find available actions +- COMPOSIO_GET_TOOL_SCHEMAS - Get parameter schemas +- COMPOSIO_MULTI_EXECUTE_TOOL - Execute actions -EXAMPLES: -- Read emails: Connect 'gmail', then use GMAIL_LIST_EMAILS or GMAIL_GET_EMAIL -- Create spreadsheet: Connect 'googlesheets', then use GOOGLESHEETS_CREATE -- Send Slack message: Connect 'slack', then use SLACK_POST_MESSAGE -- Create GitHub issue: Connect 'github', then use GITHUB_CREATE_ISSUE - -IMPORTANT: toolkit names are LOWERCASE (gmail, googlesheets, slack). Always include account_id and room_id. +IMPORTANT: toolkit names are LOWERCASE (gmail, googlesheets, slack). `.trim(); /** * Registers the "composio" tool on the MCP server. * - * This is a meta-tool that provides access to Composio's Tool Router, - * which enables Google Sheets, Google Drive, and 500+ other connectors. - * - * The AI should: - * 1. Use COMPOSIO_SEARCH_TOOLS to find available actions - * 2. Use COMPOSIO_MANAGE_CONNECTIONS if authentication is needed - * 3. Use COMPOSIO_MULTI_EXECUTE_TOOL to run actions + * Returns raw tools from Composio's Tool Router for the given user. + * Tools are filtered to the subset we currently support. * * @param server - The MCP server instance to register the tool on. */ @@ -90,20 +60,10 @@ export function registerComposioTools(server: McpServer): void { async (args: ComposioToolArgs) => { try { const tools = await getComposioTools(args.account_id, args.room_id); - const tool = tools[args.tool_name]; - - if (!tool) { - const availableTools = Object.keys(tools); - return getToolResultError( - `Tool "${args.tool_name}" not found. Available tools: ${availableTools.join(", ")}`, - ); - } - - const result = await tool.execute(args.tool_params || {}); - return getToolResultSuccess(result); + return getToolResultSuccess(tools); } catch (error) { return getToolResultError( - error instanceof Error ? error.message : "Composio tool execution failed", + error instanceof Error ? error.message : "Failed to get Composio tools", ); } }, From 50f75a6c8492ce0a0498f1b39df2e922587179f9 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 19 Jan 2026 12:44:01 -0500 Subject: [PATCH 03/18] refactor: use Zod schema validation for /api/connectors/authorize endpoint - Create validateAuthorizeConnectorBody.ts with Zod schema - Validate connector (required string) and callback_url (optional URL) - Update route to use validation function per coding guidelines --- app/api/connectors/authorize/route.ts | 11 +++-- .../validateAuthorizeConnectorBody.ts | 41 +++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 lib/composio/connectors/validateAuthorizeConnectorBody.ts diff --git a/app/api/connectors/authorize/route.ts b/app/api/connectors/authorize/route.ts index ecf55b73..5bf54d6b 100644 --- a/app/api/connectors/authorize/route.ts +++ b/app/api/connectors/authorize/route.ts @@ -3,6 +3,7 @@ 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. @@ -39,15 +40,13 @@ export async function POST(request: NextRequest): Promise { const accountId = accountIdOrError; const body = await request.json(); - const { connector, callback_url } = body; - if (!connector) { - return NextResponse.json( - { error: "connector is required (e.g., 'googlesheets', 'gmail')" }, - { status: 400, headers }, - ); + 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( diff --git a/lib/composio/connectors/validateAuthorizeConnectorBody.ts b/lib/composio/connectors/validateAuthorizeConnectorBody.ts new file mode 100644 index 00000000..df3570e3 --- /dev/null +++ b/lib/composio/connectors/validateAuthorizeConnectorBody.ts @@ -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; +} From b3a8f31f8f3e07f0c331e1138f38d7264bd53a3e Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 19 Jan 2026 12:48:29 -0500 Subject: [PATCH 04/18] security: verify connector ownership before disconnect - Add verifyConnectorOwnership() to check connected_account_id belongs to user - DELETE /api/connectors now returns 403 if user doesn't own the connection - Prevents authorization bypass where users could disconnect other users' connectors --- app/api/connectors/route.ts | 11 +++++++++ .../connectors/verifyConnectorOwnership.ts | 23 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 lib/composio/connectors/verifyConnectorOwnership.ts diff --git a/app/api/connectors/route.ts b/app/api/connectors/route.ts index 3ba84b61..8dfdab95 100644 --- a/app/api/connectors/route.ts +++ b/app/api/connectors/route.ts @@ -4,6 +4,7 @@ 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"; /** @@ -72,6 +73,7 @@ export async function DELETE(request: NextRequest): Promise { return accountIdOrError; } + const accountId = accountIdOrError; const body = await request.json(); const validated = validateDisconnectConnectorBody(body); @@ -81,6 +83,15 @@ export async function DELETE(request: NextRequest): Promise { 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( diff --git a/lib/composio/connectors/verifyConnectorOwnership.ts b/lib/composio/connectors/verifyConnectorOwnership.ts new file mode 100644 index 00000000..dc4f7a67 --- /dev/null +++ b/lib/composio/connectors/verifyConnectorOwnership.ts @@ -0,0 +1,23 @@ +import { getConnectors } from "./getConnectors"; + +/** + * Verifies that a connected account ID belongs to the specified user. + * + * Why: Before disconnecting a connector, we must verify ownership to prevent + * users from disconnecting other users' connectors (authorization bypass). + * + * @param accountId - The authenticated user's account ID + * @param connectedAccountId - The connected account ID to verify + * @returns true if the connected account belongs to the user, false otherwise + */ +export async function verifyConnectorOwnership( + accountId: string, + connectedAccountId: string +): Promise { + const connectors = await getConnectors(accountId); + + // Check if any of the user's connectors have this connected account ID + return connectors.some( + (connector) => connector.connectedAccountId === connectedAccountId + ); +} From 376aab86a5738636553140034f1dfa716724d564 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 19 Jan 2026 12:51:25 -0500 Subject: [PATCH 05/18] fix: add runtime validation for Composio session.tools() return value - Remove unsafe type assertion 'as Record' - Add isComposioTool() type guard with runtime validation - Use Object.prototype.hasOwnProperty for safe property access - Validates description (string), inputSchema (exists), execute (function) Addresses CodeRabbit's critical issue about unsafe type casting on SDK return values. --- lib/composio/toolRouter/getTools.ts | 39 +++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/lib/composio/toolRouter/getTools.ts b/lib/composio/toolRouter/getTools.ts index 8701f9dd..92683170 100644 --- a/lib/composio/toolRouter/getTools.ts +++ b/lib/composio/toolRouter/getTools.ts @@ -21,6 +21,28 @@ const ALLOWED_TOOLS = [ "COMPOSIO_MULTI_EXECUTE_TOOL", ]; +/** + * Runtime validation to check if an object conforms to ComposioTool interface. + * + * Why: The Composio SDK returns a Tools class instance from session.tools(), + * not a plain object. We validate each tool at runtime to ensure type safety. + * + * @param tool - The object to validate + * @returns true if the object has required ComposioTool properties + */ +function isComposioTool(tool: unknown): tool is ComposioTool { + if (typeof tool !== "object" || tool === null) { + return false; + } + + const obj = tool as Record; + return ( + typeof obj.description === "string" && + "inputSchema" in obj && + typeof obj.execute === "function" + ); +} + /** * Get Composio Tool Router tools for a user. * @@ -36,16 +58,23 @@ const ALLOWED_TOOLS = [ */ export async function getComposioTools( userId: string, - roomId?: string, + roomId?: string ): Promise> { const session = await createToolRouterSession(userId, roomId); - const allTools = (await session.tools()) as Record; + const allTools = await session.tools(); - // Filter to only allowed tools + // Filter to only allowed tools with runtime validation const filteredTools: Record = {}; + for (const toolName of ALLOWED_TOOLS) { - if (allTools[toolName]) { - filteredTools[toolName] = allTools[toolName]; + // Use Object.prototype.hasOwnProperty to safely check for property existence + // This handles both plain objects and class instances safely + if (Object.prototype.hasOwnProperty.call(allTools, toolName)) { + const tool = (allTools as Record)[toolName]; + + if (isComposioTool(tool)) { + filteredTools[toolName] = tool; + } } } From 1cc260afa133f8b04f379143584ba566359204d2 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 19 Jan 2026 12:58:33 -0500 Subject: [PATCH 06/18] refactor: use proper Vercel AI SDK types for Tool Router - Replace custom ComposioTool interface with Vercel AI SDK's Tool type - Import Tool and ToolSet types from 'ai' package - Update isValidTool() to check for 'parameters' (SDK format) instead of 'inputSchema' - Use ToolSet as return type for getComposioTools() - Maintains runtime validation while using correct SDK types This aligns with Tool Router's actual return type when using VercelProvider: session.tools() returns ToolSet (Record) from Vercel AI SDK. --- lib/composio/toolRouter/getTools.ts | 42 +++++++++++++---------------- lib/composio/toolRouter/index.ts | 2 +- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/lib/composio/toolRouter/getTools.ts b/lib/composio/toolRouter/getTools.ts index 92683170..0140dd58 100644 --- a/lib/composio/toolRouter/getTools.ts +++ b/lib/composio/toolRouter/getTools.ts @@ -1,14 +1,5 @@ import { createToolRouterSession } from "./createSession"; - -/** - * Tool returned by Composio Tool Router. - * Uses inputSchema (not parameters) which is MCP-compatible. - */ -export interface ComposioTool { - description: string; - inputSchema: unknown; - execute: (args: unknown) => Promise; -} +import type { Tool, ToolSet } from "ai"; /** * Tools we want to expose from Composio Tool Router. @@ -22,25 +13,28 @@ const ALLOWED_TOOLS = [ ]; /** - * Runtime validation to check if an object conforms to ComposioTool interface. + * Runtime validation to check if an object is a valid Vercel AI SDK Tool. * - * Why: The Composio SDK returns a Tools class instance from session.tools(), - * not a plain object. We validate each tool at runtime to ensure type safety. + * Why: The Composio SDK's session.tools() returns a ToolSet (Record) + * from the configured provider. With VercelProvider, this returns Vercel AI SDK tools. + * We validate at runtime to ensure type safety before using bracket notation access. * * @param tool - The object to validate - * @returns true if the object has required ComposioTool properties + * @returns true if the object has required Tool properties */ -function isComposioTool(tool: unknown): tool is ComposioTool { +function isValidTool(tool: unknown): tool is Tool { if (typeof tool !== "object" || tool === null) { return false; } const obj = tool as Record; - return ( - typeof obj.description === "string" && - "inputSchema" in obj && - typeof obj.execute === "function" - ); + + // Vercel AI SDK Tool requires: description (optional), parameters, execute + // The execute function is what makes it callable + const hasExecute = typeof obj.execute === "function"; + const hasParameters = "parameters" in obj; + + return hasExecute && hasParameters; } /** @@ -54,17 +48,17 @@ function isComposioTool(tool: unknown): tool is ComposioTool { * * @param userId - Unique identifier for the user (accountId) * @param roomId - Optional chat room ID for OAuth redirect - * @returns Record of tool name to tool definition + * @returns ToolSet containing filtered Vercel AI SDK tools */ export async function getComposioTools( userId: string, roomId?: string -): Promise> { +): Promise { const session = await createToolRouterSession(userId, roomId); const allTools = await session.tools(); // Filter to only allowed tools with runtime validation - const filteredTools: Record = {}; + const filteredTools: ToolSet = {}; for (const toolName of ALLOWED_TOOLS) { // Use Object.prototype.hasOwnProperty to safely check for property existence @@ -72,7 +66,7 @@ export async function getComposioTools( if (Object.prototype.hasOwnProperty.call(allTools, toolName)) { const tool = (allTools as Record)[toolName]; - if (isComposioTool(tool)) { + if (isValidTool(tool)) { filteredTools[toolName] = tool; } } diff --git a/lib/composio/toolRouter/index.ts b/lib/composio/toolRouter/index.ts index 1ed43eda..0e3bb33c 100644 --- a/lib/composio/toolRouter/index.ts +++ b/lib/composio/toolRouter/index.ts @@ -1,2 +1,2 @@ export { createToolRouterSession } from "./createSession"; -export { getComposioTools, type ComposioTool } from "./getTools"; +export { getComposioTools } from "./getTools"; From 90796fb58a3f8cc8a5c5ca84a050d002560ebf6e Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:12:47 -0500 Subject: [PATCH 07/18] fix: support OAuth callbacks on Vercel preview deployments - Check for VERCEL_URL to support preview deployments - Preview URLs need https:// prepended (VERCEL_URL doesn't include protocol) - Maintains production (chat.recoupable.com) and local (localhost:3001) behavior Fixes OAuth redirect issue where preview deployments would incorrectly redirect to localhost, which is unreachable. --- lib/composio/getFrontendBaseUrl.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/composio/getFrontendBaseUrl.ts b/lib/composio/getFrontendBaseUrl.ts index 605dc96f..41417527 100644 --- a/lib/composio/getFrontendBaseUrl.ts +++ b/lib/composio/getFrontendBaseUrl.ts @@ -1,12 +1,27 @@ /** * Get the base URL for the frontend based on environment. * - * Why: Different environments (production vs local) need different URLs + * Why: Different environments (production, preview, local) need different URLs * for OAuth callbacks and other frontend redirects. * - * @returns The frontend base URL (e.g., "https://chat.recoupable.com" or "http://localhost:3001") + * - Production: Uses the canonical chat.recoupable.com domain + * - Preview (Vercel): Uses VERCEL_URL for the deployment-specific URL + * - Local: Falls back to localhost:3001 + * + * @returns The frontend base URL (e.g., "https://chat.recoupable.com") */ export function getFrontendBaseUrl(): string { - const isProd = process.env.VERCEL_ENV === "production"; - return isProd ? "https://chat.recoupable.com" : "http://localhost:3001"; + // Production environment + if (process.env.VERCEL_ENV === "production") { + return "https://chat.recoupable.com"; + } + + // Vercel preview deployments - use the deployment URL + // VERCEL_URL doesn't include protocol, so we prepend https:// + if (process.env.VERCEL_URL) { + return `https://${process.env.VERCEL_URL}`; + } + + // Local development fallback + return "http://localhost:3001"; } From 9808865a047e617d05ade76c369eb53ee9a17c9e Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:15:59 -0500 Subject: [PATCH 08/18] fix: add test branch support for frontend URL - Check VERCEL_GIT_COMMIT_REF for 'test' branch specifically - Use stable test frontend URL for test deployments - Maintains preview URL fallback for other feature branches - Easier to update TEST_FRONTEND_URL constant if domain changes --- lib/composio/getFrontendBaseUrl.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/composio/getFrontendBaseUrl.ts b/lib/composio/getFrontendBaseUrl.ts index 41417527..60d24c5e 100644 --- a/lib/composio/getFrontendBaseUrl.ts +++ b/lib/composio/getFrontendBaseUrl.ts @@ -1,11 +1,18 @@ +/** + * Frontend base URL for the test environment. + * Set this to your test Recoup-Chat deployment domain. + */ +const TEST_FRONTEND_URL = "https://recoup-chat-git-test-recoupable.vercel.app"; + /** * Get the base URL for the frontend based on environment. * - * Why: Different environments (production, preview, local) need different URLs + * Why: Different environments (production, test, preview, local) need different URLs * for OAuth callbacks and other frontend redirects. * * - Production: Uses the canonical chat.recoupable.com domain - * - Preview (Vercel): Uses VERCEL_URL for the deployment-specific URL + * - Test branch: Uses the test frontend deployment + * - Preview (Vercel): Uses VERCEL_URL for deployment-specific URL * - Local: Falls back to localhost:3001 * * @returns The frontend base URL (e.g., "https://chat.recoupable.com") @@ -16,6 +23,12 @@ export function getFrontendBaseUrl(): string { return "https://chat.recoupable.com"; } + // Test branch deployment - uses stable test frontend + // VERCEL_GIT_COMMIT_REF contains the branch name on Vercel + if (process.env.VERCEL_GIT_COMMIT_REF === "test") { + return TEST_FRONTEND_URL; + } + // Vercel preview deployments - use the deployment URL // VERCEL_URL doesn't include protocol, so we prepend https:// if (process.env.VERCEL_URL) { From 47cf4bf96300fa4ee89f6db7e1f145eaa3195d4a Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:12:19 -0500 Subject: [PATCH 09/18] fix: use correct test frontend URL pattern Match API pattern: test-recoup-api -> test-recoup-chat --- lib/composio/getFrontendBaseUrl.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/composio/getFrontendBaseUrl.ts b/lib/composio/getFrontendBaseUrl.ts index 60d24c5e..8c5d3a95 100644 --- a/lib/composio/getFrontendBaseUrl.ts +++ b/lib/composio/getFrontendBaseUrl.ts @@ -1,8 +1,8 @@ /** * Frontend base URL for the test environment. - * Set this to your test Recoup-Chat deployment domain. + * Follows same pattern as API: test-recoup-api -> test-recoup-chat */ -const TEST_FRONTEND_URL = "https://recoup-chat-git-test-recoupable.vercel.app"; +const TEST_FRONTEND_URL = "https://test-recoup-chat.vercel.app"; /** * Get the base URL for the frontend based on environment. From d351e3c7aa0bc6ba98db890a2a9ffe36e8d203a8 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:14:15 -0500 Subject: [PATCH 10/18] refactor: simplify registerComposioTools per KISS principle - Remove verbose description constant and inline it - Remove separate schema type definition - Inline schema directly in registerTool call - Keep same functionality: passes raw results from getComposioTools - Tool filtering handled by ALLOWED_TOOLS in getTools.ts --- .../tools/composio/registerComposioTools.ts | 56 ++++--------------- 1 file changed, 11 insertions(+), 45 deletions(-) diff --git a/lib/mcp/tools/composio/registerComposioTools.ts b/lib/mcp/tools/composio/registerComposioTools.ts index 0515a8f5..02a1cf4e 100644 --- a/lib/mcp/tools/composio/registerComposioTools.ts +++ b/lib/mcp/tools/composio/registerComposioTools.ts @@ -4,49 +4,11 @@ import { getComposioTools } from "@/lib/composio/toolRouter"; import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; import { getToolResultError } from "@/lib/mcp/getToolResultError"; -/** - * Schema for the composio tool. - */ -const composioToolSchema = z.object({ - account_id: z - .string() - .min(1) - .describe("The user's account ID (from system prompt)"), - room_id: z - .string() - .optional() - .describe("The current chat room ID for OAuth redirect (from URL path)"), -}); - -type ComposioToolArgs = z.infer; - -/** - * Tool description that helps the LLM understand Composio's capabilities. - */ -const COMPOSIO_TOOL_DESCRIPTION = ` -Get available Composio tools for accessing 500+ external services. - -**GOOGLE SUITE**: Gmail, Google Sheets, Google Drive, Google Docs, Google Calendar -**PRODUCTIVITY**: Slack, Notion, Linear, Jira, Airtable, Trello, Asana -**DEVELOPMENT**: GitHub, GitLab, Bitbucket -**CRM/SALES**: HubSpot, Salesforce, Pipedrive -**COMMUNICATION**: Outlook, Microsoft Teams, Discord, Zoom -**SOCIAL**: Twitter/X, LinkedIn - -Returns available tools: -- COMPOSIO_MANAGE_CONNECTIONS - Connect user accounts (OAuth) -- COMPOSIO_SEARCH_TOOLS - Find available actions -- COMPOSIO_GET_TOOL_SCHEMAS - Get parameter schemas -- COMPOSIO_MULTI_EXECUTE_TOOL - Execute actions - -IMPORTANT: toolkit names are LOWERCASE (gmail, googlesheets, slack). -`.trim(); - /** * Registers the "composio" tool on the MCP server. * - * Returns raw tools from Composio's Tool Router for the given user. - * Tools are filtered to the subset we currently support. + * Returns raw Composio Tool Router tools for the given user. + * Tools are filtered in getComposioTools via ALLOWED_TOOLS constant. * * @param server - The MCP server instance to register the tool on. */ @@ -54,18 +16,22 @@ export function registerComposioTools(server: McpServer): void { server.registerTool( "composio", { - description: COMPOSIO_TOOL_DESCRIPTION, - inputSchema: composioToolSchema, + description: + "Get Composio tools for accessing 500+ external services (Gmail, Sheets, Slack, GitHub, etc). Returns meta-tools for connecting accounts, searching actions, and executing them.", + inputSchema: z.object({ + account_id: z.string().min(1).describe("User's account ID"), + room_id: z.string().optional().describe("Chat room ID for OAuth redirect"), + }), }, - async (args: ComposioToolArgs) => { + async (args) => { try { const tools = await getComposioTools(args.account_id, args.room_id); return getToolResultSuccess(tools); } catch (error) { return getToolResultError( - error instanceof Error ? error.message : "Failed to get Composio tools", + error instanceof Error ? error.message : "Failed to get Composio tools" ); } - }, + } ); } From b74b074652bafd4d90d1c8eed596b72151443a32 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:50:50 -0500 Subject: [PATCH 11/18] refactor: use getCallToolResult directly per KISS principle - Remove getToolResultSuccess and getToolResultError wrapper imports - Use getCallToolResult(JSON.stringify(...)) directly - Follows KISS: pass raw results without unnecessary wrappers --- lib/mcp/tools/composio/registerComposioTools.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/mcp/tools/composio/registerComposioTools.ts b/lib/mcp/tools/composio/registerComposioTools.ts index 02a1cf4e..42a232d5 100644 --- a/lib/mcp/tools/composio/registerComposioTools.ts +++ b/lib/mcp/tools/composio/registerComposioTools.ts @@ -1,8 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { getComposioTools } from "@/lib/composio/toolRouter"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; -import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { getCallToolResult } from "@/lib/mcp/getCallToolResult"; /** * Registers the "composio" tool on the MCP server. @@ -26,11 +25,10 @@ export function registerComposioTools(server: McpServer): void { async (args) => { try { const tools = await getComposioTools(args.account_id, args.room_id); - return getToolResultSuccess(tools); + return getCallToolResult(JSON.stringify(tools)); } catch (error) { - return getToolResultError( - error instanceof Error ? error.message : "Failed to get Composio tools" - ); + const message = error instanceof Error ? error.message : "Failed to get Composio tools"; + return getCallToolResult(JSON.stringify({ error: message })); } } ); From 1cc3ec59701300d999e99447fbe6fae223864845 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:06:39 -0500 Subject: [PATCH 12/18] fix: use correct frontend port 3000 for local development - Frontend (Recoup-Chat) runs on port 3000, not 3001 - API (Recoup-API) runs on port 3001 - getFrontendBaseUrl should return frontend port --- lib/composio/getFrontendBaseUrl.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/composio/getFrontendBaseUrl.ts b/lib/composio/getFrontendBaseUrl.ts index 8c5d3a95..da6b946a 100644 --- a/lib/composio/getFrontendBaseUrl.ts +++ b/lib/composio/getFrontendBaseUrl.ts @@ -13,7 +13,7 @@ const TEST_FRONTEND_URL = "https://test-recoup-chat.vercel.app"; * - Production: Uses the canonical chat.recoupable.com domain * - Test branch: Uses the test frontend deployment * - Preview (Vercel): Uses VERCEL_URL for deployment-specific URL - * - Local: Falls back to localhost:3001 + * - Local: Falls back to localhost:3000 (Recoup-Chat frontend port) * * @returns The frontend base URL (e.g., "https://chat.recoupable.com") */ @@ -35,6 +35,6 @@ export function getFrontendBaseUrl(): string { return `https://${process.env.VERCEL_URL}`; } - // Local development fallback - return "http://localhost:3001"; + // Local development fallback - Recoup-Chat runs on port 3000 + return "http://localhost:3000"; } From 453f1bd2726e87d52f7bd56d7079be26a6e9030c Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:07:21 -0500 Subject: [PATCH 13/18] refactor: remove verbose comments from getFrontendBaseUrl --- lib/composio/getFrontendBaseUrl.ts | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/lib/composio/getFrontendBaseUrl.ts b/lib/composio/getFrontendBaseUrl.ts index da6b946a..7a5dbe18 100644 --- a/lib/composio/getFrontendBaseUrl.ts +++ b/lib/composio/getFrontendBaseUrl.ts @@ -1,40 +1,20 @@ -/** - * Frontend base URL for the test environment. - * Follows same pattern as API: test-recoup-api -> test-recoup-chat - */ const TEST_FRONTEND_URL = "https://test-recoup-chat.vercel.app"; /** - * Get the base URL for the frontend based on environment. - * - * Why: Different environments (production, test, preview, local) need different URLs - * for OAuth callbacks and other frontend redirects. - * - * - Production: Uses the canonical chat.recoupable.com domain - * - Test branch: Uses the test frontend deployment - * - Preview (Vercel): Uses VERCEL_URL for deployment-specific URL - * - Local: Falls back to localhost:3000 (Recoup-Chat frontend port) - * - * @returns The frontend base URL (e.g., "https://chat.recoupable.com") + * Get the frontend base URL based on environment. */ export function getFrontendBaseUrl(): string { - // Production environment if (process.env.VERCEL_ENV === "production") { return "https://chat.recoupable.com"; } - // Test branch deployment - uses stable test frontend - // VERCEL_GIT_COMMIT_REF contains the branch name on Vercel if (process.env.VERCEL_GIT_COMMIT_REF === "test") { return TEST_FRONTEND_URL; } - // Vercel preview deployments - use the deployment URL - // VERCEL_URL doesn't include protocol, so we prepend https:// if (process.env.VERCEL_URL) { return `https://${process.env.VERCEL_URL}`; } - // Local development fallback - Recoup-Chat runs on port 3000 return "http://localhost:3000"; } From 5a9d55ef03e242fe3847ed125d2505f123be4f27 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:09:19 -0500 Subject: [PATCH 14/18] refactor: simplify registerComposioTools to pass raw results - Remove JSDoc comments - Remove try/catch wrapper - Simplify description and schema - Just pass through results from getComposioTools directly --- .../tools/composio/registerComposioTools.ts | 24 ++++--------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/lib/mcp/tools/composio/registerComposioTools.ts b/lib/mcp/tools/composio/registerComposioTools.ts index 42a232d5..b438803d 100644 --- a/lib/mcp/tools/composio/registerComposioTools.ts +++ b/lib/mcp/tools/composio/registerComposioTools.ts @@ -3,33 +3,19 @@ import { z } from "zod"; import { getComposioTools } from "@/lib/composio/toolRouter"; import { getCallToolResult } from "@/lib/mcp/getCallToolResult"; -/** - * Registers the "composio" tool on the MCP server. - * - * Returns raw Composio Tool Router tools for the given user. - * Tools are filtered in getComposioTools via ALLOWED_TOOLS constant. - * - * @param server - The MCP server instance to register the tool on. - */ export function registerComposioTools(server: McpServer): void { server.registerTool( "composio", { - description: - "Get Composio tools for accessing 500+ external services (Gmail, Sheets, Slack, GitHub, etc). Returns meta-tools for connecting accounts, searching actions, and executing them.", + description: "Get Composio tools for accessing external services.", inputSchema: z.object({ - account_id: z.string().min(1).describe("User's account ID"), - room_id: z.string().optional().describe("Chat room ID for OAuth redirect"), + account_id: z.string().min(1), + room_id: z.string().optional(), }), }, async (args) => { - try { - const tools = await getComposioTools(args.account_id, args.room_id); - return getCallToolResult(JSON.stringify(tools)); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to get Composio tools"; - return getCallToolResult(JSON.stringify({ error: message })); - } + const tools = await getComposioTools(args.account_id, args.room_id); + return getCallToolResult(JSON.stringify(tools)); } ); } From 7bc18e012f990323b4a5243ed5b327e8d3388ea9 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:11:19 -0500 Subject: [PATCH 15/18] docs: update composio tool description to mention Google Sheets --- lib/mcp/tools/composio/registerComposioTools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mcp/tools/composio/registerComposioTools.ts b/lib/mcp/tools/composio/registerComposioTools.ts index b438803d..6ec956d5 100644 --- a/lib/mcp/tools/composio/registerComposioTools.ts +++ b/lib/mcp/tools/composio/registerComposioTools.ts @@ -7,7 +7,7 @@ export function registerComposioTools(server: McpServer): void { server.registerTool( "composio", { - description: "Get Composio tools for accessing external services.", + description: "Get Composio tools for Google Sheets integration.", inputSchema: z.object({ account_id: z.string().min(1), room_id: z.string().optional(), From bdd98b7d99fbf55b0d15d9e7f231dbae479c9f0d Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:15:19 -0500 Subject: [PATCH 16/18] feat: limit Composio Tool Router to Google Sheets only - Add ENABLED_TOOLKITS constant for easy expansion later - Pass toolkits option to session.create() per Composio docs - Currently only googlesheets is enabled --- lib/composio/toolRouter/createSession.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/composio/toolRouter/createSession.ts b/lib/composio/toolRouter/createSession.ts index 409408dc..1cd36e41 100644 --- a/lib/composio/toolRouter/createSession.ts +++ b/lib/composio/toolRouter/createSession.ts @@ -1,15 +1,14 @@ import { getComposioClient } from "../client"; import { getCallbackUrl } from "../getCallbackUrl"; +/** + * Toolkits available in Tool Router sessions. + * Add more toolkits here as we expand Composio integration. + */ +const ENABLED_TOOLKITS = ["googlesheets"]; + /** * Create a Composio Tool Router session for a user. - * - * Why: Tool Router provides meta-tools for searching, connecting, - * and executing 500+ connectors through a single session. - * - * @param userId - Unique identifier for the user (accountId) - * @param roomId - Optional chat room ID for OAuth redirect - * @returns Composio Tool Router session */ export async function createToolRouterSession(userId: string, roomId?: string) { const composio = getComposioClient(); @@ -20,6 +19,7 @@ export async function createToolRouterSession(userId: string, roomId?: string) { }); const session = await composio.create(userId, { + toolkits: ENABLED_TOOLKITS, manageConnections: { callbackUrl, }, From b0cedbc47178a11a64ab16dee038054019bbbcbb Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:29:32 -0500 Subject: [PATCH 17/18] fix: get accountId from auth context instead of tool schema - Remove account_id from composio tool input schema - Get accountId from MCP auth info via resolveAccountId - Keep room_id as optional input for OAuth redirect - Follows API auth pattern used by other MCP tools --- .../tools/composio/registerComposioTools.ts | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/lib/mcp/tools/composio/registerComposioTools.ts b/lib/mcp/tools/composio/registerComposioTools.ts index 6ec956d5..8c6bec3e 100644 --- a/lib/mcp/tools/composio/registerComposioTools.ts +++ b/lib/mcp/tools/composio/registerComposioTools.ts @@ -1,20 +1,37 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; import { getComposioTools } from "@/lib/composio/toolRouter"; import { getCallToolResult } from "@/lib/mcp/getCallToolResult"; +const composioSchema = z.object({ + room_id: z.string().optional().describe("Chat room ID for OAuth redirect"), +}); + +type ComposioArgs = z.infer; + export function registerComposioTools(server: McpServer): void { server.registerTool( "composio", { description: "Get Composio tools for Google Sheets integration.", - inputSchema: z.object({ - account_id: z.string().min(1), - room_id: z.string().optional(), - }), + inputSchema: composioSchema, }, - async (args) => { - const tools = await getComposioTools(args.account_id, args.room_id); + async (args: ComposioArgs, extra: RequestHandlerExtra) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + + if (error || !accountId) { + return getCallToolResult(JSON.stringify({ error: error || "Authentication required" })); + } + + const tools = await getComposioTools(accountId, args.room_id); return getCallToolResult(JSON.stringify(tools)); } ); From 9b9db79f8f5f95f07c5b0eaaafdf3232112b8d1d Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:32:27 -0500 Subject: [PATCH 18/18] docs: clarify accountId should come from auth, not input schemas - Update CLAUDE.md Authentication section - Add MCP tools pattern using resolveAccountId - Clarify both API keys and Privy tokens resolve to accountId --- CLAUDE.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3055bcfe..5b86b980 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,7 +145,14 @@ export async function selectTableName({ ## Authentication -**Never use `account_id` in request bodies.** Always derive the account ID from the `x-api-key` header using `getApiKeyAccountId()`: +**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"; @@ -157,10 +164,23 @@ if (accountIdOrError instanceof NextResponse) { 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 a validated API key +- Account ID is derived from validated credentials ## Input Validation