diff --git a/.github/workspace-dep-closures.json b/.github/workspace-dep-closures.json index 875a3b90e76..2667e546e98 100644 --- a/.github/workspace-dep-closures.json +++ b/.github/workspace-dep-closures.json @@ -3737,10 +3737,7 @@ "services/mcp_auth_proxy" ], "mcp_client": [ - "crates/agent", "crates/ai_toolset", - "crates/ai_usage", - "crates/attachment", "crates/bot_id", "crates/cowlike", "crates/macro_auth", @@ -3748,14 +3745,9 @@ "crates/macro_env", "crates/macro_env_var", "crates/macro_user_id", - "crates/macro_uuid", "crates/mcp_client", - "crates/model-entity", "crates/model-error-response", - "crates/model_file_type", "crates/model_user", - "crates/non_empty", - "crates/prompt", "crates/remote_env_var", "crates/workspace-hack" ], diff --git a/.sqlx/query-05c2cf10e578df97df70d5b372d6bd4d5a2baf8313799b21496ce399d3612b1b.json b/.sqlx/query-05c2cf10e578df97df70d5b372d6bd4d5a2baf8313799b21496ce399d3612b1b.json deleted file mode 100644 index 50082306f70..00000000000 --- a/.sqlx/query-05c2cf10e578df97df70d5b372d6bd4d5a2baf8313799b21496ce399d3612b1b.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM mcp_servers\n WHERE user_id = $1 AND url = $2\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "05c2cf10e578df97df70d5b372d6bd4d5a2baf8313799b21496ce399d3612b1b" -} diff --git a/.sqlx/query-69895e1fdfef3998c26e8b62496504f96dc5d617be95ef79dd541acca264ddb4.json b/.sqlx/query-69895e1fdfef3998c26e8b62496504f96dc5d617be95ef79dd541acca264ddb4.json deleted file mode 100644 index 34a6493ba8a..00000000000 --- a/.sqlx/query-69895e1fdfef3998c26e8b62496504f96dc5d617be95ef79dd541acca264ddb4.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO mcp_servers (user_id, url, server_name, credentials, enabled)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (user_id, url) DO UPDATE\n SET server_name = EXCLUDED.server_name,\n credentials = COALESCE(EXCLUDED.credentials, mcp_servers.credentials),\n enabled = EXCLUDED.enabled,\n updated_at = NOW()\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Bytea", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "69895e1fdfef3998c26e8b62496504f96dc5d617be95ef79dd541acca264ddb4" -} diff --git a/.sqlx/query-7630495f6f9dce2941769ed6eff4baee9d9b82e4332879e3121c779d3814d43b.json b/.sqlx/query-7630495f6f9dce2941769ed6eff4baee9d9b82e4332879e3121c779d3814d43b.json new file mode 100644 index 00000000000..2730838fb36 --- /dev/null +++ b/.sqlx/query-7630495f6f9dce2941769ed6eff4baee9d9b82e4332879e3121c779d3814d43b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM mcp_servers WHERE user_id = $1 AND app_slug = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "7630495f6f9dce2941769ed6eff4baee9d9b82e4332879e3121c779d3814d43b" +} diff --git a/.sqlx/query-60160128531d4bd143edb6e3af105954c5f749b5a76592d8ba8bd78d3ed410cc.json b/.sqlx/query-7b106997a2377d3ebf8e03dbf0c342c50934b88126afe776f9a7bec9fad55815.json similarity index 63% rename from .sqlx/query-60160128531d4bd143edb6e3af105954c5f749b5a76592d8ba8bd78d3ed410cc.json rename to .sqlx/query-7b106997a2377d3ebf8e03dbf0c342c50934b88126afe776f9a7bec9fad55815.json index 07556c03561..bc86ebdd9ae 100644 --- a/.sqlx/query-60160128531d4bd143edb6e3af105954c5f749b5a76592d8ba8bd78d3ed410cc.json +++ b/.sqlx/query-7b106997a2377d3ebf8e03dbf0c342c50934b88126afe776f9a7bec9fad55815.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_id, url, server_name, credentials, enabled\n FROM mcp_servers\n WHERE user_id = $1 AND url = $2\n ", + "query": "\n SELECT user_id, app_slug, server_name, account_id, enabled\n FROM mcp_servers\n WHERE user_id = $1 AND app_slug = $2\n ", "describe": { "columns": [ { @@ -10,7 +10,7 @@ }, { "ordinal": 1, - "name": "url", + "name": "app_slug", "type_info": "Text" }, { @@ -20,8 +20,8 @@ }, { "ordinal": 3, - "name": "credentials", - "type_info": "Bytea" + "name": "account_id", + "type_info": "Text" }, { "ordinal": 4, @@ -39,9 +39,9 @@ false, false, false, - true, + false, false ] }, - "hash": "60160128531d4bd143edb6e3af105954c5f749b5a76592d8ba8bd78d3ed410cc" + "hash": "7b106997a2377d3ebf8e03dbf0c342c50934b88126afe776f9a7bec9fad55815" } diff --git a/.sqlx/query-13a6d39ef2b895cae2162fcf65f7f64a5b303859b36186f4106144a2c5cc23ac.json b/.sqlx/query-95258db91cb4f483fcc9ffeb54cf2d5cf443ec0aadc4dcf650a2e7a691f3c1e2.json similarity index 61% rename from .sqlx/query-13a6d39ef2b895cae2162fcf65f7f64a5b303859b36186f4106144a2c5cc23ac.json rename to .sqlx/query-95258db91cb4f483fcc9ffeb54cf2d5cf443ec0aadc4dcf650a2e7a691f3c1e2.json index 6792d184f7f..461b8021eaa 100644 --- a/.sqlx/query-13a6d39ef2b895cae2162fcf65f7f64a5b303859b36186f4106144a2c5cc23ac.json +++ b/.sqlx/query-95258db91cb4f483fcc9ffeb54cf2d5cf443ec0aadc4dcf650a2e7a691f3c1e2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_id, url, server_name, credentials, enabled\n FROM mcp_servers\n WHERE user_id = $1\n ORDER BY created_at\n ", + "query": "\n SELECT user_id, app_slug, server_name, account_id, enabled\n FROM mcp_servers\n WHERE user_id = $1\n ORDER BY server_name\n ", "describe": { "columns": [ { @@ -10,7 +10,7 @@ }, { "ordinal": 1, - "name": "url", + "name": "app_slug", "type_info": "Text" }, { @@ -20,8 +20,8 @@ }, { "ordinal": 3, - "name": "credentials", - "type_info": "Bytea" + "name": "account_id", + "type_info": "Text" }, { "ordinal": 4, @@ -38,9 +38,9 @@ false, false, false, - true, + false, false ] }, - "hash": "13a6d39ef2b895cae2162fcf65f7f64a5b303859b36186f4106144a2c5cc23ac" + "hash": "95258db91cb4f483fcc9ffeb54cf2d5cf443ec0aadc4dcf650a2e7a691f3c1e2" } diff --git a/.sqlx/query-b7eb809356130733019eceb84961848b6ad203237305b5a2d7d880668af103f0.json b/.sqlx/query-b7eb809356130733019eceb84961848b6ad203237305b5a2d7d880668af103f0.json new file mode 100644 index 00000000000..3dd46d1711d --- /dev/null +++ b/.sqlx/query-b7eb809356130733019eceb84961848b6ad203237305b5a2d7d880668af103f0.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO mcp_servers (user_id, app_slug, server_name, account_id, enabled)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (user_id, app_slug) DO UPDATE SET\n server_name = EXCLUDED.server_name,\n account_id = EXCLUDED.account_id,\n enabled = EXCLUDED.enabled,\n updated_at = NOW()\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "b7eb809356130733019eceb84961848b6ad203237305b5a2d7d880668af103f0" +} diff --git a/Cargo.lock b/Cargo.lock index 5250c87ca2f..08cc4cdec15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,16 +14,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common 0.1.6", - "generic-array", -] - [[package]] name = "aes" version = "0.8.4" @@ -35,20 +25,6 @@ dependencies = [ "cpufeatures 0.2.17", ] -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - [[package]] name = "agent" version = "0.1.0" @@ -3693,7 +3669,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] @@ -3753,15 +3728,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - [[package]] name = "ctutils" version = "0.4.2" @@ -6138,16 +6104,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - [[package]] name = "gif" version = "0.14.2" @@ -9050,21 +9006,13 @@ dependencies = [ name = "mcp_client" version = "0.1.0" dependencies = [ - "aes-gcm", - "agent", "ai_toolset", "anyhow", - "async-openai", - "async-trait", "axum", - "base64 0.22.1", "futures", "macro_authorization", - "macro_env_var", "macro_user_id", "model-error-response", - "oauth2", - "redis", "reqwest 0.13.4", "rmcp", "schemars 1.2.1", @@ -10237,25 +10185,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "oauth2" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" -dependencies = [ - "base64 0.22.1", - "chrono", - "getrandom 0.2.17", - "http 1.4.0", - "rand 0.8.6", - "serde", - "serde_json", - "serde_path_to_error", - "sha2 0.10.9", - "thiserror 1.0.69", - "url", -] - [[package]] name = "objc2" version = "0.6.4" @@ -10461,12 +10390,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "opensearch" version = "2.4.0" @@ -11217,18 +11140,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - [[package]] name = "portable-atomic" version = "1.13.1" @@ -12425,7 +12336,6 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "oauth2", "pastey 0.2.3", "pin-project-lite", "rand 0.10.1", @@ -12441,7 +12351,6 @@ dependencies = [ "tokio-util", "tower-service", "tracing", - "url", "uuid", ] @@ -15569,16 +15478,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common 0.1.6", - "subtle", -] - [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/apps/web/src/features/getting-started/getting-started.tsx b/apps/web/src/features/getting-started/getting-started.tsx index 4473f47a8fd..39a0fa291d2 100644 --- a/apps/web/src/features/getting-started/getting-started.tsx +++ b/apps/web/src/features/getting-started/getting-started.tsx @@ -161,11 +161,11 @@ function GettingStartedContent() { description: 'Link your inbox, GitHub, Linear, Notion & more', onActivate: () => openSettingsTab('Connected'), // Any real connection counts: a second inbox (onboarding links the - // first), the GitHub account link, or any authenticated MCP server. + // first), the GitHub account link, or any connected MCP app. isComplete: () => (emailLinks.data?.links.length ?? 0) > 1 || githubLink.data?.status === 'linked' || - (mcpServers.data ?? []).some((server) => server.authenticated), + (mcpServers.data ?? []).length > 0, }, ], }, diff --git a/apps/web/src/features/home/home-hub.tsx b/apps/web/src/features/home/home-hub.tsx index 81df8ae5b87..05c8ccb3f3f 100644 --- a/apps/web/src/features/home/home-hub.tsx +++ b/apps/web/src/features/home/home-hub.tsx @@ -220,9 +220,7 @@ function useConnectionsCount() { return () => { const inboxes = emailLinks.data?.links.length ?? 0; - const servers = (mcpServers.data ?? []).filter( - (server) => server.authenticated - ).length; + const servers = (mcpServers.data ?? []).length; return inboxes + servers; }; } diff --git a/apps/web/src/features/settings/Integrations.tsx b/apps/web/src/features/settings/Integrations.tsx index b5e24d7280f..7372f35ed8b 100644 --- a/apps/web/src/features/settings/Integrations.tsx +++ b/apps/web/src/features/settings/Integrations.tsx @@ -1,214 +1,39 @@ import { FEATURED_MCP_SERVERS, - type FeaturedMcpServer, + mcpAppAvailableInEnv, QUICK_CONNECT_ICON_MAP, - type SvgIcon, } from '@core/component/AI/constant/mcpServers'; import { toast } from '@core/component/Toast/Toast'; -import CheckIcon from '@phosphor-icons/core/regular/check.svg?component-solid'; import PlugIcon from '@phosphor-icons/core/regular/plug.svg?component-solid'; -import PlusIcon from '@phosphor-icons/core/regular/plus.svg?component-solid'; import XIcon from '@phosphor-icons/core/regular/x.svg?component-solid'; import { - useAddMcpServerMutation, + connectMcpApp, useDeleteMcpServerMutation, + useMcpCatalogQuery, useMcpServersQuery, - useStartMcpAuthMutation, useUpdateMcpServerMutation, } from '@queries/mcp-servers'; import type { - ServerResponse, - StartAuthResponse, -} from '@service-cognition/generated/schemas'; -import { Button, Dialog, Panel, ToggleSwitch } from '@ui'; -import { createEffect, createSignal, For, Show } from 'solid-js'; + CatalogEntryResponse, + McpServerResponse, +} from '@service-cognition/client'; +import { Button, ToggleSwitch } from '@ui'; +import { createSignal, For, onCleanup, Show } from 'solid-js'; import { ConnectAction } from './integration-ui'; import { IntegrationRow, SettingsCard, SettingsSection } from './primitives'; -/** Best-effort hostname for an MCP server URL — friendlier than the raw URL. */ -function hostFromUrl(url: string): string { - try { - return new URL(url).host; - } catch { - return url; - } -} - -function AddServerForm(props: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - const [name, setName] = createSignal(''); - const [url, setUrl] = createSignal(''); - const addMutation = useAddMcpServerMutation(); - const authMutation = useStartMcpAuthMutation(); - - const reset = () => { - setName(''); - setUrl(''); - }; - - const startAuth = (serverName: string, serverUrl: string) => { - authMutation.mutate( - { server_name: serverName, server_url: serverUrl }, - { - onSuccess: (result: StartAuthResponse) => { - window.open(result.authorization_url, '_blank'); - }, - onError: () => { - toast.failure('Server added but failed to start authorization'); - }, - } - ); - }; - - const handleSubmit = () => { - const n = name().trim(); - const u = url().trim(); - if (!n || !u) return; - - addMutation.mutate( - { server_name: n, url: u }, - { - onSuccess: () => { - startAuth(n, u); - reset(); - props.onOpenChange(false); - }, - onError: () => { - toast.failure('Failed to add server'); - }, - } - ); - }; - - return ( - !open && props.onOpenChange(false)} - position="center" - class="w-100" - > - - - Add MCP Server - - -
- - -
- -
- - -
-
-
-
- ); -} - -// We have no server-side signal for a failed auth, so we remember locally that a -// connect attempt was made. A disconnected server with a recorded attempt is -// treated as a failed connection; the flag is cleared once it authenticates. -const AUTH_ATTEMPT_PREFIX = 'mcp:auth-attempted:'; - -function readAuthAttempted(url: string): boolean { - try { - return localStorage.getItem(AUTH_ATTEMPT_PREFIX + url) === '1'; - } catch { - return false; - } -} - -function writeAuthAttempted(url: string, attempted: boolean): void { - try { - if (attempted) localStorage.setItem(AUTH_ATTEMPT_PREFIX + url, '1'); - else localStorage.removeItem(AUTH_ATTEMPT_PREFIX + url); - } catch { - // Ignore storage failures (private mode, quota, etc.) - } -} - -function ServerRow(props: { server: ServerResponse }) { +/** A connected app: enable/disable for tool use, or disconnect. */ +function ServerRow(props: { server: McpServerResponse }) { const updateMutation = useUpdateMcpServerMutation(); const deleteMutation = useDeleteMcpServerMutation(); - const authMutation = useStartMcpAuthMutation(); const [confirmDelete, setConfirmDelete] = createSignal(false); - const [attempted, setAttempted] = createSignal( - readAuthAttempted(props.server.url) - ); - - // A recorded attempt on a still-disconnected server means the last connect - // attempt didn't succeed. Clear the flag once the server authenticates. - createEffect(() => { - if (props.server.authenticated && attempted()) { - writeAuthAttempted(props.server.url, false); - setAttempted(false); - } - }); - - const connectionFailed = () => !props.server.authenticated && attempted(); const handleToggleEnabled = () => { updateMutation.mutate( - { url: props.server.url, enabled: !props.server.enabled }, + { app_slug: props.server.app_slug, enabled: !props.server.enabled }, { onError: () => { - toast.failure('Failed to update server'); + toast.failure('Failed to update connector'); }, } ); @@ -216,43 +41,22 @@ function ServerRow(props: { server: ServerResponse }) { const handleDelete = () => { deleteMutation.mutate( - { url: props.server.url }, + { app_slug: props.server.app_slug }, { onSuccess: () => { - toast.success('Server removed'); + toast.success('Connector removed'); setConfirmDelete(false); }, onError: () => { - toast.failure('Failed to remove server'); + toast.failure('Failed to remove connector'); setConfirmDelete(false); }, } ); }; - const handleAuth = () => { - authMutation.mutate( - { - server_url: props.server.url, - server_name: props.server.server_name, - }, - { - onSuccess: (result: StartAuthResponse) => { - window.open(result.authorization_url, '_blank'); - writeAuthAttempted(props.server.url, true); - setAttempted(true); - }, - onError: () => { - writeAuthAttempted(props.server.url, true); - setAttempted(true); - toast.failure('Failed to start authorization'); - }, - } - ); - }; - - const Icon = (): SvgIcon => - QUICK_CONNECT_ICON_MAP.get(props.server.url) ?? (PlugIcon as SvgIcon); + const Icon = () => + QUICK_CONNECT_ICON_MAP.get(props.server.app_slug) ?? PlugIcon; return ( ; })()} - title={ - - {props.server.server_name} - - - - - - - - } - description={hostFromUrl(props.server.url)} + title={props.server.server_name} + description={props.server.app_slug} > - - - - Last attempt failed - - - - - - - - + { + if (busy()) return; + setBusy(true); try { - await addMutation.mutateAsync({ - server_name: props.server.server_name, - url: props.server.url, + const outcome = await connectMcpApp({ + appSlug: props.entry.app_slug, + serverName: props.entry.display_name, }); + if (outcome === 'connected') { + toast.success(`${props.entry.display_name} connected`); + } else if (outcome === 'unsupported') { + toast.failure('Connectors are not available on this deployment'); + } } catch { - toast.failure(`Failed to add ${props.server.server_name}`); - return; - } - try { - const result: StartAuthResponse = await authMutation.mutateAsync({ - server_name: props.server.server_name, - server_url: props.server.url, - }); - window.open(result.authorization_url, '_blank'); - } catch { - toast.failure('Server added but failed to start authorization'); + toast.failure(`Failed to connect ${props.entry.display_name}`); + } finally { + setBusy(false); } }; return ( } - title={props.server.server_name} - description={props.server.tagline} + icon={} + title={props.entry.display_name} + description={props.entry.description ?? props.entry.app_slug} > - + ); } /** - * The "MCP integrations" section of the Connections page: MCP servers the - * user has connected, followed by the preset suggestions they haven't, with - * custom servers behind the "Add server" dialog. + * Connector icon: our bundled SVG for the apps we ship icons for, the + * directory-provided icon otherwise, and a generic plug as the fallback. + */ +function CatalogIcon(props: { entry: CatalogEntryResponse }) { + const BundledIcon = () => QUICK_CONNECT_ICON_MAP.get(props.entry.app_slug); + return ( + } + > + {(iconUrl) => ( + + )} + + } + > + {(Icon) => { + const C = Icon(); + return ; + }} + + ); +} + +/** + * Featured connectors as catalog entries, for when the catalog API hasn't + * answered yet (or is unavailable): the same curated list the backend pins, + * derived from the bundled presets so the section never renders empty. + */ +const FALLBACK_FEATURED: CatalogEntryResponse[] = FEATURED_MCP_SERVERS.map( + (server) => ({ + app_slug: server.app_slug, + display_name: server.server_name, + description: server.tagline, + icon_url: null, + priority: true, + }) +); + +/** + * The "MCP integrations" section of the Connections page: apps the user has + * connected, then the curated featured connectors they haven't, then a + * searchable catalog of every connectable app — all connecting through + * Pipedream, the single connect path. */ export function IntegrationsSection() { const serversQuery = useMcpServersQuery(); - const [showAddDialog, setShowAddDialog] = createSignal(false); + + const [searchInput, setSearchInput] = createSignal(''); + const [search, setSearch] = createSignal(''); + let debounceTimer: ReturnType | undefined; + const onSearchInput = (value: string) => { + setSearchInput(value); + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => setSearch(value), 250); + }; + onCleanup(() => clearTimeout(debounceTimer)); + + const catalogQuery = useMcpCatalogQuery(search); + // Separate un-searched instance backing the featured section, so it stays + // put while the user types in the catalog search below. Same cache entry + // as browsing with an empty search, so this costs no extra request. + const featuredQuery = useMcpCatalogQuery(() => ''); const servers = () => serversQuery.data ?? []; - const existingUrls = () => new Set(servers().map((s) => s.url)); - const suggestions = () => - FEATURED_MCP_SERVERS.filter((s) => !existingUrls().has(s.url)); + const connectedSlugs = () => new Set(servers().map((s) => s.app_slug)); + + const offered = (entry: CatalogEntryResponse) => + mcpAppAvailableInEnv(entry.app_slug) && + !connectedSlugs().has(entry.app_slug); + + const catalogEntries = () => + (catalogQuery.data?.pages ?? []) + .flatMap((page) => page.servers) + .filter(offered); + + // The featured section always shows the full curated list, served from + // the presets bundled with the app until the catalog answers — the + // backend pins the same list, so nothing jumps when it does. + const featured = () => { + const entries = (featuredQuery.data?.pages ?? []) + .flatMap((page) => page.servers) + .filter((entry) => entry.priority) + .filter(offered); + return entries.length > 0 ? entries : FALLBACK_FEATURED.filter(offered); + }; + + // Searching shows every match, with featured connectors ranked first by + // the backend (flagged `priority`); browsing shows only organic directory + // results, since the full featured list already sits above. + const browseResults = () => + search().trim() + ? catalogEntries() + : catalogEntries().filter((entry) => !entry.priority); return ( setShowAddDialog(true)} - > - - Add server - - } + description="Connect the tools your team already uses to give Macro's agent access to them." > @@ -444,13 +284,74 @@ export function IntegrationsSection() { {(server) => } - - {(server) => } - + {(entry) => } - + +
+ onSearchInput(e.currentTarget.value)} + /> +
+ + +
+ Couldn't load the connector catalog. + +
+
+ + + + {(entry) => } + + + +
+ Loading connectors... +
+
+ + +
+ No connectors found for "{search().trim()}". +
+
+ + +
+ +
+
+
+
); } diff --git a/apps/web/src/features/setup/ConnectorRow.tsx b/apps/web/src/features/setup/ConnectorRow.tsx index be6ab2351d5..e1051522119 100644 --- a/apps/web/src/features/setup/ConnectorRow.tsx +++ b/apps/web/src/features/setup/ConnectorRow.tsx @@ -8,17 +8,15 @@ import { createConnectorConnect } from './useConnectorConnect'; /** * One connector in the onboarding flow: brand icon, name, and an explicit - * trailing status. Clicking an unconnected row starts OAuth in a popup. + * trailing status. Clicking an unconnected row opens the Pipedream Connect UI. */ export function ConnectorRow(props: { server: FeaturedMcpServer; connected: boolean; - authenticated: boolean; }) { const { connect, busy } = createConnectorConnect({ server: props.server, connected: () => props.connected, - authenticated: () => props.authenticated, }); return ( @@ -26,7 +24,7 @@ export function ConnectorRow(props: {