Skip to content

Commit d369d64

Browse files
Merge pull request #1302 from corbitsdev/fix/mcp-search-schema-cards
fix(agent): put input schemas on tool_search cards
2 parents cf39f9c + 4684dea commit d369d64

9 files changed

Lines changed: 199 additions & 17 deletions

File tree

‎docs/MCP.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ Tools from connected servers are not advertised to the model up front; they are
9393
registered for dispatch as soon as the server connects (including later in the
9494
same turn) and surfaced on demand through dynamic tool discovery
9595
(`tool_search`). A match is a ranked handful of names plus capped
96-
descriptions — not full input schemas. `tool_search` does not promote
96+
descriptions. The top two cards also include a compact input schema so
97+
the first call is formable. `tool_search` does not promote
9798
names onto the next inference or into `run.json`. Calling a discovered
9899
name declares that one schema on the next infer (promote-on-execute),
99100
persists it in the session's `run.json`, and re-advertises it on resume

‎src/agent/environment.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export interface EnvironmentInfo {
1616
gitDirtyCount?: number;
1717
gitStatusSummary?: string;
1818
topLevel?: string;
19+
/** Configured MCP server names for this session, when any are present. */
20+
mcpServers?: readonly string[];
1921
}
2022

2123
const GIT_STATUS_LINES = 12;

‎src/agent/prompts.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ export function buildChatRole(
5555
// blocks, what loads on demand, and the harness-specific tools. Everything a
5656
// frontier model already knows about being a coding agent is deliberately omitted.
5757
// `dynamicTools` controls the tool-loading fact: the main chat agent starts with
58-
// core tools plus the advertised catalog (including skill_search) and loads MCP
59-
// and other unadvertised tools via tool_search, whereas a sub-agent is
58+
// core tools plus the advertised catalog and finds MCP and other unadvertised
59+
// tools via tool_search, whereas a sub-agent is
6060
// handed its full toolset upfront and has no tool_search — telling it otherwise
6161
// wastes turns on a tool that does not exist.
6262
export function buildHarnessFacts(
@@ -95,7 +95,7 @@ export function buildHarnessFacts(
9595
"- Attached images are native multimodal input; inspect them directly unless file-level forensics are requested.",
9696
...(dynamicTools
9797
? [
98-
"- Core tools plus the advertised catalog (including skill_search) are resident. Use tool_search to load extra capabilities from plugins or integrations when needed.",
98+
"- Core tools plus the advertised catalog are resident. Use tool_search to find MCP/plugin tools; the top two cards include the input schema. Call a listed name and it joins the tool list — unused matches stay off it.",
9999
"- Use search_agents before dispatching named specialists or teams (ids and descriptions by default; include_body=true for the loaded system prompt).",
100100
"- The user may send follow-up messages while workers run; they are queued. Enter delivers at the next parent tool.boundary; Alt+Enter on session-idle. A long parent tool holds that boundary. Update your plan, spawn or adjust workers, and keep the operator informed.",
101101
]
@@ -163,7 +163,7 @@ const GUIDELINE_SUB_BLOCKS: Record<
163163
...(ctx.subAgent
164164
? []
165165
: [
166-
"- tool_search before assuming a plugin or MCP tool exists; skill_search when choosing among listed skills, use_skill to load a body.",
166+
"- tool_search before assuming a plugin or MCP tool exists (top cards include the schema; call a listed name). use_skill to load a skill body.",
167167
]),
168168
],
169169
askVsProceed: (ctx) => [
@@ -327,6 +327,11 @@ export function buildEnvironmentContext(env: EnvironmentInfo): string {
327327
if (env.gitStatusSummary) lines.push(env.gitStatusSummary);
328328
}
329329
if (env.topLevel) lines.push(`Top level: ${env.topLevel}`);
330+
if (env.mcpServers !== undefined && env.mcpServers.length > 0) {
331+
lines.push(
332+
`MCP: ${env.mcpServers.join(", ")} — tool_search finds their tools (top hits include schema); call a listed name to use it.`,
333+
);
334+
}
330335
lines.push(`Memory file: ${env.cwd}/${SETTINGS_DIR_NAME}/MEMORY.md`);
331336
lines.push("</env>");
332337
return lines.join("\n");

‎src/agent/tool-search.test.ts‎

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
TOOL_SEARCH_DESC_MAX,
1818
TOOL_SEARCH_MAX_RESULTS,
1919
TOOL_SEARCH_LIMIT_MAX,
20+
TOOL_SEARCH_SCHEMA_MAX,
2021
toolSearchDefinition,
2122
type ToolAvailability,
2223
} from "./tool-search.js";
@@ -469,16 +470,93 @@ describe("createToolSearchTool", () => {
469470
expect(listed.every((name) => catalogNames.includes(name))).toBe(true);
470471
});
471472

472-
test("returns name and capped description without a full input schema", async () => {
473+
test("search cards include the input schema so a first call is formable", async () => {
474+
const tool = createToolSearchTool({
475+
search: (q) => index.search(q),
476+
lookup: (name) => defs.find((d) => d.name === name),
477+
});
478+
const out = await call(tool, { query: "linear issue create" });
479+
expect(out).toContain("mcp__linear__create_issue");
480+
expect(out).toContain("title");
481+
expect(out).toContain("teamId");
482+
expect(advertisedTools(defs).map((d) => d.name)).not.toContain(
483+
"mcp__linear__create_issue",
484+
);
485+
});
486+
487+
test("only the top two ranked cards include input schema", async () => {
488+
const catalog: ToolDefinition[] = Array.from({ length: 5 }, (_, i) => ({
489+
name: `mcp__linear__op_${i}`,
490+
description: "Linear issue operation",
491+
inputSchema: {
492+
type: "object",
493+
properties: { [`field_${i}`]: { type: "string" } },
494+
required: [`field_${i}`],
495+
},
496+
}));
497+
const tool = createToolSearchTool({
498+
search: () => catalog.map((d) => d.name),
499+
lookup: (name) => catalog.find((d) => d.name === name),
500+
});
501+
const out = await call(tool, { query: "linear" });
502+
expect(listedToolNames(out)).toEqual(catalog.map((d) => d.name));
503+
expect(out).toContain("field_0");
504+
expect(out).toContain("field_1");
505+
expect(out).not.toContain("field_2");
506+
expect(out).not.toContain("field_3");
507+
expect(out).not.toContain("field_4");
508+
expect(out).toContain("mcp__linear__op_2");
509+
expect(out).toContain("mcp__linear__op_4");
510+
});
511+
512+
test("tool_search description does not claim matches load on the next turn", () => {
513+
expect(toolSearchDefinition.description).not.toMatch(
514+
/load on the next turn/i,
515+
);
516+
expect(toolSearchDefinition.description).toMatch(/schema/i);
517+
expect(toolSearchDefinition.description).toMatch(/call a listed name/i);
518+
});
519+
520+
test("an oversized input schema is capped on the card", async () => {
521+
const hugeProps = Object.fromEntries(
522+
Array.from({ length: 80 }, (_, i) => [
523+
`field_${String(i).padStart(2, "0")}`,
524+
{ type: "string", description: "x".repeat(40) },
525+
]),
526+
);
527+
const huge: ToolDefinition = {
528+
name: "mcp__linear__huge_schema",
529+
description: "Linear issue with a large schema",
530+
inputSchema: {
531+
type: "object",
532+
properties: hugeProps,
533+
required: ["field_00"],
534+
},
535+
};
536+
const tool = createToolSearchTool({
537+
search: () => [huge.name],
538+
lookup: () => huge,
539+
});
540+
const out = await call(tool, { query: "linear huge" });
541+
expect(out).toContain("mcp__linear__huge_schema");
542+
expect(out).toContain("field_00");
543+
const schemaLine = out.split("\n").find((line) => line.startsWith(" {"));
544+
expect(schemaLine).toBeDefined();
545+
expect(schemaLine?.length ?? 0).toBeLessThanOrEqual(
546+
TOOL_SEARCH_SCHEMA_MAX + 2,
547+
);
548+
});
549+
550+
test("search does not promote matches and keeps description lines short", async () => {
473551
const tool = createToolSearchTool({
474552
search: (q) => linearIndex.search(q),
475553
lookup: (name) => linearCatalog.find((d) => d.name === name),
476554
});
555+
const before = advertisedTools(linearCatalog).map((d) => d.name);
477556
const out = await call(tool, { query: "linear issue" });
478557
expect(out).toContain("mcp__linear__");
479-
expect(out).not.toMatch(/input schema/i);
480-
expect(out).not.toContain("teamId");
481558
expect(out).not.toContain("end-marker");
559+
expect(advertisedTools(linearCatalog).map((d) => d.name)).toEqual(before);
482560
const descLines = out
483561
.split("\n")
484562
.filter((line) => line.startsWith("- mcp__"));
@@ -488,6 +566,7 @@ describe("createToolSearchTool", () => {
488566
}
489567
expect(out).not.toMatch(/call them now/i);
490568
expect(out).not.toMatch(/promoted onto/i);
569+
expect(out).not.toMatch(/load on the next turn/i);
491570
expect(out).toMatch(/call a listed name/i);
492571
});
493572

‎src/agent/tool-search.ts‎

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ export function createActivatedToolTracker(): ActivatedToolTracker {
252252
export const toolSearchDefinition: ToolDefinition = {
253253
name: "tool_search",
254254
description:
255-
"Find tools (MCP servers, integrations) and skills by capability. Matched tools load on the next turn; load a skill body with use_skill.",
255+
"Find tools (MCP servers, integrations) and skills by capability. Top two matches include input schema — call a listed name to use it (it then joins the tool list). Unused matches stay off the list. Load a skill body with use_skill.",
256256
inputSchema: {
257257
type: "object",
258258
properties: {
@@ -282,6 +282,8 @@ export const TOOL_SEARCH_MAX_RESULTS = 5;
282282
export const TOOL_SEARCH_LIMIT_MAX = 20;
283283
export const TOOL_SEARCH_SCORE_RATIO = 0.75;
284284
export const TOOL_SEARCH_DESC_MAX = 160;
285+
export const TOOL_SEARCH_SCHEMA_MAX = 600;
286+
const TOOL_SEARCH_SCHEMA_CARDS = 2;
285287

286288
export function createToolIndex(
287289
getDefs: () => readonly ToolDefinition[],
@@ -401,9 +403,48 @@ function capDescription(text: string, max = TOOL_SEARCH_DESC_MAX): string {
401403
return `${oneLine.slice(0, max - 1)}…`;
402404
}
403405

404-
function renderToolCard(def: ToolDefinition | undefined, name: string): string {
406+
function isEmptyInputSchema(schema: unknown): boolean {
407+
if (schema === undefined || schema === null) return true;
408+
if (typeof schema !== "object") return true;
409+
const json = JSON.stringify(schema);
410+
return (
411+
json === "{}" ||
412+
json === '{"type":"object"}' ||
413+
json === '{"type":"object","properties":{}}' ||
414+
json === '{"type":"object","properties":{},"required":[]}'
415+
);
416+
}
417+
418+
function compactInputSchema(schema: unknown): string | undefined {
419+
if (isEmptyInputSchema(schema)) return undefined;
420+
const json = JSON.stringify(schema);
421+
if (json.length <= TOOL_SEARCH_SCHEMA_MAX) return json;
422+
const obj = schema as Record<string, unknown>;
423+
const properties = obj.properties;
424+
const keys =
425+
properties !== undefined &&
426+
typeof properties === "object" &&
427+
properties !== null
428+
? Object.keys(properties)
429+
: [];
430+
const required = Array.isArray(obj.required) ? obj.required : [];
431+
const stub = JSON.stringify({ properties: keys, required });
432+
if (stub.length <= TOOL_SEARCH_SCHEMA_MAX) return stub;
433+
return `${stub.slice(0, TOOL_SEARCH_SCHEMA_MAX - 1)}…`;
434+
}
435+
436+
function renderToolCard(
437+
def: ToolDefinition | undefined,
438+
name: string,
439+
includeSchema: boolean,
440+
): string {
405441
if (def === undefined) return `- ${name}`;
406-
return `- ${def.name}: ${capDescription(def.description ?? "")}`;
442+
const desc = capDescription(def.description ?? "");
443+
const schema = includeSchema
444+
? compactInputSchema(def.inputSchema)
445+
: undefined;
446+
if (schema === undefined) return `- ${def.name}: ${desc}`;
447+
return `- ${def.name}: ${desc}\n ${schema}`;
407448
}
408449

409450
// Race the dependency's pending-count wait against a bound, so a
@@ -479,11 +520,12 @@ export function createToolSearchTool(deps: ToolSearchDeps): AgentTool {
479520
if (names.length === 0) {
480521
return `No tools or skills matched "${query}". Try different keywords describing the capability.`;
481522
}
482-
// Cards only — name + capped description. Search does not open the call
483-
// gate or grow the tools array; promote-on-execute declares a name when
484-
// the model actually calls it.
485-
const blocks = names.map((name) =>
486-
renderToolCard(deps.lookup(name), name),
523+
// Ranked cards: top TOOL_SEARCH_SCHEMA_CARDS include a compact input
524+
// schema so the first call is formable; the rest are name + description.
525+
// Search does not open the call gate or grow the tools array;
526+
// promote-on-execute declares a name when the model actually calls it.
527+
const blocks = names.map((name, i) =>
528+
renderToolCard(deps.lookup(name), name, i < TOOL_SEARCH_SCHEMA_CARDS),
487529
);
488530
return `Matching tools — call a listed name to use it:\n\n${blocks.join("\n")}${skillBlock}`;
489531
},

‎src/exec/runner.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,11 @@ export async function runExec(config: Config): Promise<ExecResult> {
846846
skills: agentToolset.skills,
847847
providerName: config.providerName,
848848
model: config.model,
849+
...(config.mcpServers !== undefined
850+
? {
851+
mcpServerNames: config.mcpServers.map((server) => server.name),
852+
}
853+
: {}),
849854
})
850855
).systemPrompt;
851856

‎src/prompts.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { expect, test } from "bun:test";
22

33
import { buildChatSystemPrompt } from "./agent/prompts.js";
4+
import type { EnvironmentInfo } from "./agent/environment.js";
45

56
test("chat system prompt keeps the routing, spawn-brief, and rules sections", () => {
67
const prompt = buildChatSystemPrompt();
@@ -12,3 +13,27 @@ test("chat system prompt keeps the routing, spawn-brief, and rules sections", ()
1213
}
1314
expect(prompt).toContain("manage_tasks");
1415
});
16+
17+
const envBase: EnvironmentInfo = {
18+
cwd: "/tmp/proj",
19+
platform: "test",
20+
arch: "arm64",
21+
runtime: "Bun 1.4.2",
22+
date: new Date("2026-01-15T00:00:00Z"),
23+
isGitRepo: false,
24+
};
25+
26+
test("environment context lists configured MCP servers", () => {
27+
const prompt = buildChatSystemPrompt(undefined, {
28+
...envBase,
29+
mcpServers: ["linear", "exa"],
30+
});
31+
expect(prompt).toContain("MCP: linear, exa");
32+
expect(prompt).toContain("tool_search");
33+
expect(prompt).toContain("schema");
34+
});
35+
36+
test("environment context omits MCP when none are configured", () => {
37+
const prompt = buildChatSystemPrompt(undefined, envBase);
38+
expect(prompt).not.toContain("MCP:");
39+
});

‎src/session/runtime-assembly.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,13 +319,28 @@ export interface SessionChatPromptArgs {
319319
// Session-start snapshot from createAgentToolset. When provided, skip
320320
// rediscovery so the prompt listing and skill_search share one catalog.
321321
skills?: readonly SkillSummary[];
322+
// Configured MCP server names to list in the env block so the model
323+
// knows to tool_search for them. Omitted or empty = no MCP line.
324+
mcpServerNames?: readonly string[];
322325
}
323326

324327
export interface SessionChatPrompt {
325328
systemPrompt: string;
326329
skills: SkillSummary[];
327330
}
328331

332+
function uniqueMcpServerNames(names: readonly string[] | undefined): string[] {
333+
if (names === undefined || names.length === 0) return [];
334+
const seen = new Set<string>();
335+
const out: string[] = [];
336+
for (const name of names) {
337+
if (name.length === 0 || seen.has(name)) continue;
338+
seen.add(name);
339+
out.push(name);
340+
}
341+
return out;
342+
}
343+
329344
/** Load AGENTS.md / SYSTEM.md / env / skills and build the main chat system prompt. */
330345
export async function loadSessionChatPrompt(
331346
args: SessionChatPromptArgs,
@@ -343,6 +358,11 @@ export async function loadSessionChatPrompt(
343358
...(args.systemPromptExtensions ?? []),
344359
...overrides.append,
345360
];
361+
const mcpServerNames = uniqueMcpServerNames(args.mcpServerNames);
362+
const env =
363+
mcpServerNames.length > 0
364+
? { ...environment, mcpServers: mcpServerNames }
365+
: environment;
346366
// Family residual on the primary prompt (CL-8310): resolved from the model
347367
// family policy as an orchestrator — primaries dispatch rather than doing
348368
// the work directly, so grok/claude primaries stay untouched (their rows
@@ -359,7 +379,7 @@ export async function loadSessionChatPrompt(
359379
return {
360380
systemPrompt: buildChatSystemPrompt(
361381
extensions.length > 0 ? extensions : undefined,
362-
environment,
382+
env,
363383
overrides.base,
364384
skills,
365385
args.sessionMode,

‎src/tui/runner/session.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,9 @@ export async function assembleTUISession(
453453
skills: toolset.skills,
454454
providerName: config.providerName,
455455
model: config.model,
456+
...(config.mcpServers !== undefined
457+
? { mcpServerNames: config.mcpServers.map((server) => server.name) }
458+
: {}),
456459
});
457460

458461
const directorHolder: { instance?: ReturnType<typeof createChatDirector> } =

0 commit comments

Comments
 (0)