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 @@
+
\ 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 @@
+
\ 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',
+ })
+ })
+})