mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: prefer the icon an MCP server publishes, and drop the favicon lookup
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgzuxyafKNF2uaEeL35XQw
This commit is contained in:
co-authored by
Claude Opus 5
parent
f4b1d8bb53
commit
56a827c6cc
@@ -16,7 +16,6 @@ switch that decides whether this chat carries its tools.
|
||||
import { isMcpEnabled, setMcpEnabled } from '$lib/components/mcp/enabledServers'
|
||||
import { loadProviderIcon } from '$lib/components/mcp/providerIcon'
|
||||
import {
|
||||
cachedProviderHost,
|
||||
cachedProviderKey,
|
||||
forgetProviderKey,
|
||||
rememberProviderKey
|
||||
@@ -85,7 +84,6 @@ switch that decides whether this chat carries its tools.
|
||||
editedAt?: string
|
||||
enabled: boolean
|
||||
icon?: Component<any>
|
||||
iconHost?: string
|
||||
}[]
|
||||
>([])
|
||||
let loading = $state(false)
|
||||
@@ -386,7 +384,6 @@ switch that decides whether this chat carries its tools.
|
||||
const icon = await loadProviderIcon(key)
|
||||
if (seq !== loadSeq) return
|
||||
server.icon = icon
|
||||
server.iconHost = cachedProviderHost(target, server.path, server.editedAt)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -473,7 +470,7 @@ switch that decides whether this chat carries its tools.
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each servers as server (server.path)}
|
||||
{#snippet icon()}
|
||||
<McpServerIcon icon={server.icon} host={server.iconHost} size={16} />
|
||||
<McpServerIcon icon={server.icon} size={16} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span class="truncate leading-5">{server.path}</span>
|
||||
|
||||
@@ -269,13 +269,17 @@
|
||||
{/snippet}
|
||||
|
||||
<!-- Which system a call reaches is the first thing to know about it, so an MCP
|
||||
call is marked with its provider before the label. Nothing is drawn until the
|
||||
mark resolves: a placeholder plug on every row would be noise. -->
|
||||
call is marked before the label — with the icon the server published for that
|
||||
tool where there is one, and Windmill's icon for the integration otherwise.
|
||||
Nothing is drawn until a mark resolves: a placeholder plug on every row would
|
||||
be noise. -->
|
||||
{#snippet serverMark()}
|
||||
{#if mcpServerPath && aiChatManager.operatingWorkspace}
|
||||
{#if message.mcpIconSrc}
|
||||
<McpServerIcon src={message.mcpIconSrc} size={14} />
|
||||
{:else if mcpServerPath && aiChatManager.operatingWorkspace}
|
||||
{#await resolveMcpServerMark(aiChatManager.operatingWorkspace, mcpServerPath) then mark}
|
||||
{#if mark.icon || mark.host}
|
||||
<McpServerIcon icon={mark.icon} host={mark.host} size={14} />
|
||||
{#if mark.icon}
|
||||
<McpServerIcon icon={mark.icon} size={14} />
|
||||
{/if}
|
||||
{/await}
|
||||
{/if}
|
||||
@@ -297,7 +301,7 @@
|
||||
labelClass={showPreviewChip ? 'truncate' : ''}
|
||||
contentClass="space-y-3"
|
||||
headerRight={showPreviewChip ? previewChip : undefined}
|
||||
headerLeft={mcpServerPath ? serverMark : undefined}
|
||||
headerLeft={message.mcpIconSrc || mcpServerPath ? serverMark : undefined}
|
||||
>
|
||||
<!-- Image a tool produced (e.g. take_screenshot) — shown inline, not gated on expand. -->
|
||||
{#snippet belowHeader()}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Globe } from 'lucide-svelte'
|
||||
import { SvelteSet } from 'svelte/reactivity'
|
||||
import { faviconUrl } from '$lib/utils/faviconUrl'
|
||||
import type { WebSearchSource } from './shared'
|
||||
|
||||
interface Props {
|
||||
@@ -37,6 +36,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Favicons come from Google's public favicon service, which discloses each
|
||||
// consulted hostname to a third party from the user's browser — an accepted
|
||||
// tradeoff for a page the user just searched for (blocked/air-gapped
|
||||
// environments degrade to the Globe icon via onerror). Hit gstatic directly
|
||||
// rather than www.google.com/s2/favicons: the app is served with COEP
|
||||
// require-corp, and the s2 redirect hop carries no Cross-Origin-Resource-Policy
|
||||
// header, so the browser blocks the image. The gstatic endpoint itself responds
|
||||
// with CORP: cross-origin.
|
||||
function faviconUrl(hostname: string): string {
|
||||
return `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${encodeURIComponent(hostname)}&size=64`
|
||||
}
|
||||
|
||||
const failedFavicons = new SvelteSet<string>()
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ResourceService, type GetMcpToolsResponse } from '$lib/gen'
|
||||
import { createToolDef, type Tool } from '../shared'
|
||||
import { normalizeToolParameterSchema } from '../toolSchema'
|
||||
import { enabledMcpPaths } from '$lib/components/mcp/enabledServers'
|
||||
import { pickMcpIconSrc } from '$lib/components/mcp/mcpIcon'
|
||||
|
||||
/**
|
||||
* Access to the MCP servers the user has connected (resources of type `mcp`),
|
||||
@@ -545,7 +546,10 @@ function createCallTool(owner: string, servers: McpServer[], mode: 'read' | 'wri
|
||||
toolCallbacks.setToolStatus(toolId, { content: error, error })
|
||||
return bounded({ success: false, error })
|
||||
}
|
||||
toolCallbacks.setToolStatus(toolId, { content: `Calling ${parsed.tool}...` })
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Calling ${parsed.tool}...`,
|
||||
mcpIconSrc: pickMcpIconSrc((resolved.tool as { icons?: unknown }).icons)
|
||||
})
|
||||
const result = await executeTool(
|
||||
owner,
|
||||
workspace,
|
||||
@@ -738,7 +742,8 @@ export function registerMcpTools(
|
||||
touchLoadedTool(owner, key)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Calling ${tool.name}...`,
|
||||
mcpServer: server.path
|
||||
mcpServer: server.path,
|
||||
mcpIconSrc: pickMcpIconSrc((tool as { icons?: unknown }).icons)
|
||||
})
|
||||
const result = await executeTool(owner, workspace, server, tool, args ?? {}, readOnly)
|
||||
const ok = JSON.parse(result).success === true
|
||||
|
||||
@@ -580,6 +580,10 @@ export type ToolDisplayMessage = {
|
||||
* Recorded here rather than looked up from the tool name: the registry of loaded
|
||||
* remote tools lives only in memory, so a reloaded transcript could not resolve it. */
|
||||
mcpServer?: string
|
||||
/** Icon the MCP server published for the tool this row called (`icons`, per the
|
||||
* spec), already validated. Recorded here for the same reason as `mcpServer`: the
|
||||
* listing it came from is not available to a reloaded transcript. */
|
||||
mcpIconSrc?: string
|
||||
showFade?: boolean
|
||||
actions?: ToolDisplayAction[]
|
||||
userQuestion?: UserQuestionDisplay
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte'
|
||||
import { Plug } from 'lucide-svelte'
|
||||
import { faviconUrl } from '$lib/utils/faviconUrl'
|
||||
|
||||
/**
|
||||
* A connected server's mark, best available first: the icon Windmill ships for
|
||||
* that integration, then the host's favicon, then a generic plug. The favicon
|
||||
* step is what covers the servers Windmill has no icon for, which is most of
|
||||
* them — MCP's own `icons` field is too new for many servers to populate.
|
||||
* A connected server's mark, best available first: the icon the MCP server itself
|
||||
* published (`icons`, per the spec), then the icon Windmill ships for that
|
||||
* integration, then a generic plug.
|
||||
*
|
||||
* The server's own icon comes first because it is the only source that is both
|
||||
* authoritative and free of a third party — the bytes arrive over the MCP
|
||||
* connection the user already made.
|
||||
*/
|
||||
let { icon, host, size = 16 }: { icon?: Component<any>; host?: string; size?: number } = $props()
|
||||
let { src, icon, size = 16 }: { src?: string; icon?: Component<any>; size?: number } = $props()
|
||||
|
||||
// Keyed by host rather than a boolean so reconnecting a path to a different
|
||||
// server retries instead of inheriting the previous one's failure.
|
||||
// Keyed by src rather than a boolean so a server publishing a different icon
|
||||
// retries instead of inheriting the previous one's failure. An `https:` src is
|
||||
// usually blocked by the app's COEP require-corp anyway, which lands here.
|
||||
let failedFor = $state<string | undefined>(undefined)
|
||||
|
||||
const px = $derived(`${size}px`)
|
||||
</script>
|
||||
|
||||
{#if icon}
|
||||
{@const Icon = icon}
|
||||
<Icon width={px} height={px} />
|
||||
{:else if host && failedFor !== host}
|
||||
{#if src && failedFor !== src}
|
||||
<img
|
||||
src={faviconUrl(host)}
|
||||
{src}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
class="rounded-sm"
|
||||
onerror={() => (failedFor = host)}
|
||||
onerror={() => (failedFor = src)}
|
||||
/>
|
||||
{:else if icon}
|
||||
{@const Icon = icon}
|
||||
<Icon width={px} height={px} />
|
||||
{:else}
|
||||
<Plug {size} class="text-tertiary" />
|
||||
{/if}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { providerHost } from './iconCache'
|
||||
|
||||
/**
|
||||
* `providerHost` decides which hostnames are sent to a third-party favicon service.
|
||||
* A regression here does not fail visibly — it silently starts disclosing an
|
||||
* endpoint — so the cases it withholds are pinned.
|
||||
*/
|
||||
describe('providerHost', () => {
|
||||
it('returns the host of a public server url', () => {
|
||||
expect(providerHost('https://mcp.linear.app/mcp')).toBe('mcp.linear.app')
|
||||
})
|
||||
|
||||
it('withholds loopback and bare addresses', () => {
|
||||
expect(providerHost('http://localhost:3000/mcp')).toBeUndefined()
|
||||
expect(providerHost('http://127.0.0.1:8000/mcp')).toBeUndefined()
|
||||
expect(providerHost('http://10.1.2.3/mcp')).toBeUndefined()
|
||||
// `new URL()` brackets an IPv6 literal, which has no dot to split on.
|
||||
expect(providerHost('http://[::1]:8000/mcp')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('withholds intranet names', () => {
|
||||
expect(providerHost('https://mcp-box/mcp')).toBeUndefined()
|
||||
expect(providerHost('https://mcp.acme.internal/mcp')).toBeUndefined()
|
||||
expect(providerHost('https://Tools.Acme.LOCAL/mcp')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns nothing for a value that is not a url', () => {
|
||||
expect(providerHost(undefined)).toBeUndefined()
|
||||
expect(providerHost('not a url')).toBeUndefined()
|
||||
expect(providerHost({ url: 'https://example.com' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -9,15 +9,10 @@ import { providerKey } from './providerIcon'
|
||||
* open. `edited_at` comes back with the list, so a row that has not been edited
|
||||
* since it was cached needs no read at all.
|
||||
*/
|
||||
// `host` backs the favicon fallback for a server Windmill ships no icon for. It is
|
||||
// cached alongside the key for the same reason: the url is only in the resource
|
||||
// value, which the list endpoint strips.
|
||||
type Entry = { key: string | null; editedAt?: string; host?: string }
|
||||
type Entry = { key: string | null; editedAt?: string }
|
||||
|
||||
// Versioned: entries written before `host` existed hold a key (often `null`, the
|
||||
// no-shipped-icon case the favicon fallback is for) and read as a cache hit, so they
|
||||
// would never learn a host and never draw a favicon. Bumping starts them over, at one
|
||||
// resource read each.
|
||||
// Versioned so a shape change starts entries over rather than reading a stale one,
|
||||
// at one resource read each.
|
||||
const STORE_KEY = 'mcp_provider_icons_v2'
|
||||
|
||||
function read(): Record<string, Record<string, Entry>> {
|
||||
@@ -48,54 +43,9 @@ export function cachedProviderKey(
|
||||
export function cachedProviderMark(
|
||||
workspace: string,
|
||||
path: string
|
||||
): { key: string | null; host?: string } | undefined {
|
||||
): { key: string | null } | undefined {
|
||||
const entry = read()[workspace]?.[path]
|
||||
return entry ? { key: entry.key, host: entry.host } : undefined
|
||||
}
|
||||
|
||||
/** Suffixes that only ever name something inside a network, so a favicon lookup for
|
||||
* one would disclose an internal endpoint and could not succeed anyway. */
|
||||
const PRIVATE_HOST_SUFFIXES = [
|
||||
'.local',
|
||||
'.localhost',
|
||||
'.internal',
|
||||
'.intranet',
|
||||
'.lan',
|
||||
'.corp',
|
||||
'.home',
|
||||
'.localdomain'
|
||||
]
|
||||
|
||||
/**
|
||||
* The url's host, when it has one worth drawing a favicon for.
|
||||
*
|
||||
* Fetching a favicon tells the favicon service which host was asked about, so this
|
||||
* withholds what it can recognise as private. It cannot recognise all of it: a public
|
||||
* domain used internally (`mcp.internal.example.com`) is indistinguishable from any
|
||||
* other, so a self-hosted instance still discloses that hostname when its server has
|
||||
* no shipped icon.
|
||||
*/
|
||||
export function providerHost(url: unknown): string | undefined {
|
||||
if (typeof url !== 'string') return undefined
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase()
|
||||
// A loopback, a bare address, or an intranet single-label name has no favicon
|
||||
// to fetch, and asking would disclose it to the favicon service for nothing.
|
||||
if (!hostname.includes('.') || /^[\d.]+$/.test(hostname)) return undefined
|
||||
if (PRIVATE_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))) return undefined
|
||||
return hostname
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function cachedProviderHost(
|
||||
workspace: string,
|
||||
path: string,
|
||||
editedAt?: string
|
||||
): string | undefined {
|
||||
const entry = read()[workspace]?.[path]
|
||||
return entry?.editedAt === editedAt ? entry?.host : undefined
|
||||
return entry ? { key: entry.key } : undefined
|
||||
}
|
||||
|
||||
export function rememberProviderKey(
|
||||
@@ -108,7 +58,7 @@ export function rememberProviderKey(
|
||||
const store = read()
|
||||
store[workspace] = {
|
||||
...(store[workspace] ?? {}),
|
||||
[path]: { key, editedAt, host: providerHost(url) }
|
||||
[path]: { key, editedAt }
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(STORE_KEY, JSON.stringify(store))
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { pickMcpIconSrc } from './mcpIcon'
|
||||
|
||||
const png = 'data:image/png;base64,iVBORw0KGgo='
|
||||
|
||||
/**
|
||||
* `src` is chosen by the MCP server and rendered in the user's browser, so what this
|
||||
* refuses is a security boundary, not a preference — a regression here fails silently.
|
||||
*/
|
||||
describe('pickMcpIconSrc', () => {
|
||||
it('takes a data URI of a safe image type', () => {
|
||||
expect(pickMcpIconSrc([{ src: png }])).toBe(png)
|
||||
expect(pickMcpIconSrc([{ src: 'data:image/webp;base64,UklGRg==' }])).toBe(
|
||||
'data:image/webp;base64,UklGRg=='
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses schemes the spec calls unsafe', () => {
|
||||
expect(pickMcpIconSrc([{ src: 'javascript:alert(1)' }])).toBeUndefined()
|
||||
expect(pickMcpIconSrc([{ src: 'file:///etc/passwd' }])).toBeUndefined()
|
||||
expect(pickMcpIconSrc([{ src: 'ftp://example.com/i.png' }])).toBeUndefined()
|
||||
expect(pickMcpIconSrc([{ src: 'http://example.com/i.png' }])).toBeUndefined()
|
||||
})
|
||||
|
||||
// SVG can carry script, and sanitising it is not worth an icon.
|
||||
it('refuses SVG in either form', () => {
|
||||
expect(pickMcpIconSrc([{ src: 'data:image/svg+xml;base64,PHN2Zz4=' }])).toBeUndefined()
|
||||
expect(pickMcpIconSrc([{ src: 'https://example.com/logo.svg' }])).toBeUndefined()
|
||||
})
|
||||
|
||||
// `mimeType` is advisory per the spec, so it must not launder the payload.
|
||||
it('judges a data URI by what it declares, not by mimeType', () => {
|
||||
expect(
|
||||
pickMcpIconSrc([{ src: 'data:image/svg+xml;base64,PHN2Zz4=', mimeType: 'image/png' }])
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses an oversized data URI', () => {
|
||||
expect(pickMcpIconSrc([{ src: `data:image/png;base64,${'A'.repeat(70_000)}` }])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prefers the icon matching the theme, then an untagged one', () => {
|
||||
const dark = 'data:image/png;base64,ZGFyaw=='
|
||||
const icons = [{ src: png, theme: 'light' }, { src: dark, theme: 'dark' }, { src: png }]
|
||||
|
||||
expect(pickMcpIconSrc(icons, true)).toBe(dark)
|
||||
expect(pickMcpIconSrc(icons, false)).toBe(png)
|
||||
})
|
||||
|
||||
it('returns nothing for a missing or malformed icons field', () => {
|
||||
expect(pickMcpIconSrc(undefined)).toBeUndefined()
|
||||
expect(pickMcpIconSrc([])).toBeUndefined()
|
||||
expect(pickMcpIconSrc([{ src: 42 }, null, 'nope'])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
/** One entry of an MCP `icons` array, as the spec defines it. */
|
||||
export type McpIcon = {
|
||||
src?: unknown
|
||||
mimeType?: unknown
|
||||
sizes?: unknown
|
||||
theme?: unknown
|
||||
}
|
||||
|
||||
// A data URI rides in the request that renders it and is persisted on a transcript
|
||||
// row, so an oversized one is refused rather than carried.
|
||||
const MAX_ICON_SRC_CHARS = 64_000
|
||||
|
||||
/**
|
||||
* The icon source to render, chosen from what an MCP server published.
|
||||
*
|
||||
* The `src` is chosen by the server, so the spec's rules are enforced here rather
|
||||
* than trusted: only `https:` and `data:` (it names `javascript:`, `file:`, `ftp:`
|
||||
* and `ws:` as the schemes to reject), and no SVG — it can carry script, and
|
||||
* sanitising it is not worth the icon. `mimeType` is advisory, so a data URI is
|
||||
* judged on the type it actually declares.
|
||||
*
|
||||
* An `https:` src is accepted but will usually fail to load: the app is served with
|
||||
* COEP require-corp and a third-party image without a Cross-Origin-Resource-Policy
|
||||
* header is blocked. `McpServerIcon` falls through to the next source when it does.
|
||||
*/
|
||||
export function pickMcpIconSrc(icons: unknown, dark = false): string | undefined {
|
||||
if (!Array.isArray(icons)) return undefined
|
||||
const usable = icons.filter((icon): icon is McpIcon => isRenderable(icon))
|
||||
// `theme` names the background the icon was drawn for; an untagged icon suits either.
|
||||
const themed = usable.find((icon) => icon.theme === (dark ? 'dark' : 'light'))
|
||||
const untagged = usable.find((icon) => icon.theme === undefined)
|
||||
return (themed ?? untagged ?? usable[0])?.src as string | undefined
|
||||
}
|
||||
|
||||
function isRenderable(icon: unknown): boolean {
|
||||
const src = (icon as McpIcon | null)?.src
|
||||
if (typeof src !== 'string' || src.length > MAX_ICON_SRC_CHARS) return false
|
||||
const lower = src.toLowerCase()
|
||||
if (lower.startsWith('https://')) return !lower.endsWith('.svg')
|
||||
if (!lower.startsWith('data:image/')) return false
|
||||
// Judge the data URI by the type it declares, not by the sibling `mimeType`.
|
||||
return ['png', 'jpeg', 'jpg', 'webp'].some((type) => lower.startsWith(`data:image/${type}`))
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { sendUserToast } from '$lib/toast'
|
||||
import type { Item } from '$lib/utils'
|
||||
import type { AIChatManager } from '../copilot/chat/AIChatManager.svelte'
|
||||
import { isMcpEnabled, setMcpEnabled } from './enabledServers'
|
||||
import { cachedProviderHost, cachedProviderKey, rememberProviderKey } from './iconCache'
|
||||
import { cachedProviderKey, rememberProviderKey } from './iconCache'
|
||||
import { loadProviderIcon } from './providerIcon'
|
||||
import McpServerIcon from './McpServerIcon.svelte'
|
||||
|
||||
@@ -16,7 +16,6 @@ type Row = {
|
||||
editedAt?: string
|
||||
enabled: boolean
|
||||
icon?: Component<any>
|
||||
iconHost?: string
|
||||
}
|
||||
|
||||
// A menu is a shortcut, not a directory: past this many the list stops being
|
||||
@@ -105,7 +104,6 @@ export class McpMenu {
|
||||
const icon = await loadProviderIcon(key)
|
||||
if (seq !== this.#seq) return
|
||||
server.icon = icon
|
||||
server.iconHost = cachedProviderHost(ws, server.path, server.editedAt)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -171,11 +169,11 @@ export class McpMenu {
|
||||
// to read through the live list rather than the row captured here, since
|
||||
// a reload replaces every row object and a getter bound to the old one
|
||||
// would go on reporting the state it was built with.
|
||||
// One component for every row, so a shipped icon, a favicon and the plug
|
||||
// all land at the same size and nothing pulls its label out of line.
|
||||
// One component for every row, so the server's own icon, Windmill's and the
|
||||
// plug all land at the same size and nothing pulls its label out of line.
|
||||
icon: McpServerIcon,
|
||||
get iconProps() {
|
||||
return { icon: row(path)?.icon, host: row(path)?.iconHost, size: 14 }
|
||||
return { icon: row(path)?.icon, size: 14 }
|
||||
},
|
||||
get toggle() {
|
||||
return row(path)?.enabled ?? false
|
||||
|
||||
@@ -68,8 +68,7 @@ export const MCP_REGISTRY: McpRegistryEntry[] = [
|
||||
icon: LinearIcon,
|
||||
url: 'https://mcp.linear.app/mcp',
|
||||
auth: 'dcr',
|
||||
tokenHint:
|
||||
'Use a Linear API key. The Read permission is enough for the read tools.',
|
||||
tokenHint: 'Use a Linear API key. The Read permission is enough for the read tools.',
|
||||
docsUrl: 'https://linear.app/docs/mcp'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { existsVariable, getVariable, createVariable, updateVariable, deleteVariable, existsResource } =
|
||||
vi.hoisted(() => ({
|
||||
existsVariable: vi.fn(),
|
||||
getVariable: vi.fn(),
|
||||
createVariable: vi.fn(),
|
||||
updateVariable: vi.fn(),
|
||||
deleteVariable: vi.fn(),
|
||||
existsResource: vi.fn()
|
||||
}))
|
||||
const {
|
||||
existsVariable,
|
||||
getVariable,
|
||||
createVariable,
|
||||
updateVariable,
|
||||
deleteVariable,
|
||||
existsResource
|
||||
} = vi.hoisted(() => ({
|
||||
existsVariable: vi.fn(),
|
||||
getVariable: vi.fn(),
|
||||
createVariable: vi.fn(),
|
||||
updateVariable: vi.fn(),
|
||||
deleteVariable: vi.fn(),
|
||||
existsResource: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('$lib/gen', () => ({
|
||||
VariableService: { existsVariable, getVariable, createVariable, updateVariable, deleteVariable },
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Component } from 'svelte'
|
||||
import { ResourceService } from '$lib/gen'
|
||||
import { cachedProviderMark, providerHost } from './iconCache'
|
||||
import { cachedProviderMark } from './iconCache'
|
||||
import { loadProviderIcon, providerKey } from './providerIcon'
|
||||
|
||||
/** What identifies a connected server visually: Windmill's icon for that integration
|
||||
* if it ships one, otherwise the host its favicon can be fetched from. */
|
||||
export type McpServerMark = { icon?: Component<any>; host?: string }
|
||||
/** Windmill's own icon for a connected server's integration, when it ships one. The
|
||||
* server's published icon is preferred over this and is resolved separately. */
|
||||
export type McpServerMark = { icon?: Component<any> }
|
||||
|
||||
// One resolution per server per session, shared by every transcript row naming it —
|
||||
// a chat can hold dozens of calls against the same server.
|
||||
@@ -23,14 +23,14 @@ export function resolveMcpServerMark(workspace: string, path: string): Promise<M
|
||||
|
||||
async function load(workspace: string, path: string): Promise<McpServerMark> {
|
||||
const cached = cachedProviderMark(workspace, path)
|
||||
if (cached) return { icon: await loadProviderIcon(cached.key), host: cached.host }
|
||||
if (cached) return { icon: await loadProviderIcon(cached.key) }
|
||||
try {
|
||||
// Deliberately not written back to the shared cache: that entry is keyed by
|
||||
// `editedAt` for the server list's sake, and storing one from here — where the
|
||||
// row is a past call and `editedAt` is unknown — would make every list re-read.
|
||||
const resource = await ResourceService.getResource({ workspace, path })
|
||||
const url = (resource.value as { url?: unknown } | undefined)?.url
|
||||
return { icon: await loadProviderIcon(providerKey(url)), host: providerHost(url) }
|
||||
return { icon: await loadProviderIcon(providerKey(url)) }
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* A hostname's favicon, from Google's public favicon service.
|
||||
*
|
||||
* This discloses each consulted hostname to a third party from the user's
|
||||
* browser — an accepted tradeoff, and blocked or air-gapped environments must
|
||||
* degrade to a local icon through the caller's `onerror`.
|
||||
*
|
||||
* Hit gstatic directly rather than `www.google.com/s2/favicons`: the app is
|
||||
* served with COEP require-corp, and the s2 redirect hop carries no
|
||||
* Cross-Origin-Resource-Policy header, so the browser blocks the image. The
|
||||
* gstatic endpoint itself responds with CORP: cross-origin.
|
||||
*/
|
||||
export function faviconUrl(hostname: string, size = 64): string {
|
||||
return `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${encodeURIComponent(hostname)}&size=${size}`
|
||||
}
|
||||
Reference in New Issue
Block a user