mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: register searched MCP tools with their real schemas in the chat
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
2023e5e1b3
commit
c07f3eed0f
@@ -134,7 +134,14 @@ import {
|
||||
} from './global/core'
|
||||
import { formatChatJobCompletion } from './datatableTools'
|
||||
import { isGlobalAiEnabled } from './global/gate'
|
||||
import { createMcpTools, loadMcpServers, type McpServer } from './global/mcpTools'
|
||||
import {
|
||||
createMcpTools,
|
||||
forgetLoadedMcpTools,
|
||||
loadedMcpServerPaths,
|
||||
loadedMcpTools,
|
||||
loadMcpServers,
|
||||
type McpServer
|
||||
} from './global/mcpTools'
|
||||
import {
|
||||
pipelineTools,
|
||||
getPipelinePromptSection,
|
||||
@@ -2231,6 +2238,13 @@ export class AIChatManager {
|
||||
// workspace's servers installed would go on advertising its paths against
|
||||
// the workspace switched to.
|
||||
this.mcpServers = workspace === (this.operatingWorkspace ?? '') ? servers : []
|
||||
// A tool registered from a server that has since been turned off, deleted, or
|
||||
// left behind by a workspace switch would still be callable, and would run
|
||||
// against whichever workspace the chat is on now.
|
||||
const live = new Set(this.mcpServers.map((s) => s.path))
|
||||
for (const path of loadedMcpServerPaths()) {
|
||||
if (!live.has(path)) forgetLoadedMcpTools(path)
|
||||
}
|
||||
if (this.mode === AIMode.GLOBAL) {
|
||||
this.configureGlobalMode()
|
||||
}
|
||||
@@ -2683,7 +2697,9 @@ export class AIChatManager {
|
||||
return base
|
||||
},
|
||||
get tools() {
|
||||
return [...self.tools, ...self.planMode.tools]
|
||||
// Re-read every iteration by `chatLoop`, so a remote MCP tool registered
|
||||
// during one iteration is callable on the next.
|
||||
return [...self.tools, ...self.planMode.tools, ...loadedMcpTools()]
|
||||
},
|
||||
get helpers() {
|
||||
return self.helpers
|
||||
|
||||
@@ -16,10 +16,12 @@ 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
|
||||
} from '$lib/components/mcp/iconCache'
|
||||
import McpServerIcon from '$lib/components/mcp/McpServerIcon.svelte'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import type { Component } from 'svelte'
|
||||
import { ResourceService } from '$lib/gen'
|
||||
@@ -83,6 +85,7 @@ switch that decides whether this chat carries its tools.
|
||||
editedAt?: string
|
||||
enabled: boolean
|
||||
icon?: Component<any>
|
||||
iconHost?: string
|
||||
}[]
|
||||
>([])
|
||||
let loading = $state(false)
|
||||
@@ -383,6 +386,7 @@ 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)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -469,12 +473,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()}
|
||||
{#if server.icon}
|
||||
{@const Icon = server.icon}
|
||||
<Icon width="16px" height="16px" />
|
||||
{:else}
|
||||
<Plug size={16} class="text-tertiary" />
|
||||
{/if}
|
||||
<McpServerIcon icon={server.icon} host={server.iconHost} size={16} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<span class="truncate leading-5">{server.path}</span>
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
shimmer?: boolean
|
||||
// Pinned to the right of the header row, outside the toggle button.
|
||||
headerRight?: Snippet
|
||||
// Sits before the label, inside the toggle button, and outside the shimmer:
|
||||
// a mark belongs to the label it names, but must not sweep with it.
|
||||
headerLeft?: Snippet
|
||||
// Always-visible content between the header and the expandable body.
|
||||
belowHeader?: Snippet
|
||||
children?: Snippet
|
||||
@@ -31,6 +34,7 @@
|
||||
toggleable = true,
|
||||
shimmer = false,
|
||||
headerRight,
|
||||
headerLeft,
|
||||
belowHeader,
|
||||
children,
|
||||
class: className,
|
||||
@@ -63,6 +67,7 @@
|
||||
disabled={!toggleable}
|
||||
aria-expanded={toggleable ? expanded : undefined}
|
||||
>
|
||||
{@render headerLeft?.()}
|
||||
{#if shimmer}
|
||||
<span class="shimmer inline-flex items-center min-w-0">
|
||||
{@render labelText(false)}
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte'
|
||||
import WebSearchSourcesDisplay from './WebSearchSourcesDisplay.svelte'
|
||||
import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte'
|
||||
import McpServerIcon from '$lib/components/mcp/McpServerIcon.svelte'
|
||||
import { resolveMcpServerMark } from '$lib/components/mcp/serverMark'
|
||||
import { mcpServerForToolName } from './global/mcpTools'
|
||||
|
||||
interface Props {
|
||||
message: ToolDisplayMessage
|
||||
@@ -46,6 +49,21 @@
|
||||
|
||||
let { message }: Props = $props()
|
||||
|
||||
// A call records its server on the row, which is what survives a reload. The rest
|
||||
// is for rows written before it did: the generic wrappers name their server in the
|
||||
// arguments, and a registered tool is resolved through the in-memory registry.
|
||||
const MCP_CALL_TOOLS = ['call_mcp_read_tool', 'call_mcp_write_tool']
|
||||
const mcpServerPath = $derived.by(() => {
|
||||
if (message.mcpServer) return message.mcpServer
|
||||
const name = message.toolName
|
||||
if (!name) return undefined
|
||||
if (MCP_CALL_TOOLS.includes(name)) {
|
||||
const server = message.parameters?.server
|
||||
return typeof server === 'string' ? server : undefined
|
||||
}
|
||||
return mcpServerForToolName(name)
|
||||
})
|
||||
|
||||
const isPlanReview = $derived(message.toolName === EXIT_PLAN_MODE_TOOL)
|
||||
const isPlanCard = $derived(isPlanCardTool(message.toolName))
|
||||
const planCopy = $derived(
|
||||
@@ -235,6 +253,19 @@
|
||||
{/if}
|
||||
{/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. -->
|
||||
{#snippet serverMark()}
|
||||
{#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}
|
||||
{/await}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- The shimmer is the only running indicator, so the states have to read off
|
||||
weight alone: queued calls (waiting their turn behind the executing tool)
|
||||
are faded, the running one sweeps, a settled one is plain. -->
|
||||
@@ -251,6 +282,7 @@
|
||||
labelClass={showPreviewChip ? 'truncate' : ''}
|
||||
contentClass="space-y-3"
|
||||
headerRight={showPreviewChip ? previewChip : undefined}
|
||||
headerLeft={mcpServerPath ? serverMark : undefined}
|
||||
>
|
||||
<!-- Image a tool produced (e.g. take_screenshot) — shown inline, not gated on expand. -->
|
||||
{#snippet belowHeader()}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<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,17 +38,6 @@
|
||||
}
|
||||
|
||||
const failedFavicons = new SvelteSet<string>()
|
||||
|
||||
// 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 now (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`
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
|
||||
@@ -27,7 +27,16 @@ vi.mock('$lib/stores', () => ({
|
||||
userStore: { subscribe: (run: (v: unknown) => void) => (run({ ...session }), () => {}) }
|
||||
}))
|
||||
|
||||
import { clearMcpToolsCache, createMcpTools, loadMcpServers, type McpServer } from './mcpTools'
|
||||
import {
|
||||
clearMcpToolsCache,
|
||||
createMcpTools,
|
||||
forgetLoadedMcpTools,
|
||||
loadedMcpServerPaths,
|
||||
loadedMcpTools,
|
||||
loadMcpServers,
|
||||
registerMcpTools,
|
||||
type McpServer
|
||||
} from './mcpTools'
|
||||
import { setMcpEnabled } from '$lib/components/mcp/enabledServers'
|
||||
|
||||
const SERVERS: McpServer[] = [{ path: 'u/hugo/github_mcp' }]
|
||||
@@ -94,6 +103,98 @@ describe('tool registration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('loaded remote tools', () => {
|
||||
const server = SERVERS[0]
|
||||
|
||||
it('registers a remote tool under its own schema and server-scoped name', () => {
|
||||
const [name] = registerMcpTools(server, [TOOLS[0]])
|
||||
|
||||
expect(name).toBe('mcp_u_hugo_github_mcp__get_issue')
|
||||
const tool = loadedMcpTools().find((t) => t.def.function.name === name)
|
||||
expect(tool?.def.function.parameters).toEqual(TOOLS[0].inputSchema)
|
||||
})
|
||||
|
||||
// The wrappers ask for confirmation by which one the model picked; a registered
|
||||
// tool carries its own hint, so the gate is per tool.
|
||||
it('gates a mutating tool and lets a read-only one through', () => {
|
||||
registerMcpTools(server, [TOOLS[0], TOOLS[1], TOOLS[2]])
|
||||
const byName = Object.fromEntries(loadedMcpTools().map((t) => [t.def.function.name, t]))
|
||||
|
||||
expect(byName['mcp_u_hugo_github_mcp__get_issue'].requiresConfirmation).toBeUndefined()
|
||||
expect(byName['mcp_u_hugo_github_mcp__merge_pull_request'].requiresConfirmation).toBe(true)
|
||||
// No annotations at all must not read as read-only.
|
||||
expect(byName['mcp_u_hugo_github_mcp__unannotated_tool'].requiresConfirmation).toBe(true)
|
||||
})
|
||||
|
||||
// A registered tool freezes a copy of `readOnlyHint`, which is what the listing
|
||||
// TTL exists to bound — so it must not outlive the listing it came from.
|
||||
it('drops loaded tools when the listing cache is cleared', () => {
|
||||
registerMcpTools(server, [TOOLS[0]])
|
||||
expect(loadedMcpTools()).toHaveLength(1)
|
||||
|
||||
clearMcpToolsCache()
|
||||
|
||||
expect(loadedMcpTools()).toEqual([])
|
||||
})
|
||||
|
||||
it('drops only the named server when reconciling against the live list', () => {
|
||||
registerMcpTools(server, [TOOLS[0]])
|
||||
registerMcpTools({ path: 'f/team/linear_mcp' }, [TOOLS[0]])
|
||||
expect(loadedMcpServerPaths().sort()).toEqual(['f/team/linear_mcp', 'u/hugo/github_mcp'])
|
||||
|
||||
forgetLoadedMcpTools('f/team/linear_mcp')
|
||||
|
||||
expect(loadedMcpServerPaths()).toEqual(['u/hugo/github_mcp'])
|
||||
})
|
||||
|
||||
// The reported bug: the model saw only parameter names, so it invented values for
|
||||
// constrained arguments (Linear's `orderBy`, whose enum it never saw). Searching
|
||||
// must put the real schema in front of it, not a list of names.
|
||||
it('registers what a search matched, with the remote schema', async () => {
|
||||
setMcpEnabled('test-ws', 'u/hugo/github_mcp', true)
|
||||
const result = await run('search_mcp_tools', { query: 'issue' })
|
||||
|
||||
const match = result.matches.find((m: any) => m.tool === 'get_issue')
|
||||
expect(match.call).toBe('mcp_u_hugo_github_mcp__get_issue')
|
||||
expect(match.params).toBeUndefined()
|
||||
|
||||
const registered = loadedMcpTools().find((t) => t.def.function.name === match.call)
|
||||
expect(registered?.def.function.parameters).toEqual(TOOLS[0].inputSchema)
|
||||
})
|
||||
|
||||
// The row's provider icon is resolved from this, and the registry of loaded tools
|
||||
// lives only in memory — so without it on the message, a reloaded transcript loses
|
||||
// every mark. Recorded before the server is resolved, so an unreachable server
|
||||
// still marks its own failure row.
|
||||
it('records the server on the row, even when it cannot be reached', async () => {
|
||||
getMcpToolsMock.mockRejectedValue(new Error('connection refused'))
|
||||
const callbacks = createToolCallbacks()
|
||||
|
||||
await getTool('call_mcp_read_tool').fn({
|
||||
args: { server: 'u/hugo/github_mcp', tool: 'get_issue', arguments: {} },
|
||||
workspace: 'test-ws',
|
||||
helpers: {},
|
||||
toolCallbacks: callbacks,
|
||||
toolId: 'tool-1'
|
||||
})
|
||||
|
||||
expect(callbacks.setToolStatus).toHaveBeenCalledWith('tool-1', {
|
||||
mcpServer: 'u/hugo/github_mcp'
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds the loaded set, evicting the least recently registered', () => {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
registerMcpTools(server, [{ ...TOOLS[0], name: `tool_${i}` }])
|
||||
}
|
||||
const names = loadedMcpTools().map((t) => t.def.function.name)
|
||||
|
||||
expect(names).toHaveLength(25)
|
||||
expect(names).not.toContain('mcp_u_hugo_github_mcp__tool_0')
|
||||
expect(names).toContain('mcp_u_hugo_github_mcp__tool_29')
|
||||
})
|
||||
})
|
||||
|
||||
describe('read/write split', () => {
|
||||
it('refuses a mutating tool on the read path', async () => {
|
||||
const result = await run('call_mcp_read_tool', {
|
||||
@@ -228,15 +329,18 @@ describe('call results', () => {
|
||||
})
|
||||
|
||||
describe('search_mcp_tools', () => {
|
||||
it('returns compact summaries without the full input schemas', async () => {
|
||||
// The schema reaches the model through the registered tool, not inlined here: the
|
||||
// result stays a summary so a 10-match search does not also dump 10 schemas into
|
||||
// the transcript on top of the tool definitions it just created.
|
||||
it('returns compact summaries that name the registered tool', async () => {
|
||||
const result = await run('search_mcp_tools', { query: 'issue' })
|
||||
expect(result.matches).toEqual([
|
||||
{
|
||||
server: 'u/hugo/github_mcp',
|
||||
tool: 'get_issue',
|
||||
call: 'mcp_u_hugo_github_mcp__get_issue',
|
||||
description: 'Get details of a GitHub issue',
|
||||
mode: 'read',
|
||||
params: ['owner', 'repo']
|
||||
mode: 'read'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
@@ -4,12 +4,17 @@ import { createToolDef, type Tool } from '../shared'
|
||||
import { enabledMcpPaths } from '$lib/components/mcp/enabledServers'
|
||||
|
||||
/**
|
||||
* Access to the MCP servers the user has connected (resources of type `mcp`)
|
||||
* as three static tools — search, read call, write call — instead of one
|
||||
* registered tool per remote tool. A server like GitHub's exposes ~90 tools,
|
||||
* whose schemas would otherwise be re-sent on every chat iteration; here only
|
||||
* matched summaries enter the model's context, and a full input schema only
|
||||
* after a call fails.
|
||||
* Access to the MCP servers the user has connected (resources of type `mcp`),
|
||||
* loaded on demand rather than all at once: a server like GitHub's exposes ~90
|
||||
* tools, whose schemas would otherwise sit in the model's context on every
|
||||
* iteration.
|
||||
*
|
||||
* `search_mcp_tools` is the entry point and registers the tools it matched, so
|
||||
* from the next iteration each one is a tool of its own carrying the remote's
|
||||
* real input schema. Only searched-for tools are ever paid for. The read/write
|
||||
* call wrappers remain as a fallback for anything not registered, and take a
|
||||
* free-form argument object — which is what the model has to guess into, and
|
||||
* why the registered tool is preferred wherever there is one.
|
||||
*/
|
||||
|
||||
type McpToolDef = GetMcpToolsResponse[number]
|
||||
@@ -18,6 +23,11 @@ export type McpServer = { path: string; editedAt?: string }
|
||||
|
||||
const MAX_SEARCH_RESULTS = 10
|
||||
const MAX_DESCRIPTION_CHARS = 200
|
||||
// A registered tool's schema sits in the model's context for the rest of the
|
||||
// session, so the set is bounded and the least recently called one is evicted.
|
||||
const MAX_LOADED_TOOLS = 25
|
||||
// Both providers cap tool names; OpenAI's 64 is the lower of the two.
|
||||
const MAX_TOOL_NAME_CHARS = 64
|
||||
const MAX_RESULT_CHARS = 20_000
|
||||
// A server writes its own error text, and every enabled server can contribute
|
||||
// one, so search results are capped the same way call results are.
|
||||
@@ -64,6 +74,62 @@ async function loadServerTools(
|
||||
export function clearMcpToolsCache() {
|
||||
cacheGeneration++
|
||||
toolsCache = {}
|
||||
forgetLoadedMcpTools()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote tools promoted to first-class chat tools for this session, keyed
|
||||
* `${server}::${tool}`. `chatLoop` re-reads its tool list on every iteration, so
|
||||
* one registered while a call is running is callable on the next one.
|
||||
*
|
||||
* A registered tool is a frozen copy of an input schema *and* of `readOnlyHint`,
|
||||
* which is the thing `TOOLS_CACHE_TTL_MS` exists to bound — so these are dropped
|
||||
* by the same triggers that drop a listing rather than left to age on a timer.
|
||||
* What keeps that safe in between is the backend, not this map: `executeTool`
|
||||
* asserts `read_only` against the live server, so a stale hint cannot turn into
|
||||
* an unconfirmed write.
|
||||
*/
|
||||
const loadedTools = new Map<string, Tool<{}>>()
|
||||
|
||||
/** Insertion order is call order, so the first entry is the least recently used. */
|
||||
function touchLoadedTool(key: string) {
|
||||
const tool = loadedTools.get(key)
|
||||
if (!tool) return
|
||||
loadedTools.delete(key)
|
||||
loadedTools.set(key, tool)
|
||||
}
|
||||
|
||||
export function loadedMcpTools(): Tool<{}>[] {
|
||||
return [...loadedTools.values()]
|
||||
}
|
||||
|
||||
/** The servers that currently have a tool registered, for reconciling against the live list. */
|
||||
export function loadedMcpServerPaths(): string[] {
|
||||
return [...new Set([...loadedTools.keys()].map((key) => key.slice(0, key.lastIndexOf('::'))))]
|
||||
}
|
||||
|
||||
/**
|
||||
* The server a chat-facing tool name belongs to, for marking its transcript row with
|
||||
* the provider. Resolved through the registry rather than by parsing the name, which
|
||||
* a long path truncates.
|
||||
*/
|
||||
export function mcpServerForToolName(toolName: string): string | undefined {
|
||||
for (const [key, tool] of loadedTools) {
|
||||
if (tool.def.function.name === toolName) return key.slice(0, key.lastIndexOf('::'))
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Drop every loaded tool, or only those belonging to one server. */
|
||||
export function forgetLoadedMcpTools(serverPath?: string) {
|
||||
if (serverPath === undefined) {
|
||||
loadedTools.clear()
|
||||
return
|
||||
}
|
||||
const prefix = `${serverPath}::`
|
||||
for (const key of [...loadedTools.keys()]) {
|
||||
if (key.startsWith(prefix)) loadedTools.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,6 +144,7 @@ function forgetServerTools(workspace: string, path: string) {
|
||||
for (const key of Object.keys(toolsCache)) {
|
||||
if (key.startsWith(prefix)) delete toolsCache[key]
|
||||
}
|
||||
forgetLoadedMcpTools(path)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,14 +216,16 @@ function truncate(text: string, max: number): string {
|
||||
return text.length > max ? text.slice(0, max) + '…' : text
|
||||
}
|
||||
|
||||
function summarizeTool(server: McpServer, tool: McpToolDef) {
|
||||
function summarizeTool(server: McpServer, tool: McpToolDef, callName?: string) {
|
||||
const params = schemaPropertyNames(tool.inputSchema)
|
||||
return {
|
||||
server: server.path,
|
||||
tool: tool.name,
|
||||
// Once the tool is registered its real schema is in front of the model, so the
|
||||
// bare parameter names it used to guess from are dropped rather than repeated.
|
||||
...(callName ? { call: callName } : params.length > 0 ? { params } : {}),
|
||||
description: truncate(tool.description ?? '', MAX_DESCRIPTION_CHARS),
|
||||
mode: isReadOnly(tool) ? 'read' : 'write',
|
||||
...(params.length > 0 ? { params } : {})
|
||||
mode: isReadOnly(tool) ? 'read' : 'write'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,8 +410,8 @@ function createCallTool(servers: McpServer[], mode: 'read' | 'write'): Tool<{}>
|
||||
callMcpToolSchema,
|
||||
isRead ? 'call_mcp_read_tool' : 'call_mcp_write_tool',
|
||||
isRead
|
||||
? 'Call a read-only tool on a connected MCP server. Use search_mcp_tools first to find the server and tool names; a failed call returns the tool argument schema.'
|
||||
: 'Call a tool that modifies data on a connected MCP server; the user is asked to confirm. Use search_mcp_tools first to find the server and tool names; a failed call returns the tool argument schema.'
|
||||
? 'Fallback for a read-only tool that search_mcp_tools did not give a `call` name for. Prefer that named tool, which carries the real argument schema; a failed call here returns it.'
|
||||
: 'Fallback for a mutating tool that search_mcp_tools did not give a `call` name for; the user is asked to confirm. Prefer that named tool, which carries the real argument schema; a failed call here returns it.'
|
||||
),
|
||||
showDetails: true,
|
||||
...(isRead
|
||||
@@ -353,6 +422,10 @@ function createCallTool(servers: McpServer[], mode: 'read' | 'write'): Tool<{}>
|
||||
}),
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
const parsed = callMcpToolSchema.parse(args)
|
||||
// Recorded from the arguments, before resolution: resolving needs a live
|
||||
// listing, so a server that cannot be reached would otherwise leave its own
|
||||
// failure row unmarked.
|
||||
toolCallbacks.setToolStatus(toolId, { mcpServer: parsed.server })
|
||||
// Listing is a live call to a third party: a server that has gone away
|
||||
// must fail this tool, not the chat loop around it.
|
||||
let resolved: Awaited<ReturnType<typeof resolveTool>>
|
||||
@@ -391,6 +464,89 @@ function createCallTool(servers: McpServer[], mode: 'read' | 'write'): Tool<{}>
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeToolNamePart(part: string): string {
|
||||
return part.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
}
|
||||
|
||||
/** djb2, so two long names truncated to the same prefix stay distinct. */
|
||||
function shortHash(text: string): string {
|
||||
let hash = 5381
|
||||
for (let i = 0; i < text.length; i++) hash = ((hash * 33) ^ text.charCodeAt(i)) >>> 0
|
||||
return hash.toString(36).padStart(7, '0').slice(0, 7)
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat-facing name. It carries the server so two servers exposing
|
||||
* `list_issues` stay apart, and nothing parses it back: the registry keys on the
|
||||
* pair, so truncation only has to stay unique.
|
||||
*/
|
||||
function registeredToolName(serverPath: string, toolName: string): string {
|
||||
const full = `mcp_${sanitizeToolNamePart(serverPath)}__${sanitizeToolNamePart(toolName)}`
|
||||
if (full.length <= MAX_TOOL_NAME_CHARS) return full
|
||||
return `${full.slice(0, MAX_TOOL_NAME_CHARS - 8)}_${shortHash(full)}`
|
||||
}
|
||||
|
||||
function evictLoadedTools() {
|
||||
while (loadedTools.size > MAX_LOADED_TOOLS) {
|
||||
const oldest = loadedTools.keys().next()
|
||||
if (oldest.done) return
|
||||
loadedTools.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote remote tools to first-class chat tools, so the model fills a real input
|
||||
* schema instead of guessing arguments into a free-form object. Returns the names
|
||||
* it registered, in the order given.
|
||||
*
|
||||
* Each tool carries its own `readOnlyHint`, so the confirmation gate is per tool
|
||||
* and the read/write wrappers' "you used the wrong one" failure cannot arise.
|
||||
*/
|
||||
export function registerMcpTools(server: McpServer, tools: McpToolDef[]): string[] {
|
||||
const names = tools.map((tool) => {
|
||||
const key = `${server.path}::${tool.name}`
|
||||
const name = registeredToolName(server.path, tool.name)
|
||||
const readOnly = isReadOnly(tool)
|
||||
// Re-registering refreshes the schema and counts as a use.
|
||||
loadedTools.delete(key)
|
||||
loadedTools.set(key, {
|
||||
def: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
description: `${tool.description ?? tool.name}\n(MCP server ${server.path})`,
|
||||
parameters: tool.inputSchema ?? { type: 'object', properties: {} }
|
||||
}
|
||||
},
|
||||
showDetails: true,
|
||||
...(readOnly
|
||||
? {}
|
||||
: {
|
||||
requiresConfirmation: true,
|
||||
confirmationMessage: `Call ${tool.name} on ${server.path}`
|
||||
}),
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
touchLoadedTool(key)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Calling ${tool.name}...`,
|
||||
mcpServer: server.path
|
||||
})
|
||||
const result = await executeTool(workspace, server, tool, args ?? {}, readOnly)
|
||||
const ok = JSON.parse(result).success === true
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: ok ? `Called ${tool.name}` : `Call to ${tool.name} failed`,
|
||||
result,
|
||||
...(ok ? {} : { error: `Call to ${tool.name} failed` })
|
||||
})
|
||||
return result
|
||||
}
|
||||
})
|
||||
return name
|
||||
})
|
||||
evictLoadedTools()
|
||||
return names
|
||||
}
|
||||
|
||||
/**
|
||||
* Built per session from the servers the user connected: with none, the tools
|
||||
* are not registered at all, so a workspace without an MCP connection pays no
|
||||
@@ -405,8 +561,12 @@ export function createMcpTools(servers: McpServer[]): Tool<{}>[] {
|
||||
def: createToolDef(
|
||||
searchMcpToolsSchema,
|
||||
'search_mcp_tools',
|
||||
'Search the tools exposed by the MCP servers connected to this workspace (listed in the system prompt). Returns server + tool names to pass to call_mcp_read_tool or call_mcp_write_tool.'
|
||||
"Search the tools exposed by the MCP servers connected to this workspace (listed in the system prompt). Each match becomes a callable tool of its own, named by `call` in the result, carrying that tool's real argument schema — call it directly rather than guessing arguments."
|
||||
),
|
||||
// Openable, so the matches and the `call` names they registered can be read;
|
||||
// collapsed once it succeeded, like a call result, since a ten-match search
|
||||
// would otherwise take over the transcript.
|
||||
showDetails: true,
|
||||
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
|
||||
const parsed = searchMcpToolsSchema.parse(args)
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Searching MCP tools...' })
|
||||
@@ -443,8 +603,27 @@ export function createMcpTools(servers: McpServer[]): Tool<{}>[] {
|
||||
return result
|
||||
}
|
||||
const top = scored.slice(0, MAX_SEARCH_RESULTS)
|
||||
// Register the matches, so the next iteration puts each one's real input
|
||||
// schema in front of the model instead of leaving it to guess arguments
|
||||
// into `call_mcp_*`'s free-form object. Bounded by MAX_SEARCH_RESULTS, and
|
||||
// paid only after a search actually matched.
|
||||
const callNames = new Map<McpToolDef, string>()
|
||||
const perServerMatches = new Map<string, { server: McpServer; tools: McpToolDef[] }>()
|
||||
for (const match of top) {
|
||||
const entry = perServerMatches.get(match.server.path) ?? {
|
||||
server: match.server,
|
||||
tools: []
|
||||
}
|
||||
entry.tools.push(match.tool)
|
||||
perServerMatches.set(match.server.path, entry)
|
||||
}
|
||||
for (const { server, tools } of perServerMatches.values()) {
|
||||
const registered = registerMcpTools(server, tools)
|
||||
tools.forEach((tool, i) => callNames.set(tool, registered[i]))
|
||||
}
|
||||
const result = boundedSearch({
|
||||
matches: top.map((s) => summarizeTool(s.server, s.tool)),
|
||||
matches: top.map((s) => summarizeTool(s.server, s.tool, callNames.get(s.tool))),
|
||||
hint: 'Each match is now a tool of its own, named by `call`. Call that tool directly with its own arguments — it carries the real schema. `call_mcp_read_tool` / `call_mcp_write_tool` are only for a tool that has no `call`.',
|
||||
...(scored.length > top.length
|
||||
? {
|
||||
note: `${scored.length - top.length} more match(es) — refine the query to see them.`
|
||||
|
||||
@@ -575,6 +575,10 @@ export type ToolDisplayMessage = {
|
||||
autoCollapseDetails?: boolean
|
||||
isStreamingArguments?: boolean
|
||||
toolName?: string
|
||||
/** Path of the MCP server this call reached, which marks the row with its provider.
|
||||
* 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
|
||||
showFade?: boolean
|
||||
actions?: ToolDisplayAction[]
|
||||
userQuestion?: UserQuestionDisplay
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<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.
|
||||
*/
|
||||
let { icon, host, size = 16 }: { icon?: Component<any>; host?: string; 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.
|
||||
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}
|
||||
<img
|
||||
src={faviconUrl(host)}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
class="rounded-sm"
|
||||
onerror={() => (failedFor = host)}
|
||||
/>
|
||||
{:else}
|
||||
<Plug {size} class="text-tertiary" />
|
||||
{/if}
|
||||
@@ -9,7 +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.
|
||||
*/
|
||||
type Entry = { key: string | null; editedAt?: string }
|
||||
// `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 }
|
||||
|
||||
const STORE_KEY = 'mcp_provider_icons'
|
||||
|
||||
@@ -33,6 +36,42 @@ export function cachedProviderKey(
|
||||
return entry.editedAt === editedAt ? entry.key : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored mark for a path, ignoring `editedAt`. A transcript row marks a call
|
||||
* that already happened, so the provider from before a reconnect is still the one
|
||||
* to draw — and unlike `readOnlyHint`, nothing acts on it.
|
||||
*/
|
||||
export function cachedProviderMark(
|
||||
workspace: string,
|
||||
path: string
|
||||
): { key: string | null; host?: string } | undefined {
|
||||
const entry = read()[workspace]?.[path]
|
||||
return entry ? { key: entry.key, host: entry.host } : undefined
|
||||
}
|
||||
|
||||
/** The url's host, when it has one worth drawing a favicon for. */
|
||||
export function providerHost(url: unknown): string | undefined {
|
||||
if (typeof url !== 'string') return undefined
|
||||
try {
|
||||
const { hostname } = new URL(url)
|
||||
// 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
|
||||
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
|
||||
}
|
||||
|
||||
export function rememberProviderKey(
|
||||
workspace: string,
|
||||
path: string,
|
||||
@@ -41,7 +80,10 @@ export function rememberProviderKey(
|
||||
): string | null {
|
||||
const key = providerKey(url) ?? null
|
||||
const store = read()
|
||||
store[workspace] = { ...(store[workspace] ?? {}), [path]: { key, editedAt } }
|
||||
store[workspace] = {
|
||||
...(store[workspace] ?? {}),
|
||||
[path]: { key, editedAt, host: providerHost(url) }
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(STORE_KEY, JSON.stringify(store))
|
||||
} catch {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { List, Plug, Plus } from 'lucide-svelte'
|
||||
import { List, Plus } from 'lucide-svelte'
|
||||
import type { Component } from 'svelte'
|
||||
import { get } from 'svelte/store'
|
||||
import { ResourceService } from '$lib/gen'
|
||||
@@ -7,14 +7,16 @@ 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 { cachedProviderKey, rememberProviderKey } from './iconCache'
|
||||
import { cachedProviderHost, cachedProviderKey, rememberProviderKey } from './iconCache'
|
||||
import { loadProviderIcon } from './providerIcon'
|
||||
import McpServerIcon from './McpServerIcon.svelte'
|
||||
|
||||
type Row = {
|
||||
path: string
|
||||
editedAt?: string
|
||||
enabled: boolean
|
||||
icon?: Component<any>
|
||||
iconHost?: string
|
||||
}
|
||||
|
||||
// A menu is a shortcut, not a directory: past this many the list stops being
|
||||
@@ -103,6 +105,7 @@ export class McpMenu {
|
||||
const icon = await loadProviderIcon(key)
|
||||
if (seq !== this.#seq) return
|
||||
server.icon = icon
|
||||
server.iconHost = cachedProviderHost(ws, server.path, server.editedAt)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -168,15 +171,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.
|
||||
get icon() {
|
||||
// Plug where the provider is unknown, so one nameless server does not
|
||||
// pull its label out of line with the rest.
|
||||
return row(path)?.icon ?? Plug
|
||||
},
|
||||
// Provider icons take css lengths and ignore lucide's `size`, so without
|
||||
// this one of them renders at its 24px default among 14px menu icons.
|
||||
// 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.
|
||||
icon: McpServerIcon,
|
||||
get iconProps() {
|
||||
return row(path)?.icon ? { width: '14px', height: '14px' } : undefined
|
||||
return { icon: row(path)?.icon, host: row(path)?.iconHost, size: 14 }
|
||||
},
|
||||
get toggle() {
|
||||
return row(path)?.enabled ?? false
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Component } from 'svelte'
|
||||
import { ResourceService } from '$lib/gen'
|
||||
import { cachedProviderMark, providerHost } 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 }
|
||||
|
||||
// One resolution per server per session, shared by every transcript row naming it —
|
||||
// a chat can hold dozens of calls against the same server.
|
||||
const marks = new Map<string, Promise<McpServerMark>>()
|
||||
|
||||
export function resolveMcpServerMark(workspace: string, path: string): Promise<McpServerMark> {
|
||||
const key = `${workspace}:${path}`
|
||||
let pending = marks.get(key)
|
||||
if (!pending) {
|
||||
pending = load(workspace, path)
|
||||
marks.set(key, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
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 }
|
||||
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) }
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 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