History

feat(sources): add iconUrl field to source schema and runtime

- 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

Saatvik Arya committed 7 months ago

Commit 1176bc41 parent 128b7779 files · +154 −0Browse files

Unified · Split

9 files
  1. packages/platform/sdk/src/runtime/sources/source-definitions.ts +6 −0
  2. packages/platform/sdk/src/runtime/sources/source-icon.test.ts +40 −0
  3. packages/platform/sdk/src/runtime/sources/source-icon.ts +88 −0
  4. packages/platform/sdk/src/runtime/sources/source-store/config.ts +3 −0
  5. packages/platform/sdk/src/runtime/sources/source-store/plugin-scope-config.ts +3 −0
  6. packages/platform/sdk/src/runtime/sources/source-store/records.ts +11 −0
  7. packages/platform/sdk/src/schema/models/scope-config.ts +1 −0
  8. packages/platform/sdk/src/schema/models/source.ts +1 −0
  9. packages/sources/core/src/source-models.ts +1 −0
packages/platform/sdk/src/runtime/sources/source-definitions.ts+6−0
@@ -126,6 +126,9 @@ export const normalizeSourceForCreate = (input: { · 125 unmodified lines
126126 status: input.source.status,
127127 enabled: input.source.enabled,
128128 namespace: trimOrNull(input.source.namespace),
129+ ...(trimOrNull(input.source.iconUrl)
130+ ? { iconUrl: trimOrNull(input.source.iconUrl) ?? undefined }
131+ : {}),
129132 createdAt: input.now,
130133 updatedAt: input.now,
131134 });
@@ -138,6 +141,9 @@ export const normalizeSourceForSave = (input: { · 6 unmodified lines
138141 ...input.source,
139142 name: input.source.name.trim(),
140143 namespace: trimOrNull(input.source.namespace),
144+ ...(trimOrNull(input.source.iconUrl)
145+ ? { iconUrl: trimOrNull(input.source.iconUrl) ?? undefined }
146+ : {}),
141147 updatedAt: input.now,
142148 });
143149
packages/platform/sdk/src/runtime/sources/source-icon.test.ts+40−0
@@ -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+});
packages/platform/sdk/src/runtime/sources/source-icon.ts+88−0
@@ -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+};
packages/platform/sdk/src/runtime/sources/source-store/config.ts+3−0
@@ -58,6 +58,9 @@ export const scopeConfigSourceFromSource = (input: { · 57 unmodified lines
5858 ...scopeConfigSourceBaseFromSource({
5959 source: input.source,
6060 }),
61+ ...(trimOrNull(input.existingConfig?.iconUrl)
62+ ? { iconUrl: trimOrNull(input.existingConfig?.iconUrl) ?? undefined }
63+ : {}),
6164 kind: input.source.kind as ExecutorScopeConfigSource["kind"],
6265 ...(input.existingConfig?.config !== undefined
6366 ? {
packages/platform/sdk/src/runtime/sources/source-store/plugin-scope-config.ts+3−0
@@ -21,6 +21,7 @@ export const createPluginScopeConfigEntrySchema = < · 20 unmodified lines
2121 kind: Schema.Literal(input.kind),
2222 name: Schema.optional(Schema.String),
2323 namespace: Schema.optional(Schema.String),
24+ iconUrl: Schema.optional(Schema.String),
2425 enabled: Schema.optional(Schema.Boolean),
2526 config: input.config,
2627 });
@@ -28,10 +29,12 @@ export const createPluginScopeConfigEntrySchema = < · 1 unmodified line
2829 export const pluginScopeConfigSourceFromConfig = <TConfig>(input: {
2930 source: Source;
3031 config: TConfig;
32+ iconUrl?: string | null;
3133 }): ExecutorScopeConfigSource => ({
3234 ...scopeConfigSourceBaseFromSource({
3335 source: input.source,
3436 }),
37+ ...(input.iconUrl ? { iconUrl: input.iconUrl } : {}),
3538 kind: input.source.kind as ExecutorScopeConfigSource["kind"],
3639 config: cloneJson(input.config),
3740 });
packages/platform/sdk/src/runtime/sources/source-store/records.ts+11−0
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
1818 type RuntimeSourceStoreDeps,
1919 } from "./deps";
2020 import { trimOrNull } from "./config";
21+import { resolveSourceIconUrl } from "../source-icon";
2122
2223 export const buildLocalSourceRecord = (input: {
2324 scopeId: ScopeId;
@@ -42,6 +43,11 @@ export const buildLocalSourceRecord = (input: { · 18 unmodified lines
4243 }
4344
4445 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+ });
4551 const source: Source = {
4652 id: SourceIdSchema.make(input.sourceId),
4753 scopeId: input.scopeId,
@@ -52,6 +58,11 @@ export const buildLocalSourceRecord = (input: { · 4 unmodified lines
5258 (sourceConfig.enabled ?? true ? "connected" : "draft"),
5359 enabled: sourceConfig.enabled ?? true,
5460 namespace: trimOrNull(sourceConfig.namespace) ?? input.sourceId,
61+ ...(resolvedIconUrl
62+ ? {
63+ iconUrl: resolvedIconUrl,
64+ }
65+ : {}),
5566 createdAt: existingState?.createdAt ?? Date.now(),
5667 updatedAt: existingState?.updatedAt ?? Date.now(),
5768 };
packages/platform/sdk/src/schema/models/scope-config.ts+1−0
@@ -11,6 +11,7 @@ export const ExecutorRuntimeConfigSchema = Schema.Literal( · 10 unmodified lines
1111 const ExecutorScopeConfigSourceBaseSchema = Schema.Struct({
1212 name: Schema.optional(Schema.String),
1313 namespace: Schema.optional(Schema.String),
14+ iconUrl: Schema.optional(Schema.String),
1415 enabled: Schema.optional(Schema.Boolean),
1516 config: Schema.optional(Schema.Unknown),
1617 });
packages/platform/sdk/src/schema/models/source.ts+1−0
@@ -97,6 +97,7 @@ export const SourceSchema = Schema.Struct({ · 96 unmodified lines
9797 status: SourceStatusSchema,
9898 enabled: Schema.Boolean,
9999 namespace: Schema.NullOr(Schema.String),
100+ iconUrl: Schema.optional(Schema.String),
100101 createdAt: TimestampMsSchema,
101102 updatedAt: TimestampMsSchema,
102103 });
packages/sources/core/src/source-models.ts+1−0
@@ -72,6 +72,7 @@ export const SourceSchema = Schema.Struct({ · 71 unmodified lines
7272 status: SourceStatusSchema,
7373 enabled: Schema.Boolean,
7474 namespace: Schema.NullOr(Schema.String),
75+ iconUrl: Schema.optional(Schema.String),
7576 createdAt: Schema.Number,
7677 updatedAt: Schema.Number,
7778 });