mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 00:01:24 +00:00
239 lines
6.9 KiB
Go
239 lines
6.9 KiB
Go
// Package mcp is a minimal client for the MCP streamable-HTTP transport:
|
|
// initialize, tools/list, and tools/call over JSON-RPC 2.0 to a single POST
|
|
// endpoint. No third-party dependency; the response may be a single JSON
|
|
// message or an SSE stream, and both are handled. The HTTP client blocks
|
|
// private/loopback/metadata IPs at dial time (safehttp), and callers validate
|
|
// the URL up front (webhook.ValidateOutboundURL).
|
|
package mcp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/warmbly/warmbly/internal/pkg/safehttp"
|
|
)
|
|
|
|
const (
|
|
protocolVersion = "2025-03-26"
|
|
sessionHeader = "Mcp-Session-Id"
|
|
maxResponse = 4 << 20
|
|
)
|
|
|
|
// Client speaks MCP streamable-HTTP.
|
|
type Client struct {
|
|
http *http.Client
|
|
}
|
|
|
|
// NewClient returns a client with an SSRF-safe dialer and the given timeout.
|
|
func NewClient(timeout time.Duration) *Client {
|
|
if timeout <= 0 {
|
|
timeout = 20 * time.Second
|
|
}
|
|
return &Client{http: safehttp.Client(timeout)}
|
|
}
|
|
|
|
// Tool is a tool discovered from a server.
|
|
type Tool struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
InputSchema json.RawMessage `json:"inputSchema,omitempty"`
|
|
}
|
|
|
|
type rpcRequest struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID *int `json:"id,omitempty"`
|
|
Method string `json:"method"`
|
|
Params any `json:"params,omitempty"`
|
|
}
|
|
|
|
type rpcResponse struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID *int `json:"id"`
|
|
Result json.RawMessage `json:"result"`
|
|
Error *struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
|
|
// ListTools initializes a session and returns the server's tools.
|
|
func (c *Client) ListTools(ctx context.Context, url, bearer string) ([]Tool, error) {
|
|
sess, err := c.initialize(ctx, url, bearer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw, err := c.call(ctx, url, bearer, sess, "tools/list", map[string]any{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out struct {
|
|
Tools []Tool `json:"tools"`
|
|
}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return nil, fmt.Errorf("mcp: decode tools/list: %w", err)
|
|
}
|
|
return out.Tools, nil
|
|
}
|
|
|
|
// CallTool initializes a session and invokes a tool, returning its text content.
|
|
func (c *Client) CallTool(ctx context.Context, url, bearer, name string, args json.RawMessage) (string, error) {
|
|
sess, err := c.initialize(ctx, url, bearer)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
params := map[string]any{"name": name}
|
|
if len(args) > 0 {
|
|
params["arguments"] = json.RawMessage(args)
|
|
} else {
|
|
params["arguments"] = map[string]any{}
|
|
}
|
|
raw, err := c.call(ctx, url, bearer, sess, "tools/call", params)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var out struct {
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
IsError bool `json:"isError"`
|
|
}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return string(raw), nil // pass through non-standard shapes
|
|
}
|
|
var b strings.Builder
|
|
for _, blk := range out.Content {
|
|
if blk.Type == "text" {
|
|
b.WriteString(blk.Text)
|
|
}
|
|
}
|
|
res := b.String()
|
|
if res == "" {
|
|
res = string(raw)
|
|
}
|
|
if out.IsError {
|
|
return "", fmt.Errorf("mcp tool error: %s", res)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// initialize performs the MCP handshake and returns the session id (may be "").
|
|
func (c *Client) initialize(ctx context.Context, url, bearer string) (string, error) {
|
|
id := 1
|
|
req := rpcRequest{
|
|
JSONRPC: "2.0", ID: &id, Method: "initialize",
|
|
Params: map[string]any{
|
|
"protocolVersion": protocolVersion,
|
|
"capabilities": map[string]any{},
|
|
"clientInfo": map[string]any{"name": "warmbly", "version": "1.0"},
|
|
},
|
|
}
|
|
_, sess, err := c.post(ctx, url, bearer, "", req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Best-effort initialized notification (no response expected).
|
|
_, _, _ = c.post(ctx, url, bearer, sess, rpcRequest{JSONRPC: "2.0", Method: "notifications/initialized"})
|
|
return sess, nil
|
|
}
|
|
|
|
// call sends a JSON-RPC request expecting a result.
|
|
func (c *Client) call(ctx context.Context, url, bearer, sess, method string, params any) (json.RawMessage, error) {
|
|
id := 2
|
|
resp, _, err := c.post(ctx, url, bearer, sess, rpcRequest{JSONRPC: "2.0", ID: &id, Method: method, Params: params})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp == nil {
|
|
return nil, errors.New("mcp: empty response")
|
|
}
|
|
if resp.Error != nil {
|
|
return nil, fmt.Errorf("mcp: %s (%d)", resp.Error.Message, resp.Error.Code)
|
|
}
|
|
return resp.Result, nil
|
|
}
|
|
|
|
// post performs one HTTP POST of a JSON-RPC message and returns the parsed
|
|
// response (nil for notifications) plus any session id from the header.
|
|
func (c *Client) post(ctx context.Context, url, bearer, sess string, msg rpcRequest) (*rpcResponse, string, error) {
|
|
body, err := json.Marshal(msg)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("Accept", "application/json, text/event-stream")
|
|
if bearer != "" {
|
|
httpReq.Header.Set("Authorization", "Bearer "+bearer)
|
|
}
|
|
if sess != "" {
|
|
httpReq.Header.Set(sessionHeader, sess)
|
|
}
|
|
|
|
resp, err := c.http.Do(httpReq)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
newSess := resp.Header.Get(sessionHeader)
|
|
if newSess == "" {
|
|
newSess = sess
|
|
}
|
|
if resp.StatusCode == http.StatusAccepted || msg.ID == nil {
|
|
// Notification accepted; no body to parse.
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponse))
|
|
return nil, newSess, nil
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponse))
|
|
if err != nil {
|
|
return nil, newSess, err
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, newSess, fmt.Errorf("mcp: status %d", resp.StatusCode)
|
|
}
|
|
|
|
parsed, err := parseRPC(resp.Header.Get("Content-Type"), raw)
|
|
if err != nil {
|
|
return nil, newSess, err
|
|
}
|
|
return parsed, newSess, nil
|
|
}
|
|
|
|
// parseRPC decodes a JSON-RPC response from a plain JSON body or an SSE stream.
|
|
func parseRPC(contentType string, raw []byte) (*rpcResponse, error) {
|
|
if strings.Contains(strings.ToLower(contentType), "text/event-stream") {
|
|
// Find the last data frame that parses as a JSON-RPC response with a
|
|
// result or error (skipping server notifications).
|
|
for _, frame := range strings.Split(string(raw), "\n\n") {
|
|
for _, line := range strings.Split(frame, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if !strings.HasPrefix(line, "data:") {
|
|
continue
|
|
}
|
|
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
|
var r rpcResponse
|
|
if err := json.Unmarshal([]byte(payload), &r); err == nil && r.ID != nil {
|
|
return &r, nil
|
|
}
|
|
}
|
|
}
|
|
return nil, errors.New("mcp: no response in event stream")
|
|
}
|
|
var r rpcResponse
|
|
if err := json.Unmarshal(raw, &r); err != nil {
|
|
return nil, fmt.Errorf("mcp: decode response: %w", err)
|
|
}
|
|
return &r, nil
|
|
}
|