Skip to content

ExposedMcpTools — expose your toolkit over MCP

Go · package github.com/muthuishere/toolnexus/golang · SPEC §7C · golang/mcpserve.go

type MCPServeConfig struct {
Name string // advertised server name, default "toolnexus"
Version string // advertised server version, default "0.1.0"
Tools []string // filter; nil ⇒ all, unknown names ignored
}
func ExposedMcpTools(tools []Tool, cfg *MCPServeConfig) []Tool
// mount it: independent of A2A, co-located on the same Serve() server
tk.Serve(addr, toolnexus.ServeOptions{MCP: &toolnexus.MCPServeConfig{...}})

Where Toolkit.Serve’s A2A profile advertises skills and fulfils a whole Task through the client loop, the MCP profile advertises the toolkit’s unified tools — every source (mcp · skill · native · http · builtin · a2a) — and dispatches each inbound tools/call straight to Tool.Execute. ExposedMcpTools is the filtering step: it decides which of the toolkit’s tools actually reach tools/list.

Reach for the MCP profile when the caller is itself an MCP client (Claude Desktop, an IDE, another agent’s MCP-speaking host) rather than an A2A peer — Toolkit.Serve turns your aggregated toolkit (N MCP servers + skills + your own tools) into one universal MCP gateway. Call ExposedMcpTools directly when you want the filtered list itself, decoupled from a running server.

1. The filter itself — omit, narrow, unknown names ignored

Section titled “1. The filter itself — omit, narrow, unknown names ignored”
package main
import (
"context"
"fmt"
"log"
toolnexus "github.com/muthuishere/toolnexus/golang"
)
func mkTool(name string) toolnexus.Tool {
return toolnexus.NativeTool(name, "does "+name,
toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}},
func(_ context.Context, _ map[string]any) (string, error) { return name, nil },
)
}
func main() {
tools := []toolnexus.Tool{mkTool("echo"), mkTool("boom")}
all := toolnexus.ExposedMcpTools(tools, nil)
if len(all) != 2 {
log.Fatalf("nil cfg should expose everything, got %d", len(all))
}
narrowed := toolnexus.ExposedMcpTools(tools, &toolnexus.MCPServeConfig{Tools: []string{"echo"}})
if len(narrowed) != 1 || narrowed[0].Name != "echo" {
log.Fatalf("expected only echo, got %+v", narrowed)
}
// An unknown name in the filter is ignored, never an error.
stillOne := toolnexus.ExposedMcpTools(tools, &toolnexus.MCPServeConfig{Tools: []string{"echo", "nope"}})
if len(stillOne) != 1 {
log.Fatalf("unknown filter names should be silently dropped, got %d", len(stillOne))
}
fmt.Println("ok:", len(all), "->", len(narrowed), "tools after filtering")
}

2. The realistic case — a real MCP client, over real streamable-HTTP

Section titled “2. The realistic case — a real MCP client, over real streamable-HTTP”

Two independent processes in spirit: a served toolkit, and a real mark3labs/mcp-go MCP client connecting to it over POST /mcp on a local ephemeral port — no external network.

package main
import (
"context"
"fmt"
"log"
mcpclient "github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/mcp"
toolnexus "github.com/muthuishere/toolnexus/golang"
)
func main() {
echo := toolnexus.NativeTool("echo", "echo back the text",
toolnexus.JSONSchema{"type": "object", "properties": map[string]any{"text": map[string]any{"type": "string"}}, "required": []string{"text"}},
func(_ context.Context, args map[string]any) (string, error) {
return fmt.Sprintf("%v", args["text"]), nil
},
)
tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false, ExtraTools: []toolnexus.Tool{echo}})
if err != nil {
log.Fatal(err)
}
defer tk.Close()
handle, err := tk.Serve("127.0.0.1:0", toolnexus.ServeOptions{MCP: &toolnexus.MCPServeConfig{Name: "gateway"}})
if err != nil {
log.Fatal(err)
}
defer handle.Stop()
c, err := mcpclient.NewStreamableHttpClient(handle.URL + "/mcp")
if err != nil {
log.Fatal(err)
}
defer c.Close()
if err := c.Start(context.Background()); err != nil {
log.Fatal(err)
}
initRes, err := c.Initialize(context.Background(), mcp.InitializeRequest{})
if err != nil {
log.Fatal(err)
}
if initRes.ServerInfo.Name != "gateway" {
log.Fatalf("expected serverInfo.name = gateway, got %q", initRes.ServerInfo.Name)
}
req := mcp.CallToolRequest{}
req.Params.Name = "echo"
req.Params.Arguments = map[string]any{"text": "over mcp"}
res, err := c.CallTool(context.Background(), req)
if err != nil {
log.Fatal(err)
}
text := res.Content[0].(mcp.TextContent).Text
if text != "over mcp" {
log.Fatalf("unexpected: %q", text)
}
fmt.Println("ok:", text)
}

3. The full surface — tools/list narrowed, and an Execute error becomes isError, never a crash

Section titled “3. The full surface — tools/list narrowed, and an Execute error becomes isError, never a crash”
package main
import (
"context"
"errors"
"fmt"
"log"
mcpclient "github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/mcp"
toolnexus "github.com/muthuishere/toolnexus/golang"
)
func main() {
safe := toolnexus.NativeTool("safe", "always works",
toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}},
func(_ context.Context, _ map[string]any) (string, error) { return "ok", nil },
)
boom := toolnexus.Tool{
Name: "boom", Description: "always explodes", Source: toolnexus.SourceNative,
InputSchema: toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}},
Execute: func(_ map[string]any, _ *toolnexus.ToolContext) (toolnexus.ToolResult, error) {
return toolnexus.ToolResult{}, errors.New("kaboom")
},
}
tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{
Builtins: false, ExtraTools: []toolnexus.Tool{safe, boom},
})
if err != nil {
log.Fatal(err)
}
defer tk.Close()
calls := 0
handle, err := tk.Serve("127.0.0.1:0", toolnexus.ServeOptions{
MCP: &toolnexus.MCPServeConfig{Name: "narrow-gw", Tools: []string{"safe", "boom"}},
OnCall: func(ev toolnexus.OnCallEvent) { calls++ },
})
if err != nil {
log.Fatal(err)
}
defer handle.Stop()
c, err := mcpclient.NewStreamableHttpClient(handle.URL + "/mcp")
if err != nil {
log.Fatal(err)
}
defer c.Close()
_ = c.Start(context.Background())
_, _ = c.Initialize(context.Background(), mcp.InitializeRequest{})
list, err := c.ListTools(context.Background(), mcp.ListToolsRequest{})
if err != nil || len(list.Tools) != 2 {
log.Fatalf("expected exactly 2 tools listed, got %+v %v", list.Tools, err)
}
req := mcp.CallToolRequest{}
req.Params.Name = "boom"
res, err := c.CallTool(context.Background(), req)
if err != nil {
log.Fatal(err) // a tool ERROR must not be a protocol/transport error
}
if !res.IsError {
log.Fatal("expected isError=true for a tool that returned an error")
}
if calls != 1 {
log.Fatalf("expected OnCall to fire once, got %d", calls)
}
fmt.Println("ok:", len(list.Tools), "tools listed; boom isError =", res.IsError)
}
Field Type Default
Name string "toolnexus"
Version string "0.1.0"
Tools []string nil ⇒ every toolkit tool; set ⇒ exactly that subset, unknown names ignored

tools/call maps ToolResult.Output to one {type:"text", text} content part and IsError propagates verbatim; an Execute error becomes isError:true with the error text — never a server crash. OnCallEvent{Name, Source, Ms, IsError} fires per call.

  • Toolkit.Serve — Publish an Agent Card and answer JSON-RPC over the client loop — your toolkit becomes someone else’s remote agent.
  • BuildAgentCard — Construct the Agent Card that advertises your name, skills and endpoint.
  • NewFileTaskStore — Persist inbound A2A tasks so a suspended request survives a restart.