- Add iconUrl to SourceSchema and ExecutorScopeConfigSourceBaseSchema - Add resolveSourceIconUrl() utility that derives Google Favicon URLs from MCP endpoint domains (handles compound TLDs like co.uk, com.au) - Propagate iconUrl through normalizeSourceForCreate/Save and store layers - Add source-icon.ts with resolveSourceIconUrl for auto-favicon derivation
Unified · Split
| @@ -126,6 +126,9 @@ export const normalizeSourceForCreate = (input: { · 125 unmodified lines | ||
| 126 | 126 | status: input.source.status, |
| 127 | 127 | enabled: input.source.enabled, |
| 128 | 128 | namespace: trimOrNull(input.source.namespace), |
| 129 | + ...(trimOrNull(input.source.iconUrl) | |
| 130 | + ? { iconUrl: trimOrNull(input.source.iconUrl) ?? undefined } | |
| 131 | + : {}), | |
| 129 | 132 | createdAt: input.now, |
| 130 | 133 | updatedAt: input.now, |
| 131 | 134 | }); |
| @@ -138,6 +141,9 @@ export const normalizeSourceForSave = (input: { · 6 unmodified lines | ||
| 138 | 141 | ...input.source, |
| 139 | 142 | name: input.source.name.trim(), |
| 140 | 143 | namespace: trimOrNull(input.source.namespace), |
| 144 | + ...(trimOrNull(input.source.iconUrl) | |
| 145 | + ? { iconUrl: trimOrNull(input.source.iconUrl) ?? undefined } | |
| 146 | + : {}), | |
| 141 | 147 | updatedAt: input.now, |
| 142 | 148 | }); |
| 143 | 149 | |
| @@ -0,0 +1,40 @@ | ||
| 1 | +import { | |
| 2 | + describe, | |
| 3 | + expect, | |
| 4 | + it, | |
| 5 | +} from "@effect/vitest"; | |
| 6 | + | |
| 7 | +import { | |
| 8 | + resolveSourceIconUrl, | |
| 9 | +} from "./source-icon"; | |
| 10 | + | |
| 11 | +describe("resolveSourceIconUrl", () => { | |
| 12 | + it("prefers the configured override", () => { | |
| 13 | + expect(resolveSourceIconUrl({ | |
| 14 | + configuredIconUrl: "https://cdn.example.com/icon.png", | |
| 15 | + kind: "mcp", | |
| 16 | + config: { | |
| 17 | + endpoint: "https://mcp.axiom.co/mcp", | |
| 18 | + }, | |
| 19 | + })).toBe("https://cdn.example.com/icon.png"); | |
| 20 | + }); | |
| 21 | + | |
| 22 | + it("derives remote MCP icons from the endpoint host", () => { | |
| 23 | + expect(resolveSourceIconUrl({ | |
| 24 | + kind: "mcp", | |
| 25 | + config: { | |
| 26 | + endpoint: "https://mcp.axiom.co/mcp", | |
| 27 | + }, | |
| 28 | + })).toBe("https://www.google.com/s2/favicons?domain=axiom.co&sz=32"); | |
| 29 | + }); | |
| 30 | + | |
| 31 | + it("returns null for stdio MCP sources without an override", () => { | |
| 32 | + expect(resolveSourceIconUrl({ | |
| 33 | + kind: "mcp", | |
| 34 | + config: { | |
| 35 | + endpoint: null, | |
| 36 | + command: "npx", | |
| 37 | + }, | |
| 38 | + })).toBe(null); | |
| 39 | + }); | |
| 40 | +}); | |
| @@ -0,0 +1,88 @@ | ||
| 1 | +const COMMON_COMPOUND_SUFFIXES = new Set([ | |
| 2 | + "ac.uk", | |
| 3 | + "co.in", | |
| 4 | + "co.jp", | |
| 5 | + "co.nz", | |
| 6 | + "co.uk", | |
| 7 | + "com.au", | |
| 8 | + "com.br", | |
| 9 | + "com.mx", | |
| 10 | + "net.au", | |
| 11 | + "org.au", | |
| 12 | + "org.uk", | |
| 13 | +]); | |
| 14 | + | |
| 15 | +const trimOrNull = (value: string | null | undefined): string | null => { | |
| 16 | + if (value == null) { | |
| 17 | + return null; | |
| 18 | + } | |
| 19 | + | |
| 20 | + const trimmed = value.trim(); | |
| 21 | + return trimmed.length > 0 ? trimmed : null; | |
| 22 | +}; | |
| 23 | + | |
| 24 | +const isRecord = (value: unknown): value is Record<string, unknown> => | |
| 25 | + typeof value === "object" && value !== null && !Array.isArray(value); | |
| 26 | + | |
| 27 | +const isIpv4Address = (value: string): boolean => | |
| 28 | + /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value); | |
| 29 | + | |
| 30 | +const toRegistrableDomain = (hostname: string): string | null => { | |
| 31 | + const normalized = hostname.trim().toLowerCase().replace(/^\.+|\.+$/g, ""); | |
| 32 | + if (!normalized) { | |
| 33 | + return null; | |
| 34 | + } | |
| 35 | + | |
| 36 | + if (normalized === "localhost" || isIpv4Address(normalized)) { | |
| 37 | + return normalized; | |
| 38 | + } | |
| 39 | + | |
| 40 | + const parts = normalized.split(".").filter((part) => part.length > 0); | |
| 41 | + if (parts.length < 2) { | |
| 42 | + return null; | |
| 43 | + } | |
| 44 | + | |
| 45 | + const suffix = parts.slice(-2).join("."); | |
| 46 | + if (parts.length >= 3 && COMMON_COMPOUND_SUFFIXES.has(suffix)) { | |
| 47 | + return parts.slice(-3).join("."); | |
| 48 | + } | |
| 49 | + | |
| 50 | + return parts.slice(-2).join("."); | |
| 51 | +}; | |
| 52 | + | |
| 53 | +const toGoogleFaviconUrl = (domain: string): string => | |
| 54 | + `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=32`; | |
| 55 | + | |
| 56 | +export const resolveSourceIconUrl = (input: { | |
| 57 | + configuredIconUrl?: string | null; | |
| 58 | + kind: string; | |
| 59 | + config?: unknown; | |
| 60 | +}): string | null => { | |
| 61 | + const configuredIconUrl = trimOrNull(input.configuredIconUrl); | |
| 62 | + if (configuredIconUrl) { | |
| 63 | + return configuredIconUrl; | |
| 64 | + } | |
| 65 | + | |
| 66 | + if (input.kind !== "mcp" || !isRecord(input.config)) { | |
| 67 | + return null; | |
| 68 | + } | |
| 69 | + | |
| 70 | + const endpoint = typeof input.config.endpoint === "string" | |
| 71 | + ? trimOrNull(input.config.endpoint) | |
| 72 | + : null; | |
| 73 | + if (!endpoint) { | |
| 74 | + return null; | |
| 75 | + } | |
| 76 | + | |
| 77 | + try { | |
| 78 | + const url = new URL(endpoint); | |
| 79 | + if (url.protocol !== "http:" && url.protocol !== "https:") { | |
| 80 | + return null; | |
| 81 | + } | |
| 82 | + | |
| 83 | + const domain = toRegistrableDomain(url.hostname); | |
| 84 | + return domain ? toGoogleFaviconUrl(domain) : null; | |
| 85 | + } catch { | |
| 86 | + return null; | |
| 87 | + } | |
| 88 | +}; | |
| @@ -58,6 +58,9 @@ export const scopeConfigSourceFromSource = (input: { · 57 unmodified lines | ||
| 58 | 58 | ...scopeConfigSourceBaseFromSource({ |
| 59 | 59 | source: input.source, |
| 60 | 60 | }), |
| 61 | + ...(trimOrNull(input.existingConfig?.iconUrl) | |
| 62 | + ? { iconUrl: trimOrNull(input.existingConfig?.iconUrl) ?? undefined } | |
| 63 | + : {}), | |
| 61 | 64 | kind: input.source.kind as ExecutorScopeConfigSource["kind"], |
| 62 | 65 | ...(input.existingConfig?.config !== undefined |
| 63 | 66 | ? { |
| @@ -21,6 +21,7 @@ export const createPluginScopeConfigEntrySchema = < · 20 unmodified lines | ||
| 21 | 21 | kind: Schema.Literal(input.kind), |
| 22 | 22 | name: Schema.optional(Schema.String), |
| 23 | 23 | namespace: Schema.optional(Schema.String), |
| 24 | + iconUrl: Schema.optional(Schema.String), | |
| 24 | 25 | enabled: Schema.optional(Schema.Boolean), |
| 25 | 26 | config: input.config, |
| 26 | 27 | }); |
| @@ -28,10 +29,12 @@ export const createPluginScopeConfigEntrySchema = < · 1 unmodified line | ||
| 28 | 29 | export const pluginScopeConfigSourceFromConfig = <TConfig>(input: { |
| 29 | 30 | source: Source; |
| 30 | 31 | config: TConfig; |
| 32 | + iconUrl?: string | null; | |
| 31 | 33 | }): ExecutorScopeConfigSource => ({ |
| 32 | 34 | ...scopeConfigSourceBaseFromSource({ |
| 33 | 35 | source: input.source, |
| 34 | 36 | }), |
| 37 | + ...(input.iconUrl ? { iconUrl: input.iconUrl } : {}), | |
| 35 | 38 | kind: input.source.kind as ExecutorScopeConfigSource["kind"], |
| 36 | 39 | config: cloneJson(input.config), |
| 37 | 40 | }); |
| @@ -18,6 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | type RuntimeSourceStoreDeps, |
| 19 | 19 | } from "./deps"; |
| 20 | 20 | import { trimOrNull } from "./config"; |
| 21 | +import { resolveSourceIconUrl } from "../source-icon"; | |
| 21 | 22 | |
| 22 | 23 | export const buildLocalSourceRecord = (input: { |
| 23 | 24 | scopeId: ScopeId; |
| @@ -42,6 +43,11 @@ export const buildLocalSourceRecord = (input: { · 18 unmodified lines | ||
| 42 | 43 | } |
| 43 | 44 | |
| 44 | 45 | const existingState = input.scopeState.sources[input.sourceId]; |
| 46 | + const resolvedIconUrl = resolveSourceIconUrl({ | |
| 47 | + configuredIconUrl: trimOrNull(sourceConfig.iconUrl), | |
| 48 | + kind: sourceConfig.kind, | |
| 49 | + config: sourceConfig.config, | |
| 50 | + }); | |
| 45 | 51 | const source: Source = { |
| 46 | 52 | id: SourceIdSchema.make(input.sourceId), |
| 47 | 53 | scopeId: input.scopeId, |
| @@ -52,6 +58,11 @@ export const buildLocalSourceRecord = (input: { · 4 unmodified lines | ||
| 52 | 58 | (sourceConfig.enabled ?? true ? "connected" : "draft"), |
| 53 | 59 | enabled: sourceConfig.enabled ?? true, |
| 54 | 60 | namespace: trimOrNull(sourceConfig.namespace) ?? input.sourceId, |
| 61 | + ...(resolvedIconUrl | |
| 62 | + ? { | |
| 63 | + iconUrl: resolvedIconUrl, | |
| 64 | + } | |
| 65 | + : {}), | |
| 55 | 66 | createdAt: existingState?.createdAt ?? Date.now(), |
| 56 | 67 | updatedAt: existingState?.updatedAt ?? Date.now(), |
| 57 | 68 | }; |
| @@ -11,6 +11,7 @@ export const ExecutorRuntimeConfigSchema = Schema.Literal( · 10 unmodified lines | ||
| 11 | 11 | const ExecutorScopeConfigSourceBaseSchema = Schema.Struct({ |
| 12 | 12 | name: Schema.optional(Schema.String), |
| 13 | 13 | namespace: Schema.optional(Schema.String), |
| 14 | + iconUrl: Schema.optional(Schema.String), | |
| 14 | 15 | enabled: Schema.optional(Schema.Boolean), |
| 15 | 16 | config: Schema.optional(Schema.Unknown), |
| 16 | 17 | }); |
| @@ -97,6 +97,7 @@ export const SourceSchema = Schema.Struct({ · 96 unmodified lines | ||
| 97 | 97 | status: SourceStatusSchema, |
| 98 | 98 | enabled: Schema.Boolean, |
| 99 | 99 | namespace: Schema.NullOr(Schema.String), |
| 100 | + iconUrl: Schema.optional(Schema.String), | |
| 100 | 101 | createdAt: TimestampMsSchema, |
| 101 | 102 | updatedAt: TimestampMsSchema, |
| 102 | 103 | }); |
| @@ -72,6 +72,7 @@ export const SourceSchema = Schema.Struct({ · 71 unmodified lines | ||
| 72 | 72 | status: SourceStatusSchema, |
| 73 | 73 | enabled: Schema.Boolean, |
| 74 | 74 | namespace: Schema.NullOr(Schema.String), |
| 75 | + iconUrl: Schema.optional(Schema.String), | |
| 75 | 76 | createdAt: Schema.Number, |
| 76 | 77 | updatedAt: Schema.Number, |
| 77 | 78 | }); |