diff --git a/apps/agent/assets/mcp-icons/asana.svg b/apps/agent/assets/mcp-icons/asana.svg new file mode 100644 index 000000000..8a89c47f4 --- /dev/null +++ b/apps/agent/assets/mcp-icons/asana.svg @@ -0,0 +1,6 @@ + + + + diff --git a/apps/agent/assets/mcp-icons/box.svg b/apps/agent/assets/mcp-icons/box.svg new file mode 100644 index 000000000..1c88da6b6 --- /dev/null +++ b/apps/agent/assets/mcp-icons/box.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/agent/assets/mcp-icons/brave_search.svg b/apps/agent/assets/mcp-icons/brave_search.svg new file mode 100644 index 000000000..062dc8940 --- /dev/null +++ b/apps/agent/assets/mcp-icons/brave_search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/agent/assets/mcp-icons/cal_com.svg b/apps/agent/assets/mcp-icons/cal_com.svg new file mode 100644 index 000000000..7d6972a22 --- /dev/null +++ b/apps/agent/assets/mcp-icons/cal_com.svg @@ -0,0 +1,252 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/agent/assets/mcp-icons/clickup.svg b/apps/agent/assets/mcp-icons/clickup.svg new file mode 100644 index 000000000..0ae45e9be --- /dev/null +++ b/apps/agent/assets/mcp-icons/clickup.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/agent/assets/mcp-icons/cloudflare.svg b/apps/agent/assets/mcp-icons/cloudflare.svg new file mode 100644 index 000000000..ebfa9f0f2 --- /dev/null +++ b/apps/agent/assets/mcp-icons/cloudflare.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/agent/assets/mcp-icons/discord.svg b/apps/agent/assets/mcp-icons/discord.svg new file mode 100644 index 000000000..5806fa7fc --- /dev/null +++ b/apps/agent/assets/mcp-icons/discord.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/agent/assets/mcp-icons/dropbox.svg b/apps/agent/assets/mcp-icons/dropbox.svg new file mode 100644 index 000000000..e467522a5 --- /dev/null +++ b/apps/agent/assets/mcp-icons/dropbox.svg @@ -0,0 +1 @@ + diff --git a/apps/agent/assets/mcp-icons/exa.png b/apps/agent/assets/mcp-icons/exa.png new file mode 100644 index 000000000..09a05730d Binary files /dev/null and b/apps/agent/assets/mcp-icons/exa.png differ diff --git a/apps/agent/assets/mcp-icons/google_forms.svg b/apps/agent/assets/mcp-icons/google_forms.svg new file mode 100644 index 000000000..aa541f98a --- /dev/null +++ b/apps/agent/assets/mcp-icons/google_forms.svg @@ -0,0 +1 @@ + diff --git a/apps/agent/assets/mcp-icons/hubspot.svg b/apps/agent/assets/mcp-icons/hubspot.svg new file mode 100644 index 000000000..be31d302d --- /dev/null +++ b/apps/agent/assets/mcp-icons/hubspot.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/apps/agent/assets/mcp-icons/intercom.svg b/apps/agent/assets/mcp-icons/intercom.svg new file mode 100644 index 000000000..157dd7559 --- /dev/null +++ b/apps/agent/assets/mcp-icons/intercom.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/agent/assets/mcp-icons/memo.webp b/apps/agent/assets/mcp-icons/memo.webp new file mode 100644 index 000000000..ed5fa18b9 Binary files /dev/null and b/apps/agent/assets/mcp-icons/memo.webp differ diff --git a/apps/agent/assets/mcp-icons/microsoft_teams.svg b/apps/agent/assets/mcp-icons/microsoft_teams.svg new file mode 100644 index 000000000..3409e6cfa --- /dev/null +++ b/apps/agent/assets/mcp-icons/microsoft_teams.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/agent/assets/mcp-icons/mixpanel.svg b/apps/agent/assets/mcp-icons/mixpanel.svg new file mode 100644 index 000000000..0a983dfcf --- /dev/null +++ b/apps/agent/assets/mcp-icons/mixpanel.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/agent/assets/mcp-icons/monday.svg b/apps/agent/assets/mcp-icons/monday.svg new file mode 100644 index 000000000..795d7c7fc --- /dev/null +++ b/apps/agent/assets/mcp-icons/monday.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/apps/agent/assets/mcp-icons/onedrive.svg b/apps/agent/assets/mcp-icons/onedrive.svg new file mode 100644 index 000000000..f7d7a6a60 --- /dev/null +++ b/apps/agent/assets/mcp-icons/onedrive.svg @@ -0,0 +1 @@ +OfficeCore10_32x_24x_20x_16x_01-22-2019 \ No newline at end of file diff --git a/apps/agent/assets/mcp-icons/outlook.svg b/apps/agent/assets/mcp-icons/outlook.svg new file mode 100644 index 000000000..4e1be88f9 --- /dev/null +++ b/apps/agent/assets/mcp-icons/outlook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/agent/assets/mcp-icons/postman.svg b/apps/agent/assets/mcp-icons/postman.svg new file mode 100644 index 000000000..9d077b1b1 --- /dev/null +++ b/apps/agent/assets/mcp-icons/postman.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/agent/assets/mcp-icons/resend.svg b/apps/agent/assets/mcp-icons/resend.svg new file mode 100644 index 000000000..506dad83c --- /dev/null +++ b/apps/agent/assets/mcp-icons/resend.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/agent/assets/mcp-icons/shopify.svg b/apps/agent/assets/mcp-icons/shopify.svg new file mode 100644 index 000000000..084e09437 --- /dev/null +++ b/apps/agent/assets/mcp-icons/shopify.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/agent/assets/mcp-icons/stripe.svg b/apps/agent/assets/mcp-icons/stripe.svg new file mode 100644 index 000000000..df61834df --- /dev/null +++ b/apps/agent/assets/mcp-icons/stripe.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/agent/assets/mcp-icons/supabase.svg b/apps/agent/assets/mcp-icons/supabase.svg new file mode 100644 index 000000000..cf8e0f29a --- /dev/null +++ b/apps/agent/assets/mcp-icons/supabase.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/agent/assets/mcp-icons/vercel.svg b/apps/agent/assets/mcp-icons/vercel.svg new file mode 100644 index 000000000..72948d01a --- /dev/null +++ b/apps/agent/assets/mcp-icons/vercel.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/agent/assets/mcp-icons/whatsapp.webp b/apps/agent/assets/mcp-icons/whatsapp.webp new file mode 100644 index 000000000..1b2b44762 Binary files /dev/null and b/apps/agent/assets/mcp-icons/whatsapp.webp differ diff --git a/apps/agent/assets/mcp-icons/wordpress.svg b/apps/agent/assets/mcp-icons/wordpress.svg new file mode 100644 index 000000000..72183e904 --- /dev/null +++ b/apps/agent/assets/mcp-icons/wordpress.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/agent/assets/mcp-icons/youtube.svg b/apps/agent/assets/mcp-icons/youtube.svg new file mode 100644 index 000000000..50b99cd3d --- /dev/null +++ b/apps/agent/assets/mcp-icons/youtube.svg @@ -0,0 +1,25 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/agent/assets/mcp-icons/zendesk.svg b/apps/agent/assets/mcp-icons/zendesk.svg new file mode 100644 index 000000000..1d1de4111 --- /dev/null +++ b/apps/agent/assets/mcp-icons/zendesk.svg @@ -0,0 +1 @@ + diff --git a/apps/agent/entrypoints/app/connect-mcp/ApiKeyDialog.tsx b/apps/agent/entrypoints/app/connect-mcp/ApiKeyDialog.tsx index ad4dceb60..9c1057c18 100644 --- a/apps/agent/entrypoints/app/connect-mcp/ApiKeyDialog.tsx +++ b/apps/agent/entrypoints/app/connect-mcp/ApiKeyDialog.tsx @@ -51,7 +51,7 @@ export const ApiKeyDialog: FC = ({ }, }) - // biome-ignore lint/correctness/useExhaustiveDependencies: We only want to reset the form when the dialog is closed, not on every render + // biome-ignore lint/correctness/useExhaustiveDependencies: reset form when dialog closes useEffect(() => { if (!open) form.reset() }, [open]) diff --git a/apps/agent/entrypoints/app/connect-mcp/McpServerIcon.tsx b/apps/agent/entrypoints/app/connect-mcp/McpServerIcon.tsx index ab8f3701d..f846e8d5c 100644 --- a/apps/agent/entrypoints/app/connect-mcp/McpServerIcon.tsx +++ b/apps/agent/entrypoints/app/connect-mcp/McpServerIcon.tsx @@ -1,8 +1,17 @@ import { Server } from 'lucide-react' import type { FC } from 'react' import AirtableSvg from '@/assets/mcp-icons/airtable.svg' +import AsanaSvg from '@/assets/mcp-icons/asana.svg' +import BoxSvg from '@/assets/mcp-icons/box.svg' +import BraveSearchSvg from '@/assets/mcp-icons/brave_search.svg' +import CalComSvg from '@/assets/mcp-icons/cal_com.svg' import CanvaSvg from '@/assets/mcp-icons/canva.svg' +import ClickUpSvg from '@/assets/mcp-icons/clickup.svg' +import CloudflareSvg from '@/assets/mcp-icons/cloudflare.svg' import ConfluenceSvg from '@/assets/mcp-icons/confluence.svg' +import DiscordSvg from '@/assets/mcp-icons/discord.svg' +import DropboxSvg from '@/assets/mcp-icons/dropbox.svg' +import ExaPng from '@/assets/mcp-icons/exa.png' import FigmaSvg from '@/assets/mcp-icons/figma.svg' import GithubSvg from '@/assets/mcp-icons/github.svg' import GitlabSvg from '@/assets/mcp-icons/gitlab.svg' @@ -11,13 +20,32 @@ import GoogleSvg from '@/assets/mcp-icons/google.svg' import GoogleCalendarSvg from '@/assets/mcp-icons/google_calendar.svg' import GoogleDocsSvg from '@/assets/mcp-icons/google_docs_editors.svg' import GoogleDriveSvg from '@/assets/mcp-icons/google_drive.svg' +import GoogleFormsSvg from '@/assets/mcp-icons/google_forms.svg' +import HubSpotSvg from '@/assets/mcp-icons/hubspot.svg' +import IntercomSvg from '@/assets/mcp-icons/intercom.svg' import JiraSvg from '@/assets/mcp-icons/jira.svg' import LinearSvg from '@/assets/mcp-icons/linear.svg' import LinkedinSvg from '@/assets/mcp-icons/linkedin.svg' +import Mem0Webp from '@/assets/mcp-icons/memo.webp' +import MicrosoftTeamsSvg from '@/assets/mcp-icons/microsoft_teams.svg' +import MixpanelSvg from '@/assets/mcp-icons/mixpanel.svg' +import MondaySvg from '@/assets/mcp-icons/monday.svg' import NotionSvg from '@/assets/mcp-icons/notion.svg' +import OneDriveSvg from '@/assets/mcp-icons/onedrive.svg' +import OutlookSvg from '@/assets/mcp-icons/outlook.svg' import PostHogSvg from '@/assets/mcp-icons/posthog.svg' +import PostmanSvg from '@/assets/mcp-icons/postman.svg' +import ResendSvg from '@/assets/mcp-icons/resend.svg' import SalesforceSvg from '@/assets/mcp-icons/salesforce.svg' +import ShopifySvg from '@/assets/mcp-icons/shopify.svg' import SlackSvg from '@/assets/mcp-icons/slack.svg' +import StripeSvg from '@/assets/mcp-icons/stripe.svg' +import SupabaseSvg from '@/assets/mcp-icons/supabase.svg' +import VercelSvg from '@/assets/mcp-icons/vercel.svg' +import WhatsAppWebp from '@/assets/mcp-icons/whatsapp.webp' +import WordPressSvg from '@/assets/mcp-icons/wordpress.svg' +import YouTubeSvg from '@/assets/mcp-icons/youtube.svg' +import ZendeskSvg from '@/assets/mcp-icons/zendesk.svg' const mcpIconMap: Record = { Gmail: GmailSvg, @@ -25,6 +53,7 @@ const mcpIconMap: Record = { 'Google Docs': GoogleDocsSvg, 'Google Drive': GoogleDriveSvg, 'Google Sheets': GoogleSvg, + 'Google Forms': GoogleFormsSvg, Slack: SlackSvg, LinkedIn: LinkedinSvg, Notion: NotionSvg, @@ -37,7 +66,35 @@ const mcpIconMap: Record = { Figma: FigmaSvg, Canva: CanvaSvg, Salesforce: SalesforceSvg, + ClickUp: ClickUpSvg, + Asana: AsanaSvg, + Monday: MondaySvg, + 'Microsoft Teams': MicrosoftTeamsSvg, + 'Outlook Mail': OutlookSvg, + 'Outlook Calendar': OutlookSvg, + Supabase: SupabaseSvg, + Vercel: VercelSvg, + Postman: PostmanSvg, + Stripe: StripeSvg, + Cloudflare: CloudflareSvg, + 'Brave Search': BraveSearchSvg, + Mem0: Mem0Webp, + Exa: ExaPng, + Dropbox: DropboxSvg, + OneDrive: OneDriveSvg, + WordPress: WordPressSvg, + YouTube: YouTubeSvg, + Box: BoxSvg, + HubSpot: HubSpotSvg, PostHog: PostHogSvg, + Mixpanel: MixpanelSvg, + Discord: DiscordSvg, + WhatsApp: WhatsAppWebp, + Shopify: ShopifySvg, + 'Cal.com': CalComSvg, + Resend: ResendSvg, + Zendesk: ZendeskSvg, + Intercom: IntercomSvg, } interface McpServerIconProps { diff --git a/apps/server/src/agent/prompt.ts b/apps/server/src/agent/prompt.ts index 98f16dc45..09b2a3f96 100644 --- a/apps/server/src/agent/prompt.ts +++ b/apps/server/src/agent/prompt.ts @@ -3,6 +3,9 @@ * Copyright 2025 BrowserOS * SPDX-License-Identifier: AGPL-3.0-or-later */ + +import { OAUTH_MCP_SERVERS } from '../lib/clients/klavis/oauth-mcp-servers' + /** * BrowserOS Agent System Prompt v5 * @@ -251,10 +254,13 @@ Use \`browser_get_bookmarks\` to find existing folder IDs, or create new folders // ----------------------------------------------------------------------------- function getExternalIntegrations(): string { + const serverNames = OAUTH_MCP_SERVERS.map((s) => s.name).join(', ') + const serverCount = OAUTH_MCP_SERVERS.length + return ` ## External Integrations (Klavis Strata) -You have access to 15+ external services, including Gmail, Slack, Google Calendar, Notion, GitHub, and Jira, via Strata tools. Use progressive discovery. +You have access to ${serverCount}+ external services (Gmail, Slack, Google Calendar, Notion, GitHub, Jira, etc.) via Strata tools. Use progressive discovery. 1. \`discover_server_categories_or_actions(user_query, server_names[])\` - **Start here**. Returns categories or actions for specified servers. @@ -281,7 +287,7 @@ When \`execute_action\` fails with an authentication error: ## Available Servers -Gmail, Google Calendar, Google Docs, Google Sheets, Google Drive, Slack, LinkedIn, Notion, Airtable, Confluence, GitHub, GitLab, Linear, Jira, Figma, Canva, Salesforce. +${serverNames}. ## Usage Guidelines - Always discover before executing, do not guess action names diff --git a/apps/server/src/api/routes/klavis.ts b/apps/server/src/api/routes/klavis.ts index 89a74d8ca..2c43de610 100644 --- a/apps/server/src/api/routes/klavis.ts +++ b/apps/server/src/api/routes/klavis.ts @@ -19,6 +19,29 @@ interface KlavisRouteDeps { browserosId: string } +const normalizeServerKey = (value: string): string => + value.toLowerCase().replace(/[^a-z0-9]+/g, '') + +const getAuthUrlForServer = ( + authUrlMap: Record | undefined, + serverName: string, +): string | undefined => { + if (!authUrlMap) { + return undefined + } + const directMatch = authUrlMap[serverName] + if (directMatch) { + return directMatch + } + const targetKey = normalizeServerKey(serverName) + for (const [key, value] of Object.entries(authUrlMap)) { + if (normalizeServerKey(key) === targetKey) { + return value + } + } + return undefined +} + export function createKlavisRoutes(deps: KlavisRouteDeps) { const { browserosId } = deps const klavisClient = new KlavisClient() @@ -67,11 +90,18 @@ export function createKlavisRoutes(deps: KlavisRouteDeps) { try { const integrations = await klavisClient.getUserIntegrations(browserosId) + const normalizedIntegrations = integrations.map((integration) => ({ + name: integration.name, + is_authenticated: integration.isAuthenticated, + })) logger.info('Fetched user integrations', { browserosId: browserosId.slice(0, 12), - count: integrations.length, + count: normalizedIntegrations.length, + }) + return c.json({ + integrations: normalizedIntegrations, + count: normalizedIntegrations.length, }) - return c.json({ integrations, count: integrations.length }) } catch (error) { logger.error('Error fetching user integrations', { browserosId: browserosId?.slice(0, 12), @@ -101,8 +131,8 @@ export function createKlavisRoutes(deps: KlavisRouteDeps) { serverName, strataId: result.strataId, addedServers: result.addedServers, - oauthUrl: result.oauthUrls?.[serverName], - apiKeyUrl: result.apiKeyUrls?.[serverName], + oauthUrl: getAuthUrlForServer(result.oauthUrls, serverName), + apiKeyUrl: getAuthUrlForServer(result.apiKeyUrls, serverName), }) }) .post( diff --git a/apps/server/src/lib/clients/klavis/klavis-client.ts b/apps/server/src/lib/clients/klavis/klavis-client.ts index 98a8a2a6e..1ff861af4 100644 --- a/apps/server/src/lib/clients/klavis/klavis-client.ts +++ b/apps/server/src/lib/clients/klavis/klavis-client.ts @@ -15,6 +15,19 @@ export interface StrataCreateResponse { apiKeyUrls?: Record } +interface KlavisIntegrationObject { + name?: string + isAuthenticated?: boolean + is_authenticated?: boolean +} + +type KlavisIntegrationItem = string | KlavisIntegrationObject + +export interface UserIntegration { + name: string + isAuthenticated: boolean +} + export class KlavisClient { private baseUrl: string @@ -81,19 +94,42 @@ export class KlavisClient { /** * Get user integrations with authentication status */ - async getUserIntegrations( - userId: string, - ): Promise> { + async getUserIntegrations(userId: string): Promise { const data = await this.request<{ - integrations: Array<{ name: string; isAuthenticated: boolean }> + integrations?: KlavisIntegrationItem[] }>('GET', `/user/${userId}/integrations`) - return data.integrations || [] + const integrations = Array.isArray(data.integrations) + ? data.integrations + : [] + return integrations + .map((integration) => this.normalizeIntegration(integration)) + .filter((integration): integration is UserIntegration => + Boolean(integration), + ) + } + + private normalizeIntegration( + integration: KlavisIntegrationItem, + ): UserIntegration | null { + if (typeof integration === 'string') { + return { name: integration, isAuthenticated: true } + } + const name = integration.name + if (!name || typeof name !== 'string') { + return null + } + const isAuthenticated = + typeof integration.isAuthenticated === 'boolean' + ? integration.isAuthenticated + : typeof integration.is_authenticated === 'boolean' + ? integration.is_authenticated + : false + return { name, isAuthenticated } } /** * Submit an API key to Klavis's set-auth endpoint via the proxy. * Extracts instanceId from the apiKeyUrl and routes through the proxy. - * Docs: POST /mcp-server/instance/set-auth with { instanceId, authData } */ async submitApiKey(apiKeyUrl: string, apiKey: string): Promise { const parsedUrl = new URL(apiKeyUrl) @@ -102,15 +138,11 @@ export class KlavisClient { throw new Error('Missing instance_id in apiKeyUrl') } - const data = await this.request<{ success: boolean; message?: string }>( - 'POST', - '/mcp-server/instance/set-auth', - { instanceId, authData: { api_key: apiKey } }, - ) - - if (!data.success) { - throw new Error(data.message || 'Klavis API key submission failed') - } + // request() already throws on non-2xx responses + await this.request('POST', '/mcp-server/instance/set-auth', { + instanceId, + authData: { api_key: apiKey }, + }) } /** diff --git a/apps/server/src/lib/clients/klavis/oauth-mcp-servers.ts b/apps/server/src/lib/clients/klavis/oauth-mcp-servers.ts index 59e8e4206..c9375c3e1 100644 --- a/apps/server/src/lib/clients/klavis/oauth-mcp-servers.ts +++ b/apps/server/src/lib/clients/klavis/oauth-mcp-servers.ts @@ -30,5 +30,34 @@ export const OAUTH_MCP_SERVERS: OAuthMcpServer[] = [ { name: 'Figma', description: 'Access and manage design files' }, { name: 'Canva', description: 'Create and manage designs' }, { name: 'Salesforce', description: 'Manage leads, contacts, opportunities' }, + { name: 'ClickUp', description: 'Manage tasks, projects, and workflows' }, + { name: 'Asana', description: 'Organize and track team projects' }, + { name: 'Monday', description: 'Manage work and team collaboration' }, + { name: 'Microsoft Teams', description: 'Chat, meet, and collaborate' }, + { name: 'Outlook Mail', description: 'Send, read, and manage emails' }, + { name: 'Outlook Calendar', description: 'Schedule meetings and events' }, + { name: 'Supabase', description: 'Manage databases and backend services' }, + { name: 'Vercel', description: 'Deploy and manage web applications' }, + { name: 'Postman', description: 'Test and manage APIs' }, + { name: 'Stripe', description: 'Manage payments and subscriptions' }, + { name: 'Cloudflare', description: 'Manage domains, DNS, and security' }, + { name: 'Brave Search', description: 'Search the web privately' }, + { name: 'Mem0', description: 'Store and retrieve AI memory' }, + { name: 'Exa', description: 'AI-powered semantic web search' }, + { name: 'Dropbox', description: 'Store and share files in the cloud' }, + { name: 'OneDrive', description: 'Store and sync files with Microsoft' }, + { name: 'WordPress', description: 'Manage websites and blog content' }, + { name: 'YouTube', description: 'Access video info and transcripts' }, + { name: 'Box', description: 'Manage and share enterprise files' }, + { name: 'HubSpot', description: 'Manage contacts, deals, and marketing' }, { name: 'PostHog', description: 'Query analytics, manage feature flags' }, + { name: 'Mixpanel', description: 'Analyze user behavior and metrics' }, + { name: 'Discord', description: 'Send messages and manage servers' }, + { name: 'WhatsApp', description: 'Send messages and manage conversations' }, + { name: 'Shopify', description: 'Manage products, orders, and store' }, + { name: 'Cal.com', description: 'Schedule meetings and manage availability' }, + { name: 'Resend', description: 'Send transactional and marketing emails' }, + { name: 'Google Forms', description: 'Create and manage forms and surveys' }, + { name: 'Zendesk', description: 'Manage support tickets and customers' }, + { name: 'Intercom', description: 'Manage customer messaging and support' }, ] diff --git a/apps/server/tests/api/routes/klavis.test.ts b/apps/server/tests/api/routes/klavis.test.ts new file mode 100644 index 000000000..bbe7a4f15 --- /dev/null +++ b/apps/server/tests/api/routes/klavis.test.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2025 BrowserOS + */ + +import { afterEach, describe, it } from 'bun:test' +import assert from 'node:assert' +import { createKlavisRoutes } from '../../../src/api/routes/klavis' + +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +describe('createKlavisRoutes', () => { + it('normalizes string integrations into authenticated entries', async () => { + globalThis.fetch = (async () => + Response.json({ + integrations: ['Google Docs', 'Slack'], + })) as typeof fetch + + const route = createKlavisRoutes({ browserosId: 'user-123' }) + const response = await route.request('/user-integrations') + const body = await response.json() + + assert.strictEqual(response.status, 200) + assert.deepStrictEqual(body, { + integrations: [ + { name: 'Google Docs', is_authenticated: true }, + { name: 'Slack', is_authenticated: true }, + ], + count: 2, + }) + }) + + it('supports object integrations with mixed auth flag formats', async () => { + globalThis.fetch = (async () => + Response.json({ + integrations: [ + { name: 'Google Docs', isAuthenticated: false }, + { name: 'Slack', is_authenticated: true }, + ], + })) as typeof fetch + + const route = createKlavisRoutes({ browserosId: 'user-123' }) + const response = await route.request('/user-integrations') + const body = await response.json() + + assert.strictEqual(response.status, 200) + assert.deepStrictEqual(body, { + integrations: [ + { name: 'Google Docs', is_authenticated: false }, + { name: 'Slack', is_authenticated: true }, + ], + count: 2, + }) + }) + + it('resolves auth URLs with normalized server name keys', async () => { + globalThis.fetch = (async () => + Response.json({ + strataServerUrl: 'https://strata.example.com', + strataId: 'strata-123', + addedServers: ['Google Docs'], + oauthUrls: { google_docs: 'https://oauth.example.com/google-docs' }, + apiKeyUrls: { + google_docs: 'https://auth.example.com/setup?instance_id=abc123', + }, + })) as typeof fetch + + const route = createKlavisRoutes({ browserosId: 'user-123' }) + const response = await route.request('/servers/add', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ serverName: 'Google Docs' }), + }) + const body = await response.json() + + assert.strictEqual(response.status, 200) + assert.deepStrictEqual(body, { + success: true, + serverName: 'Google Docs', + strataId: 'strata-123', + addedServers: ['Google Docs'], + oauthUrl: 'https://oauth.example.com/google-docs', + apiKeyUrl: 'https://auth.example.com/setup?instance_id=abc123', + }) + }) +})