Skip to content

Commit add42aa

Browse files
committed
feat: give each routing endpoint its own focused tool set
/mcp/code now only exposes code_execution + retrieve_tools (with instructions to use code_execution, not call_tool_*). /mcp/call now has a dedicated server with retrieve_tools (with instructions to use call_tool_read/write/destructive) + the three call_tool variants + read_cache. No code_execution, upstream_servers, or other tools that don't belong. Previously /mcp/call served the full default server with all tools. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f204be8 commit add42aa

4 files changed

Lines changed: 171 additions & 6 deletions

File tree

internal/server/mcp.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ type MCPProxyServer struct {
7575
// Each instance has different tools registered for its routing mode.
7676
directServer *mcpserver.MCPServer // Direct mode: upstream tools with serverName__toolName naming
7777
codeExecServer *mcpserver.MCPServer // Code execution mode: code_execution + retrieve_tools
78+
callToolServer *mcpserver.MCPServer // Call tool mode: retrieve_tools + call_tool_read/write/destructive
7879

7980
// Docker availability cache
8081
dockerAvailableCache *bool

internal/server/mcp_routing.go

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,9 +264,12 @@ func (p *MCPProxyServer) buildCodeExecModeTools() []mcpserver.ServerTool {
264264
})
265265
}
266266

267-
// retrieve_tools for discovery
267+
// retrieve_tools for discovery — instructs to use code_execution (NOT call_tool_*)
268268
retrieveToolsTool := mcp.NewTool("retrieve_tools",
269-
mcp.WithDescription("Search and discover available upstream tools using BM25 full-text search. Use this to find tools before orchestrating them with code_execution. Use natural language to describe what you want to accomplish."),
269+
mcp.WithDescription("Search and discover available upstream tools using BM25 full-text search. "+
270+
"Use this to find tools, then use the `code_execution` tool to call them via `call_tool(serverName, toolName, args)` in JavaScript. "+
271+
"Do NOT use call_tool_read/write/destructive — they are not available in this mode. "+
272+
"Use natural language to describe what you want to accomplish."),
270273
mcp.WithTitleAnnotation("Retrieve Tools"),
271274
mcp.WithReadOnlyHintAnnotation(true),
272275
mcp.WithString("query",
@@ -288,6 +291,142 @@ func (p *MCPProxyServer) buildCodeExecModeTools() []mcpserver.ServerTool {
288291
return tools
289292
}
290293

294+
// buildCallToolModeTools builds the tool set for retrieve_tools routing mode (/mcp/call).
295+
// Includes: retrieve_tools (with call_tool_* instructions) + call_tool_read/write/destructive + read_cache.
296+
// Does NOT include code_execution or upstream_servers.
297+
func (p *MCPProxyServer) buildCallToolModeTools() []mcpserver.ServerTool {
298+
tools := make([]mcpserver.ServerTool, 0, 5)
299+
300+
// retrieve_tools — instructs to use call_tool_read/write/destructive
301+
retrieveToolsTool := mcp.NewTool("retrieve_tools",
302+
mcp.WithDescription("Search and discover available upstream tools using BM25 full-text search. "+
303+
"WORKFLOW: 1) Call this tool first to find relevant tools, 2) Check the 'call_with' field in results "+
304+
"to determine which variant to use, 3) Call the tool using call_tool_read, call_tool_write, or call_tool_destructive. "+
305+
"Results include 'annotations' (tool behavior hints like destructiveHint) and 'call_with' recommendation. "+
306+
"Use natural language to describe what you want to accomplish."),
307+
mcp.WithTitleAnnotation("Retrieve Tools"),
308+
mcp.WithReadOnlyHintAnnotation(true),
309+
mcp.WithString("query",
310+
mcp.Required(),
311+
mcp.Description("Natural language description of what you want to accomplish. Be specific (e.g., 'create a new GitHub repository', 'get weather for London')."),
312+
),
313+
mcp.WithNumber("limit",
314+
mcp.Description("Maximum number of tools to return (default: configured tools_limit, max: 100)"),
315+
),
316+
mcp.WithBoolean("include_stats",
317+
mcp.Description("Include usage statistics for returned tools (default: false)"),
318+
),
319+
mcp.WithBoolean("debug",
320+
mcp.Description("Enable debug mode with detailed scoring and ranking explanations (default: false)"),
321+
),
322+
mcp.WithString("explain_tool",
323+
mcp.Description("When debug=true, explain why a specific tool was ranked low (format: 'server:tool')"),
324+
),
325+
)
326+
tools = append(tools, mcpserver.ServerTool{
327+
Tool: retrieveToolsTool,
328+
Handler: p.handleRetrieveTools,
329+
})
330+
331+
// call_tool_read
332+
callToolReadTool := mcp.NewTool(contracts.ToolVariantRead,
333+
mcp.WithDescription("Execute a READ-ONLY tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. Use this for: search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. This is the DEFAULT choice when unsure."),
334+
mcp.WithTitleAnnotation("Call Tool (Read)"),
335+
mcp.WithReadOnlyHintAnnotation(true),
336+
mcp.WithString("name",
337+
mcp.Required(),
338+
mcp.Description("Tool name in format 'server:tool' (e.g., 'github:get_user'). Use exact names from retrieve_tools results."),
339+
),
340+
mcp.WithString("args_json",
341+
mcp.Description("Arguments as JSON string. Refer to the tool's inputSchema from retrieve_tools."),
342+
),
343+
mcp.WithString("intent_data_sensitivity",
344+
mcp.Description("Classify data: public, internal, private, or unknown."),
345+
),
346+
mcp.WithString("intent_reason",
347+
mcp.Description("Why is this tool being called? Provide context."),
348+
),
349+
)
350+
tools = append(tools, mcpserver.ServerTool{
351+
Tool: callToolReadTool,
352+
Handler: p.handleCallToolRead,
353+
})
354+
355+
// call_tool_write
356+
callToolWriteTool := mcp.NewTool(contracts.ToolVariantWrite,
357+
mcp.WithDescription("Execute a STATE-MODIFYING tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. Use this for: create, update, modify, add, set, send, edit, change, write, post, put, patch, insert, upload, submit. Use only when explicitly modifying state."),
358+
mcp.WithTitleAnnotation("Call Tool (Write)"),
359+
mcp.WithDestructiveHintAnnotation(false),
360+
mcp.WithString("name",
361+
mcp.Required(),
362+
mcp.Description("Tool name in format 'server:tool' (e.g., 'github:create_issue'). Use exact names from retrieve_tools results."),
363+
),
364+
mcp.WithString("args_json",
365+
mcp.Description("Arguments as JSON string. Refer to the tool's inputSchema from retrieve_tools."),
366+
),
367+
mcp.WithString("intent_data_sensitivity",
368+
mcp.Description("Classify data: public, internal, private, or unknown."),
369+
),
370+
mcp.WithString("intent_reason",
371+
mcp.Description("Why is this modification needed? Provide context."),
372+
),
373+
)
374+
tools = append(tools, mcpserver.ServerTool{
375+
Tool: callToolWriteTool,
376+
Handler: p.handleCallToolWrite,
377+
})
378+
379+
// call_tool_destructive
380+
callToolDestructiveTool := mcp.NewTool(contracts.ToolVariantDestructive,
381+
mcp.WithDescription("Execute a DESTRUCTIVE tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results. Use this for: delete, remove, drop, revoke, disable, destroy, purge, reset, clear, terminate. Use for irreversible or high-impact operations."),
382+
mcp.WithTitleAnnotation("Call Tool (Destructive)"),
383+
mcp.WithDestructiveHintAnnotation(true),
384+
mcp.WithString("name",
385+
mcp.Required(),
386+
mcp.Description("Tool name in format 'server:tool' (e.g., 'github:delete_repo'). Use exact names from retrieve_tools results."),
387+
),
388+
mcp.WithString("args_json",
389+
mcp.Description("Arguments as JSON string. Refer to the tool's inputSchema from retrieve_tools."),
390+
),
391+
mcp.WithString("intent_data_sensitivity",
392+
mcp.Description("Classify data: public, internal, private, or unknown."),
393+
),
394+
mcp.WithString("intent_reason",
395+
mcp.Description("Why is this deletion needed? Provide justification."),
396+
),
397+
)
398+
tools = append(tools, mcpserver.ServerTool{
399+
Tool: callToolDestructiveTool,
400+
Handler: p.handleCallToolDestructive,
401+
})
402+
403+
// read_cache for paginated responses
404+
readCacheTool := mcp.NewTool("read_cache",
405+
mcp.WithDescription("Retrieve paginated data when mcpproxy indicates a tool response was truncated. Use the cache key provided in truncation messages."),
406+
mcp.WithTitleAnnotation("Read Cache"),
407+
mcp.WithReadOnlyHintAnnotation(true),
408+
mcp.WithString("key",
409+
mcp.Required(),
410+
mcp.Description("Cache key provided by mcpproxy when a response was truncated."),
411+
),
412+
mcp.WithNumber("offset",
413+
mcp.Description("Starting record offset for pagination (default: 0)"),
414+
),
415+
mcp.WithNumber("limit",
416+
mcp.Description("Maximum number of records to return per page (default: 50, max: 1000)"),
417+
),
418+
)
419+
tools = append(tools, mcpserver.ServerTool{
420+
Tool: readCacheTool,
421+
Handler: p.handleReadCache,
422+
})
423+
424+
p.logger.Info("built call tool mode tools",
425+
zap.Int("tool_count", len(tools)))
426+
427+
return tools
428+
}
429+
291430
// buildToolCatalogDescription builds a human-readable catalog of available tools for the code_execution description.
292431
func (p *MCPProxyServer) buildToolCatalogDescription(ctx context.Context) string {
293432
tools, err := p.upstreamManager.DiscoverTools(ctx)
@@ -340,12 +479,26 @@ func (p *MCPProxyServer) initRoutingModeServers() {
340479
mcpserver.WithRecovery(),
341480
)
342481

482+
// Create call tool mode server (/mcp/call)
483+
p.callToolServer = mcpserver.NewMCPServer(
484+
"mcpproxy-go",
485+
"1.0.0",
486+
mcpserver.WithToolCapabilities(true),
487+
mcpserver.WithRecovery(),
488+
)
489+
343490
// Register tools for code execution mode (static tools that don't change)
344491
codeExecTools := p.buildCodeExecModeTools()
345492
for _, st := range codeExecTools {
346493
p.codeExecServer.AddTool(st.Tool, st.Handler)
347494
}
348495

496+
// Register tools for call tool mode
497+
callToolModeTools := p.buildCallToolModeTools()
498+
for _, st := range callToolModeTools {
499+
p.callToolServer.AddTool(st.Tool, st.Handler)
500+
}
501+
349502
// Note: Direct mode tools are built lazily/on-demand via RefreshDirectModeTools
350503
// because upstream servers may not be connected yet during initialization.
351504
// The servers.changed event will trigger a refresh.
@@ -403,6 +556,10 @@ func (p *MCPProxyServer) GetMCPServerForMode(mode string) *mcpserver.MCPServer {
403556
if p.codeExecServer != nil {
404557
return p.codeExecServer
405558
}
559+
case config.RoutingModeRetrieveTools:
560+
if p.callToolServer != nil {
561+
return p.callToolServer
562+
}
406563
}
407564
// Default: retrieve_tools mode (the original server)
408565
return p.server
@@ -417,3 +574,8 @@ func (p *MCPProxyServer) GetDirectServer() *mcpserver.MCPServer {
417574
func (p *MCPProxyServer) GetCodeExecServer() *mcpserver.MCPServer {
418575
return p.codeExecServer
419576
}
577+
578+
// GetCallToolServer returns the call tool mode MCP server instance.
579+
func (p *MCPProxyServer) GetCallToolServer() *mcpserver.MCPServer {
580+
return p.callToolServer
581+
}

internal/server/mcp_routing_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,20 +165,22 @@ func TestGetMCPServerForMode(t *testing.T) {
165165
mainServer := mcpserver.NewMCPServer("main", "1.0.0", mcpserver.WithToolCapabilities(true))
166166
directServer := mcpserver.NewMCPServer("direct", "1.0.0", mcpserver.WithToolCapabilities(true))
167167
codeExecServer := mcpserver.NewMCPServer("code_exec", "1.0.0", mcpserver.WithToolCapabilities(true))
168+
callToolServer := mcpserver.NewMCPServer("call_tool", "1.0.0", mcpserver.WithToolCapabilities(true))
168169

169170
proxy.server = mainServer
170171
proxy.directServer = directServer
171172
proxy.codeExecServer = codeExecServer
173+
proxy.callToolServer = callToolServer
172174

173175
tests := []struct {
174176
name string
175177
mode string
176178
expected *mcpserver.MCPServer
177179
}{
178180
{
179-
name: "retrieve_tools returns main server",
181+
name: "retrieve_tools returns call tool server",
180182
mode: "retrieve_tools",
181-
expected: mainServer,
183+
expected: callToolServer,
182184
},
183185
{
184186
name: "direct returns direct server",

internal/server/server.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1511,8 +1511,8 @@ func (s *Server) startCustomHTTPServer(ctx context.Context, streamableServer *se
15111511
mux.Handle("/mcp/code", codeExecHandler)
15121512
mux.Handle("/mcp/code/", codeExecHandler)
15131513

1514-
// /mcp/call → retrieve_tools mode (explicit, same as default server)
1515-
callToolStreamable := server.NewStreamableHTTPServer(s.mcpProxy.GetMCPServer())
1514+
// /mcp/call → retrieve_tools mode (focused: retrieve_tools + call_tool_read/write/destructive)
1515+
callToolStreamable := server.NewStreamableHTTPServer(s.mcpProxy.GetMCPServerForMode(config.RoutingModeRetrieveTools))
15161516
callToolHandler := s.mcpAuthMiddleware(loggingHandler(callToolStreamable))
15171517
mux.Handle("/mcp/call", callToolHandler)
15181518
mux.Handle("/mcp/call/", callToolHandler)

0 commit comments

Comments
 (0)