Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@
- `examples/policy` — `BeforeCallHook` gate blocking tools with `Destructive == true`.
- `examples/redact` — `ResultTransformHook` replacing SSN patterns with `[REDACTED]`.
Each example compiles with `go build ./examples/...` and uses only the root `go.mod`.
- **`policy` subpackage** — ready-made `BeforeCallHook` and `AfterCallHook` builders
(`github.com/inhuman/mcp-multiplexer/policy`). Ships as a separate import; the core
stays policy-free.
- `policy.DenyDestructive()` — blocks any tool with `ToolInfo.Destructive == true`.
- `policy.RequireRoles(roles ...string)` — allows only callers whose context carries
one of the required roles under `policy.RolesKey`.
- `policy.RateLimit(per time.Duration, burst int)` — per-(server, tool) token-bucket
limiter using only stdlib; no external dependencies.
- `policy.AuditLog(logger mcpx.Logger)` — `AfterCallHook` that logs every call
outcome (success → Info, error → Error) without blocking the call.
- **`eino` subpackage** — Cloudwego/eino framework adapter
(`github.com/inhuman/mcp-multiplexer/eino`). Ships with its own `go.mod` so
cloudwego/eino is not pulled into the core dependency graph.
- `eino.Tools(mx)` — returns one `tool.InvokableTool` per MCP tool across all servers.
- `eino.ToolsForServer(mx, server)` — returns tools for a specific server only.
- Each tool's `Info()` maps `mcpx.ToolInfo` → `*schema.ToolInfo` including the input
JSON schema. `InvokableRun` delegates to `mx.CallTool`.

- **`Metrics` interface** — `RecordCall(server, tool string, dur time.Duration, err error)` and
`RecordToolList(server string, count int)`. Register an implementation via `WithMetrics(m Metrics)`.
Expand Down
9 changes: 9 additions & 0 deletions eino/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Package eino adapts every MCP tool from a [mcpx.Multiplexer] into an
// eino-native tool.InvokableTool value. Call [Tools] to obtain a slice ready
// for an eino agent's ToolsNode, or [ToolsForServer] for a single server's
// tools.
//
// This package lives in its own Go module
// (github.com/inhuman/mcp-multiplexer/eino) so that importing the root
// module does not pull in cloudwego/eino transitively.
package eino
73 changes: 73 additions & 0 deletions eino/eino.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package eino

import (
"context"
"encoding/json"

einotool "github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
jsschema "github.com/eino-contrib/jsonschema"

mcpx "github.com/inhuman/mcp-multiplexer"
)

// Tools returns one [einotool.InvokableTool] per MCP tool across all connected
// servers. The order matches [mcpx.Multiplexer.AllTools]. Returns an empty
// slice if no servers are connected.
func Tools(mx *mcpx.Multiplexer) []einotool.InvokableTool {
all := mx.AllTools()
out := make([]einotool.InvokableTool, 0, len(all))
for _, ti := range all {
out = append(out, &mcpxTool{mx: mx, server: ti.Server, info: ti})
}
return out
}

// ToolsForServer returns [einotool.InvokableTool] values for the named server
// only. Returns an empty slice (not an error) if the server is not found.
func ToolsForServer(mx *mcpx.Multiplexer, server string) []einotool.InvokableTool {
infos := mx.ToolsForServers([]string{server})
out := make([]einotool.InvokableTool, 0, len(infos))
for _, ti := range infos {
out = append(out, &mcpxTool{mx: mx, server: server, info: ti})
}
return out
}

// mcpxTool implements [einotool.InvokableTool] by wrapping a single MCP tool.
type mcpxTool struct {
mx *mcpx.Multiplexer
server string
info mcpx.ToolInfo
}

// Info maps mcpx.ToolInfo fields to *schema.ToolInfo for the eino framework.
func (t *mcpxTool) Info(_ context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: t.info.Name,
Desc: t.info.Description,
ParamsOneOf: inputSchemaToParams(t.info.InputSchema),
}, nil
}

// InvokableRun calls the underlying MCP tool and returns its text result.
func (t *mcpxTool) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...einotool.Option) (string, error) {
result, err := t.mx.CallTool(ctx, t.server, t.info.Name, json.RawMessage(argumentsInJSON))
if err != nil {
return "", err
}
return result.Text, nil
}

// inputSchemaToParams converts raw JSON schema bytes to *schema.ParamsOneOf.
// Returns nil when the schema is empty or cannot be parsed.
func inputSchemaToParams(raw []byte) *schema.ParamsOneOf {
if len(raw) == 0 {
return nil
}
var s jsschema.Schema
if err := json.Unmarshal(raw, &s); err != nil {
return nil
}
return schema.NewParamsOneOfByJSONSchema(&s)
}
122 changes: 122 additions & 0 deletions eino/eino_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package eino

import (
"context"
"encoding/json"
"testing"

"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"

mcpx "github.com/inhuman/mcp-multiplexer"
)

// buildMX creates a Multiplexer from an in-process MCP server with the given tools.
func buildMX(t *testing.T, serverName string, specs []toolSpec) *mcpx.Multiplexer {
t.Helper()
srv := mcp.NewServer(&mcp.Implementation{Name: serverName, Version: "v0"}, nil)
for _, s := range specs {
tool := &mcp.Tool{
Name: s.name,
Description: s.description,
InputSchema: &jsonschema.Schema{Type: "object"},
}
srv.AddTool(tool, func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "ok"}},
}, nil
})
}

serverT, clientT := mcp.NewInMemoryTransports()
ctx := context.Background()
if _, err := srv.Connect(ctx, serverT, nil); err != nil {
t.Fatalf("server connect: %v", err)
}
client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, nil)
cs, err := client.Connect(ctx, clientT, nil)
if err != nil {
t.Fatalf("client connect: %v", err)
}
t.Cleanup(func() { _ = cs.Close() })

mx := mcpx.NewFromSessions(ctx, map[string]*mcp.ClientSession{serverName: cs})
t.Cleanup(func() { mx.Close() })
return mx
}

type toolSpec struct {
name string
description string
}

func TestTools_CountMatchesAllServers(t *testing.T) {
specs := []toolSpec{
{name: "tool1", description: "first"},
{name: "tool2", description: "second"},
{name: "tool3", description: "third"},
}
mx := buildMX(t, "srv", specs)
got := Tools(mx)
if len(got) != 3 {
t.Fatalf("expected 3 tools, got %d", len(got))
}
}

func TestToolsForServer_FiltersCorrectly(t *testing.T) {
specs := []toolSpec{
{name: "a"}, {name: "b"},
}
mx := buildMX(t, "myserver", specs)
got := ToolsForServer(mx, "myserver")
if len(got) != 2 {
t.Fatalf("expected 2 tools, got %d", len(got))
}
}

func TestToolsForServer_NotFound(t *testing.T) {
mx := buildMX(t, "srv", []toolSpec{{name: "x"}})
got := ToolsForServer(mx, "nonexistent")
if len(got) != 0 {
t.Fatalf("expected empty slice, got %d", len(got))
}
}

func TestMcpxTool_Info(t *testing.T) {
schema := json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`)
tool := &mcpxTool{
info: mcpx.ToolInfo{
Name: "list_dir",
Description: "lists a directory",
InputSchema: schema,
},
}
info, err := tool.Info(context.Background())
if err != nil {
t.Fatalf("Info error: %v", err)
}
if info.Name != "list_dir" {
t.Errorf("Name: got %q", info.Name)
}
if info.Desc != "lists a directory" {
t.Errorf("Desc: got %q", info.Desc)
}
if info.ParamsOneOf == nil {
t.Error("ParamsOneOf should not be nil for non-empty schema")
}
}

func TestMcpxTool_InvokableRun(t *testing.T) {
mx := buildMX(t, "srv", []toolSpec{{name: "echo"}})
tools := Tools(mx)
if len(tools) == 0 {
t.Fatal("expected at least one tool")
}
result, err := tools[0].InvokableRun(context.Background(), `{}`)
if err != nil {
t.Fatalf("InvokableRun error: %v", err)
}
if result != "ok" {
t.Errorf("expected result 'ok', got %q", result)
}
}
45 changes: 45 additions & 0 deletions eino/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
module github.com/inhuman/mcp-multiplexer/eino

go 1.26

replace github.com/inhuman/mcp-multiplexer => ../

require (
github.com/cloudwego/eino v0.8.13
github.com/eino-contrib/jsonschema v1.0.3
github.com/inhuman/mcp-multiplexer v0.0.0-00010101000000-000000000000
github.com/modelcontextprotocol/go-sdk v0.6.0
)

require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/jsonschema-go v0.2.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/goph/emperror v0.17.2 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/nikolalohinski/gonja v1.5.3 // indirect
github.com/pelletier/go-toml/v2 v2.0.9 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/arch v0.11.0 // indirect
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect
golang.org/x/sys v0.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Loading
Loading