// 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" "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 } // New returns a Client. token may be empty for unauthenticated servers. func New(url, token string) *Client { return &Client{ url: url, token: token, http: &http.Client{Timeout: 60 * time.Second}, } } // 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:""}] } 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 }