@@ -37,6 +37,7 @@ import (
3737 "github.com/ObolNetwork/obol-stack/internal/ui"
3838 "github.com/ObolNetwork/obol-stack/internal/validate"
3939 x402verifier "github.com/ObolNetwork/obol-stack/internal/x402"
40+ "github.com/ObolNetwork/obol-stack/internal/x402mcp"
4041 "github.com/ethereum/go-ethereum/common"
4142 "github.com/urfave/cli/v3"
4243 "gopkg.in/yaml.v3"
@@ -49,6 +50,7 @@ func sellCommand(cfg *config.Config) *cli.Command {
4950 Commands : []* cli.Command {
5051 sellInferenceCommand (cfg ),
5152 sellHTTPCommand (cfg ),
53+ sellMCPCommand (cfg ),
5254 sellAgentCommand (cfg ),
5355 sellDemoCommand (cfg ),
5456 sellListCommand (cfg ),
@@ -531,6 +533,131 @@ Examples:
531533// sell http — create a ServiceOffer CRD for any HTTP service
532534// ---------------------------------------------------------------------------
533535
536+ func sellMCPCommand (cfg * config.Config ) * cli.Command {
537+ _ = cfg
538+ return & cli.Command {
539+ Name : "mcp" ,
540+ Usage : "Sell a paid MCP tool over x402 (in-band _meta payment)" ,
541+ ArgsUsage : "[name]" ,
542+ Description : `Runs a local x402-paid MCP (Model Context Protocol) server in the
543+ foreground. The paid tool forwards the buyer's JSON arguments to a backend HTTP
544+ service, injecting the seller's own credential (an API key the buyer never
545+ sees), so any credentialed real-world service can be resold to agents per call.
546+ Buyers (e.g. hermes-agent's pay_mcp plugin) settle in-band via the MCP request
547+ _meta["x402/payment"] field, per specs/transports-v2/mcp.md. The wrapper runs
548+ verify -> execute -> settle inside the tool call, so a caller is never charged
549+ for a failed tool. This is the application-layer counterpart to the HTTP-402
550+ ForwardAuth gate used by 'obol sell inference' / 'obol sell http'.
551+
552+ Connect a buyer at http://localhost:<port>/mcp (streamable HTTP).
553+
554+ Examples:
555+ # Front a weather API as a paid MCP tool (the canonical x402 paid-MCP shape):
556+ obol sell mcp weather --pay-to 0x... --price 0.001 --chain base-sepolia \
557+ --tool-name get_weather \
558+ --description 'Current weather for a city. Args: {city}' \
559+ --upstream https://your-weather-service/current
560+
561+ # Any JSON HTTP service works the same way; pass the backend's auth header if
562+ # it needs one (set server-side, never sent to buyers):
563+ obol sell mcp my-tool --pay-to 0x... --price 0.005 --tool-name call \
564+ --upstream https://api.example.com/do --upstream-header 'X-Api-Key: <key>'` ,
565+ Flags : []cli.Flag {
566+ payToFlag ("Payment recipient address" ),
567+ & cli.StringFlag {
568+ Name : "chain" ,
569+ Usage : "Payment chain (base, base-sepolia, ethereum, polygon)" ,
570+ Value : "base-sepolia" ,
571+ },
572+ & cli.StringFlag {
573+ Name : "price" ,
574+ Usage : "Per-call price, USD-denominated (e.g. 0.001)" ,
575+ Value : "0.001" ,
576+ },
577+ & cli.StringFlag {
578+ Name : "tool-name" ,
579+ Usage : "Name of the paid MCP tool" ,
580+ Value : "call" ,
581+ },
582+ & cli.StringFlag {
583+ Name : "description" ,
584+ Usage : "Human-readable description of the paid tool" ,
585+ },
586+ & cli.IntFlag {
587+ Name : "port" ,
588+ Usage : "Port to serve the MCP server on" ,
589+ Value : 4022 ,
590+ },
591+ & cli.StringFlag {
592+ Name : "upstream" ,
593+ Usage : "Backend HTTP service URL the paid tool POSTs the buyer's JSON args to (e.g. a weather/data API)" ,
594+ },
595+ & cli.StringSliceFlag {
596+ Name : "upstream-header" ,
597+ Usage : "Optional auth header for the backend, set server-side and never sent to buyers (repeatable, \" Key: Value\" , e.g. \" X-Api-Key: <key>\" )" ,
598+ },
599+ & cli.StringFlag {
600+ Name : "facilitator" ,
601+ Usage : "x402 facilitator URL (verify/settle)" ,
602+ },
603+ },
604+ Action : func (ctx context.Context , cmd * cli.Command ) error {
605+ u := getUI (cmd )
606+
607+ payTo := cmd .String ("pay-to" )
608+ if payTo == "" {
609+ return errors .New ("--pay-to is required" )
610+ }
611+ name := cmd .Args ().First ()
612+ if name == "" {
613+ name = "obol-mcp"
614+ }
615+ facilitator := cmd .String ("facilitator" )
616+ if facilitator == "" {
617+ facilitator = x402verifier .DefaultFacilitatorURL
618+ }
619+
620+ headers , err := parseUpstreamHeaders (cmd .StringSlice ("upstream-header" ))
621+ if err != nil {
622+ return err
623+ }
624+
625+ u .Infof ("Starting paid MCP server %q on port %d (Ctrl-C to stop)" , name , cmd .Int ("port" ))
626+ return x402mcp .Serve (ctx , x402mcp.Options {
627+ Name : name ,
628+ ToolName : cmd .String ("tool-name" ),
629+ Description : cmd .String ("description" ),
630+ Port : cmd .Int ("port" ),
631+ PayTo : payTo ,
632+ Price : cmd .String ("price" ),
633+ Chain : cmd .String ("chain" ),
634+ FacilitatorURL : facilitator ,
635+ Upstream : cmd .String ("upstream" ),
636+ UpstreamHeaders : headers ,
637+ })
638+ },
639+ }
640+ }
641+
642+ // parseUpstreamHeaders turns repeatable "Key: Value" --upstream-header flags
643+ // into a header map injected on upstream calls (the seller's credential).
644+ func parseUpstreamHeaders (pairs []string ) (map [string ]string , error ) {
645+ if len (pairs ) == 0 {
646+ return nil , nil
647+ }
648+ headers := make (map [string ]string , len (pairs ))
649+ for _ , p := range pairs {
650+ k , v , ok := strings .Cut (p , ":" )
651+ k = strings .TrimSpace (k )
652+ v = strings .TrimSpace (v )
653+ if ! ok || k == "" {
654+ return nil , fmt .Errorf ("invalid --upstream-header %q: want \" Key: Value\" " , p )
655+ }
656+ headers [k ] = v
657+ }
658+ return headers , nil
659+ }
660+
534661func sellHTTPCommand (cfg * config.Config ) * cli.Command {
535662 return & cli.Command {
536663 Name : "http" ,
0 commit comments