History

Resolve all favicons through integrations.sh/logo (#1400)

* Resolve all favicons through integrations.sh/logo * Match preset icons on exact tokens only * Drop fuzzy name matching from the preset icon matcher

Rhys Sullivan committed 4 months ago

Commit 8bc037d1 parent 04ce4b43 files · +90 −215Browse files

Unified · Split

3 files
  1. packages/plugins/mcp/src/sdk/presets.ts +2 −2
  2. packages/react/src/components/integration-favicon.test.tsx +75 −144
  3. packages/react/src/components/integration-favicon.tsx +13 −69
packages/plugins/mcp/src/sdk/presets.ts+2−2
@@ -30,7 +30,7 @@ export const mcpPresets: readonly McpPreset[] = [ · 29 unmodified lines
3030 summary: "Deterministic MCP fixtures for validating native text and image content.",
3131 url: "https://emulators.dev/mcp/query/mcp?token=demo-token",
3232 endpoint: "https://emulators.dev/mcp/query/mcp?token=demo-token",
33− icon: "https://emulators.dev/favicon.ico",
33+ icon: "https://integrations.sh/logo/emulators.dev",
3434 },
3535 {
3636 id: "deepwiki",
@@ -142,7 +142,7 @@ export const mcpPresets: readonly McpPreset[] = [ · 105 unmodified lines
142142 id: "chrome-devtools",
143143 name: "Chrome DevTools",
144144 summary: "Debug a live Chrome browser session via local stdio.",
145− icon: "https://www.google.com/chrome/static/images/favicons/favicon-32x32.png",
145+ icon: "https://integrations.sh/logo/chrome.com",
146146 featured: true,
147147 transport: "stdio",
148148 command: "npx",
packages/react/src/components/integration-favicon.test.tsx+75−144
@@ -1,7 +1,6 @@
11 import { describe, expect, it } from "@effect/vitest";
22
33 import {
4− integrationFaviconFallbackUrl,
54 integrationFaviconSrc,
65 integrationFaviconUrl,
76 integrationInferredUrl,
@@ -10,22 +9,15 @@ import { · 2 unmodified lines
109 } from "./integration-favicon";
1110
1211 describe("IntegrationFavicon", () => {
13− it("uses the integrations.sh logo proxy as the primary favicon source", () => {
12+ it("resolves favicons only through the integrations.sh logo proxy", () => {
1413 expect(integrationFaviconUrl("https://api.github.com/graphql", 20)).toBe(
1514 "https://integrations.sh/logo/github.com?sz=40",
1615 );
1716 });
1817
19− it("falls back to the Google favicon service", () => {
20− expect(integrationFaviconFallbackUrl("https://api.github.com/graphql", 20)).toBe(
21− "https://www.google.com/s2/favicons?domain=github.com&sz=40",
22− );
23− });
24−
2518 it("does not request favicons for local URLs", () => {
2619 expect(integrationFaviconUrl("http://localhost:3000/private", 20)).toBeNull();
2720 expect(integrationFaviconUrl("http://127.0.0.1:3000/private", 20)).toBeNull();
28− expect(integrationFaviconFallbackUrl("http://localhost:3000/private", 20)).toBeNull();
2921 });
3022
3123 it("sends only the registrable domain to the logo proxy", () => {
@@ -34,13 +26,11 @@ describe("IntegrationFavicon", () => { · 2 unmodified lines
3426 );
3527 });
3628
37− it("walks the cascade from the proxy to the favicon service on failure", () => {
29+ it("falls through to the placeholder when the proxy fails", () => {
3830 const url = "https://api.github.com/graphql";
3931 const primary = integrationFaviconSrc({ url, size: 20 });
4032 expect(primary).toBe("https://integrations.sh/logo/github.com?sz=40");
41− expect(integrationFaviconSrc({ url, size: 20, failedSrcs: [primary ?? ""] })).toBe(
42− "https://www.google.com/s2/favicons?domain=github.com&sz=40",
43− );
33+ expect(integrationFaviconSrc({ url, size: 20, failedSrcs: [primary ?? ""] })).toBeNull();
4434 });
4535
4636 it("uses the Executor favicon for the built-in executor integration", () => {
@@ -102,63 +92,11 @@ describe("IntegrationFavicon", () => { · 55 unmodified lines
10292 ).toBe("https://example.com/sheets.svg");
10393 });
10494
105− it("finds preset icons from display names with suffixes", () => {
106− expect(
107− integrationPresetIconUrl(
108− {
109− id: "google_search_console_api",
110− kind: "googleDiscovery",
111− name: "Google Search Console API",
112− },
113− [
114− {
115− key: "google",
116− label: "Google",
117− add: () => null,
118− edit: () => null,
119− presets: [
120− {
121− id: "google-search-console",
122− name: "Google Search Console",
123− summary: "Search performance.",
124− icon: "https://example.com/google.svg",
125− },
126− ],
127− },
128− ],
129− ),
130− ).toBe("https://example.com/google.svg");
131− });
132−
133− it("finds preset icons from an integration id when the URL is missing", () => {
134− expect(
135− integrationPresetIconUrl(
136− {
137− id: "sentry",
138− kind: "mcp",
139− name: "Sentry MCP",
140− },
141− [
142− {
143− key: "mcp",
144− label: "MCP",
145− add: () => null,
146− edit: () => null,
147− presets: [
148− {
149− id: "sentry",
150− name: "Sentry",
151− summary: "Errors.",
152− icon: "https://example.com/sentry.png",
153− },
154− ],
155− },
156− ],
157− ),
158− ).toBe("https://example.com/sentry.png");
159− });
160−
161− it("matches migrated MCP slugs with host/suffix noise", () => {
95+ it("does not fuzzy-match preset icons from names or slugs", () => {
96+ // Name/slug token matching is gone: without an exact defaultSlug or URL
97+ // match, the preset matcher declines and the cascade resolves through the
98+ // integrations.sh favicon instead, which is derived from the integration's
99+ // own domain and therefore cannot render the wrong brand.
162100 const presets = [
163101 {
164102 key: "mcp",
@@ -167,63 +105,25 @@ describe("IntegrationFavicon", () => { · 2 unmodified lines
167105 edit: () => null,
168106 presets: [
169107 {
170− id: "posthog",
171− name: "PostHog",
172− summary: "Analytics.",
173− icon: "https://example.com/posthog.png",
174− },
175− {
176− id: "linear",
177− name: "Linear",
178− summary: "Issues.",
179− icon: "https://example.com/linear.png",
180− },
181− {
182− id: "planetscale",
183− name: "PlanetScale",
184− summary: "Databases.",
185− icon: "https://example.com/pscale.png",
108+ id: "sentry",
109+ name: "Sentry",
110+ summary: "Errors.",
111+ icon: "https://example.com/sentry.png",
186112 },
187113 ],
188114 },
189115 ];
190116
191117 expect(
192− integrationPresetIconUrl(
193− { id: "mcp_posthog_com", kind: "mcp", name: "mcp.posthog.com" },
194− presets,
195− ),
196− ).toBe("https://example.com/posthog.png");
197− expect(
198− integrationPresetIconUrl(
199− { id: "mcp_linear_app", kind: "mcp", name: "mcp.linear.app" },
200− presets,
201− ),
202− ).toBe("https://example.com/linear.png");
203− expect(
204− integrationPresetIconUrl({ id: "pscale_mcp", kind: "mcp", name: "Pscale MCP" }, presets),
205− ).toBe("https://example.com/pscale.png");
206− });
207−
208− it("matches migrated OpenAPI slugs with API/REST suffixes", () => {
209− expect(
210− integrationPresetIconUrl({ id: "stripe_api", kind: "openapi", name: "Stripe API" }, [
211− {
212− key: "openapi",
213− label: "OpenAPI",
214− add: () => null,
215− edit: () => null,
216− presets: [
217− {
218− id: "stripe",
219− name: "Stripe",
220− summary: "Payments.",
221− icon: "https://example.com/stripe.png",
222− },
223− ],
224− },
225− ]),
226− ).toBe("https://example.com/stripe.png");
118+ integrationPresetIconUrl({ id: "sentry", kind: "mcp", name: "Sentry MCP" }, presets),
119+ ).toBeNull();
120+
121+ // Migrated host-shaped integrations resolve through the inferred URL:
122+ const inferred = integrationInferredUrl({ id: "mcp_posthog_com", name: "mcp.posthog.com" });
123+ expect(inferred).toBe("https://mcp.posthog.com");
124+ expect(integrationFaviconSrc({ url: inferred ?? undefined, size: 16 })).toBe(
125+ "https://integrations.sh/logo/posthog.com?sz=32",
126+ );
227127 });
228128
229129 it("prefers exact catalog defaultSlug icons for OpenAPI provider services", () => {
@@ -248,7 +148,25 @@ describe("IntegrationFavicon", () => { · 18 unmodified lines
248148 ).toBe("https://example.com/gmail.png");
249149 });
250150
251− it("does not split generic words into brand matches", () => {
151+ it("matches presets on exact URL equality, not names", () => {
152+ const presets = [
153+ {
154+ key: "openapi",
155+ label: "OpenAPI",
156+ add: () => null,
157+ edit: () => null,
158+ presets: [
159+ {
160+ id: "spotify",
161+ name: "Spotify",
162+ summary: "Music.",
163+ url: "https://api.spotify.com/v1",
164+ icon: "https://example.com/spotify.png",
165+ },
166+ ],
167+ },
168+ ];
169+
252170 expect(
253171 integrationPresetIconUrl(
254172 {
@@ -257,30 +175,43 @@ describe("IntegrationFavicon", () => { · 2 unmodified lines
257175 name: "Spotify Web API",
258176 url: "https://api.spotify.com/v1",
259177 },
260− [
261− {
262− key: "openapi",
263− label: "OpenAPI",
264− add: () => null,
265− edit: () => null,
266− presets: [
267− {
268− id: "exa-websets",
269− name: "Exa Websets",
270− summary: "Web data.",
271− icon: "https://example.com/exa.png",
272− },
273− {
274− id: "spotify",
275− name: "Spotify",
276− summary: "Music.",
277− icon: "https://example.com/spotify.png",
278− },
279− ],
280− },
281− ],
178+ presets,
282179 ),
283180 ).toBe("https://example.com/spotify.png");
181+
182+ // Same name, different URL: no match — the URL-derived favicon takes over.
183+ expect(
184+ integrationPresetIconUrl(
185+ {
186+ id: "spotify_web_api",
187+ kind: "openapi",
188+ name: "Spotify Web API",
189+ url: "https://api.spotify.com/v2",
190+ },
191+ presets,
192+ ),
193+ ).toBeNull();
194+ });
195+
196+ it("does not match a different brand sharing a word fragment (ClickHouse Cloud vs Cloudflare)", () => {
197+ expect(
198+ integrationPresetIconUrl({ id: "clickhouse", kind: "mcp", name: "ClickHouse Cloud" }, [
199+ {
200+ key: "mcp",
201+ label: "MCP",
202+ add: () => null,
203+ edit: () => null,
204+ presets: [
205+ {
206+ id: "cloudflare",
207+ name: "Cloudflare",
208+ summary: "Workers, KV, D1, R2, and DNS management via MCP.",
209+ icon: "https://integrations.sh/logo/cloudflare.com",
210+ },
211+ ],
212+ },
213+ ]),
214+ ).toBeNull();
284215 });
285216
286217 it("infers favicon URLs from migrated host-shaped MCP names and slugs", () => {
packages/react/src/components/integration-favicon.tsx+13−69
@@ -14,24 +14,15 @@ const integrationFaviconDomain = (url: string | undefined): string | null => { · 13 unmodified lines
1414 };
1515
1616 // integrations.sh/logo proxies context.dev's Logo Link behind an edge cache
17−// and is executor's single logo source; Google's favicon service remains in
18−// the cascade as the fallback the <img> onError walks to when the proxy is
19−// unreachable or serves nothing for the domain.
17+// and is executor's single logo source. Fallbacks (Google's favicon service,
18+// a letter placeholder for unknown domains) live inside the proxy, so clients
19+// never resolve favicons against a third party directly.
2020 export function integrationFaviconUrl(url: string | undefined, size: number): string | null {
2121 const domain = integrationFaviconDomain(url);
2222 if (!domain) return null;
2323 return `https://integrations.sh/logo/${domain}?sz=${size * 2}`;
2424 }
2525
26−export function integrationFaviconFallbackUrl(
27− url: string | undefined,
28− size: number,
29−): string | null {
30− const domain = integrationFaviconDomain(url);
31− if (!domain) return null;
32− return `https://www.google.com/s2/favicons?domain=${domain}&sz=${size * 2}`;
33−}
34−
3526 export function integrationLocalIconUrl(integrationId: string | undefined): string | null {
3627 if (integrationId !== "executor") return null;
3728 return "/favicon-32.png";
@@ -85,46 +76,6 @@ const googleApiServiceFromUrl = (url: string | undefined): string | null => { · 47 unmodified lines
8576 return null;
8677 };
8778
88−const normalizeToken = (value: string | undefined): string =>
89− value?.toLowerCase().replace(/[^a-z0-9]+/g, "") ?? "";
90−
91−const tokenVariants = (value: string | undefined): readonly string[] => {
92− const tokens = new Set<string>();
93− const normalized = normalizeToken(value);
94− if (normalized.length > 0) tokens.add(normalized);
95−
96− const parts =
97− value
98− ?.toLowerCase()
99− .split(/[^a-z0-9]+/g)
100− .filter(Boolean) ?? [];
101− const noise = new Set([
102− "api",
103− "mcp",
104− "openapi",
105− "rest",
106− "graphql",
107− "web",
108− "com",
109− "net",
110− "org",
111− "dev",
112− ]);
113− const cleaned = parts.filter((part) => !noise.has(part));
114− const cleanedToken = cleaned.join("");
115− if (cleanedToken.length > 0) tokens.add(cleanedToken);
116− const aliases: Record<string, string> = {
117− pscale: "planetscale",
118− };
119− for (const part of cleaned) {
120− tokens.add(part);
121− const alias = aliases[part];
122− if (alias) tokens.add(alias);
123− }
124−
125− return [...tokens];
126−};
127−
12879 export function integrationInferredUrl(integration: {
12980 readonly id: string;
13081 readonly name?: string;
@@ -138,13 +89,12 @@ export function integrationInferredUrl(integration: { · 7 unmodified lines
13889 return null;
13990 }
14091
141−const tokenMatches = (integrationValue: string, presetValue: string): boolean =>
142− presetValue.length > 0 &&
143− integrationValue.length > 0 &&
144− (integrationValue === presetValue ||
145− integrationValue.includes(presetValue) ||
146− presetValue.includes(integrationValue));
147−
92+// Exact identity signals only — preset defaultSlug, normalized URL equality,
93+// or the same Google discovery service. Fuzzy name/slug token matching used to
94+// live here and matched unrelated brands sharing a word fragment ("ClickHouse
95+// Cloud" rendered Cloudflare's logo via "cloud"). A missed match is recoverable
96+// (the cascade falls through to the domain-derived integrations.sh favicon,
97+// which is always the right brand); a wrong-brand icon is not.
14898 export function integrationPresetIconUrl(
14999 integration: {
150100 readonly id: string;
@@ -162,18 +112,13 @@ export function integrationPresetIconUrl( · 11 unmodified lines
162112
163113 const integrationUrl = normalizeUrl(integration.url);
164114 const integrationGoogleService = googleApiServiceFromUrl(integration.url);
165− const integrationTokens = [...tokenVariants(integration.id), ...tokenVariants(integration.name)];
166115
167116 const preset = presets.find((p) => {
168117 const presetUrl = normalizeUrl(p.url);
169118 const presetGoogleService = googleApiServiceFromUrl(p.url);
170− const presetTokens = [...tokenVariants(p.id), ...tokenVariants(p.name)];
171119 return (
172120 (integrationUrl !== null && presetUrl === integrationUrl) ||
173− (integrationGoogleService !== null && presetGoogleService === integrationGoogleService) ||
174− integrationTokens.some((integrationToken) =>
175− presetTokens.some((presetToken) => tokenMatches(integrationToken, presetToken)),
176− )
121+ (integrationGoogleService !== null && presetGoogleService === integrationGoogleService)
177122 );
178123 });
179124
@@ -181,9 +126,9 @@ export function integrationPresetIconUrl( · 1 unmodified line
181126 }
182127
183128 // Resolution cascade for the rendered favicon: first non-null, non-failed of an
184−// explicit preset icon, the bundled local icon for a known integration id, the
185−// integrations.sh logo proxy derived from the integration URL, then the Google
186−// favicon service as a last resort. The built-in executor integration has no preset
129+// explicit preset icon, the bundled local icon for a known integration id, then
130+// the integrations.sh logo proxy derived from the integration URL (which owns
131+// its own upstream fallbacks). The built-in executor integration has no preset
187132 // icon and no URL, so it resolves ONLY through the integrationId branch: callers
188133 // that drop integrationId fall through to the neutral BoxIcon placeholder.
189134 export function integrationFaviconSrc(args: {
@@ -199,7 +144,6 @@ export function integrationFaviconSrc(args: { · 9 unmodified lines
199144 args.icon ?? null,
200145 integrationLocalIconUrl(args.integrationId),
201146 integrationFaviconUrl(args.url, args.size),
202− integrationFaviconFallbackUrl(args.url, args.size),
203147 ].find((candidate) => candidate !== null && !failedSrcs.includes(candidate)) ?? null
204148 );
205149 }

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t