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<