mcpclient.New previously accepted an empty token and silently omitted the Authorization header at request time. When the env var sourcing the token was missing from a Kubernetes Secret (envFrom doesn't warn on missing keys), this surfaced as an opaque 401 from the upstream MCP server with no log trail — see hyperguild #13 and brain entry "mcpclient-empty-token-silent-401-envfrom-missing-key". mcpclient.New now returns ErrTokenRequired when token is empty. The routing pod's project_create init checks the error and exits with a clear message pointing at routing-secrets, turning a runtime 401 storm into a startup crashloop the operator can fix immediately. Tests pass a dummy "test" token (httptest servers don't enforce bearer auth, so any non-empty value works). Added a regression test asserting empty-token construction returns ErrTokenRequired. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
151 lines
4.2 KiB
Go
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
|
|
}
|