Files
hyperguild/internal/mcpclient/client.go
T
mathias 1a9be0cfb1
CI / Lint / Test / Vet (push) Successful in 30s
CI / Mirror to GitHub (push) Successful in 4s
style: apply gofmt
Estate-wide consolidation pass. golangci-lint v2 default set enables NO
formatter, so gofmt drift accumulated silently across the estate -- 8 repos
were carrying it, this one included. Same hole that let two files rot in
dma-cli until they were found by hand (dma-cli#12).

Mechanical and deterministic: `gofmt -w`, nothing hand-edited. Verified
before commit -- gofmt -l now reports 0, go build and go vet both pass.

Clears this repo to adopt the canonical .golangci.yml (infra
ci-templates/golangci.yml, infra#309 pattern) in a later wave.
2026-09-05 12:40:34 +02:00

151 lines
4.2 KiB
Go

// Package mcpclient is a minimal JSON-RPC over HTTP client for talking to
// MCP servers from inside hyperguild components. It only implements
// `tools/call` because that's all consumer skills need today.
package mcpclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
)
// Client calls an MCP server over Streamable HTTP / JSON-RPC.
type Client struct {
url string
token string
http *http.Client
}
// ErrTokenRequired is returned by New when token is empty. Empty token
// causes mcpclient to omit the Authorization header at request time,
// which is silently misread as 401 by bearer-auth servers — see
// hyperguild #13 and the brain entry on the failure mode.
var ErrTokenRequired = errors.New("mcpclient: token required")
// New returns a Client. Returns ErrTokenRequired when token is empty:
// every MCP server we talk to today is bearer-protected, and an empty
// token is always a configuration bug (typically a Kubernetes Secret
// missing the expected key, see hyperguild #13). Callers that genuinely
// need an unauthenticated client should construct &Client{} directly in
// tests, not call New.
func New(url, token string) (*Client, error) {
if token == "" {
return nil, ErrTokenRequired
}
return &Client{
url: url,
token: token,
http: &http.Client{Timeout: 60 * time.Second},
}, nil
}
// WithHTTPClient overrides the underlying HTTP client (test injection).
func (c *Client) WithHTTPClient(h *http.Client) *Client {
c.http = h
return c
}
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Method string `json:"method"`
Params map[string]any `json:"params"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
// Error is returned when the remote MCP server signals a typed failure.
// Code follows JSON-RPC conventions; see gitea-mcp internal/mcp/jsonrpc.go
// for the codes the server uses (e.g. -32002 NotFound, -32003 Conflict).
type Error struct {
Code int
Message string
}
func (e *Error) Error() string { return fmt.Sprintf("mcp error %d: %s", e.Code, e.Message) }
// CallTool issues `tools/call`. result is JSON-unmarshalled from the
// server's content[0].text field; pass nil to discard.
func (c *Client) CallTool(ctx context.Context, name string, args any, result any) error {
body, err := json.Marshal(rpcRequest{
JSONRPC: "2.0",
ID: 1,
Method: "tools/call",
Params: map[string]any{
"name": name,
"arguments": args,
},
})
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
if resp.StatusCode >= 400 {
return fmt.Errorf("mcp http %d: %s", resp.StatusCode, string(raw))
}
var rpc rpcResponse
if err := json.Unmarshal(raw, &rpc); err != nil {
return fmt.Errorf("decode response: %w (body=%s)", err, string(raw))
}
if rpc.Error != nil {
return &Error{Code: rpc.Error.Code, Message: rpc.Error.Message}
}
if result == nil {
return nil
}
// MCP success result shape: { content: [{type:"text", text:"<json>"}] }
var wrap struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(rpc.Result, &wrap); err != nil {
return fmt.Errorf("decode wrap: %w (result=%s)", err, string(rpc.Result))
}
if len(wrap.Content) == 0 {
return fmt.Errorf("empty content in tool response")
}
if err := json.Unmarshal([]byte(wrap.Content[0].Text), result); err != nil {
return fmt.Errorf("decode tool result text: %w (text=%s)", err, wrap.Content[0].Text)
}
return nil
}