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
30 summary: "Deterministic MCP fixtures for validating native text and image content.",30 summary: "Deterministic MCP fixtures for validating native text and image content.",
31 url: "https://emulators.dev/mcp/query/mcp?token=demo-token",31 url: "https://emulators.dev/mcp/query/mcp?token=demo-token",
32 endpoint: "https://emulators.dev/mcp/query/mcp?token=demo-token",32 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",
34 },34 },
35 {35 {
36 id: "deepwiki",36 id: "deepwiki",
@@ -142,7 +142,7 @@ export const mcpPresets: readonly McpPreset[] = [ · 105 unmodified lines
142 id: "chrome-devtools",142 id: "chrome-devtools",
143 name: "Chrome DevTools",143 name: "Chrome DevTools",
144 summary: "Debug a live Chrome browser session via local stdio.",144 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",
146 featured: true,146 featured: true,
147 transport: "stdio",147 transport: "stdio",
148 command: "npx",148 command: "npx",
packages/react/src/components/integration-favicon.test.tsx+75−144
@@ -1,7 +1,6 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import {3import {
4 integrationFaviconFallbackUrl,
5 integrationFaviconSrc,4 integrationFaviconSrc,
6 integrationFaviconUrl,5 integrationFaviconUrl,
7 integrationInferredUrl,6 integrationInferredUrl,
@@ -10,22 +9,15 @@ import { · 2 unmodified lines
10} from "./integration-favicon";9} from "./integration-favicon";
11 10
12describe("IntegrationFavicon", () => {11describe("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", () => {
14 expect(integrationFaviconUrl("https://api.github.com/graphql", 20)).toBe(13 expect(integrationFaviconUrl("https://api.github.com/graphql", 20)).toBe(
15 "https://integrations.sh/logo/github.com?sz=40",14 "https://integrations.sh/logo/github.com?sz=40",
16 );15 );
17 });16 });
18 17
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
25 it("does not request favicons for local URLs", () => {18 it("does not request favicons for local URLs", () => {
26 expect(integrationFaviconUrl("http://localhost:3000/private", 20)).toBeNull();19 expect(integrationFaviconUrl("http://localhost:3000/private", 20)).toBeNull();
27 expect(integrationFaviconUrl("http://127.0.0.1:3000/private", 20)).toBeNull();20 expect(integrationFaviconUrl("http://127.0.0.1:3000/private", 20)).toBeNull();
28 expect(integrationFaviconFallbackUrl("http://localhost:3000/private", 20)).toBeNull();
29 });21 });
30 22
31 it("sends only the registrable domain to the logo proxy", () => {23 it("sends only the registrable domain to the logo proxy", () => {
@@ -34,13 +26,11 @@ describe("IntegrationFavicon", () => { · 2 unmodified lines
34 );26 );
35 });27 });
36 28
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", () => {
38 const url = "https://api.github.com/graphql";30 const url = "https://api.github.com/graphql";
39 const primary = integrationFaviconSrc({ url, size: 20 });31 const primary = integrationFaviconSrc({ url, size: 20 });
40 expect(primary).toBe("https://integrations.sh/logo/github.com?sz=40");32 expect(primary).toBe("https://integrations.sh/logo/github.com?sz=40");
41 expect(integrationFaviconSrc({ url, size: 20, failedSrcs: [primary ?? ""] })).toBe(33 expect(integrationFaviconSrc({ url, size: 20, failedSrcs: [primary ?? ""] })).toBeNull();
42 "https://www.google.com/s2/favicons?domain=github.com&sz=40",
43 );
44 });34 });
45 35
46 it("uses the Executor favicon for the built-in executor integration", () => {36 it("uses the Executor favicon for the built-in executor integration", () => {
@@ -102,63 +92,11 @@ describe("IntegrationFavicon", () => { · 55 unmodified lines
102 ).toBe("https://example.com/sheets.svg");92 ).toBe("https://example.com/sheets.svg");
103 });93 });
104 94
105 it("finds preset icons from display names with suffixes", () => {95 it("does not fuzzy-match preset icons from names or slugs", () => {
106 expect(96 // Name/slug token matching is gone: without an exact defaultSlug or URL
107 integrationPresetIconUrl(97 // match, the preset matcher declines and the cascade resolves through the
108 {98 // integrations.sh favicon instead, which is derived from the integration's
109 id: "google_search_console_api",99 // own domain and therefore cannot render the wrong brand.
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", () => {
162 const presets = [100 const presets = [
163 {101 {
164 key: "mcp",102 key: "mcp",
@@ -167,63 +105,25 @@ describe("IntegrationFavicon", () => { · 2 unmodified lines
167 edit: () => null,105 edit: () => null,
168 presets: [106 presets: [
169 {107 {
170 id: "posthog",108 id: "sentry",
171 name: "PostHog",109 name: "Sentry",
172 summary: "Analytics.",110 summary: "Errors.",
173 icon: "https://example.com/posthog.png",111 icon: "https://example.com/sentry.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",
186 },112 },
187 ],113 ],
188 },114 },
189 ];115 ];
190 116
191 expect(117 expect(
192 integrationPresetIconUrl(118 integrationPresetIconUrl({ id: "sentry", kind: "mcp", name: "Sentry MCP" }, presets),
193 { id: "mcp_posthog_com", kind: "mcp", name: "mcp.posthog.com" },119 ).toBeNull();
194 presets,120
195 ),121 // Migrated host-shaped integrations resolve through the inferred URL:
196 ).toBe("https://example.com/posthog.png");122 const inferred = integrationInferredUrl({ id: "mcp_posthog_com", name: "mcp.posthog.com" });
197 expect(123 expect(inferred).toBe("https://mcp.posthog.com");
198 integrationPresetIconUrl(124 expect(integrationFaviconSrc({ url: inferred ?? undefined, size: 16 })).toBe(
199 { id: "mcp_linear_app", kind: "mcp", name: "mcp.linear.app" },125 "https://integrations.sh/logo/posthog.com?sz=32",
200 presets,126 );
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");
227 });127 });
228 128
229 it("prefers exact catalog defaultSlug icons for OpenAPI provider services", () => {129 it("prefers exact catalog defaultSlug icons for OpenAPI provider services", () => {
@@ -248,7 +148,25 @@ describe("IntegrationFavicon", () => { · 18 unmodified lines
248 ).toBe("https://example.com/gmail.png");148 ).toBe("https://example.com/gmail.png");
249 });149 });
250 150
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
252 expect(170 expect(
253 integrationPresetIconUrl(171 integrationPresetIconUrl(
254 {172 {
@@ -257,30 +175,43 @@ describe("IntegrationFavicon", () => { · 2 unmodified lines
257 name: "Spotify Web API",175 name: "Spotify Web API",
258 url: "https://api.spotify.com/v1",176 url: "https://api.spotify.com/v1",
259 },177 },
260 [178 presets,
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 ],
282 ),179 ),
283 ).toBe("https://example.com/spotify.png");180 ).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();
284 });215 });
285 216
286 it("infers favicon URLs from migrated host-shaped MCP names and slugs", () => {217 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
14};14};
15 15
16// integrations.sh/logo proxies context.dev's Logo Link behind an edge cache16// 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 in17// and is executor's single logo source. Fallbacks (Google's favicon service,
18// the cascade as the fallback the <img> onError walks to when the proxy is18// a letter placeholder for unknown domains) live inside the proxy, so clients
19// unreachable or serves nothing for the domain.19// never resolve favicons against a third party directly.
20export function integrationFaviconUrl(url: string | undefined, size: number): string | null {20export function integrationFaviconUrl(url: string | undefined, size: number): string | null {
21 const domain = integrationFaviconDomain(url);21 const domain = integrationFaviconDomain(url);
22 if (!domain) return null;22 if (!domain) return null;
23 return `https://integrations.sh/logo/${domain}?sz=${size * 2}`;23 return `https://integrations.sh/logo/${domain}?sz=${size * 2}`;
24}24}
25 25
26export 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
35export function integrationLocalIconUrl(integrationId: string | undefined): string | null {26export function integrationLocalIconUrl(integrationId: string | undefined): string | null {
36 if (integrationId !== "executor") return null;27 if (integrationId !== "executor") return null;
37 return "/favicon-32.png";28 return "/favicon-32.png";
@@ -85,46 +76,6 @@ const googleApiServiceFromUrl = (url: string | undefined): string | null => { · 47 unmodified lines
85 return null;76 return null;
86};77};
87 78
88const normalizeToken = (value: string | undefined): string =>
89 value?.toLowerCase().replace(/[^a-z0-9]+/g, "") ?? "";
90
91const 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
128export function integrationInferredUrl(integration: {79export function integrationInferredUrl(integration: {
129 readonly id: string;80 readonly id: string;
130 readonly name?: string;81 readonly name?: string;
@@ -138,13 +89,12 @@ export function integrationInferredUrl(integration: { · 7 unmodified lines
138 return null;89 return null;
139}90}
140 91
141const tokenMatches = (integrationValue: string, presetValue: string): boolean =>92// Exact identity signals only — preset defaultSlug, normalized URL equality,
142 presetValue.length > 0 &&93// or the same Google discovery service. Fuzzy name/slug token matching used to
143 integrationValue.length > 0 &&94// live here and matched unrelated brands sharing a word fragment ("ClickHouse
144 (integrationValue === presetValue ||95// Cloud" rendered Cloudflare's logo via "cloud"). A missed match is recoverable
145 integrationValue.includes(presetValue) ||96// (the cascade falls through to the domain-derived integrations.sh favicon,
146 presetValue.includes(integrationValue));97// which is always the right brand); a wrong-brand icon is not.
147
148export function integrationPresetIconUrl(98export function integrationPresetIconUrl(
149 integration: {99 integration: {
150 readonly id: string;100 readonly id: string;
@@ -162,18 +112,13 @@ export function integrationPresetIconUrl( · 11 unmodified lines
162 112
163 const integrationUrl = normalizeUrl(integration.url);113 const integrationUrl = normalizeUrl(integration.url);
164 const integrationGoogleService = googleApiServiceFromUrl(integration.url);114 const integrationGoogleService = googleApiServiceFromUrl(integration.url);
165 const integrationTokens = [...tokenVariants(integration.id), ...tokenVariants(integration.name)];
166 115
167 const preset = presets.find((p) => {116 const preset = presets.find((p) => {
168 const presetUrl = normalizeUrl(p.url);117 const presetUrl = normalizeUrl(p.url);
169 const presetGoogleService = googleApiServiceFromUrl(p.url);118 const presetGoogleService = googleApiServiceFromUrl(p.url);
170 const presetTokens = [...tokenVariants(p.id), ...tokenVariants(p.name)];
171 return (119 return (
172 (integrationUrl !== null && presetUrl === integrationUrl) ||120 (integrationUrl !== null && presetUrl === integrationUrl) ||
173 (integrationGoogleService !== null && presetGoogleService === integrationGoogleService) ||121 (integrationGoogleService !== null && presetGoogleService === integrationGoogleService)
174 integrationTokens.some((integrationToken) =>
175 presetTokens.some((presetToken) => tokenMatches(integrationToken, presetToken)),
176 )
177 );122 );
178 });123 });
179 124
@@ -181,9 +126,9 @@ export function integrationPresetIconUrl( · 1 unmodified line
181}126}
182 127
183// Resolution cascade for the rendered favicon: first non-null, non-failed of an128// 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, the129// explicit preset icon, the bundled local icon for a known integration id, then
185// integrations.sh logo proxy derived from the integration URL, then the Google130// the integrations.sh logo proxy derived from the integration URL (which owns
186// favicon service as a last resort. The built-in executor integration has no preset131// its own upstream fallbacks). The built-in executor integration has no preset
187// icon and no URL, so it resolves ONLY through the integrationId branch: callers132// icon and no URL, so it resolves ONLY through the integrationId branch: callers
188// that drop integrationId fall through to the neutral BoxIcon placeholder.133// that drop integrationId fall through to the neutral BoxIcon placeholder.
189export function integrationFaviconSrc(args: {134export function integrationFaviconSrc(args: {
@@ -199,7 +144,6 @@ export function integrationFaviconSrc(args: { · 9 unmodified lines
199 args.icon ?? null,144 args.icon ?? null,
200 integrationLocalIconUrl(args.integrationId),145 integrationLocalIconUrl(args.integrationId),
201 integrationFaviconUrl(args.url, args.size),146 integrationFaviconUrl(args.url, args.size),
202 integrationFaviconFallbackUrl(args.url, args.size),
203 ].find((candidate) => candidate !== null && !failedSrcs.includes(candidate)) ?? null147 ].find((candidate) => candidate !== null && !failedSrcs.includes(candidate)) ?? null
204 );148 );
205}149}

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