feat: give the chat the full MCP tool schema, and mark calls with the provider icon (#11086)

* feat: mark MCP server lists and chat tool calls with the provider icon

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgzuxyafKNF2uaEeL35XQw

* feat: return the full MCP tool schema from search_mcp_tools

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgzuxyafKNF2uaEeL35XQw

* fix: bound an empty MCP search result and keep the server mark decorative

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgzuxyafKNF2uaEeL35XQw

* fix: keep the more-matches hint and scope a marked row to its own workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgzuxyafKNF2uaEeL35XQw

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-09-11 15:47:15 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent e0a34a86a4
commit e7c6f85553
10 changed files with 273 additions and 48 deletions
@@ -20,6 +20,7 @@ switch that decides whether this chat carries its tools.
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'
@@ -29,6 +30,7 @@ switch that decides whether this chat carries its tools.
import { untrack } from 'svelte'
import { getAiChatManager } from './aiChatManagerContext'
import { clearMcpToolsCache } from './global/mcpTools'
import { forgetMcpServerMarks } from '$lib/components/mcp/serverMark'
let {
ws,
@@ -389,8 +391,10 @@ switch that decides whether this chat carries its tools.
async function refresh(target = ws) {
// A path can be reconnected to a different server, so the cached tool list
// (and the readOnlyHint the confirmation gate reads) must not survive.
// (and the readOnlyHint the confirmation gate reads) must not survive — nor the
// provider mark the transcript's call rows show.
clearMcpToolsCache()
forgetMcpServerMarks()
await loadServers(target)
// `refreshMcpServers` blanks the list when the workspace it is handed is not
// the one the chat is on, so a refresh landing after a switch would take B's
@@ -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} size={16} />
{/snippet}
{#snippet title()}
<span class="truncate leading-5">{server.path}</span>
@@ -40,6 +40,8 @@
import RunScriptCard from './RunScriptCard.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'
interface Props {
message: ToolDisplayMessage
@@ -47,6 +49,11 @@
let { message }: Props = $props()
// Recorded by the call itself, from the connected-server list rather than from the
// model's arguments — which is what lets a reloaded transcript still resolve it, and
// what keeps a path the model made up from being read as a workspace resource.
const mcpServer = $derived(message.mcpServer)
const isPlanReview = $derived(message.toolName === EXIT_PLAN_MODE_TOOL)
const isPlanCard = $derived(isPlanCardTool(message.toolName))
const planCopy = $derived(
@@ -242,6 +249,17 @@
{/if}
{/snippet}
<!-- Which system a call reaches is the first thing to know about it, so an MCP call
is marked before its label. Awaited rather than drawn immediately: the MCP logo
appearing first and being replaced would flicker on every row. -->
{#snippet serverMark()}
{#if mcpServer?.workspace && mcpServer.path}
{#await resolveMcpServerMark(mcpServer.workspace, mcpServer.path) then mark}
<McpServerIcon icon={mark.icon} size={14} />
{/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. -->
@@ -258,6 +276,7 @@
labelClass={showPreviewChip ? 'truncate' : ''}
contentClass="space-y-3"
headerRight={showPreviewChip ? previewChip : undefined}
headerLeft={mcpServer?.workspace ? serverMark : undefined}
>
<!-- Image a tool produced (e.g. take_screenshot) — shown inline, not gated on expand. -->
{#snippet belowHeader()}
@@ -228,7 +228,7 @@ describe('call results', () => {
})
describe('search_mcp_tools', () => {
it('returns compact summaries without the full input schemas', async () => {
it('returns each match with its full input schema', async () => {
const result = await run('search_mcp_tools', { query: 'issue' })
expect(result.matches).toEqual([
{
@@ -236,7 +236,7 @@ describe('search_mcp_tools', () => {
tool: 'get_issue',
description: 'Get details of a GitHub issue',
mode: 'read',
params: ['owner', 'repo']
inputSchema: TOOLS[0].inputSchema
}
])
})
@@ -330,6 +330,64 @@ describe('result size cap', () => {
expect(callMcpToolMock).not.toHaveBeenCalled()
})
it('drops the lowest-ranked schemas before dropping the matches themselves', async () => {
getMcpToolsMock.mockResolvedValue(
['issue_a', 'issue_b', 'issue_c'].map((name) => ({
name,
description: 'An issue tool',
inputSchema: {
type: 'object',
properties: { body: { type: 'string', description: 'x'.repeat(9_000) } }
},
annotations: { readOnlyHint: true }
}))
)
const result = await run('search_mcp_tools', { query: 'issue' })
expect(result.matches.map((m: any) => m.tool)).toEqual(['issue_a', 'issue_b', 'issue_c'])
expect(result.matches[0].inputSchema).toBeDefined()
expect(result.matches[2].inputSchema).toBeUndefined()
expect(result.truncated).toBe(true)
})
// Truncating must not swallow the count of matches the score cut off: those
// tools are not in the result at all, so the model has to know to ask again.
it('keeps the more-matches hint when truncation strips schemas', async () => {
getMcpToolsMock.mockResolvedValue(
Array.from({ length: 12 }, (_, i) => ({
name: `issue_${i}`,
description: 'An issue tool',
inputSchema: {
type: 'object',
properties: { body: { type: 'string', description: 'x'.repeat(3_000) } }
},
annotations: { readOnlyHint: true }
}))
)
const result = await run('search_mcp_tools', { query: 'issue' })
expect(result.matches).toHaveLength(10)
expect(result.note).toContain('2 more match(es)')
expect(result.note).toContain('inputSchema')
})
it('holds the cap when every server failed and nothing matched', async () => {
getMcpToolsMock.mockRejectedValue(new Error('x'.repeat(500)))
const servers = Array.from({ length: 50 }, (_, i) => ({ path: `u/hugo/mcp_${i}` }))
const raw = await createMcpTools(servers)
.find((t) => t.def.function.name === 'search_mcp_tools')!
.fn({
args: { query: 'issue' },
workspace: 'test-ws',
helpers: {},
toolCallbacks: createToolCallbacks(),
toolId: 'tool-1'
})
expect(raw.length).toBeLessThanOrEqual(20_000)
expect(JSON.parse(raw).unavailableCount).toBe(50)
})
it('truncates an oversized tools/list failure in search', async () => {
getMcpToolsMock.mockRejectedValue(new Error('x'.repeat(80_000)))
const result = await run('search_mcp_tools', { query: 'issue' })
@@ -8,8 +8,7 @@ import { enabledMcpPaths } from '$lib/components/mcp/enabledServers'
* 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.
* the tools a search matched enter the model's context, with their schemas.
*/
type McpToolDef = GetMcpToolsResponse[number]
@@ -17,7 +16,7 @@ type McpToolDef = GetMcpToolsResponse[number]
export type McpServer = { path: string; editedAt?: string }
const MAX_SEARCH_RESULTS = 10
const MAX_DESCRIPTION_CHARS = 200
const MAX_DESCRIPTION_CHARS = 1_000
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.
@@ -139,24 +138,17 @@ function isReadOnly(tool: McpToolDef): boolean {
return tool.annotations?.readOnlyHint === true
}
function schemaPropertyNames(schema: unknown): string[] {
const properties = (schema as { properties?: Record<string, unknown> } | null | undefined)
?.properties
return properties ? Object.keys(properties) : []
}
function truncate(text: string, max: number): string {
return text.length > max ? text.slice(0, max) + '…' : text
}
function summarizeTool(server: McpServer, tool: McpToolDef) {
const params = schemaPropertyNames(tool.inputSchema)
return {
server: server.path,
tool: tool.name,
description: truncate(tool.description ?? '', MAX_DESCRIPTION_CHARS),
mode: isReadOnly(tool) ? 'read' : 'write',
...(params.length > 0 ? { params } : {})
inputSchema: tool.inputSchema
}
}
@@ -285,28 +277,44 @@ function bounded(payload: {
}
/**
* The search payload under the same ceiling as a call result. Server error text
* goes first: the matches are what the model asked for.
* The search payload under the same ceiling as a call result. Schemas go before
* matches: a tool listed without its schema is still callable, since a rejected
* call returns the schema, while a tool dropped from the list is not.
*/
function boundedSearch(payload: { matches: unknown[]; [k: string]: unknown }): string {
const { unavailable, ...rest } = payload
const dropped = Array.isArray(unavailable) ? { unavailableCount: unavailable.length } : {}
let out = JSON.stringify(payload, null, 2)
let out = JSON.stringify(payload)
if (out.length <= MAX_RESULT_CHARS) return out
const matches = [...payload.matches]
const build = () =>
JSON.stringify(
{
...rest,
...dropped,
matches,
truncated: true,
note: `Truncated to ${MAX_RESULT_CHARS} characters. Refine the query.`
},
null,
2
)
// The caller's note says how many matches the score cut off, which truncating
// only adds to.
const { unavailable, note: scoreNote, ...rest } = payload
const dropped = Array.isArray(unavailable) ? { unavailableCount: unavailable.length } : {}
const found = payload.matches.length
const matches = payload.matches.map((m) => ({ ...(m as object) })) as Record<string, unknown>[]
const build = () => {
const schemaless = matches.filter((m) => !m.inputSchema).length
const note = typeof scoreNote === 'string' ? [scoreNote] : []
note.push(`Truncated to ${MAX_RESULT_CHARS} characters.`)
if (schemaless > 0) {
note.push(
`${schemaless} lower-ranked match(es) are listed without their inputSchema; calling one returns the schema if the arguments are wrong.`
)
}
if (matches.length < found) {
note.push(`${found - matches.length} match(es) dropped — refine the query to see them.`)
}
return JSON.stringify({ ...rest, ...dropped, matches, truncated: true, note: note.join(' ') })
}
// Dropping the server error text is the first reduction, and the only one left
// when nothing matched.
out = build()
if (out.length <= MAX_RESULT_CHARS) return out
// `matches` is ordered by score, so the tail is what the query matched least.
for (let i = matches.length - 1; i >= 0; i--) {
delete matches[i].inputSchema
out = build()
if (out.length <= MAX_RESULT_CHARS) return out
}
while (out.length > MAX_RESULT_CHARS && matches.length > 0) {
matches.pop()
out = build()
@@ -353,6 +361,14 @@ function createCallTool(servers: McpServer[], mode: 'read' | 'write'): Tool<{}>
}),
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
const parsed = callMcpToolSchema.parse(args)
// Marks the row with the provider it reaches. Recorded before the listing is
// awaited, so an unreachable server still marks its own failure row, and taken
// from the connected list rather than from `parsed.server`, which is a path the
// model could point anywhere.
const named = servers.find((s) => s.path === parsed.server)
if (named) {
toolCallbacks.setToolStatus(toolId, { mcpServer: { workspace, path: named.path } })
}
// 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>>
@@ -405,7 +421,7 @@ 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). Returns server + tool names to pass to call_mcp_read_tool or call_mcp_write_tool, each with the input schema its arguments must follow.'
),
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
const parsed = searchMcpToolsSchema.parse(args)
@@ -629,6 +629,11 @@ export type ToolDisplayMessage = {
autoCollapseDetails?: boolean
isStreamingArguments?: boolean
toolName?: string
/** What marks this row with its provider. Recorded rather than looked up, because the
* server listing lives only in memory and a reloaded transcript does not have it. The
* workspace rides along: a chat is readable from any workspace, and the same path names
* a different server in each. */
mcpServer?: { workspace: string; path: string }
showFade?: boolean
actions?: ToolDisplayAction[]
userQuestion?: UserQuestionDisplay
@@ -0,0 +1,29 @@
<script lang="ts">
import type { Component } from 'svelte'
import McpIcon from '$lib/components/icons/McpIcon.svelte'
import { twMerge } from 'tailwind-merge'
/**
* A connected server's mark: the icon Windmill ships for that integration, or the
* MCP logo for a server it has none for — which still says what kind of thing the
* row reaches, where a generic plug did not.
*/
let {
icon,
size = 16,
class: className = ''
}: { icon?: Component<any>; size?: number; class?: string } = $props()
const px = $derived(`${size}px`)
</script>
<!-- Decorative: the row's own label names the server, and the MCP logo carries a
<title> that would otherwise land in the accessible name of the row it marks. -->
<span class={twMerge('inline-flex shrink-0', className)} aria-hidden="true">
{#if icon}
{@const Icon = icon}
<Icon width={px} height={px} />
{:else}
<McpIcon width={size} height={size} />
{/if}
</span>
@@ -33,6 +33,18 @@ export function cachedProviderKey(
return entry.editedAt === editedAt ? entry.key : undefined
}
/**
* The stored mark for a path, ignoring `editedAt`: a transcript row has none to
* match against, and nothing acts on a mark.
*/
export function cachedProviderMark(
workspace: string,
path: string
): { key: string | null } | undefined {
const entry = read()[workspace]?.[path]
return entry ? { key: entry.key } : undefined
}
export function rememberProviderKey(
workspace: string,
path: string,
@@ -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'
@@ -9,6 +9,7 @@ import type { AIChatManager } from '../copilot/chat/AIChatManager.svelte'
import { isMcpEnabled, setMcpEnabled } from './enabledServers'
import { cachedProviderKey, rememberProviderKey } from './iconCache'
import { loadProviderIcon } from './providerIcon'
import McpServerIcon from './McpServerIcon.svelte'
type Row = {
path: string
@@ -164,19 +165,13 @@ export class McpMenu {
return [
...shown.map(({ path }) => ({
displayName: path,
icon: McpServerIcon,
// Getters, not snapshots: the menu stays open across a click, and it has
// 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.
get iconProps() {
return row(path)?.icon ? { width: '14px', height: '14px' } : undefined
return { icon: row(path)?.icon, size: 14 }
},
get toggle() {
return row(path)?.enabled ?? false
@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getResourceMock } = vi.hoisted(() => ({ getResourceMock: vi.fn() }))
vi.mock('$lib/gen', () => ({ ResourceService: { getResource: getResourceMock } }))
vi.mock('./iconCache', () => ({ cachedProviderMark: () => undefined }))
vi.mock('./providerIcon', () => ({
loadProviderIcon: async (key: string | null) => (key ? `icon:${key}` : undefined),
providerKey: (url: unknown) =>
typeof url === 'string' && url.includes('linear') ? 'linear' : null
}))
import { forgetMcpServerMarks, resolveMcpServerMark } from './serverMark'
describe('resolveMcpServerMark', () => {
beforeEach(() => {
forgetMcpServerMarks()
getResourceMock.mockReset()
})
it('reads a server once and shares the answer', async () => {
getResourceMock.mockResolvedValue({ value: { url: 'https://mcp.linear.app/mcp' } })
const marks = await Promise.all([
resolveMcpServerMark('ws', 'u/admin/linear_mcp'),
resolveMcpServerMark('ws', 'u/admin/linear_mcp')
])
expect(marks.map((m) => m.icon)).toEqual(['icon:linear', 'icon:linear'])
expect(getResourceMock).toHaveBeenCalledTimes(1)
})
// A failure must not be memoized: one offline blip would otherwise leave the
// server unmarked in every later row until the page reloads.
it('retries after a failed read', async () => {
getResourceMock.mockRejectedValueOnce(new Error('offline'))
expect(await resolveMcpServerMark('ws', 'u/admin/linear_mcp')).toEqual({})
getResourceMock.mockResolvedValue({ value: { url: 'https://mcp.linear.app/mcp' } })
const mark = await resolveMcpServerMark('ws', 'u/admin/linear_mcp')
expect(mark.icon).toBe('icon:linear')
expect(getResourceMock).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,47 @@
import type { Component } from 'svelte'
import { ResourceService } from '$lib/gen'
import { cachedProviderMark } from './iconCache'
import { loadProviderIcon, providerKey } from './providerIcon'
/** Windmill's own icon for a connected server's integration, when it ships one. */
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.
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) {
// A failed read is dropped rather than memoized: one offline blip or a 401 during
// a token refresh would otherwise leave the server unmarked in every later row
// until the page reloads.
pending = load(workspace, path).catch(() => {
marks.delete(key)
return {}
})
marks.set(key, pending)
}
return pending
}
/**
* Forget what was resolved, for the settings section to call when it reloads the
* connections: a path can be reconnected to a different provider, and a mark held for
* the life of the page would go on marking new call rows with the old provider's icon.
*/
export function forgetMcpServerMarks() {
marks.clear()
}
async function load(workspace: string, path: string): Promise<McpServerMark> {
const cached = cachedProviderMark(workspace, path)
if (cached) return { icon: await loadProviderIcon(cached.key) }
// 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)) }
}