diff --git a/packages/plugins/mcp/src/sdk/presets.ts b/packages/plugins/mcp/src/sdk/presets.ts index f2a867541..0cced50e7 100644 --- a/packages/plugins/mcp/src/sdk/presets.ts +++ b/packages/plugins/mcp/src/sdk/presets.ts @@ -30,7 +30,7 @@ export const mcpPresets: readonly McpPreset[] = [ summary: "Deterministic MCP fixtures for validating native text and image content.", url: "https://emulators.dev/mcp/query/mcp?token=demo-token", endpoint: "https://emulators.dev/mcp/query/mcp?token=demo-token", - icon: "https://emulators.dev/favicon.ico", + icon: "https://integrations.sh/logo/emulators.dev", }, { id: "deepwiki", @@ -142,7 +142,7 @@ export const mcpPresets: readonly McpPreset[] = [ id: "chrome-devtools", name: "Chrome DevTools", summary: "Debug a live Chrome browser session via local stdio.", - icon: "https://www.google.com/chrome/static/images/favicons/favicon-32x32.png", + icon: "https://integrations.sh/logo/chrome.com", featured: true, transport: "stdio", command: "npx", diff --git a/packages/react/src/components/integration-favicon.test.tsx b/packages/react/src/components/integration-favicon.test.tsx index 853cde425..e9e79cc66 100644 --- a/packages/react/src/components/integration-favicon.test.tsx +++ b/packages/react/src/components/integration-favicon.test.tsx @@ -1,7 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { - integrationFaviconFallbackUrl, integrationFaviconSrc, integrationFaviconUrl, integrationInferredUrl, @@ -10,22 +9,15 @@ import { } from "./integration-favicon"; describe("IntegrationFavicon", () => { - it("uses the integrations.sh logo proxy as the primary favicon source", () => { + it("resolves favicons only through the integrations.sh logo proxy", () => { expect(integrationFaviconUrl("https://api.github.com/graphql", 20)).toBe( "https://integrations.sh/logo/github.com?sz=40", ); }); - it("falls back to the Google favicon service", () => { - expect(integrationFaviconFallbackUrl("https://api.github.com/graphql", 20)).toBe( - "https://www.google.com/s2/favicons?domain=github.com&sz=40", - ); - }); - it("does not request favicons for local URLs", () => { expect(integrationFaviconUrl("http://localhost:3000/private", 20)).toBeNull(); expect(integrationFaviconUrl("http://127.0.0.1:3000/private", 20)).toBeNull(); - expect(integrationFaviconFallbackUrl("http://localhost:3000/private", 20)).toBeNull(); }); it("sends only the registrable domain to the logo proxy", () => { @@ -34,13 +26,11 @@ describe("IntegrationFavicon", () => { ); }); - it("walks the cascade from the proxy to the favicon service on failure", () => { + it("falls through to the placeholder when the proxy fails", () => { const url = "https://api.github.com/graphql"; const primary = integrationFaviconSrc({ url, size: 20 }); expect(primary).toBe("https://integrations.sh/logo/github.com?sz=40"); - expect(integrationFaviconSrc({ url, size: 20, failedSrcs: [primary ?? ""] })).toBe( - "https://www.google.com/s2/favicons?domain=github.com&sz=40", - ); + expect(integrationFaviconSrc({ url, size: 20, failedSrcs: [primary ?? ""] })).toBeNull(); }); it("uses the Executor favicon for the built-in executor integration", () => { @@ -102,63 +92,11 @@ describe("IntegrationFavicon", () => { ).toBe("https://example.com/sheets.svg"); }); - it("finds preset icons from display names with suffixes", () => { - expect( - integrationPresetIconUrl( - { - id: "google_search_console_api", - kind: "googleDiscovery", - name: "Google Search Console API", - }, - [ - { - key: "google", - label: "Google", - add: () => null, - edit: () => null, - presets: [ - { - id: "google-search-console", - name: "Google Search Console", - summary: "Search performance.", - icon: "https://example.com/google.svg", - }, - ], - }, - ], - ), - ).toBe("https://example.com/google.svg"); - }); - - it("finds preset icons from an integration id when the URL is missing", () => { - expect( - integrationPresetIconUrl( - { - id: "sentry", - kind: "mcp", - name: "Sentry MCP", - }, - [ - { - key: "mcp", - label: "MCP", - add: () => null, - edit: () => null, - presets: [ - { - id: "sentry", - name: "Sentry", - summary: "Errors.", - icon: "https://example.com/sentry.png", - }, - ], - }, - ], - ), - ).toBe("https://example.com/sentry.png"); - }); - - it("matches migrated MCP slugs with host/suffix noise", () => { + it("does not fuzzy-match preset icons from names or slugs", () => { + // Name/slug token matching is gone: without an exact defaultSlug or URL + // match, the preset matcher declines and the cascade resolves through the + // integrations.sh favicon instead, which is derived from the integration's + // own domain and therefore cannot render the wrong brand. const presets = [ { key: "mcp", @@ -167,63 +105,25 @@ describe("IntegrationFavicon", () => { edit: () => null, presets: [ { - id: "posthog", - name: "PostHog", - summary: "Analytics.", - icon: "https://example.com/posthog.png", - }, - { - id: "linear", - name: "Linear", - summary: "Issues.", - icon: "https://example.com/linear.png", - }, - { - id: "planetscale", - name: "PlanetScale", - summary: "Databases.", - icon: "https://example.com/pscale.png", + id: "sentry", + name: "Sentry", + summary: "Errors.", + icon: "https://example.com/sentry.png", }, ], }, ]; expect( - integrationPresetIconUrl( - { id: "mcp_posthog_com", kind: "mcp", name: "mcp.posthog.com" }, - presets, - ), - ).toBe("https://example.com/posthog.png"); - expect( - integrationPresetIconUrl( - { id: "mcp_linear_app", kind: "mcp", name: "mcp.linear.app" }, - presets, - ), - ).toBe("https://example.com/linear.png"); - expect( - integrationPresetIconUrl({ id: "pscale_mcp", kind: "mcp", name: "Pscale MCP" }, presets), - ).toBe("https://example.com/pscale.png"); - }); - - it("matches migrated OpenAPI slugs with API/REST suffixes", () => { - expect( - integrationPresetIconUrl({ id: "stripe_api", kind: "openapi", name: "Stripe API" }, [ - { - key: "openapi", - label: "OpenAPI", - add: () => null, - edit: () => null, - presets: [ - { - id: "stripe", - name: "Stripe", - summary: "Payments.", - icon: "https://example.com/stripe.png", - }, - ], - }, - ]), - ).toBe("https://example.com/stripe.png"); + integrationPresetIconUrl({ id: "sentry", kind: "mcp", name: "Sentry MCP" }, presets), + ).toBeNull(); + + // Migrated host-shaped integrations resolve through the inferred URL: + const inferred = integrationInferredUrl({ id: "mcp_posthog_com", name: "mcp.posthog.com" }); + expect(inferred).toBe("https://mcp.posthog.com"); + expect(integrationFaviconSrc({ url: inferred ?? undefined, size: 16 })).toBe( + "https://integrations.sh/logo/posthog.com?sz=32", + ); }); it("prefers exact catalog defaultSlug icons for OpenAPI provider services", () => { @@ -248,7 +148,25 @@ describe("IntegrationFavicon", () => { ).toBe("https://example.com/gmail.png"); }); - it("does not split generic words into brand matches", () => { + it("matches presets on exact URL equality, not names", () => { + const presets = [ + { + key: "openapi", + label: "OpenAPI", + add: () => null, + edit: () => null, + presets: [ + { + id: "spotify", + name: "Spotify", + summary: "Music.", + url: "https://api.spotify.com/v1", + icon: "https://example.com/spotify.png", + }, + ], + }, + ]; + expect( integrationPresetIconUrl( { @@ -257,30 +175,43 @@ describe("IntegrationFavicon", () => { name: "Spotify Web API", url: "https://api.spotify.com/v1", }, - [ - { - key: "openapi", - label: "OpenAPI", - add: () => null, - edit: () => null, - presets: [ - { - id: "exa-websets", - name: "Exa Websets", - summary: "Web data.", - icon: "https://example.com/exa.png", - }, - { - id: "spotify", - name: "Spotify", - summary: "Music.", - icon: "https://example.com/spotify.png", - }, - ], - }, - ], + presets, ), ).toBe("https://example.com/spotify.png"); + + // Same name, different URL: no match — the URL-derived favicon takes over. + expect( + integrationPresetIconUrl( + { + id: "spotify_web_api", + kind: "openapi", + name: "Spotify Web API", + url: "https://api.spotify.com/v2", + }, + presets, + ), + ).toBeNull(); + }); + + it("does not match a different brand sharing a word fragment (ClickHouse Cloud vs Cloudflare)", () => { + expect( + integrationPresetIconUrl({ id: "clickhouse", kind: "mcp", name: "ClickHouse Cloud" }, [ + { + key: "mcp", + label: "MCP", + add: () => null, + edit: () => null, + presets: [ + { + id: "cloudflare", + name: "Cloudflare", + summary: "Workers, KV, D1, R2, and DNS management via MCP.", + icon: "https://integrations.sh/logo/cloudflare.com", + }, + ], + }, + ]), + ).toBeNull(); }); it("infers favicon URLs from migrated host-shaped MCP names and slugs", () => { diff --git a/packages/react/src/components/integration-favicon.tsx b/packages/react/src/components/integration-favicon.tsx index ae4a4be70..cfe12df43 100644 --- a/packages/react/src/components/integration-favicon.tsx +++ b/packages/react/src/components/integration-favicon.tsx @@ -14,24 +14,15 @@ const integrationFaviconDomain = (url: string | undefined): string | null => { }; // integrations.sh/logo proxies context.dev's Logo Link behind an edge cache -// and is executor's single logo source; Google's favicon service remains in -// the cascade as the fallback the onError walks to when the proxy is -// unreachable or serves nothing for the domain. +// and is executor's single logo source. Fallbacks (Google's favicon service, +// a letter placeholder for unknown domains) live inside the proxy, so clients +// never resolve favicons against a third party directly. export function integrationFaviconUrl(url: string | undefined, size: number): string | null { const domain = integrationFaviconDomain(url); if (!domain) return null; return `https://integrations.sh/logo/${domain}?sz=${size * 2}`; } -export function integrationFaviconFallbackUrl( - url: string | undefined, - size: number, -): string | null { - const domain = integrationFaviconDomain(url); - if (!domain) return null; - return `https://www.google.com/s2/favicons?domain=${domain}&sz=${size * 2}`; -} - export function integrationLocalIconUrl(integrationId: string | undefined): string | null { if (integrationId !== "executor") return null; return "/favicon-32.png"; @@ -85,46 +76,6 @@ const googleApiServiceFromUrl = (url: string | undefined): string | null => { return null; }; -const normalizeToken = (value: string | undefined): string => - value?.toLowerCase().replace(/[^a-z0-9]+/g, "") ?? ""; - -const tokenVariants = (value: string | undefined): readonly string[] => { - const tokens = new Set(); - const normalized = normalizeToken(value); - if (normalized.length > 0) tokens.add(normalized); - - const parts = - value - ?.toLowerCase() - .split(/[^a-z0-9]+/g) - .filter(Boolean) ?? []; - const noise = new Set([ - "api", - "mcp", - "openapi", - "rest", - "graphql", - "web", - "com", - "net", - "org", - "dev", - ]); - const cleaned = parts.filter((part) => !noise.has(part)); - const cleanedToken = cleaned.join(""); - if (cleanedToken.length > 0) tokens.add(cleanedToken); - const aliases: Record = { - pscale: "planetscale", - }; - for (const part of cleaned) { - tokens.add(part); - const alias = aliases[part]; - if (alias) tokens.add(alias); - } - - return [...tokens]; -}; - export function integrationInferredUrl(integration: { readonly id: string; readonly name?: string; @@ -138,13 +89,12 @@ export function integrationInferredUrl(integration: { return null; } -const tokenMatches = (integrationValue: string, presetValue: string): boolean => - presetValue.length > 0 && - integrationValue.length > 0 && - (integrationValue === presetValue || - integrationValue.includes(presetValue) || - presetValue.includes(integrationValue)); - +// Exact identity signals only — preset defaultSlug, normalized URL equality, +// or the same Google discovery service. Fuzzy name/slug token matching used to +// live here and matched unrelated brands sharing a word fragment ("ClickHouse +// Cloud" rendered Cloudflare's logo via "cloud"). A missed match is recoverable +// (the cascade falls through to the domain-derived integrations.sh favicon, +// which is always the right brand); a wrong-brand icon is not. export function integrationPresetIconUrl( integration: { readonly id: string; @@ -162,18 +112,13 @@ export function integrationPresetIconUrl( const integrationUrl = normalizeUrl(integration.url); const integrationGoogleService = googleApiServiceFromUrl(integration.url); - const integrationTokens = [...tokenVariants(integration.id), ...tokenVariants(integration.name)]; const preset = presets.find((p) => { const presetUrl = normalizeUrl(p.url); const presetGoogleService = googleApiServiceFromUrl(p.url); - const presetTokens = [...tokenVariants(p.id), ...tokenVariants(p.name)]; return ( (integrationUrl !== null && presetUrl === integrationUrl) || - (integrationGoogleService !== null && presetGoogleService === integrationGoogleService) || - integrationTokens.some((integrationToken) => - presetTokens.some((presetToken) => tokenMatches(integrationToken, presetToken)), - ) + (integrationGoogleService !== null && presetGoogleService === integrationGoogleService) ); }); @@ -181,9 +126,9 @@ export function integrationPresetIconUrl( } // Resolution cascade for the rendered favicon: first non-null, non-failed of an -// explicit preset icon, the bundled local icon for a known integration id, the -// integrations.sh logo proxy derived from the integration URL, then the Google -// favicon service as a last resort. The built-in executor integration has no preset +// explicit preset icon, the bundled local icon for a known integration id, then +// the integrations.sh logo proxy derived from the integration URL (which owns +// its own upstream fallbacks). The built-in executor integration has no preset // icon and no URL, so it resolves ONLY through the integrationId branch: callers // that drop integrationId fall through to the neutral BoxIcon placeholder. export function integrationFaviconSrc(args: { @@ -199,7 +144,6 @@ export function integrationFaviconSrc(args: { args.icon ?? null, integrationLocalIconUrl(args.integrationId), integrationFaviconUrl(args.url, args.size), - integrationFaviconFallbackUrl(args.url, args.size), ].find((candidate) => candidate !== null && !failedSrcs.includes(candidate)) ?? null ); }