- 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 | status: input.source.status, | 126 | status: input.source.status, |
| 127 | enabled: input.source.enabled, | 127 | enabled: input.source.enabled, |
| 128 | namespace: trimOrNull(input.source.namespace), | 128 | namespace: trimOrNull(input.source.namespace), |
| 129 | ...(trimOrNull(input.source.iconUrl) | ||
| 130 | ? { iconUrl: trimOrNull(input.source.iconUrl) ?? undefined } | ||
| 131 | : {}), | ||
| 129 | createdAt: input.now, | 132 | createdAt: input.now, |
| 130 | updatedAt: input.now, | 133 | updatedAt: input.now, |
| 131 | }); | 134 | }); |
| @@ -138,6 +141,9 @@ export const normalizeSourceForSave = (input: { · 6 unmodified lines | |||
| 138 | ...input.source, | 141 | ...input.source, |
| 139 | name: input.source.name.trim(), | 142 | name: input.source.name.trim(), |
| 140 | namespace: trimOrNull(input.source.namespace), | 143 | namespace: trimOrNull(input.source.namespace), |
| 144 | ...(trimOrNull(input.source.iconUrl) | ||
| 145 | ? { iconUrl: trimOrNull(input.source.iconUrl) ?? undefined } | ||
| 146 | : {}), | ||
| 141 | updatedAt: input.now, | 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 | ...scopeConfigSourceBaseFromSource({ | 58 | ...scopeConfigSourceBaseFromSource({ |
| 59 | source: input.source, | 59 | source: input.source, |
| 60 | }), | 60 | }), |
| 61 | ...(trimOrNull(input.existingConfig?.iconUrl) | ||
| 62 | ? { iconUrl: trimOrNull(input.existingConfig?.iconUrl) ?? undefined } | ||
| 63 | : {}), | ||
| 61 | kind: input.source.kind as ExecutorScopeConfigSource["kind"], | 64 | kind: input.source.kind as ExecutorScopeConfigSource["kind"], |
| 62 | ...(input.existingConfig?.config !== undefined | 65 | ...(input.existingConfig?.config !== undefined |
| 63 | ? { | 66 | ? { |
| @@ -21,6 +21,7 @@ export const createPluginScopeConfigEntrySchema = < · 20 unmodified lines | |||
| 21 | kind: Schema.Literal(input.kind), | 21 | kind: Schema.Literal(input.kind), |
| 22 | name: Schema.optional(Schema.String), | 22 | name: Schema.optional(Schema.String), |
| 23 | namespace: Schema.optional(Schema.String), | 23 | namespace: Schema.optional(Schema.String), |
| 24 | iconUrl: Schema.optional(Schema.String), | ||
| 24 | enabled: Schema.optional(Schema.Boolean), | 25 | enabled: Schema.optional(Schema.Boolean), |
| 25 | config: input.config, | 26 | config: input.config, |
| 26 | }); | 27 | }); |
| @@ -28,10 +29,12 @@ export const createPluginScopeConfigEntrySchema = < · 1 unmodified line | |||
| 28 | export const pluginScopeConfigSourceFromConfig = <TConfig>(input: { | 29 | export const pluginScopeConfigSourceFromConfig = <TConfig>(input: { |
| 29 | source: Source; | 30 | source: Source; |
| 30 | config: TConfig; | 31 | config: TConfig; |
| 32 | iconUrl?: string | null; | ||
| 31 | }): ExecutorScopeConfigSource => ({ | 33 | }): ExecutorScopeConfigSource => ({ |
| 32 | ...scopeConfigSourceBaseFromSource({ | 34 | ...scopeConfigSourceBaseFromSource({ |
| 33 | source: input.source, | 35 | source: input.source, |
| 34 | }), | 36 | }), |
| 37 | ...(input.iconUrl ? { iconUrl: input.iconUrl } : {}), | ||
| 35 | kind: input.source.kind as ExecutorScopeConfigSource["kind"], | 38 | kind: input.source.kind as ExecutorScopeConfigSource["kind"], |
| 36 | config: cloneJson(input.config), | 39 | config: cloneJson(input.config), |
| 37 | }); | 40 | }); |
| @@ -18,6 +18,7 @@ import { · 17 unmodified lines | |||
| 18 | type RuntimeSourceStoreDeps, | 18 | type RuntimeSourceStoreDeps, |
| 19 | } from "./deps"; | 19 | } from "./deps"; |
| 20 | import { trimOrNull } from "./config"; | 20 | import { trimOrNull } from "./config"; |
| 21 | import { resolveSourceIconUrl } from "../source-icon"; | ||
| 21 | 22 | ||
| 22 | export const buildLocalSourceRecord = (input: { | 23 | export const buildLocalSourceRecord = (input: { |
| 23 | scopeId: ScopeId; | 24 | scopeId: ScopeId; |
| @@ -42,6 +43,11 @@ export const buildLocalSourceRecord = (input: { · 18 unmodified lines | |||
| 42 | } | 43 | } |
| 43 | 44 | ||
| 44 | const existingState = input.scopeState.sources[input.sourceId]; | 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 | const source: Source = { | 51 | const source: Source = { |
| 46 | id: SourceIdSchema.make(input.sourceId), | 52 | id: SourceIdSchema.make(input.sourceId), |
| 47 | scopeId: input.scopeId, | 53 | scopeId: input.scopeId, |
| @@ -52,6 +58,11 @@ export const buildLocalSourceRecord = (input: { · 4 unmodified lines | |||
| 52 | (sourceConfig.enabled ?? true ? "connected" : "draft"), | 58 | (sourceConfig.enabled ?? true ? "connected" : "draft"), |
| 53 | enabled: sourceConfig.enabled ?? true, | 59 | enabled: sourceConfig.enabled ?? true, |
| 54 | namespace: trimOrNull(sourceConfig.namespace) ?? input.sourceId, | 60 | namespace: trimOrNull(sourceConfig.namespace) ?? input.sourceId, |
| 61 | ...(resolvedIconUrl | ||
| 62 | ? { | ||
| 63 | iconUrl: resolvedIconUrl, | ||
| 64 | } | ||
| 65 | : {}), | ||
| 55 | createdAt: existingState?.createdAt ?? Date.now(), | 66 | createdAt: existingState?.createdAt ?? Date.now(), |
| 56 | updatedAt: existingState?.updatedAt ?? Date.now(), | 67 | updatedAt: existingState?.updatedAt ?? Date.now(), |
| 57 | }; | 68 | }; |
| @@ -11,6 +11,7 @@ export const ExecutorRuntimeConfigSchema = Schema.Literal( · 10 unmodified lines | |||
| 11 | const ExecutorScopeConfigSourceBaseSchema = Schema.Struct({ | 11 | const ExecutorScopeConfigSourceBaseSchema = Schema.Struct({ |
| 12 | name: Schema.optional(Schema.String), | 12 | name: Schema.optional(Schema.String), |
| 13 | namespace: Schema.optional(Schema.String), | 13 | namespace: Schema.optional(Schema.String), |
| 14 | iconUrl: Schema.optional(Schema.String), | ||
| 14 | enabled: Schema.optional(Schema.Boolean), | 15 | enabled: Schema.optional(Schema.Boolean), |
| 15 | config: Schema.optional(Schema.Unknown), | 16 | config: Schema.optional(Schema.Unknown), |
| 16 | }); | 17 | }); |
| @@ -97,6 +97,7 @@ export const SourceSchema = Schema.Struct({ · 96 unmodified lines | |||
| 97 | status: SourceStatusSchema, | 97 | status: SourceStatusSchema, |
| 98 | enabled: Schema.Boolean, | 98 | enabled: Schema.Boolean, |
| 99 | namespace: Schema.NullOr(Schema.String), | 99 | namespace: Schema.NullOr(Schema.String), |
| 100 | iconUrl: Schema.optional(Schema.String), | ||
| 100 | createdAt: TimestampMsSchema, | 101 | createdAt: TimestampMsSchema, |
| 101 | updatedAt: TimestampMsSchema, | 102 | updatedAt: TimestampMsSchema, |
| 102 | }); | 103 | }); |
| @@ -72,6 +72,7 @@ export const SourceSchema = Schema.Struct({ · 71 unmodified lines | |||
| 72 | status: SourceStatusSchema, | 72 | status: SourceStatusSchema, |
| 73 | enabled: Schema.Boolean, | 73 | enabled: Schema.Boolean, |
| 74 | namespace: Schema.NullOr(Schema.String), | 74 | namespace: Schema.NullOr(Schema.String), |
| 75 | iconUrl: Schema.optional(Schema.String), | ||
| 75 | createdAt: Schema.Number, | 76 | createdAt: Schema.Number, |
| 76 | updatedAt: Schema.Number, | 77 | updatedAt: Schema.Number, |
| 77 | }); | 78 | }); |