naco-api/internal/x402/x402.go
Nacorid 2ae53d8dc5
add: supported network config option
update: align testClient with x402-v2
2026-08-03 12:40:04 +00:00

393 lines
10 KiB
Go

package x402
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"reflect"
"strings"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
log "github.com/nacorid/logger"
x402 "github.com/x402-foundation/x402/go/v2"
x402http "github.com/x402-foundation/x402/go/v2/http"
nethttp "github.com/x402-foundation/x402/go/v2/http/nethttp"
x402mcp "github.com/x402-foundation/x402/go/v2/mcp"
evm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/server"
)
type X402Middlewares struct {
MCPMiddleware []mcp.Middleware
HTTPMiddleware []func(http.Handler) http.Handler
}
type ToolInfo struct {
Tool mcp.Tool
IsPayable bool
Requirements []x402.PaymentRequirements
Handler mcp.ToolHandler
}
type X402Server struct {
mcpServer *mcp.Server
x402ResourceServer *x402.X402ResourceServer
facilitatorClient *x402http.HTTPFacilitatorClient
httpSettlementHook func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse)
mux *http.ServeMux
name string
version string
middlewareChain func(http.Handler) http.Handler
tools []ToolInfo
globalReqLog func(http.Handler) http.Handler
}
const VERSION1 = "v1/"
func NewX402Server(name, version string, facilitatorURL string, auth x402http.AuthProvider, mws X402Middlewares, globalLoggingMw func(http.Handler) http.Handler, settleHook func(http.ResponseWriter, *http.Request, *x402.SettleResponse)) (*X402Server, error) {
mcpServer := mcp.NewServer(
&mcp.Implementation{
Name: name,
Version: version,
},
nil,
)
for _, mw := range mws.MCPMiddleware {
mcpServer.AddReceivingMiddleware(mw)
}
if facilitatorURL == "" {
facilitatorURL = "https://facilitator.mogami.tech"
}
facilitator := x402http.NewHTTPFacilitatorClient(&x402http.FacilitatorConfig{
URL: facilitatorURL,
AuthProvider: auth,
})
resourceServer := x402.Newx402ResourceServer(
x402.WithFacilitatorClient(facilitator),
)
resourceServer.Register("eip155:8453", evm.NewExactEvmScheme())
s := &X402Server{
mcpServer: mcpServer,
x402ResourceServer: resourceServer,
facilitatorClient: facilitator,
httpSettlementHook: settleHook,
mux: http.NewServeMux(),
name: name,
version: version,
middlewareChain: chainHTTPMiddlewares(mws.HTTPMiddleware...),
tools: make([]ToolInfo, 0),
globalReqLog: globalLoggingMw,
}
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
s.mux.HandleFunc("GET /openapi.json", s.serveOpenAPI)
mcpHandler := mcp.NewStreamableHTTPHandler(func(req *http.Request) *mcp.Server {
return s.mcpServer
}, nil)
s.mux.Handle("/mcp", mcpHandler)
return s, nil
}
func (s *X402Server) AddTool(tool mcp.Tool, handler mcp.ToolHandler) {
tool.InputSchema = toJSONSchema(tool.InputSchema)
tool.OutputSchema = toJSONSchema(tool.OutputSchema)
s.mcpServer.AddTool(&tool, handler)
s.tools = append(s.tools, ToolInfo{
Tool: tool,
IsPayable: false,
Handler: handler,
})
restHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.handleRESTCall(w, r, tool.Name, handler)
})
s.mux.Handle("POST /"+VERSION1+tool.Name, s.middlewareChain(restHandler))
}
func (s *X402Server) AddPayableTool(
tool mcp.Tool,
handler mcp.ToolHandler,
requirements ...x402.PaymentRequirements,
) {
if len(requirements) == 0 {
log.Fatalf("tool %s requires at least one payment requirement", tool.Name)
}
tool.InputSchema = toJSONSchema(tool.InputSchema)
tool.OutputSchema = toJSONSchema(tool.OutputSchema)
wrapper := x402mcp.NewPaymentWrapper(s.x402ResourceServer, x402mcp.PaymentWrapperConfig{
Accepts: requirements,
Resource: &x402mcp.ResourceInfo{
URL: "mcp://tool/" + tool.Name,
Description: tool.Description,
},
})
s.mcpServer.AddTool(&tool, wrapper.Wrap(handler))
s.tools = append(s.tools, ToolInfo{
Tool: tool,
IsPayable: true,
Requirements: requirements,
Handler: handler,
})
var options x402http.PaymentOptions
for _, req := range requirements {
options = append(options, x402http.PaymentOption{
Scheme: req.Scheme,
PayTo: req.PayTo,
Price: req.Amount,
Network: x402.Network(req.Network),
})
}
paymentMiddleware := nethttp.X402Payment(nethttp.Config{
Routes: x402http.RoutesConfig{
"POST /" + VERSION1 + tool.Name: {
Accepts: options,
Description: tool.Description,
},
},
Facilitator: s.facilitatorClient,
Schemes: []nethttp.SchemeConfig{
{Network: "eip155:8453", Server: evm.NewExactEvmScheme()},
},
SettlementHandler: s.httpSettlementHook,
})
restHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.handleRESTCall(w, r, tool.Name, handler)
})
s.mux.Handle("POST /"+VERSION1+tool.Name, s.middlewareChain(paymentMiddleware(restHandler)))
}
func (s *X402Server) handleRESTCall(w http.ResponseWriter, r *http.Request, toolName string, handler mcp.ToolHandler) {
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
log.DebugWithContext(r.Context(), "RESTful API request.", "RemoteAddr", r.RemoteAddr, "X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
callReq := &mcp.CallToolRequest{
Params: &mcp.CallToolParamsRaw{
Name: toolName,
Arguments: json.RawMessage(bodyBytes),
},
}
resp, err := handler(r.Context(), callReq)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if len(resp.Content) == 1 {
if textContent, ok := resp.Content[0].(*mcp.TextContent); ok {
var js json.RawMessage
if json.Unmarshal([]byte(textContent.Text), &js) == nil {
w.Write([]byte(textContent.Text))
return
}
}
}
json.NewEncoder(w).Encode(resp)
}
func (s *X402Server) OnAfterSettle(hook func(ctx x402.SettleResultContext) error) {
s.x402ResourceServer.OnAfterSettle(hook)
}
func (s *X402Server) serveOpenAPI(w http.ResponseWriter, r *http.Request) {
paths := make(map[string]any)
log.DebugWithContext(r.Context(), "OpenAPI Spec requested.", "RemoteAddr", r.RemoteAddr, "X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
for _, info := range s.tools {
inputSchema := parseSchema(info.Tool.InputSchema)
outputSchema := parseSchema(info.Tool.OutputSchema)
postOp := map[string]any{
"summary": info.Tool.Description,
"description": fmt.Sprintf("Exposes the MCP tool '%s' as a RESTful endpoint.", info.Tool.Name),
"requestBody": map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": inputSchema,
},
},
},
"responses": map[string]any{
"200": map[string]any{
"description": "Successful operation",
"content": map[string]any{
"application/json": map[string]any{
"schema": outputSchema,
},
},
},
},
}
if info.IsPayable {
postOp["description"] = fmt.Sprintf("%s\n\n**Paid Route**: Requires payments via x402.", postOp["description"])
postOp["responses"].(map[string]any)["402"] = map[string]any{
"description": "Payment Required. Responds with the PAYMENT-REQUIRED header challenge.",
"headers": map[string]any{
"PAYMENT-REQUIRED": map[string]any{
"schema": map[string]any{"type": "string"},
"description": "Base64 encoded PaymentRequired challenge matching the x402 specification.",
},
},
}
}
paths["/"+VERSION1+info.Tool.Name] = map[string]any{
"post": postOp,
}
}
openapiDoc := map[string]any{
"openapi": "3.0.3",
"info": map[string]any{
"title": s.name,
"version": s.version,
"description": "Auto-generated REST API matching active Model Context Protocol (MCP) tools.",
"contact": map[string]any{
"email": "x402@naco.li",
},
},
"paths": paths,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(openapiDoc)
}
func (s *X402Server) Start(addr string, permissions os.FileMode) error {
var (
listener net.Listener
err error
)
if socketPath, ok := strings.CutPrefix(addr, "unix:"); ok {
_ = os.RemoveAll(socketPath)
listener, err = net.Listen("unix", socketPath)
if err != nil {
return fmt.Errorf("failed to listen on unix socket: %w", err)
}
if err := os.Chmod(socketPath, permissions); err != nil {
return fmt.Errorf("failed to set socket permissions: %w", err)
}
log.Info("Starting X402 MCP Server on unix socket", "socketPath", socketPath)
} else {
log.Info("Starting X402 MCP Server on TCP", "addr", addr)
listener, err = net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("failed to listen on tcp: %w", err)
}
}
defer listener.Close()
srv := &http.Server{
Handler: s.globalReqLog(s.mux),
ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelDebug),
}
return srv.Serve(listener)
}
func toJSONSchema(val any) json.RawMessage {
if val == nil {
return json.RawMessage(`{"type": "object"}`)
}
switch v := val.(type) {
case json.RawMessage:
if len(v) == 0 {
return json.RawMessage(`{"type": "object"}`)
}
return v
case []byte:
if len(v) == 0 {
return json.RawMessage(`{"type": "object"}`)
}
return json.RawMessage(v)
case string:
if v == "" {
return json.RawMessage(`{"type": "object"}`)
}
return json.RawMessage(v)
}
t := reflect.TypeOf(val)
schema, err := jsonschema.ForType(t, &jsonschema.ForOptions{})
if err != nil {
return json.RawMessage(`{"type": "object"}`)
}
bytes, err := json.Marshal(schema)
if err != nil {
return json.RawMessage(`{"type": "object"}`)
}
return bytes
}
func parseSchema(schemaAny any) any {
if schemaAny == nil {
return map[string]any{"type": "object"}
}
switch v := schemaAny.(type) {
case json.RawMessage:
var m any
if err := json.Unmarshal(v, &m); err == nil {
return m
}
case []byte:
var m any
if err := json.Unmarshal(v, &m); err == nil {
return m
}
case string:
var m any
if err := json.Unmarshal([]byte(v), &m); err == nil {
return m
}
}
return schemaAny
}
func chainHTTPMiddlewares(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
next = middlewares[i](next)
}
return next
}
}