@oh-my-pi/pi-coding-agent 13.12.6 → 13.12.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.12.7] - 2026-03-16
6
+ ### Changed
7
+
8
+ - Modified `getSelectedMCPToolNames()` to return only active MCP tools in non-discovery sessions, filtering by tool registry availability
9
+ - Updated `search_tool_bm25` tool instantiation to conditionally create the tool only when MCP discovery mode is enabled and execution hooks are available
10
+ - Changed search results to exclude already-selected MCP tools before applying the limit parameter, allowing discovery of additional tools in subsequent searches
11
+
12
+ ### Fixed
13
+
14
+ - Fixed MCP tool selection tracking to properly distinguish between discovery-enabled and non-discovery sessions, preventing orphaned tool selections after manual deactivation
15
+
5
16
  ## [13.12.6] - 2026-03-15
6
17
  ### Changed
7
18
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.12.6",
4
+ "version": "13.12.7",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@mozilla/readability": "^0.6",
44
- "@oh-my-pi/omp-stats": "13.12.6",
45
- "@oh-my-pi/pi-agent-core": "13.12.6",
46
- "@oh-my-pi/pi-ai": "13.12.6",
47
- "@oh-my-pi/pi-natives": "13.12.6",
48
- "@oh-my-pi/pi-tui": "13.12.6",
49
- "@oh-my-pi/pi-utils": "13.12.6",
44
+ "@oh-my-pi/omp-stats": "13.12.7",
45
+ "@oh-my-pi/pi-agent-core": "13.12.7",
46
+ "@oh-my-pi/pi-ai": "13.12.7",
47
+ "@oh-my-pi/pi-natives": "13.12.7",
48
+ "@oh-my-pi/pi-tui": "13.12.7",
49
+ "@oh-my-pi/pi-utils": "13.12.7",
50
50
  "@sinclair/typebox": "^0.34",
51
51
  "@xterm/headless": "^6.0",
52
52
  "ajv": "^8.18",
@@ -1188,6 +1188,16 @@ export const SETTINGS_SCHEMA = {
1188
1188
  ui: { tab: "tools", label: "MCP Project Config", description: "Load .mcp.json/mcp.json from project root" },
1189
1189
  },
1190
1190
 
1191
+ "mcp.discoveryMode": {
1192
+ type: "boolean",
1193
+ default: false,
1194
+ ui: {
1195
+ tab: "tools",
1196
+ label: "MCP Tool Discovery",
1197
+ description: "Hide MCP tools by default and expose them through a tool discovery tool",
1198
+ },
1199
+ },
1200
+
1191
1201
  "mcp.notifications": {
1192
1202
  type: "boolean",
1193
1203
  default: false,
@@ -1463,9 +1473,6 @@ export const SETTINGS_SCHEMA = {
1463
1473
  ui: { tab: "providers", label: "Exa Websets", description: "Webset management and enrichment tools" },
1464
1474
  },
1465
1475
 
1466
- // ────────────────────────────────────────────────────────────────────────
1467
- // Advanced settings (no UI)
1468
- // ────────────────────────────────────────────────────────────────────────
1469
1476
  "commit.mapReduceEnabled": { type: "boolean", default: true },
1470
1477
 
1471
1478
  "commit.mapReduceMinFiles": { type: "number", default: 4 },
@@ -89,6 +89,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
89
89
  const serverConfig = config as Record<string, unknown>;
90
90
  return {
91
91
  name,
92
+ timeout: typeof serverConfig.timeout === "number" ? serverConfig.timeout : undefined,
92
93
  command: serverConfig.command as string | undefined,
93
94
  args: serverConfig.args as string[] | undefined,
94
95
  env: serverConfig.env as Record<string, string> | undefined,
@@ -197,6 +197,10 @@ function extractMCPServersFromToml(toml: Record<string, unknown>): Record<string
197
197
  }
198
198
  // Note: validation of transport vs endpoint is handled by mcpCapability.validate()
199
199
 
200
+ // Map Codex tool_timeout_sec (seconds) to MCPServer timeout (milliseconds)
201
+ if (typeof config.tool_timeout_sec === "number" && config.tool_timeout_sec > 0) {
202
+ server.timeout = config.tool_timeout_sec * 1000;
203
+ }
200
204
  result[name] = server;
201
205
  }
202
206
 
@@ -65,6 +65,7 @@ function parseMCPServers(
65
65
  transport: ["stdio", "sse", "http"].includes(serverConfig.type as string)
66
66
  ? (serverConfig.type as "stdio" | "sse" | "http")
67
67
  : undefined,
68
+ timeout: typeof serverConfig.timeout === "number" ? serverConfig.timeout : undefined,
68
69
  _source: createSourceMeta(PROVIDER_ID, path, level),
69
70
  });
70
71
  }
@@ -110,6 +110,7 @@ async function loadMCPFromSettings(
110
110
  transport: ["stdio", "sse", "http"].includes(raw.type as string)
111
111
  ? (raw.type as "stdio" | "sse" | "http")
112
112
  : undefined,
113
+ timeout: typeof raw.timeout === "number" ? raw.timeout : undefined,
113
114
  _source: createSourceMeta(PROVIDER_ID, path, level),
114
115
  } as MCPServer);
115
116
  }
@@ -94,6 +94,7 @@ async function loadMCPConfig(
94
94
  transport: ["stdio", "sse", "http"].includes(expanded.transport as string)
95
95
  ? (expanded.transport as "stdio" | "sse" | "http")
96
96
  : undefined,
97
+ timeout: typeof expanded.timeout === "number" ? expanded.timeout : undefined,
97
98
  _source: createSourceMeta(PROVIDER_ID, path, level),
98
99
  };
99
100
 
@@ -54,6 +54,7 @@ function parseServerConfig(
54
54
  url: server.url as string | undefined,
55
55
  headers: server.headers as Record<string, string> | undefined,
56
56
  transport: server.type as "stdio" | "sse" | "http" | undefined,
57
+ timeout: typeof server.timeout === "number" ? server.timeout : undefined,
57
58
  _source: createSourceMeta(PROVIDER_ID, path, scope),
58
59
  },
59
60
  };
@@ -0,0 +1,192 @@
1
+ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
+
3
+ export interface DiscoverableMCPTool {
4
+ name: string;
5
+ label: string;
6
+ description: string;
7
+ serverName?: string;
8
+ mcpToolName?: string;
9
+ schemaKeys: string[];
10
+ }
11
+
12
+ export interface DiscoverableMCPToolServerSummary {
13
+ name: string;
14
+ toolCount: number;
15
+ }
16
+
17
+ export interface DiscoverableMCPToolSummary {
18
+ servers: DiscoverableMCPToolServerSummary[];
19
+ toolCount: number;
20
+ }
21
+
22
+ export function formatDiscoverableMCPToolServerSummary(server: DiscoverableMCPToolServerSummary): string {
23
+ const toolLabel = server.toolCount === 1 ? "tool" : "tools";
24
+ return `${server.name} (${server.toolCount} ${toolLabel})`;
25
+ }
26
+
27
+ export interface DiscoverableMCPSearchDocument {
28
+ tool: DiscoverableMCPTool;
29
+ termFrequencies: Map<string, number>;
30
+ length: number;
31
+ }
32
+
33
+ export interface DiscoverableMCPSearchIndex {
34
+ documents: DiscoverableMCPSearchDocument[];
35
+ averageLength: number;
36
+ documentFrequencies: Map<string, number>;
37
+ }
38
+
39
+ export interface DiscoverableMCPSearchResult {
40
+ tool: DiscoverableMCPTool;
41
+ score: number;
42
+ }
43
+
44
+ const BM25_K1 = 1.2;
45
+ const BM25_B = 0.75;
46
+ const FIELD_WEIGHTS = {
47
+ name: 6,
48
+ label: 4,
49
+ serverName: 2,
50
+ mcpToolName: 4,
51
+ description: 2,
52
+ schemaKey: 1,
53
+ } as const;
54
+
55
+ export function isMCPToolName(name: string): boolean {
56
+ return name.startsWith("mcp_");
57
+ }
58
+
59
+ function getSchemaPropertyKeys(parameters: unknown): string[] {
60
+ if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) return [];
61
+ const properties = (parameters as { properties?: unknown }).properties;
62
+ if (!properties || typeof properties !== "object" || Array.isArray(properties)) return [];
63
+ return Object.keys(properties as Record<string, unknown>).sort();
64
+ }
65
+
66
+ function tokenize(value: string): string[] {
67
+ return value
68
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
69
+ .replace(/[^a-zA-Z0-9]+/g, " ")
70
+ .toLowerCase()
71
+ .trim()
72
+ .split(/\s+/)
73
+ .filter(token => token.length > 0);
74
+ }
75
+
76
+ function addWeightedTokens(termFrequencies: Map<string, number>, value: string | undefined, weight: number): void {
77
+ if (!value) return;
78
+ for (const token of tokenize(value)) {
79
+ termFrequencies.set(token, (termFrequencies.get(token) ?? 0) + weight);
80
+ }
81
+ }
82
+
83
+ function buildSearchDocument(tool: DiscoverableMCPTool): DiscoverableMCPSearchDocument {
84
+ const termFrequencies = new Map<string, number>();
85
+ addWeightedTokens(termFrequencies, tool.name, FIELD_WEIGHTS.name);
86
+ addWeightedTokens(termFrequencies, tool.label, FIELD_WEIGHTS.label);
87
+ addWeightedTokens(termFrequencies, tool.serverName, FIELD_WEIGHTS.serverName);
88
+ addWeightedTokens(termFrequencies, tool.mcpToolName, FIELD_WEIGHTS.mcpToolName);
89
+ addWeightedTokens(termFrequencies, tool.description, FIELD_WEIGHTS.description);
90
+ for (const schemaKey of tool.schemaKeys) {
91
+ addWeightedTokens(termFrequencies, schemaKey, FIELD_WEIGHTS.schemaKey);
92
+ }
93
+ const length = Array.from(termFrequencies.values()).reduce((sum, value) => sum + value, 0);
94
+ return { tool, termFrequencies, length };
95
+ }
96
+
97
+ export function getDiscoverableMCPTool(tool: AgentTool): DiscoverableMCPTool | null {
98
+ if (!isMCPToolName(tool.name)) return null;
99
+ const toolRecord = tool as AgentTool & {
100
+ label?: string;
101
+ description?: string;
102
+ mcpServerName?: string;
103
+ mcpToolName?: string;
104
+ parameters?: unknown;
105
+ };
106
+ return {
107
+ name: tool.name,
108
+ label: typeof toolRecord.label === "string" ? toolRecord.label : tool.name,
109
+ description: typeof toolRecord.description === "string" ? toolRecord.description : "",
110
+ serverName: typeof toolRecord.mcpServerName === "string" ? toolRecord.mcpServerName : undefined,
111
+ mcpToolName: typeof toolRecord.mcpToolName === "string" ? toolRecord.mcpToolName : undefined,
112
+ schemaKeys: getSchemaPropertyKeys(toolRecord.parameters),
113
+ };
114
+ }
115
+
116
+ export function collectDiscoverableMCPTools(tools: Iterable<AgentTool>): DiscoverableMCPTool[] {
117
+ const discoverable: DiscoverableMCPTool[] = [];
118
+ for (const tool of tools) {
119
+ const metadata = getDiscoverableMCPTool(tool);
120
+ if (metadata) {
121
+ discoverable.push(metadata);
122
+ }
123
+ }
124
+ return discoverable;
125
+ }
126
+
127
+ export function summarizeDiscoverableMCPTools(tools: DiscoverableMCPTool[]): DiscoverableMCPToolSummary {
128
+ const serverToolCounts = new Map<string, number>();
129
+ for (const tool of tools) {
130
+ if (!tool.serverName) continue;
131
+ serverToolCounts.set(tool.serverName, (serverToolCounts.get(tool.serverName) ?? 0) + 1);
132
+ }
133
+ const servers = Array.from(serverToolCounts.entries())
134
+ .sort(([left], [right]) => left.localeCompare(right))
135
+ .map(([name, toolCount]) => ({ name, toolCount }));
136
+ return {
137
+ servers,
138
+ toolCount: tools.length,
139
+ };
140
+ }
141
+
142
+ export function buildDiscoverableMCPSearchIndex(tools: Iterable<DiscoverableMCPTool>): DiscoverableMCPSearchIndex {
143
+ const documents = Array.from(tools, buildSearchDocument);
144
+ const averageLength = documents.reduce((sum, document) => sum + document.length, 0) / documents.length || 1;
145
+ const documentFrequencies = new Map<string, number>();
146
+ for (const document of documents) {
147
+ for (const token of new Set(document.termFrequencies.keys())) {
148
+ documentFrequencies.set(token, (documentFrequencies.get(token) ?? 0) + 1);
149
+ }
150
+ }
151
+ return {
152
+ documents,
153
+ averageLength,
154
+ documentFrequencies,
155
+ };
156
+ }
157
+
158
+ export function searchDiscoverableMCPTools(
159
+ index: DiscoverableMCPSearchIndex,
160
+ query: string,
161
+ limit: number,
162
+ ): DiscoverableMCPSearchResult[] {
163
+ const queryTokens = tokenize(query);
164
+ if (queryTokens.length === 0) {
165
+ throw new Error("Query must contain at least one letter or number.");
166
+ }
167
+ if (index.documents.length === 0) {
168
+ return [];
169
+ }
170
+
171
+ const queryTermCounts = new Map<string, number>();
172
+ for (const token of queryTokens) {
173
+ queryTermCounts.set(token, (queryTermCounts.get(token) ?? 0) + 1);
174
+ }
175
+
176
+ return index.documents
177
+ .map(document => {
178
+ let score = 0;
179
+ for (const [token, queryTermCount] of queryTermCounts) {
180
+ const termFrequency = document.termFrequencies.get(token) ?? 0;
181
+ if (termFrequency === 0) continue;
182
+ const documentFrequency = index.documentFrequencies.get(token) ?? 0;
183
+ const idf = Math.log(1 + (index.documents.length - documentFrequency + 0.5) / (documentFrequency + 0.5));
184
+ const normalization = BM25_K1 * (1 - BM25_B + BM25_B * (document.length / index.averageLength));
185
+ score += queryTermCount * idf * ((termFrequency * (BM25_K1 + 1)) / (termFrequency + normalization));
186
+ }
187
+ return { tool: document.tool, score };
188
+ })
189
+ .filter(result => result.score > 0)
190
+ .sort((left, right) => right.score - left.score || left.tool.name.localeCompare(right.tool.name))
191
+ .slice(0, limit);
192
+ }
@@ -155,6 +155,13 @@ You **MUST** use the following tools, as effectively as possible, to complete th
155
155
  {{/each}}
156
156
  {{/if}}
157
157
 
158
+ {{#if mcpDiscoveryMode}}
159
+ ### MCP tool discovery
160
+
161
+ Some MCP tools are intentionally hidden from the initial tool list.
162
+ {{#if hasMCPDiscoveryServers}}Discoverable MCP servers in this session: {{#list mcpDiscoveryServerSummaries join=", "}}{{this}}{{/list}}.{{/if}}
163
+ If the task may involve external systems, SaaS APIs, chat, tickets, databases, deployments, or other non-local integrations, you **SHOULD** call `search_tool_bm25` before concluding no such tool exists.
164
+ {{/if}}
158
165
  ## Precedence
159
166
  {{#ifAny (includes tools "python") (includes tools "bash")}}
160
167
  Pick the right tool for the job:
@@ -0,0 +1,34 @@
1
+ Search hidden MCP tool metadata when MCP tool discovery is enabled.
2
+
3
+ Use this tool to discover MCP tools that are loaded into the session but not exposed to the model by default.
4
+
5
+ {{#if hasDiscoverableMCPServers}}Discoverable MCP servers in this session: {{#list discoverableMCPServerSummaries join=", "}}{{this}}{{/list}}.{{/if}}
6
+ {{#if discoverableMCPToolCount}}Total discoverable MCP tools loaded: {{discoverableMCPToolCount}}.{{/if}}
7
+ Input:
8
+ - `query` — required natural-language or keyword query
9
+ - `limit` — optional maximum number of tools to return and activate (default `8`)
10
+
11
+ Behavior:
12
+ - Searches hidden MCP tool metadata using BM25-style relevance ranking
13
+ - Matches against MCP tool name, server name, description, and input schema keys
14
+ - Activates the top matching MCP tools for the rest of the current session
15
+ - Repeated searches add to the active MCP tool set; they do not remove earlier selections
16
+ - Newly activated MCP tools become available before the next model call in the same overall turn
17
+
18
+ Notes:
19
+ - If you are unsure, start with `limit` between 5 and 10 to see a broader set of tools.
20
+ - `query` is matched against MCP tool metadata fields:
21
+ - `name`
22
+ - `label`
23
+ - `server_name`
24
+ - `mcp_tool_name`
25
+ - `description`
26
+ - input schema property keys (`schema_keys`)
27
+
28
+ This is not repository search, file search, or code search. Use it only for MCP tool discovery.
29
+
30
+ Returns JSON with:
31
+ - `query`
32
+ - `activated_tools` — MCP tools activated by this search call
33
+ - `match_count` — number of ranked matches returned by the search
34
+ - `total_tools`
package/src/sdk.ts CHANGED
@@ -65,6 +65,11 @@ import {
65
65
  } from "./internal-urls";
66
66
  import { disposeAllKernelSessions } from "./ipy/executor";
67
67
  import { discoverAndLoadMCPTools, type MCPManager, type MCPToolsLoadResult } from "./mcp";
68
+ import {
69
+ collectDiscoverableMCPTools,
70
+ formatDiscoverableMCPToolServerSummary,
71
+ summarizeDiscoverableMCPTools,
72
+ } from "./mcp/discoverable-tool-metadata";
68
73
  import { buildMemoryToolDeveloperInstructions, getMemoryRoot, startMemoryStartupTask } from "./memories";
69
74
  import asyncResultTemplate from "./prompts/tools/async-result.md" with { type: "text" };
70
75
  import { collectEnvSecrets, loadSecrets, obfuscateMessages, SecretObfuscator } from "./secrets";
@@ -76,6 +81,7 @@ import { closeAllConnections } from "./ssh/connection-manager";
76
81
  import { unmountAll } from "./ssh/sshfs-mount";
77
82
  import {
78
83
  buildSystemPrompt as buildSystemPromptInternal,
84
+ buildSystemPromptToolMetadata,
79
85
  loadProjectContextFiles as loadContextFilesInternal,
80
86
  } from "./system-prompt";
81
87
  import { AgentOutputManager } from "./task/output-manager";
@@ -95,6 +101,7 @@ import {
95
101
  PythonTool,
96
102
  ReadTool,
97
103
  ResolveTool,
104
+ renderSearchToolBm25Description,
98
105
  setPreferredCodeSearchProvider,
99
106
  setPreferredImageProvider,
100
107
  setPreferredSearchProvider,
@@ -864,6 +871,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
864
871
  getCompactContext: () => session.formatCompactContext(),
865
872
  getTodoPhases: () => session.getTodoPhases(),
866
873
  setTodoPhases: phases => session.setTodoPhases(phases),
874
+ isMCPDiscoveryEnabled: () => session.isMCPDiscoveryEnabled(),
875
+ getDiscoverableMCPTools: () => session.getDiscoverableMCPTools(),
876
+ getDiscoverableMCPSearchIndex: () => session.getDiscoverableMCPSearchIndex(),
877
+ getSelectedMCPToolNames: () => session.getSelectedMCPToolNames(),
878
+ activateDiscoveredMCPTools: toolNames => session.activateDiscoveredMCPTools(toolNames),
867
879
  getCheckpointState: () => session.getCheckpointState(),
868
880
  setCheckpointState: state => session.setCheckpointState(state ?? undefined),
869
881
  allocateOutputArtifact: async toolType => {
@@ -1189,6 +1201,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1189
1201
  const intentField = settings.get("tools.intentTracing") || $env.PI_INTENT_TRACING === "1" ? INTENT_FIELD : undefined;
1190
1202
  const rebuildSystemPrompt = async (toolNames: string[], tools: Map<string, AgentTool>): Promise<string> => {
1191
1203
  toolContextStore.setToolNames(toolNames);
1204
+ const discoverableMCPTools = mcpDiscoveryEnabled ? collectDiscoverableMCPTools(tools.values()) : [];
1205
+ const discoverableMCPSummary = summarizeDiscoverableMCPTools(discoverableMCPTools);
1206
+ const hasDiscoverableMCPTools =
1207
+ mcpDiscoveryEnabled && toolNames.includes("search_tool_bm25") && discoverableMCPTools.length > 0;
1208
+ const promptTools = buildSystemPromptToolMetadata(tools, {
1209
+ search_tool_bm25: { description: renderSearchToolBm25Description(discoverableMCPTools) },
1210
+ });
1192
1211
  const memoryInstructions = await buildMemoryToolDeveloperInstructions(agentDir, settings);
1193
1212
 
1194
1213
  // Build combined append prompt: memory instructions + MCP server instructions
@@ -1214,14 +1233,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1214
1233
  cwd,
1215
1234
  skills,
1216
1235
  contextFiles,
1217
- tools,
1236
+ tools: promptTools,
1218
1237
  toolNames,
1219
1238
  rules: rulebookRules,
1220
1239
  skillsSettings: settings.getGroup("skills"),
1221
1240
  appendSystemPrompt: appendPrompt,
1222
1241
  repeatToolDescriptions,
1223
- eagerTasks,
1224
1242
  intentField,
1243
+ mcpDiscoveryMode: hasDiscoverableMCPTools,
1244
+ mcpDiscoveryServerSummaries: discoverableMCPSummary.servers.map(formatDiscoverableMCPToolServerSummary),
1245
+ eagerTasks,
1225
1246
  });
1226
1247
 
1227
1248
  if (options.systemPrompt === undefined) {
@@ -1232,15 +1253,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1232
1253
  cwd,
1233
1254
  skills,
1234
1255
  contextFiles,
1235
- tools,
1256
+ tools: promptTools,
1236
1257
  toolNames,
1237
1258
  rules: rulebookRules,
1238
1259
  skillsSettings: settings.getGroup("skills"),
1239
1260
  customPrompt: options.systemPrompt,
1240
1261
  appendSystemPrompt: appendPrompt,
1241
1262
  repeatToolDescriptions,
1242
- eagerTasks,
1243
1263
  intentField,
1264
+ mcpDiscoveryMode: hasDiscoverableMCPTools,
1265
+ mcpDiscoveryServerSummaries: discoverableMCPSummary.servers.map(formatDiscoverableMCPToolServerSummary),
1266
+ eagerTasks,
1244
1267
  });
1245
1268
  }
1246
1269
  return options.systemPrompt(defaultPrompt);
@@ -1250,9 +1273,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1250
1273
  const requestedToolNames = options.toolNames?.map(name => name.toLowerCase()) ?? toolNamesFromRegistry;
1251
1274
  const normalizedRequested = requestedToolNames.filter(name => toolRegistry.has(name));
1252
1275
  const includeExitPlanMode = requestedToolNames.includes("exit_plan_mode");
1253
- const initialToolNames = includeExitPlanMode
1276
+ const mcpDiscoveryEnabled = settings.get("mcp.discoveryMode") ?? false;
1277
+ const requestedActiveToolNames = includeExitPlanMode
1254
1278
  ? normalizedRequested
1255
1279
  : normalizedRequested.filter(name => name !== "exit_plan_mode");
1280
+ const explicitlyRequestedMCPToolNames = options.toolNames
1281
+ ? requestedActiveToolNames.filter(name => name.startsWith("mcp_"))
1282
+ : [];
1283
+ const initialToolNames = mcpDiscoveryEnabled
1284
+ ? [...requestedActiveToolNames.filter(name => !name.startsWith("mcp_")), ...explicitlyRequestedMCPToolNames]
1285
+ : [...requestedActiveToolNames];
1286
+ const initialSelectedMCPToolNames = mcpDiscoveryEnabled ? [...explicitlyRequestedMCPToolNames] : [];
1256
1287
 
1257
1288
  // Custom tools and extension-registered tools are always included regardless of toolNames filter
1258
1289
  const alwaysInclude: string[] = [
@@ -1260,6 +1291,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1260
1291
  ...registeredTools.map(t => t.definition.name),
1261
1292
  ];
1262
1293
  for (const name of alwaysInclude) {
1294
+ if (mcpDiscoveryEnabled && name.startsWith("mcp_")) {
1295
+ continue;
1296
+ }
1263
1297
  if (toolRegistry.has(name) && !initialToolNames.includes(name)) {
1264
1298
  initialToolNames.push(name);
1265
1299
  }
@@ -1440,6 +1474,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1440
1474
  onPayload,
1441
1475
  convertToLlm: convertToLlmFinal,
1442
1476
  rebuildSystemPrompt,
1477
+ mcpDiscoveryEnabled,
1478
+ initialSelectedMCPToolNames,
1443
1479
  ttsrManager,
1444
1480
  obfuscator,
1445
1481
  asyncJobManager,
@@ -87,6 +87,13 @@ import type { Skill, SkillWarning } from "../extensibility/skills";
87
87
  import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
88
88
  import { resolveLocalUrlToPath } from "../internal-urls";
89
89
  import { executePython as executePythonCommand, type PythonResult } from "../ipy/executor";
90
+ import {
91
+ buildDiscoverableMCPSearchIndex,
92
+ collectDiscoverableMCPTools,
93
+ type DiscoverableMCPSearchIndex,
94
+ type DiscoverableMCPTool,
95
+ isMCPToolName,
96
+ } from "../mcp/discoverable-tool-metadata";
90
97
  import { getCurrentThemeName, theme } from "../modes/theme/theme";
91
98
  import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../patch";
92
99
  import type { PlanModeState } from "../plan-mode/state";
@@ -206,6 +213,10 @@ export interface AgentSessionConfig {
206
213
  convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
207
214
  /** System prompt builder that can consider tool availability */
208
215
  rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>;
216
+ /** Enable hidden-by-default MCP tool discovery for this session. */
217
+ mcpDiscoveryEnabled?: boolean;
218
+ /** MCP tool names previously selected via discovery in this session. */
219
+ initialSelectedMCPToolNames?: string[];
209
220
  /** TTSR manager for time-traveling stream rules */
210
221
  ttsrManager?: TtsrManager;
211
222
  /** Secret obfuscator for deobfuscating streaming edit content */
@@ -400,6 +411,10 @@ export class AgentSession {
400
411
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
401
412
  #rebuildSystemPrompt: ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>) | undefined;
402
413
  #baseSystemPrompt: string;
414
+ #mcpDiscoveryEnabled = false;
415
+ #discoverableMCPTools = new Map<string, DiscoverableMCPTool>();
416
+ #discoverableMCPSearchIndex: DiscoverableMCPSearchIndex | null = null;
417
+ #selectedMCPToolNames = new Set<string>();
403
418
 
404
419
  // TTSR manager for time-traveling stream rules
405
420
  #ttsrManager: TtsrManager | undefined = undefined;
@@ -446,6 +461,10 @@ export class AgentSession {
446
461
  this.#convertToLlm = config.convertToLlm ?? convertToLlm;
447
462
  this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
448
463
  this.#baseSystemPrompt = this.agent.state.systemPrompt;
464
+ this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false;
465
+ this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry());
466
+ this.#selectedMCPToolNames = new Set(config.initialSelectedMCPToolNames ?? []);
467
+ this.#pruneSelectedMCPToolNames();
449
468
  this.#ttsrManager = config.ttsrManager;
450
469
  this.#obfuscator = config.obfuscator;
451
470
  this.agent.providerSessionState = this.#providerSessionState;
@@ -1604,6 +1623,36 @@ export class AgentSession {
1604
1623
  return this.#retryAttempt;
1605
1624
  }
1606
1625
 
1626
+ #collectDiscoverableMCPToolsFromRegistry(): Map<string, DiscoverableMCPTool> {
1627
+ return new Map(collectDiscoverableMCPTools(this.#toolRegistry.values()).map(tool => [tool.name, tool] as const));
1628
+ }
1629
+
1630
+ #setDiscoverableMCPTools(discoverableMCPTools: Map<string, DiscoverableMCPTool>): void {
1631
+ this.#discoverableMCPTools = discoverableMCPTools;
1632
+ this.#discoverableMCPSearchIndex = null;
1633
+ }
1634
+
1635
+ #pruneSelectedMCPToolNames(): void {
1636
+ for (const name of Array.from(this.#selectedMCPToolNames)) {
1637
+ if (!this.#discoverableMCPTools.has(name) || !this.#toolRegistry.has(name)) {
1638
+ this.#selectedMCPToolNames.delete(name);
1639
+ }
1640
+ }
1641
+ }
1642
+
1643
+ #getVisibleMCPToolNames(): string[] {
1644
+ if (!this.#mcpDiscoveryEnabled) {
1645
+ return Array.from(this.#toolRegistry.keys()).filter(name => isMCPToolName(name));
1646
+ }
1647
+ return Array.from(this.#selectedMCPToolNames).filter(
1648
+ name => this.#discoverableMCPTools.has(name) && this.#toolRegistry.has(name),
1649
+ );
1650
+ }
1651
+
1652
+ #getActiveNonMCPToolNames(): string[] {
1653
+ return this.getActiveToolNames().filter(name => !isMCPToolName(name) && this.#toolRegistry.has(name));
1654
+ }
1655
+
1607
1656
  /**
1608
1657
  * Get the names of currently active tools.
1609
1658
  * Returns the names of tools currently set on the agent.
@@ -1631,11 +1680,52 @@ export class AgentSession {
1631
1680
  return Array.from(this.#toolRegistry.keys());
1632
1681
  }
1633
1682
 
1683
+ isMCPDiscoveryEnabled(): boolean {
1684
+ return this.#mcpDiscoveryEnabled;
1685
+ }
1686
+
1687
+ getDiscoverableMCPTools(): DiscoverableMCPTool[] {
1688
+ return Array.from(this.#discoverableMCPTools.values());
1689
+ }
1690
+
1691
+ getDiscoverableMCPSearchIndex(): DiscoverableMCPSearchIndex {
1692
+ if (!this.#discoverableMCPSearchIndex) {
1693
+ this.#discoverableMCPSearchIndex = buildDiscoverableMCPSearchIndex(this.#discoverableMCPTools.values());
1694
+ }
1695
+ return this.#discoverableMCPSearchIndex;
1696
+ }
1697
+
1698
+ getSelectedMCPToolNames(): string[] {
1699
+ if (!this.#mcpDiscoveryEnabled) {
1700
+ return this.getActiveToolNames().filter(name => isMCPToolName(name) && this.#toolRegistry.has(name));
1701
+ }
1702
+ return Array.from(this.#selectedMCPToolNames).filter(
1703
+ name => this.#discoverableMCPTools.has(name) && this.#toolRegistry.has(name),
1704
+ );
1705
+ }
1706
+
1707
+ async activateDiscoveredMCPTools(toolNames: string[]): Promise<string[]> {
1708
+ const activated: string[] = [];
1709
+ for (const name of toolNames) {
1710
+ if (!isMCPToolName(name) || !this.#discoverableMCPTools.has(name) || !this.#toolRegistry.has(name)) {
1711
+ continue;
1712
+ }
1713
+ this.#selectedMCPToolNames.add(name);
1714
+ activated.push(name);
1715
+ }
1716
+ if (activated.length === 0) {
1717
+ return [];
1718
+ }
1719
+ const nextActive = [...this.#getActiveNonMCPToolNames(), ...this.#getVisibleMCPToolNames()];
1720
+ await this.setActiveToolsByName(nextActive);
1721
+ return [...new Set(activated)];
1722
+ }
1723
+
1634
1724
  /**
1635
1725
  * Set active tools by name.
1636
1726
  * Only tools in the registry can be enabled. Unknown tool names are ignored.
1637
1727
  * Also rebuilds the system prompt to reflect the new tool set.
1638
- * Changes take effect on the next agent turn.
1728
+ * Changes take effect before the next model call.
1639
1729
  */
1640
1730
  async setActiveToolsByName(toolNames: string[]): Promise<void> {
1641
1731
  const tools: AgentTool[] = [];
@@ -1647,6 +1737,13 @@ export class AgentSession {
1647
1737
  validToolNames.push(name);
1648
1738
  }
1649
1739
  }
1740
+ if (this.#mcpDiscoveryEnabled) {
1741
+ this.#selectedMCPToolNames = new Set(
1742
+ validToolNames.filter(
1743
+ name => isMCPToolName(name) && this.#discoverableMCPTools.has(name) && this.#toolRegistry.has(name),
1744
+ ),
1745
+ );
1746
+ }
1650
1747
  this.agent.setTools(tools);
1651
1748
 
1652
1749
  // Rebuild base system prompt with new tool set
@@ -1665,14 +1762,13 @@ export class AgentSession {
1665
1762
  }
1666
1763
 
1667
1764
  /**
1668
- * Replace MCP tools in the registry and activate the latest MCP tool set immediately.
1765
+ * Replace MCP tools in the registry and recompute the visible MCP tool set immediately.
1669
1766
  * This allows /mcp add/remove/reauth to take effect without restarting the session.
1670
1767
  */
1671
1768
  async refreshMCPTools(mcpTools: CustomTool[]): Promise<void> {
1672
- const prefix = "mcp_";
1673
1769
  const existingNames = Array.from(this.#toolRegistry.keys());
1674
1770
  for (const name of existingNames) {
1675
- if (name.startsWith(prefix)) {
1771
+ if (isMCPToolName(name)) {
1676
1772
  this.#toolRegistry.delete(name);
1677
1773
  }
1678
1774
  }
@@ -1696,17 +1792,10 @@ export class AgentSession {
1696
1792
  this.#toolRegistry.set(finalTool.name, finalTool);
1697
1793
  }
1698
1794
 
1699
- const currentActive = this.getActiveToolNames().filter(
1700
- name => !name.startsWith(prefix) && this.#toolRegistry.has(name),
1701
- );
1702
- const mcpToolNames = Array.from(this.#toolRegistry.keys()).filter(name => name.startsWith(prefix));
1703
- const nextActive = [...currentActive];
1704
- for (const name of mcpToolNames) {
1705
- if (!nextActive.includes(name)) {
1706
- nextActive.push(name);
1707
- }
1708
- }
1795
+ this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry());
1796
+ this.#pruneSelectedMCPToolNames();
1709
1797
 
1798
+ const nextActive = [...this.#getActiveNonMCPToolNames(), ...this.getSelectedMCPToolNames()];
1710
1799
  await this.setActiveToolsByName(nextActive);
1711
1800
  }
1712
1801
 
@@ -5,6 +5,7 @@
5
5
  import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
+ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
8
9
  import { $env, getGpuCachePath, getProjectDir, hasFsCode, isEnoent, logger } from "@oh-my-pi/pi-utils";
9
10
  import { $ } from "bun";
10
11
  import { contextFileCapability } from "./capability/context-file";
@@ -315,11 +316,36 @@ export async function loadSystemPromptFiles(options: LoadContextFilesOptions = {
315
316
  return parts.join("\n\n");
316
317
  }
317
318
 
319
+ export interface SystemPromptToolMetadata {
320
+ label: string;
321
+ description: string;
322
+ }
323
+
324
+ export function buildSystemPromptToolMetadata(
325
+ tools: Map<string, AgentTool>,
326
+ overrides: Partial<Record<string, Partial<SystemPromptToolMetadata>>> = {},
327
+ ): Map<string, SystemPromptToolMetadata> {
328
+ return new Map(
329
+ Array.from(tools.entries(), ([name, tool]) => {
330
+ const toolRecord = tool as AgentTool & { label?: string; description?: string };
331
+ const override = overrides[name];
332
+ return [
333
+ name,
334
+ {
335
+ label: override?.label ?? (typeof toolRecord.label === "string" ? toolRecord.label : ""),
336
+ description:
337
+ override?.description ?? (typeof toolRecord.description === "string" ? toolRecord.description : ""),
338
+ },
339
+ ] as const;
340
+ }),
341
+ );
342
+ }
343
+
318
344
  export interface BuildSystemPromptOptions {
319
345
  /** Custom system prompt (replaces default). */
320
346
  customPrompt?: string;
321
347
  /** Tools to include in prompt. */
322
- tools?: Map<string, { description: string; label: string }>;
348
+ tools?: Map<string, SystemPromptToolMetadata>;
323
349
  /** Tool names to include in prompt. */
324
350
  toolNames?: string[];
325
351
  /** Text to append to system prompt. */
@@ -338,6 +364,10 @@ export interface BuildSystemPromptOptions {
338
364
  rules?: Array<{ name: string; description?: string; path: string; globs?: string[] }>;
339
365
  /** Intent field name injected into every tool schema. If set, explains the field in the prompt. */
340
366
  intentField?: string;
367
+ /** Whether MCP tool discovery is active for this prompt build. */
368
+ mcpDiscoveryMode?: boolean;
369
+ /** Discoverable MCP server summaries to advertise when discovery mode is active. */
370
+ mcpDiscoveryServerSummaries?: string[];
341
371
  /** Encourage the agent to delegate via tasks unless changes are trivial. */
342
372
  eagerTasks?: boolean;
343
373
  }
@@ -360,6 +390,8 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
360
390
  skills: providedSkills,
361
391
  rules,
362
392
  intentField,
393
+ mcpDiscoveryMode = false,
394
+ mcpDiscoveryServerSummaries = [],
363
395
  eagerTasks = false,
364
396
  } = options;
365
397
  const resolvedCwd = cwd ?? getProjectDir();
@@ -494,6 +526,9 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
494
526
  cwd: promptCwd,
495
527
  intentTracing: !!intentField,
496
528
  intentField: intentField ?? "",
529
+ mcpDiscoveryMode,
530
+ hasMCPDiscoveryServers: mcpDiscoveryServerSummaries.length > 0,
531
+ mcpDiscoveryServerSummaries,
497
532
  eagerTasks,
498
533
  };
499
534
  return renderPromptTemplate(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data);
@@ -8,6 +8,7 @@ import type { InternalUrlRouter } from "../internal-urls";
8
8
  import { getPreludeDocs, warmPythonEnvironment } from "../ipy/executor";
9
9
  import { checkPythonKernelAvailability } from "../ipy/kernel";
10
10
  import { LspTool } from "../lsp";
11
+ import type { DiscoverableMCPSearchIndex, DiscoverableMCPTool } from "../mcp/discoverable-tool-metadata";
11
12
  import { EditTool } from "../patch";
12
13
  import type { PlanModeState } from "../plan-mode/state";
13
14
  import { TaskTool } from "../task";
@@ -35,6 +36,7 @@ import { ReadTool } from "./read";
35
36
  import { RenderMermaidTool } from "./render-mermaid";
36
37
  import { ResolveTool } from "./resolve";
37
38
  import { reportFindingTool } from "./review";
39
+ import { SearchToolBm25Tool } from "./search-tool-bm25";
38
40
  import { loadSshTool } from "./ssh";
39
41
  import { SubmitResultTool } from "./submit-result";
40
42
  import { type TodoPhase, TodoWriteTool } from "./todo-write";
@@ -71,6 +73,7 @@ export * from "./read";
71
73
  export * from "./render-mermaid";
72
74
  export * from "./resolve";
73
75
  export * from "./review";
76
+ export * from "./search-tool-bm25";
74
77
  export * from "./ssh";
75
78
  export * from "./submit-result";
76
79
  export * from "./todo-write";
@@ -85,6 +88,8 @@ export type ContextFileEntry = {
85
88
  depth?: number;
86
89
  };
87
90
 
91
+ export type { DiscoverableMCPTool } from "../mcp/discoverable-tool-metadata";
92
+
88
93
  /** Session context for tool factories */
89
94
  export interface ToolSession {
90
95
  /** Current working directory */
@@ -147,6 +152,16 @@ export interface ToolSession {
147
152
  getTodoPhases?: () => TodoPhase[];
148
153
  /** Replace cached todo phases for this session. */
149
154
  setTodoPhases?: (phases: TodoPhase[]) => void;
155
+ /** Whether MCP tool discovery is active for this session. */
156
+ isMCPDiscoveryEnabled?: () => boolean;
157
+ /** Get hidden-but-discoverable MCP tools for search_tool_bm25 prompts and fallbacks. */
158
+ getDiscoverableMCPTools?: () => DiscoverableMCPTool[];
159
+ /** Get the cached discoverable MCP search index for search_tool_bm25 execution. */
160
+ getDiscoverableMCPSearchIndex?: () => DiscoverableMCPSearchIndex;
161
+ /** Get MCP tools activated by prior search_tool_bm25 calls. */
162
+ getSelectedMCPToolNames?: () => string[];
163
+ /** Merge MCP tool selections into the active session tool set. */
164
+ activateDiscoveredMCPTools?: (toolNames: string[]) => Promise<string[]>;
150
165
  /** Pending action store for preview/apply workflows */
151
166
  pendingActionStore?: import("./pending-action").PendingActionStore;
152
167
  /** Get active checkpoint state if any. */
@@ -182,6 +197,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
182
197
  todo_write: s => new TodoWriteTool(s),
183
198
  fetch: s => new FetchTool(s),
184
199
  web_search: s => new SearchTool(s),
200
+ search_tool_bm25: SearchToolBm25Tool.createIf,
185
201
  write: s => new WriteTool(s),
186
202
  };
187
203
 
@@ -319,6 +335,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
319
335
  if (name === "inspect_image") return session.settings.get("inspect_image.enabled");
320
336
  if (name === "fetch") return session.settings.get("fetch.enabled");
321
337
  if (name === "web_search") return session.settings.get("web_search.enabled");
338
+ if (name === "search_tool_bm25") return session.settings.get("mcp.discoveryMode");
322
339
  if (name === "lsp") return session.settings.get("lsp.enabled");
323
340
  if (name === "calc") return session.settings.get("calc.enabled");
324
341
  if (name === "browser") return session.settings.get("browser.enabled");
@@ -23,6 +23,7 @@ import { notebookToolRenderer } from "./notebook";
23
23
  import { pythonToolRenderer } from "./python";
24
24
  import { readToolRenderer } from "./read";
25
25
  import { resolveToolRenderer } from "./resolve";
26
+ import { searchToolBm25Renderer } from "./search-tool-bm25";
26
27
  import { sshToolRenderer } from "./ssh";
27
28
  import { todoWriteToolRenderer } from "./todo-write";
28
29
  import { writeToolRenderer } from "./write";
@@ -55,6 +56,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
55
56
  inspect_image: inspectImageToolRenderer as ToolRenderer,
56
57
  read: readToolRenderer as ToolRenderer,
57
58
  resolve: resolveToolRenderer as ToolRenderer,
59
+ search_tool_bm25: searchToolBm25Renderer as ToolRenderer,
58
60
  ssh: sshToolRenderer as ToolRenderer,
59
61
  task: taskToolRenderer as ToolRenderer,
60
62
  todo_write: todoWriteToolRenderer as ToolRenderer,
@@ -0,0 +1,278 @@
1
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
+ import { type Component, Text } from "@oh-my-pi/pi-tui";
3
+ import { type Static, Type } from "@sinclair/typebox";
4
+ import { renderPromptTemplate } from "../config/prompt-templates";
5
+ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
6
+ import {
7
+ buildDiscoverableMCPSearchIndex,
8
+ type DiscoverableMCPSearchIndex,
9
+ type DiscoverableMCPTool,
10
+ formatDiscoverableMCPToolServerSummary,
11
+ searchDiscoverableMCPTools,
12
+ summarizeDiscoverableMCPTools,
13
+ } from "../mcp/discoverable-tool-metadata";
14
+ import type { Theme } from "../modes/theme/theme";
15
+ import searchToolBm25Description from "../prompts/tools/search-tool-bm25.md" with { type: "text" };
16
+ import { renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
17
+ import type { ToolSession } from ".";
18
+ import { formatCount, replaceTabs, TRUNCATE_LENGTHS } from "./render-utils";
19
+ import { ToolError } from "./tool-errors";
20
+
21
+ const DEFAULT_LIMIT = 8;
22
+ const TOOL_DISCOVERY_TITLE = "Tool Discovery";
23
+ const COLLAPSED_MATCH_LIMIT = 5;
24
+ const MATCH_LABEL_LEN = 72;
25
+ const MATCH_DESCRIPTION_LEN = 96;
26
+
27
+ const searchToolBm25Schema = Type.Object({
28
+ query: Type.String({ description: "Search query for hidden MCP tool metadata" }),
29
+ limit: Type.Optional(
30
+ Type.Integer({ description: "Max matching tools to activate and return (default 8)", minimum: 1 }),
31
+ ),
32
+ });
33
+
34
+ type SearchToolBm25Params = Static<typeof searchToolBm25Schema>;
35
+
36
+ interface SearchToolBm25Match {
37
+ name: string;
38
+ label: string;
39
+ description: string;
40
+ server_name?: string;
41
+ mcp_tool_name?: string;
42
+ schema_keys: string[];
43
+ score: number;
44
+ }
45
+
46
+ export interface SearchToolBm25Details {
47
+ query: string;
48
+ limit: number;
49
+ total_tools: number;
50
+ activated_tools: string[];
51
+ active_selected_tools: string[];
52
+ tools: SearchToolBm25Match[];
53
+ }
54
+
55
+ function formatMatch(tool: DiscoverableMCPTool, score: number): SearchToolBm25Match {
56
+ return {
57
+ name: tool.name,
58
+ label: tool.label,
59
+ description: tool.description,
60
+ server_name: tool.serverName,
61
+ mcp_tool_name: tool.mcpToolName,
62
+ schema_keys: tool.schemaKeys,
63
+ score: Number(score.toFixed(6)),
64
+ };
65
+ }
66
+
67
+ function buildSearchToolBm25Content(details: SearchToolBm25Details): string {
68
+ return JSON.stringify({
69
+ query: details.query,
70
+ activated_tools: details.activated_tools,
71
+ match_count: details.tools.length,
72
+ total_tools: details.total_tools,
73
+ });
74
+ }
75
+
76
+ function getDiscoverableMCPToolsForDescription(session: ToolSession): DiscoverableMCPTool[] {
77
+ try {
78
+ return session.getDiscoverableMCPTools?.() ?? [];
79
+ } catch {
80
+ return [];
81
+ }
82
+ }
83
+
84
+ function getDiscoverableMCPSearchIndexForExecution(session: ToolSession): DiscoverableMCPSearchIndex {
85
+ try {
86
+ const cached = session.getDiscoverableMCPSearchIndex?.();
87
+ if (cached) return cached;
88
+ } catch {}
89
+ return buildDiscoverableMCPSearchIndex(session.getDiscoverableMCPTools?.() ?? []);
90
+ }
91
+
92
+ type MCPDiscoveryExecutionSession = ToolSession & {
93
+ isMCPDiscoveryEnabled: () => boolean;
94
+ getSelectedMCPToolNames: () => string[];
95
+ activateDiscoveredMCPTools: (toolNames: string[]) => Promise<string[]>;
96
+ };
97
+
98
+ function supportsMCPToolDiscoveryExecution(session: ToolSession): session is MCPDiscoveryExecutionSession {
99
+ return (
100
+ typeof session.isMCPDiscoveryEnabled === "function" &&
101
+ typeof session.getSelectedMCPToolNames === "function" &&
102
+ typeof session.activateDiscoveredMCPTools === "function"
103
+ );
104
+ }
105
+
106
+ export function renderSearchToolBm25Description(discoverableTools: DiscoverableMCPTool[] = []): string {
107
+ const summary = summarizeDiscoverableMCPTools(discoverableTools);
108
+ return renderPromptTemplate(searchToolBm25Description, {
109
+ discoverableMCPToolCount: summary.toolCount,
110
+ discoverableMCPServerSummaries: summary.servers.map(formatDiscoverableMCPToolServerSummary),
111
+ hasDiscoverableMCPServers: summary.servers.length > 0,
112
+ });
113
+ }
114
+
115
+ function renderMatchLines(match: SearchToolBm25Match, theme: Theme): string[] {
116
+ const safeServerName = match.server_name ? replaceTabs(match.server_name) : undefined;
117
+ const safeLabel = replaceTabs(match.label);
118
+ const safeDescription = replaceTabs(match.description.trim());
119
+ const metaParts: string[] = [];
120
+ if (safeServerName) metaParts.push(theme.fg("muted", safeServerName));
121
+ metaParts.push(theme.fg("dim", `score ${match.score.toFixed(3)}`));
122
+ const metaSep = theme.fg("dim", theme.sep.dot);
123
+ const metaSuffix = metaParts.length > 0 ? ` ${metaParts.join(metaSep)}` : "";
124
+ const lines = [`${theme.fg("accent", truncateToWidth(safeLabel, MATCH_LABEL_LEN))}${metaSuffix}`];
125
+ if (safeDescription) {
126
+ lines.push(theme.fg("muted", truncateToWidth(safeDescription, MATCH_DESCRIPTION_LEN)));
127
+ }
128
+ return lines;
129
+ }
130
+
131
+ function renderFallbackResult(text: string, theme: Theme): Component {
132
+ const header = renderStatusLine({ icon: "warning", title: TOOL_DISCOVERY_TITLE }, theme);
133
+ const bodyLines = (text || "Tool discovery completed")
134
+ .split("\n")
135
+ .map(line => theme.fg("dim", truncateToWidth(replaceTabs(line), TRUNCATE_LENGTHS.LINE)));
136
+ return new Text([header, ...bodyLines].join("\n"), 0, 0);
137
+ }
138
+
139
+ export class SearchToolBm25Tool implements AgentTool<typeof searchToolBm25Schema, SearchToolBm25Details> {
140
+ readonly name = "search_tool_bm25";
141
+ readonly label = "SearchToolBm25";
142
+ get description(): string {
143
+ return renderSearchToolBm25Description(getDiscoverableMCPToolsForDescription(this.session));
144
+ }
145
+ readonly parameters = searchToolBm25Schema;
146
+ readonly strict = true;
147
+
148
+ constructor(private readonly session: ToolSession) {}
149
+
150
+ static createIf(session: ToolSession): SearchToolBm25Tool | null {
151
+ if (!session.settings.get("mcp.discoveryMode")) return null;
152
+ return supportsMCPToolDiscoveryExecution(session) ? new SearchToolBm25Tool(session) : null;
153
+ }
154
+
155
+ async execute(
156
+ _toolCallId: string,
157
+ params: SearchToolBm25Params,
158
+ _signal?: AbortSignal,
159
+ _onUpdate?: AgentToolUpdateCallback<SearchToolBm25Details>,
160
+ _context?: AgentToolContext,
161
+ ): Promise<AgentToolResult<SearchToolBm25Details>> {
162
+ if (!supportsMCPToolDiscoveryExecution(this.session)) {
163
+ throw new ToolError("MCP tool discovery is unavailable in this session.");
164
+ }
165
+ if (!this.session.isMCPDiscoveryEnabled()) {
166
+ throw new ToolError("MCP tool discovery is disabled. Enable mcp.discoveryMode to use search_tool_bm25.");
167
+ }
168
+
169
+ const query = params.query.trim();
170
+ if (query.length === 0) {
171
+ throw new ToolError("Query is required and must not be empty.");
172
+ }
173
+ const limit = params.limit ?? DEFAULT_LIMIT;
174
+ if (!Number.isInteger(limit) || limit <= 0) {
175
+ throw new ToolError("Limit must be a positive integer.");
176
+ }
177
+
178
+ const searchIndex = getDiscoverableMCPSearchIndexForExecution(this.session);
179
+ const selectedToolNames = new Set(this.session.getSelectedMCPToolNames());
180
+ let ranked: Array<{ tool: DiscoverableMCPTool; score: number }> = [];
181
+ try {
182
+ ranked = searchDiscoverableMCPTools(searchIndex, query, searchIndex.documents.length)
183
+ .filter(result => !selectedToolNames.has(result.tool.name))
184
+ .slice(0, limit);
185
+ } catch (error) {
186
+ if (error instanceof Error) {
187
+ throw new ToolError(error.message);
188
+ }
189
+ throw error;
190
+ }
191
+ const activated =
192
+ ranked.length > 0 ? await this.session.activateDiscoveredMCPTools(ranked.map(result => result.tool.name)) : [];
193
+
194
+ const details: SearchToolBm25Details = {
195
+ query,
196
+ limit,
197
+ total_tools: searchIndex.documents.length,
198
+ activated_tools: activated,
199
+ active_selected_tools: this.session.getSelectedMCPToolNames(),
200
+ tools: ranked.map(result => formatMatch(result.tool, result.score)),
201
+ };
202
+
203
+ return {
204
+ content: [{ type: "text", text: buildSearchToolBm25Content(details) }],
205
+ details,
206
+ };
207
+ }
208
+ }
209
+
210
+ export const searchToolBm25Renderer = {
211
+ renderCall(args: SearchToolBm25Params, _options: RenderResultOptions, uiTheme: Theme): Component {
212
+ const query = typeof args.query === "string" ? replaceTabs(args.query.trim()) : "";
213
+ const meta = args.limit ? [`limit:${args.limit}`] : [];
214
+ return new Text(
215
+ renderStatusLine(
216
+ { icon: "pending", title: TOOL_DISCOVERY_TITLE, description: query || "(empty query)", meta },
217
+ uiTheme,
218
+ ),
219
+ 0,
220
+ 0,
221
+ );
222
+ },
223
+
224
+ renderResult(
225
+ result: { content: Array<{ type: string; text?: string }>; details?: SearchToolBm25Details; isError?: boolean },
226
+ options: RenderResultOptions,
227
+ uiTheme: Theme,
228
+ ): Component {
229
+ if (!result.details) {
230
+ const fallbackText = result.content
231
+ .filter(part => part.type === "text")
232
+ .map(part => part.text)
233
+ .filter((text): text is string => typeof text === "string" && text.length > 0)
234
+ .join("\n");
235
+ return renderFallbackResult(fallbackText, uiTheme);
236
+ }
237
+
238
+ const { details } = result;
239
+ const meta = [
240
+ formatCount("match", details.tools.length),
241
+ `${details.active_selected_tools.length} active`,
242
+ `${details.total_tools} total`,
243
+ `limit:${details.limit}`,
244
+ ];
245
+ const safeQuery = replaceTabs(details.query);
246
+ const header = renderStatusLine(
247
+ {
248
+ icon: details.tools.length > 0 ? "success" : "warning",
249
+ title: TOOL_DISCOVERY_TITLE,
250
+ description: truncateToWidth(safeQuery, MATCH_LABEL_LEN),
251
+ meta,
252
+ },
253
+ uiTheme,
254
+ );
255
+ if (details.tools.length === 0) {
256
+ const emptyMessage =
257
+ details.total_tools === 0 ? "No discoverable MCP tools are currently loaded." : "No matching tools found.";
258
+ return new Text(`${header}\n${uiTheme.fg("muted", emptyMessage)}`, 0, 0);
259
+ }
260
+
261
+ const lines = [header];
262
+ const treeLines = renderTreeList(
263
+ {
264
+ items: details.tools,
265
+ expanded: options.expanded,
266
+ maxCollapsed: COLLAPSED_MATCH_LIMIT,
267
+ itemType: "tool",
268
+ renderItem: match => renderMatchLines(match, uiTheme),
269
+ },
270
+ uiTheme,
271
+ );
272
+ lines.push(...treeLines);
273
+ return new Text(lines.join("\n"), 0, 0);
274
+ },
275
+
276
+ mergeCallAndResult: true,
277
+ inline: true,
278
+ };