From 9a056127051282cd30fc5e73f4e24c6427927bdd Mon Sep 17 00:00:00 2001 From: Leonardo Araujo Date: Sat, 11 Apr 2026 18:48:59 -0300 Subject: [PATCH 1/2] feat(tools/mcp): stdio MCP client with retries and registry wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add JSON-RPC newline transport, initialize handshake, tools/call, result parsing to §13.2-style maps, and CallStdio with retry/backoff from ToolSpec.retry. Wire Registry type mcp + transport stdio to mcp.CallStdio. Include testdata/mockmcp subprocess and integration tests (issue #19). Made-with: Cursor --- internal/tools/doc.go | 2 +- internal/tools/mcp/call.go | 106 +++++++++++++ internal/tools/mcp/client.go | 73 +++++++++ internal/tools/mcp/doc.go | 4 + internal/tools/mcp/errors.go | 16 ++ internal/tools/mcp/mcp_test.go | 50 ++++++ internal/tools/mcp/testdata/mockmcp/main.go | 62 ++++++++ internal/tools/mcp/transport_stdio.go | 160 ++++++++++++++++++++ internal/tools/registry.go | 14 +- internal/tools/tools_test.go | 44 ++++++ 10 files changed, 528 insertions(+), 3 deletions(-) create mode 100644 internal/tools/mcp/call.go create mode 100644 internal/tools/mcp/client.go create mode 100644 internal/tools/mcp/doc.go create mode 100644 internal/tools/mcp/errors.go create mode 100644 internal/tools/mcp/mcp_test.go create mode 100644 internal/tools/mcp/testdata/mockmcp/main.go create mode 100644 internal/tools/mcp/transport_stdio.go diff --git a/internal/tools/doc.go b/internal/tools/doc.go index d01fe47..b8343b6 100644 --- a/internal/tools/doc.go +++ b/internal/tools/doc.go @@ -1,5 +1,5 @@ // Package tools defines tool registries and integrations (MCP, HTTP, native). // -// [Registry] resolves tool.. uses strings and dispatches MVP native and mock tools. +// [Registry] resolves tool.. uses strings and dispatches MVP native, mock, and MCP stdio tools. // Responses use [ToolCallResponse] with output + meta per §13.2. package tools diff --git a/internal/tools/mcp/call.go b/internal/tools/mcp/call.go new file mode 100644 index 0000000..fc5a877 --- /dev/null +++ b/internal/tools/mcp/call.go @@ -0,0 +1,106 @@ +package mcp + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec" +) + +// ExecMeta is timing/cost metadata for an MCP call (§13.2 placeholders). +type ExecMeta struct { + DurationMs int64 + CostUSD float64 +} + +// CallStdio runs one MCP tools/call over a fresh stdio subprocess, with optional retries on transport errors (§13.4). +func CallStdio(ctx context.Context, cfg *spec.ToolMCP, retry *spec.ToolRetry, toolName string, arguments map[string]any) (map[string]any, ExecMeta, error) { + if cfg == nil { + return nil, ExecMeta{}, errors.New("mcp: nil mcp config") + } + if strings.ToLower(strings.TrimSpace(cfg.Transport)) != "stdio" { + return nil, ExecMeta{}, errors.New("mcp: only transport stdio is supported in MVP") + } + cmd := strings.TrimSpace(cfg.Command) + if cmd == "" { + return nil, ExecMeta{}, errors.New("mcp: empty command") + } + + attempts := 1 + if retry != nil && retry.MaxAttempts > 0 { + attempts = retry.MaxAttempts + } + backoff := "" + if retry != nil { + backoff = retry.Backoff + } + + startAll := time.Now() + var lastErr error + for attempt := 0; attempt < attempts; attempt++ { + if attempt > 0 { + sleepBackoff(ctx, attempt, backoff) + } + out, err := oneStdioAttempt(ctx, cmd, cfg.Args, toolName, arguments) + if err == nil { + return out, ExecMeta{DurationMs: time.Since(startAll).Milliseconds(), CostUSD: 0}, nil + } + lastErr = err + if !retryableTransportErr(err) { + break + } + } + return nil, ExecMeta{DurationMs: time.Since(startAll).Milliseconds(), CostUSD: 0}, lastErr +} + +func oneStdioAttempt(ctx context.Context, command string, args []string, toolName string, arguments map[string]any) (map[string]any, error) { + tr := NewStdioTransport(command, args) + if err := tr.Start(ctx); err != nil { + return nil, err + } + defer tr.Close() + + if err := tr.Initialize(ctx); err != nil { + return nil, err + } + return tr.CallTool(ctx, toolName, arguments) +} + +func retryableTransportErr(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + var re *rpcError + if errors.As(err, &re) { + return false + } + return true +} + +func sleepBackoff(ctx context.Context, attempt int, kind string) { + if attempt <= 0 { + return + } + var d time.Duration + switch strings.ToLower(strings.TrimSpace(kind)) { + case "exponential": + shift := attempt + if shift > 8 { + shift = 8 + } + d = time.Millisecond * time.Duration(50*(1< Date: Sat, 11 Apr 2026 18:55:25 -0300 Subject: [PATCH 2/2] fix(test): use mockmcp.exe on Windows for go build -o output exec.Command requires a .exe path on windows-latest CI; without it the built binary path is not recognized as executable. Made-with: Cursor --- internal/tools/mcp/mcp_test.go | 7 ++++++- internal/tools/tools_test.go | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/tools/mcp/mcp_test.go b/internal/tools/mcp/mcp_test.go index 7e213af..94fdbe0 100644 --- a/internal/tools/mcp/mcp_test.go +++ b/internal/tools/mcp/mcp_test.go @@ -4,6 +4,7 @@ import ( "context" "os/exec" "path/filepath" + "runtime" "testing" "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec" @@ -11,7 +12,11 @@ import ( func mockMCPExecutable(t *testing.T) string { t.Helper() - out := filepath.Join(t.TempDir(), "mockmcp") + name := "mockmcp" + if runtime.GOOS == "windows" { + name += ".exe" + } + out := filepath.Join(t.TempDir(), name) cmd := exec.Command("go", "build", "-o", out, "./testdata/mockmcp") cmd.Dir = "." if b, err := cmd.CombinedOutput(); err != nil { diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go index d44324e..56a1d50 100644 --- a/internal/tools/tools_test.go +++ b/internal/tools/tools_test.go @@ -5,6 +5,7 @@ import ( "errors" "os/exec" "path/filepath" + "runtime" "testing" "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec" @@ -79,7 +80,11 @@ func TestParseUses_githubExample(t *testing.T) { func mockMCPBinaryFromTools(t *testing.T) string { t.Helper() - out := filepath.Join(t.TempDir(), "mockmcp") + name := "mockmcp" + if runtime.GOOS == "windows" { + name += ".exe" + } + out := filepath.Join(t.TempDir(), name) cmd := exec.Command("go", "build", "-o", out, "./mcp/testdata/mockmcp") cmd.Dir = "." if b, err := cmd.CombinedOutput(); err != nil {