@@ -16,6 +16,7 @@ import (
1616
1717 "github.com/joho/godotenv"
1818 "github.com/kagent-dev/tools/internal/logger"
19+ mcpserver "github.com/kagent-dev/tools/internal/mcp"
1920 "github.com/kagent-dev/tools/internal/metrics"
2021 "github.com/kagent-dev/tools/internal/telemetry"
2122 "github.com/kagent-dev/tools/internal/version"
@@ -33,8 +34,7 @@ import (
3334 "go.opentelemetry.io/otel/attribute"
3435 "go.opentelemetry.io/otel/codes"
3536
36- "github.com/mark3labs/mcp-go/mcp"
37- "github.com/mark3labs/mcp-go/server"
37+ sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
3838)
3939
4040var (
@@ -140,16 +140,14 @@ func run(cmd *cobra.Command, args []string) {
140140 logger .Get ().Info ("Running in read-only mode - write operations are disabled" )
141141 }
142142
143- mcp := server .NewMCPServer (
144- Name ,
145- Version ,
146- )
143+ mcpSrv := sdkmcp .NewServer (& sdkmcp.Implementation {Name : Name , Version : Version }, nil )
144+
145+ // Attach a single receiving middleware that instruments every tools/call
146+ // with an OTel span and Prometheus invocation counters. Per-tool provider
147+ // labels are recorded as each provider registers its tools.
148+ mcpSrv .AddReceivingMiddleware (mcpserver .ToolMiddleware ())
147149
148- // Register tools and wrap handlers with metrics instrumentation.
149- // registerMCP returns a map of tool_name -> tool_provider so that
150- // wrapToolHandlersWithMetrics knows which provider each tool belongs to.
151- toolProviders := registerMCP (mcp , tools , * kubeconfig , readOnly )
152- wrapToolHandlersWithMetrics (mcp , toolProviders )
150+ registerMCP (mcpSrv , tools , * kubeconfig , readOnly )
153151
154152 // Create wait group for server goroutines
155153 var wg sync.WaitGroup
@@ -167,11 +165,12 @@ func run(cmd *cobra.Command, args []string) {
167165 if stdio {
168166 go func () {
169167 defer wg .Done ()
170- runStdioServer (ctx , mcp )
168+ runStdioServer (ctx , mcpSrv )
171169 }()
172170 } else {
173- sseServer := server .NewStreamableHTTPServer (mcp ,
174- server .WithHeartbeatInterval (30 * time .Second ),
171+ sseServer := sdkmcp .NewStreamableHTTPHandler (
172+ func (* http.Request ) * sdkmcp.Server { return mcpSrv },
173+ nil ,
175174 )
176175
177176 // Create a mux to handle different routes
@@ -293,29 +292,27 @@ func writeResponse(w http.ResponseWriter, data []byte) error {
293292 return err
294293}
295294
296- func runStdioServer (ctx context.Context , mcp * server. MCPServer ) {
295+ func runStdioServer (ctx context.Context , mcpSrv * sdkmcp. Server ) {
297296 logger .Get ().Info ("Running KAgent Tools Server STDIO:" , "tools" , strings .Join (tools , "," ))
298- stdioServer := server .NewStdioServer (mcp )
299- if err := stdioServer .Listen (ctx , os .Stdin , os .Stdout ); err != nil {
297+ if err := mcpSrv .Run (ctx , & sdkmcp.StdioTransport {}); err != nil {
300298 logger .Get ().Info ("Stdio server stopped" , "error" , err )
301299 }
302300}
303301
304- // registerMCP registers tool providers with the MCP server and returns a mapping
305- // of tool_name -> tool_provider. This mapping is built using the ListTools() diff
306- // technique: we snapshot the tool list before and after each provider registers,
307- // so we know exactly which tools belong to which provider.
308- func registerMCP (mcp * server.MCPServer , enabledToolProviders []string , kubeconfig string , readOnly bool ) map [string ]string {
309- // A map to hold tool providers and their registration functions
310- toolProviderMap := map [string ]func (* server.MCPServer ){
311- "argo" : func (s * server.MCPServer ) { argo .RegisterTools (s , readOnly ) },
312- "cilium" : func (s * server.MCPServer ) { cilium .RegisterTools (s , readOnly ) },
313- "helm" : func (s * server.MCPServer ) { helm .RegisterTools (s , readOnly ) },
314- "istio" : func (s * server.MCPServer ) { istio .RegisterTools (s , readOnly ) },
315- "k8s" : func (s * server.MCPServer ) { k8s .RegisterTools (s , nil , kubeconfig , readOnly ) },
316- "kubescape" : func (s * server.MCPServer ) { kubescape .RegisterTools (s , kubeconfig , readOnly ) },
317- "prometheus" : func (s * server.MCPServer ) { prometheus .RegisterTools (s , readOnly ) },
318- "utils" : func (s * server.MCPServer ) { utils .RegisterTools (s , readOnly ) },
302+ // registerMCP registers the enabled tool providers with the MCP server. Each
303+ // provider's RegisterTools call records tool->provider mappings and the tool
304+ // inventory metric centrally (see internal/mcp.AddTool); invocation metrics and
305+ // tracing are applied by the receiving middleware installed in run().
306+ func registerMCP (mcpSrv * sdkmcp.Server , enabledToolProviders []string , kubeconfig string , readOnly bool ) {
307+ toolProviderMap := map [string ]func (* sdkmcp.Server ){
308+ "argo" : func (s * sdkmcp.Server ) { argo .RegisterTools (s , readOnly ) },
309+ "cilium" : func (s * sdkmcp.Server ) { cilium .RegisterTools (s , readOnly ) },
310+ "helm" : func (s * sdkmcp.Server ) { helm .RegisterTools (s , readOnly ) },
311+ "istio" : func (s * sdkmcp.Server ) { istio .RegisterTools (s , readOnly ) },
312+ "k8s" : func (s * sdkmcp.Server ) { k8s .RegisterTools (s , nil , kubeconfig , readOnly ) },
313+ "kubescape" : func (s * sdkmcp.Server ) { kubescape .RegisterTools (s , kubeconfig , readOnly ) },
314+ "prometheus" : func (s * sdkmcp.Server ) { prometheus .RegisterTools (s , readOnly ) },
315+ "utils" : func (s * sdkmcp.Server ) { utils .RegisterTools (s , readOnly ) },
319316 }
320317
321318 // If no specific tools are specified, register all available tools.
@@ -325,82 +322,11 @@ func registerMCP(mcp *server.MCPServer, enabledToolProviders []string, kubeconfi
325322 }
326323 }
327324
328- // toolToProvider maps each tool name to its provider (e.g., "kubectl_get" -> "k8s").
329- // This is used later by wrapToolHandlersWithMetrics to set the correct tool_provider label.
330- toolToProvider := make (map [string ]string )
331-
332325 for _ , toolProviderName := range enabledToolProviders {
333326 if registerFunc , ok := toolProviderMap [toolProviderName ]; ok {
334- // Snapshot the tool list before this provider registers its tools.
335- // We need this because ListTools() returns ALL tools from ALL providers,
336- // so the only way to know which tools belong to THIS provider is to compare
337- // the list before and after registration.
338- toolsBefore := mcp .ListTools ()
339-
340- registerFunc (mcp )
341-
342- // Determine which tools were just registered by this provider
343- // by finding tools that exist now but didn't exist before.
344- // Record each one in Prometheus so we can observe the full tool inventory.
345- for toolName := range mcp .ListTools () {
346- if _ , existed := toolsBefore [toolName ]; ! existed {
347- metrics .KagentToolsMCPRegisteredTools .WithLabelValues (toolName , toolProviderName ).Set (1 )
348- toolToProvider [toolName ] = toolProviderName
349- }
350- }
327+ registerFunc (mcpSrv )
351328 } else {
352329 logger .Get ().Error ("Unknown tool specified" , "provider" , toolProviderName )
353330 }
354331 }
355-
356- return toolToProvider
357- }
358-
359- // wrapToolHandlersWithMetrics applies the wrapper/middleware pattern to instrument
360- // all registered MCP tool handlers with Prometheus invocation counters.
361- //
362- // How it works:
363- // 1. Grab all registered tools from the MCP server using ListTools()
364- // 2. For each tool, wrap its handler with a function that increments metrics
365- // 3. Replace all tools in the MCP server using SetTools()
366- //
367- // The wrapper function:
368- // - Increments kagent_tools_mcp_invocations_total on every call
369- // - Increments kagent_tools_mcp_invocations_failure_total when the handler returns a
370- // non-nil Go error OR when result.IsError is true (the MCP convention for tool-level
371- // failures - handlers return NewToolResultError(...), nil, not a Go error)
372- // - Calls the original handler unchanged - the tool's behaviour is not affected
373- //
374- // This uses the standard middleware/decorator pattern: the original handler and the
375- // wrapped handler have the same function signature, so they are interchangeable.
376- // No changes are required in any pkg/ file - all instrumentation happens centrally here.
377- func wrapToolHandlersWithMetrics (mcpServer * server.MCPServer , toolToProvider map [string ]string ) {
378- allTools := mcpServer .ListTools ()
379- wrapped := make ([]server.ServerTool , 0 , len (allTools ))
380-
381- for name , st := range allTools {
382- originalHandler := st .Handler
383- toolName := name // capture for closure
384- provider := toolToProvider [toolName ]
385-
386- wrapped = append (wrapped , server.ServerTool {
387- Tool : st .Tool ,
388- Handler : func (ctx context.Context , req mcp.CallToolRequest ) (* mcp.CallToolResult , error ) {
389- metrics .KagentToolsMCPInvocationsTotal .WithLabelValues (toolName , provider ).Inc ()
390-
391- result , err := originalHandler (ctx , req )
392-
393- // Count as failure if the Go error is non-nil OR if the tool returned
394- // a result with IsError=true (the MCP convention for tool-level failures,
395- // which always return nil for the Go error).
396- if err != nil || (result != nil && result .IsError ) {
397- metrics .KagentToolsMCPInvocationsFailureTotal .WithLabelValues (toolName , provider ).Inc ()
398- }
399-
400- return result , err
401- },
402- })
403- }
404-
405- mcpServer .SetTools (wrapped ... )
406332}
0 commit comments