156 lines
5.1 KiB
Go
156 lines
5.1 KiB
Go
package x402
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
log "github.com/nacorid/logger"
|
|
x402 "github.com/x402-foundation/x402/go/v2"
|
|
"github.com/x402-foundation/x402/go/v2/types"
|
|
)
|
|
|
|
// Payment meta keys exchanged over MCP, mirroring the x402 SDK contract.
|
|
const (
|
|
PaymentMetaKey = "x402/payment"
|
|
PaymentResponseMetaKey = "x402/payment-response"
|
|
)
|
|
|
|
// SettleAmountResolver computes the actual settlement amount for a completed
|
|
// tool call. It returns ("", false) to settle the amount advertised in the
|
|
// payment requirements as-is.
|
|
type SettleAmountResolver func(result *mcp.CallToolResult) (string, bool)
|
|
|
|
// PaymentWrapperConfig configures a PaymentWrapper. Unlike the SDK's wrapper,
|
|
// it accepts a SettleAmountResolver so upto-scheme tools can charge the actual
|
|
// computed cost after the handler runs (the same behavior the REST path gets
|
|
// via nethttp.SetSettlementOverrides).
|
|
type PaymentWrapperConfig struct {
|
|
Accepts []x402.PaymentRequirements
|
|
Resource *types.ResourceInfo
|
|
Extensions map[string]interface{}
|
|
// SettleAmountResolver is invoked after a successful handler call. When it
|
|
// returns a non-empty amount, that amount is charged instead of the
|
|
// advertised maximum (which must therefore be <= the accept amount).
|
|
SettleAmountResolver SettleAmountResolver
|
|
}
|
|
|
|
// PaymentWrapper gates an MCP tool behind x402 payments and settles them.
|
|
type PaymentWrapper struct {
|
|
server *x402.X402ResourceServer
|
|
config PaymentWrapperConfig
|
|
}
|
|
|
|
// NewPaymentWrapper creates a PaymentWrapper for the given resource server.
|
|
func NewPaymentWrapper(server *x402.X402ResourceServer, config PaymentWrapperConfig) *PaymentWrapper {
|
|
if len(config.Accepts) == 0 {
|
|
panic("PaymentWrapperConfig.Accepts must have at least one payment requirement")
|
|
}
|
|
return &PaymentWrapper{server: server, config: config}
|
|
}
|
|
|
|
// Wrap returns a ToolHandler that requires a valid x402 payment before invoking
|
|
// the wrapped handler and settles it afterwards.
|
|
func (w *PaymentWrapper) Wrap(handler mcp.ToolHandler) mcp.ToolHandler {
|
|
return func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
paymentData := extractPaymentFromRequest(request)
|
|
if paymentData == nil {
|
|
return w.paymentRequiredResult("Payment Required"), nil
|
|
}
|
|
|
|
payloadBytes, err := json.Marshal(paymentData)
|
|
if err != nil {
|
|
return w.paymentRequiredResult(fmt.Sprintf("Invalid payment payload: %v", err)), nil
|
|
}
|
|
var payload types.PaymentPayload
|
|
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
|
return w.paymentRequiredResult(fmt.Sprintf("Invalid payment payload: %v", err)), nil
|
|
}
|
|
|
|
matched := w.server.FindMatchingRequirements(w.config.Accepts, payload)
|
|
if matched == nil {
|
|
return w.paymentRequiredResult("No matching payment requirements found"), nil
|
|
}
|
|
requirements := *matched
|
|
|
|
verifyResp, err := w.server.VerifyPayment(ctx, payload, requirements)
|
|
if err != nil {
|
|
return w.paymentRequiredResult(fmt.Sprintf("Payment verification error: %v", err)), nil
|
|
}
|
|
if !verifyResp.IsValid {
|
|
return w.paymentRequiredResult(fmt.Sprintf("Payment verification failed: %s", verifyResp.InvalidReason)), nil
|
|
}
|
|
|
|
result, err := handler(ctx, request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if result.IsError {
|
|
return result, nil
|
|
}
|
|
|
|
var overrides *x402.SettlementOverrides
|
|
if w.config.SettleAmountResolver != nil {
|
|
if amount, ok := w.config.SettleAmountResolver(result); ok && amount != "" {
|
|
overrides = &x402.SettlementOverrides{Amount: amount}
|
|
log.Debug("MCP settlement override", "amount", amount)
|
|
}
|
|
}
|
|
|
|
settleResp, err := w.server.SettlePayment(ctx, payload, requirements, overrides)
|
|
if err != nil {
|
|
return w.settlementFailedResult(fmt.Sprintf("Settlement error: %v", err)), nil
|
|
}
|
|
if !settleResp.Success {
|
|
return w.settlementFailedResult(fmt.Sprintf("Settlement failed: %s", settleResp.ErrorReason)), nil
|
|
}
|
|
|
|
if result.Meta == nil {
|
|
result.Meta = mcp.Meta{}
|
|
}
|
|
result.Meta[PaymentResponseMetaKey] = settleResp
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
func extractPaymentFromRequest(request *mcp.CallToolRequest) interface{} {
|
|
if request == nil || request.Params.Meta == nil {
|
|
return nil
|
|
}
|
|
return request.Params.Meta[PaymentMetaKey]
|
|
}
|
|
|
|
func (w *PaymentWrapper) paymentRequiredResult(errorMessage string) *mcp.CallToolResult {
|
|
var resource *types.ResourceInfo
|
|
if w.config.Resource != nil {
|
|
resource = &types.ResourceInfo{
|
|
URL: w.config.Resource.URL,
|
|
Description: w.config.Resource.Description,
|
|
}
|
|
}
|
|
payment := types.PaymentRequired{
|
|
X402Version: 2,
|
|
Error: errorMessage,
|
|
Resource: resource,
|
|
Accepts: w.config.Accepts,
|
|
Extensions: w.config.Extensions,
|
|
}
|
|
b, err := json.Marshal(payment)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return &mcp.CallToolResult{
|
|
IsError: true,
|
|
Content: []mcp.Content{&mcp.TextContent{Text: string(b)}},
|
|
}
|
|
}
|
|
|
|
func (w *PaymentWrapper) settlementFailedResult(errorMessage string) *mcp.CallToolResult {
|
|
return &mcp.CallToolResult{
|
|
IsError: true,
|
|
Content: []mcp.Content{
|
|
&mcp.TextContent{Text: fmt.Sprintf("{\"x402Version\":2,\"error\":%q}", errorMessage)},
|
|
},
|
|
}
|
|
}
|