refactor: state each MCP invariant once and drop the last of the churn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgzuxyafKNF2uaEeL35XQw
This commit is contained in:
Guilhem Lemouel
2026-09-10 17:52:42 +02:00
co-authored by Claude Opus 5
parent 197e492ddf
commit 46e3ae3db3
6 changed files with 39 additions and 62 deletions
+5 -8
View File
@@ -264,11 +264,8 @@ pub fn make_schema_compatible(schema: &mut Value) {
obj.insert("type".to_string(), Value::String("string".to_string()));
}
// 2c. `required` is `uniqueItems` at every subschema, not just the root, so a
// repeat nested inside a property makes a strict validator reject the whole tool
// just as a root-level one does -- and the tool then vanishes from the client's
// list rather than failing loudly. `transform_property_keys` covers only the root,
// where it also has to follow key renames.
// 2c. See the `required` bullet above. `transform_property_keys` covers only the
// root, where it also has to follow key renames.
if let Some(Value::Array(required)) = obj.get_mut("required") {
let mut seen = HashSet::new();
required.retain(|name| match name.as_str() {
@@ -892,9 +889,9 @@ mod tests {
#[test]
fn enriched_resource_stays_a_string_through_make_schema_compatible() {
// A resource param is stored with the resource's own object shape. Enrichment
// retypes it to the `$res:` string, and the leftover `properties` used to make
// `make_schema_compatible` retype it back to "object" with nothing in it --
// the model then sent `{}` instead of a resource path.
// retypes it to the `$res:` string, and must clear that shape: a node with
// `properties` is retyped back to "object" below, which reads to the model as
// an empty object rather than a resource path.
let (cache, types) = aws_resources();
let mut node = json!({
"type": "object",
@@ -41,8 +41,7 @@ const MAX_TOOL_SCHEMA_CHARS = 8_000
// And a ceiling on the set, since the per-tool bound alone would allow 25 large ones —
// far more context than the search indirection exists to save.
const MAX_LOADED_SCHEMA_CHARS = 40_000
// Same reasoning for the description, which rides alongside it. Roomier than the
// search summary's 200: this one is what the model chooses the tool from.
// Roomier than the search summary's 200: this is what the model chooses the tool from.
const MAX_TOOL_DESCRIPTION_CHARS = 2_000
const MAX_RESULT_CHARS = 20_000
// A server writes its own error text, and every enabled server can contribute
@@ -109,15 +108,11 @@ export function clearMcpToolsCache() {
*/
type OwnerRegistry = {
tools: Map<string, Tool<{}>>
/**
* Recency for eviction, kept beside the map rather than as its order. The emitted
/** Recency for eviction, kept beside the map rather than as its order: the emitted
* tool list carries Anthropic's `cache_control` breakpoint on its last entry, so
* reordering it on every call would invalidate the cached prefix — system prompt,
* skills and transcript — for the rest of the turn.
*/
* reordering it would invalidate the cached prefix for the rest of the turn. */
lastUsed: Map<string, number>
/** The server revision each entry's frozen schema came from, so a connection edited
* elsewhere can be told apart from the same path still pointing at the same server. */
/** The server revision each entry was frozen at (see `loadedMcpServers`). */
editedAt: Map<string, string | undefined>
counter: number
}
@@ -179,10 +174,10 @@ export function loadedMcpTools(owner: string): Tool<{}>[] {
}
/**
* The servers that currently have a tool registered, each with the revision its schemas
* were frozen from, for reconciling against the live list. The revision matters as much
* as the path: a registered call bypasses the listing cache, so a connection edited
* elsewhere would otherwise keep running against the schema it had before the edit.
* The servers holding a registered tool, with the revision each was frozen at, for the
* reconcile in `refreshMcpServers`. The revision matters as much as the path: a
* registered call bypasses the listing cache, so a connection edited elsewhere would
* keep running against the schema it had before the edit.
*/
export function loadedMcpServers(owner: string): { path: string; editedAt?: string }[] {
const registry = registries.get(owner)
@@ -224,6 +219,7 @@ export function forgetLoadedMcpTools(owner: string, serverPath?: string) {
if (key.startsWith(prefix)) {
registry.tools.delete(key)
registry.lastUsed.delete(key)
registry.editedAt.delete(key)
}
}
if (registry.tools.size === 0) registries.delete(owner)
@@ -731,9 +727,6 @@ export function registerMcpTools(
type: 'function',
function: {
name,
// Bounded like the schema above and like every other payload the server
// controls: this rides in the request on every iteration, for up to
// MAX_LOADED_TOOLS tools, and servers do ship multi-KB descriptions.
description: `${truncate(tool.description ?? tool.name, MAX_TOOL_DESCRIPTION_CHARS)}\n(MCP server ${server.path})`,
parameters
}
@@ -854,7 +847,7 @@ export function createMcpTools(owner: string, servers: McpServer[]): Tool<{}>[]
}
const result = boundedSearch({
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`.',
hint: 'Call each `call` name directly; use the wrappers only for a match without one.',
...(scored.length > top.length
? {
note: `${scored.length - top.length} more match(es) — refine the query to see them.`
@@ -576,13 +576,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. */
/** What marks this row with its provider. Recorded rather than looked up, because
* both sources — the loaded-tool registry and the server listing — live only in
* memory, and a reloaded transcript has neither. */
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[]
+10 -15
View File
@@ -1,5 +1,6 @@
/** One entry of an MCP `icons` array, as the spec defines it. */
export type McpIcon = {
/** One entry of an MCP `icons` array, as the spec defines it. Not `McpIcon`: that
* names the Windmill-shipped MCP logo component in `$lib/components/icons`. */
export type PublishedIcon = {
src?: unknown
mimeType?: unknown
sizes?: unknown
@@ -12,26 +13,20 @@ export type McpIcon = {
const MAX_ICON_SRC_CHARS = 8_000
/**
* The icon source to render, chosen from what an MCP server published.
* The icon source to render, chosen from what an MCP server published. `src` comes
* from a third party, so the spec's rules are enforced here rather than trusted.
*
* **`data:` only.** The spec also permits an HTTPS src, but rendering one makes the
* user's browser fetch from a host the server names, disclosing their IP, user agent
* and the moment they looked — and a per-user URL turns it into a read receipt. COEP
* `require-corp` does not prevent that: it blocks the *response*, so the request has
* already left. A `data:` src carries its bytes over the MCP connection the user
* already made and fetches nothing.
*
* The rest is the spec's own list, enforced rather than trusted, since `src` is chosen
* by a third party: no SVG (it can carry script), and a data URI judged by the type it
* declares rather than the sibling `mimeType`, which the spec calls advisory.
* `data:` only: an HTTPS src would make the browser fetch from a host the server
* names, and COEP blocks the response, not the request. No SVG — it can carry script.
* A data URI is judged by the type it declares; `mimeType` is advisory.
*/
export function pickMcpIconSrc(icons: unknown): string | undefined {
if (!Array.isArray(icons)) return undefined
return icons.find((icon): icon is McpIcon => isRenderable(icon))?.src as string | undefined
return icons.find((icon): icon is PublishedIcon => isRenderable(icon))?.src as string | undefined
}
function isRenderable(icon: unknown): boolean {
const src = (icon as McpIcon | null)?.src
const src = (icon as PublishedIcon | null)?.src
if (typeof src !== 'string' || src.length > MAX_ICON_SRC_CHARS) return false
const lower = src.toLowerCase()
if (!lower.startsWith('data:image/')) return false
+2 -1
View File
@@ -68,7 +68,8 @@ 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,20 +1,14 @@
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 },