clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

main
tinyrelay / tinyagent / client / mcp.go
mcp.go 3.7K
1package client23import (4 "bytes"5 "context"6 "encoding/json"7 "errors"8 "fmt"9 "io"10 "net/http"11 "strings"12 "sync/atomic"1314 "github.com/FelineStateMachine/tinyrelay/internal/mcp"15)1617// MCPError is a JSON-RPC error answered by the relay's MCP endpoint together18// with the HTTP status it arrived with. Failures to reach the relay at all19// are returned as ordinary errors instead.20type MCPError struct {21 Status int22 Code int23 Message string24 Data any25}2627func (e *MCPError) Error() string {28 if e.Code != 0 {29 return fmt.Sprintf("relay HTTP %d, JSON-RPC %d: %s", e.Status, e.Code, e.Message)30 }31 return fmt.Sprintf("relay HTTP %d: %s", e.Status, e.Message)32}3334var mcpRequestID atomic.Int643536// MCP posts one JSON-RPC request to the relay's stateless MCP endpoint and37// returns the raw result. Each call carries a fresh NIP-98 proof bound to38// the URL and body, the mirrored Mcp-* headers and the reserved _meta39// fields. The endpoint is /mcp under the client's base URL, so a tenant40// served under a path prefix is reached at /r/<name>/mcp.41func (c *Client) MCP(ctx context.Context, method string, params map[string]any, info mcp.Implementation) (json.RawMessage, error) {42 if method == "" {43 return nil, errors.New("method is required")44 }45 merged := make(map[string]any, len(params)+1)46 for k, v := range params {47 merged[k] = v48 }49 merged["_meta"] = map[string]any{50 mcp.MetaProtocolVersion: mcp.Version,51 mcp.MetaClientCapabilities: map[string]any{},52 mcp.MetaClientInfo: info,53 }54 body, err := json.Marshal(mcp.Request{JSONRPC: "2.0", ID: json.RawMessage(fmt.Sprint(mcpRequestID.Add(1))), Method: method, Params: mustJSON(merged)})55 if err != nil {56 return nil, err57 }58 u, err := c.requestURL("/mcp")59 if err != nil {60 return nil, err61 }62 proof, err := c.proof(http.MethodPost, u.String(), body)63 if err != nil {64 return nil, err65 }66 req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body))67 if err != nil {68 return nil, err69 }70 req.Header.Set("Authorization", proof)71 req.Header.Set("Content-Type", "application/json")72 req.Header.Set("Accept", "application/json")73 req.Header.Set(mcp.HeaderProtocolVersion, mcp.Version)74 req.Header.Set(mcp.HeaderMethod, method)75 if method == "tools/call" {76 name, _ := params["name"].(string)77 req.Header.Set(mcp.HeaderName, mcp.EncodeHeaderValue(name))78 }79 resp, err := c.httpClient().Do(req)80 if err != nil {81 return nil, err82 }83 defer resp.Body.Close()84 data, err := io.ReadAll(io.LimitReader(resp.Body, maxDownload+1))85 if err != nil {86 return nil, err87 }88 if len(data) > maxDownload {89 return nil, errors.New("response exceeds 32 MiB")90 }91 var response struct {92 Result json.RawMessage `json:"result"`93 Error *mcp.Error `json:"error"`94 }95 decoded := json.Unmarshal(data, &response) == nil96 if resp.StatusCode/100 != 2 {97 if decoded && response.Error != nil {98 return nil, &MCPError{Status: resp.StatusCode, Code: response.Error.Code, Message: response.Error.Message, Data: response.Error.Data}99 }100 message := strings.TrimSpace(string(data))101 if message == "" {102 message = resp.Status103 }104 return nil, &MCPError{Status: resp.StatusCode, Message: message}105 }106 if !decoded {107 return nil, errors.New("relay returned an invalid JSON-RPC response")108 }109 if response.Error != nil {110 return nil, &MCPError{Status: resp.StatusCode, Code: response.Error.Code, Message: response.Error.Message, Data: response.Error.Data}111 }112 if len(response.Result) == 0 {113 return nil, errors.New("relay returned a JSON-RPC response without a result")114 }115 return response.Result, nil116}117118func mustJSON(value any) json.RawMessage {119 raw, err := json.Marshal(value)120 if err != nil {121 return json.RawMessage("{}")122 }123 return raw124}