From 3ee00d745b4a26c2a2a6f67924390adc39d74e4b Mon Sep 17 00:00:00 2001 From: Leonardo Araujo Date: Sat, 11 Apr 2026 19:02:38 -0300 Subject: [PATCH] feat(tools): HTTP tool runtime (spec.type http) - Add httptool package: map uses operation to method/path, JSON body for POST/PUT/PATCH, env: header resolution, retries for 5xx/transport (not 4xx) - Wire registry dispatch for type http with ToolHTTP + optional ToolRetry - Tests: httptest success, env headers, 404 single attempt, 503 recovery, registry integration Closes #20 Made-with: Cursor --- internal/tools/doc.go | 2 +- internal/tools/http/client.go | 256 +++++++++++++++++++++++++++++ internal/tools/http/client_test.go | 117 +++++++++++++ internal/tools/http/doc.go | 16 ++ internal/tools/registry.go | 14 +- internal/tools/tools_test.go | 43 +++++ 6 files changed, 445 insertions(+), 3 deletions(-) create mode 100644 internal/tools/http/client.go create mode 100644 internal/tools/http/client_test.go create mode 100644 internal/tools/http/doc.go diff --git a/internal/tools/doc.go b/internal/tools/doc.go index b8343b6..98b5fa7 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, mock, and MCP stdio tools. +// [Registry] resolves tool.. uses strings and dispatches MVP native, mock, MCP stdio, and HTTP tools. // Responses use [ToolCallResponse] with output + meta per §13.2. package tools diff --git a/internal/tools/http/client.go b/internal/tools/http/client.go new file mode 100644 index 0000000..f633281 --- /dev/null +++ b/internal/tools/http/client.go @@ -0,0 +1,256 @@ +package httptool + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/models" + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec" +) + +// ExecMeta is timing/cost metadata for an HTTP tool call (§13.2 placeholders). +type ExecMeta struct { + DurationMs int64 + CostUSD float64 +} + +// clientError is a 4xx response (not retried). +type clientError struct { + code int + msg string +} + +func (e *clientError) Error() string { + return fmt.Sprintf("httptool: HTTP %d %s", e.code, e.msg) +} + +// serverHTTPError is a 5xx response (retried when policy allows). +type serverHTTPError struct { + code int + body string +} + +func (e *serverHTTPError) Error() string { + return fmt.Sprintf("httptool: HTTP %d", e.code) +} + +// Execute performs one logical HTTP tool call, including optional retries on transport/5xx errors. +// client may be nil to use http.DefaultClient (tests should pass srv.Client()). +func Execute(ctx context.Context, cfg *spec.ToolHTTP, retry *spec.ToolRetry, operation string, with map[string]any, client *http.Client) (map[string]any, ExecMeta, error) { + if cfg == nil { + return nil, ExecMeta{}, errors.New("httptool: nil http config") + } + base := strings.TrimSpace(cfg.BaseURL) + if base == "" { + return nil, ExecMeta{}, errors.New("httptool: empty baseUrl") + } + method, path, err := parseOperation(operation) + if err != nil { + return nil, ExecMeta{}, err + } + urlStr := joinURL(base, path) + + attempts := 1 + if retry != nil && retry.MaxAttempts > 0 { + attempts = retry.MaxAttempts + } + backoff := "" + if retry != nil { + backoff = retry.Backoff + } + if client == nil { + client = http.DefaultClient + } + + start := time.Now() + var lastErr error + for attempt := 0; attempt < attempts; attempt++ { + if attempt > 0 { + sleepBackoff(ctx, attempt, backoff) + } + out, err := doRequest(ctx, client, method, urlStr, cfg.Headers, with) + if err == nil { + return out, ExecMeta{DurationMs: time.Since(start).Milliseconds(), CostUSD: 0}, nil + } + lastErr = err + if !retryableHTTP(err) { + break + } + } + return nil, ExecMeta{DurationMs: time.Since(start).Milliseconds(), CostUSD: 0}, lastErr +} + +func parseOperation(operation string) (method, path string, err error) { + operation = strings.TrimSpace(operation) + if operation == "" { + return "", "", fmt.Errorf("httptool: empty operation") + } + parts := strings.Split(operation, ".") + verbs := map[string]string{ + "get": "GET", "post": "POST", "put": "PUT", "delete": "DELETE", "patch": "PATCH", + } + if m, ok := verbs[strings.ToLower(parts[0])]; ok { + if len(parts) == 1 { + return m, "/", nil + } + return m, "/" + strings.Join(parts[1:], "/"), nil + } + return "GET", "/" + strings.Join(parts, "/"), nil +} + +func joinURL(base, path string) string { + base = strings.TrimRight(strings.TrimSpace(base), "/") + if path == "" { + return base + "/" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return base + path +} + +func resolveHeaders(h map[string]string) (http.Header, error) { + hdr := make(http.Header) + if h == nil { + return hdr, nil + } + for k, v := range h { + resolved, err := resolveHeaderValue(v) + if err != nil { + return nil, fmt.Errorf("httptool: header %q: %w", k, err) + } + hdr.Set(k, resolved) + } + return hdr, nil +} + +func resolveHeaderValue(v string) (string, error) { + v = strings.TrimSpace(v) + if strings.HasPrefix(v, "env:") { + return models.ResolveAPIKeyFrom(v) + } + return v, nil +} + +func doRequest(ctx context.Context, cli *http.Client, method, urlStr string, headers map[string]string, with map[string]any) (map[string]any, error) { + hdr, err := resolveHeaders(headers) + if err != nil { + return nil, err + } + + var body io.Reader + switch method { + case "POST", "PUT", "PATCH": + if with == nil { + with = map[string]any{} + } + b, err := json.Marshal(with) + if err != nil { + return nil, err + } + body = bytes.NewReader(b) + if hdr.Get("Content-Type") == "" { + hdr.Set("Content-Type", "application/json") + } + } + + req, err := http.NewRequestWithContext(ctx, method, urlStr, body) + if err != nil { + return nil, err + } + req.Header = hdr + + resp, err := cli.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 500 { + return nil, &serverHTTPError{code: resp.StatusCode, body: string(b)} + } + if resp.StatusCode >= 400 { + return nil, &clientError{code: resp.StatusCode, msg: truncateBody(b, 512)} + } + + return decodeResponseBody(b, resp.Header.Get("Content-Type")) +} + +func decodeResponseBody(b []byte, contentType string) (map[string]any, error) { + ct := strings.ToLower(contentType) + if len(b) == 0 { + return map[string]any{}, nil + } + if strings.Contains(ct, "application/json") || b[0] == '{' || b[0] == '[' { + var obj map[string]any + if json.Unmarshal(b, &obj) == nil { + return obj, nil + } + var arr []any + if json.Unmarshal(b, &arr) == nil { + return map[string]any{"items": arr}, nil + } + } + return map[string]any{"body": string(b)}, nil +} + +func truncateBody(b []byte, n int) string { + s := string(b) + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +func retryableHTTP(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + var ce *clientError + if errors.As(err, &ce) { + return false + } + var se *serverHTTPError + if errors.As(err, &se) { + return true + } + 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<.) is split on ".": +// +// - If the first segment is get, post, put, delete, or patch (case-insensitive), +// that becomes the HTTP method and the remaining segments form the path joined with "/" +// (with a leading slash). Example: post.api.v1.items → POST /api/v1/items +// +// - Otherwise the method is GET and all segments form the path. +// Example: health.live → GET /health/live +// +// baseUrl and path are concatenated (trailing slash on baseUrl is stripped). diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 062c719..5ab78f5 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -8,11 +8,12 @@ import ( "time" "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec" + httptool "github.com/LAA-Software-Engineering/agentic-control-plane/internal/tools/http" "github.com/LAA-Software-Engineering/agentic-control-plane/internal/tools/mcp" "github.com/LAA-Software-Engineering/agentic-control-plane/internal/tools/native" ) -// Registry resolves workflow uses strings against declared tools and dispatches by transport (MVP: native, mock, mcp stdio). +// Registry resolves workflow uses strings against declared tools and dispatches by transport (MVP: native, mock, mcp stdio, http). type Registry struct { graph *spec.ProjectGraph native *native.Registry @@ -97,7 +98,16 @@ func (r *Registry) Call(ctx context.Context, req ToolCallRequest) (ToolCallRespo return ToolCallResponse{}, err } return normalizeResponse(out, ToolCallMeta{DurationMs: meta.DurationMs, CostUSD: meta.CostUSD}, start), nil + case "http": + if tr.Spec.HTTP == nil { + return ToolCallResponse{}, fmt.Errorf("tools: http tool %q missing http configuration", toolName) + } + out, meta, err := httptool.Execute(ctx, tr.Spec.HTTP, tr.Spec.Retry, operation, req.With, nil) + if err != nil { + return ToolCallResponse{}, err + } + return normalizeResponse(out, ToolCallMeta{DurationMs: meta.DurationMs, CostUSD: meta.CostUSD}, start), nil default: - return ToolCallResponse{}, fmt.Errorf("tools: tool %q type %q not supported by MVP registry (native|mock|mcp)", toolName, tr.Spec.Type) + return ToolCallResponse{}, fmt.Errorf("tools: tool %q type %q not supported by MVP registry (native|mock|mcp|http)", toolName, tr.Spec.Type) } } diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go index 56a1d50..5be706d 100644 --- a/internal/tools/tools_test.go +++ b/internal/tools/tools_test.go @@ -3,6 +3,8 @@ package tools import ( "context" "errors" + "net/http" + "net/http/httptest" "os/exec" "path/filepath" "runtime" @@ -109,6 +111,47 @@ func testGraphMCP(bin string) *spec.ProjectGraph { } } +func testGraphHTTP(baseURL string) *spec.ProjectGraph { + return &spec.ProjectGraph{ + Tools: map[string]*spec.ToolResource{ + "api": { + APIVersion: spec.APIVersionV0, + Kind: spec.KindTool, + Metadata: spec.Metadata{Name: "api"}, + Spec: spec.ToolSpec{ + Type: "http", + HTTP: &spec.ToolHTTP{BaseURL: baseURL}, + }, + }, + }, + } +} + +func TestRegistry_HTTP_httptest(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/status" { + t.Errorf("got %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"up":true}`)) + })) + defer srv.Close() + + reg := NewRegistry(testGraphHTTP(srv.URL)) + resp, err := reg.Call(context.Background(), ToolCallRequest{ + Uses: "tool.api.get.v1.status", + With: nil, + }) + if err != nil { + t.Fatal(err) + } + if resp.Output["up"] != true { + t.Fatalf("output %+v", resp.Output) + } +} + func TestRegistry_MCP_stdio_mockServer(t *testing.T) { bin := mockMCPBinaryFromTools(t) reg := NewRegistry(testGraphMCP(bin))