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
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
packages/platform/sdk/src/runtime/sources/source-icon.test.ts+40−0
@@ -0,0 +1,40 @@
1import {
2 describe,
3 expect,
4 it,
5} from "@effect/vitest";
6
7import {
8 resolveSourceIconUrl,
9} from "./source-icon";
10
11describe("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 @@
1const 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
15const 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
24const isRecord = (value: unknown): value is Record<string, unknown> =>
25 typeof value === "object" && value !== null && !Array.isArray(value);
26
27const isIpv4Address = (value: string): boolean =>
28 /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value);
29
30const 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
53const toGoogleFaviconUrl = (domain: string): string =>
54 `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=32`;
55
56export 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
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 !== undefined65 ...(input.existingConfig?.config !== undefined
63 ? {66 ? {
packages/platform/sdk/src/runtime/sources/source-store/plugin-scope-config.ts+3−0
@@ -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
28export const pluginScopeConfigSourceFromConfig = <TConfig>(input: {29export 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});
packages/platform/sdk/src/runtime/sources/source-store/records.ts+11−0
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
18 type RuntimeSourceStoreDeps,18 type RuntimeSourceStoreDeps,
19} from "./deps";19} from "./deps";
20import { trimOrNull } from "./config";20import { trimOrNull } from "./config";
21import { resolveSourceIconUrl } from "../source-icon";
21 22
22export const buildLocalSourceRecord = (input: {23export 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 };
packages/platform/sdk/src/schema/models/scope-config.ts+1−0
@@ -11,6 +11,7 @@ export const ExecutorRuntimeConfigSchema = Schema.Literal( · 10 unmodified lines
11const ExecutorScopeConfigSourceBaseSchema = Schema.Struct({11const 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});
packages/platform/sdk/src/schema/models/source.ts+1−0
@@ -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});
packages/sources/core/src/source-models.ts+1−0
@@ -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});