Skip to content

Commit 1e981d3

Browse files
committed
feat(sell): add obol sell mcp — paid MCP tool over x402 in-band _meta
Runs a local x402-paid MCP (Model Context Protocol) server. The paid tool forwards the buyer's JSON arguments to a backend HTTP service and returns the response, so any real service can be resold to agents per call. Buyers settle in-band via the MCP request _meta["x402/payment"] field (per specs/transports-v2/mcp.md); verify -> execute -> settle runs inside the tool call, so a caller is never charged for a failed tool. Mirrors the canonical x402 paid-MCP example (a paid get_weather tool), generalising the upstream from a stub to a real backend. - internal/x402mcp: PaymentWrapper over the official modelcontextprotocol/go-sdk; proxyTool forwards args (+ optional backend auth header, set server-side, never sent to buyers) and returns the service response; free ping tool + /health. - cmd/obol/sell.go: `sell mcp` subcommand (--pay-to/--price/--chain/--tool-name/ --port/--upstream/--upstream-header/--description/--facilitator). Uses the x402-foundation/x402/go SDK.
1 parent 0a8d04e commit 1e981d3

6 files changed

Lines changed: 499 additions & 0 deletions

File tree

cmd/obol/sell.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
534661
func sellHTTPCommand(cfg *config.Config) *cli.Command {
535662
return &cli.Command{
536663
Name: "http",

cmd/obol/sell_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,28 @@ func newTestConfig(t *testing.T) *config.Config {
147147
// Tests
148148
// ─────────────────────────────────────────────────────────────────────────────
149149

150+
func TestParseUpstreamHeaders(t *testing.T) {
151+
t.Run("parses Key: Value pairs and trims", func(t *testing.T) {
152+
got, err := parseUpstreamHeaders([]string{"X-Api-Key: abc", "X-Region:eu"})
153+
if err != nil {
154+
t.Fatalf("unexpected error: %v", err)
155+
}
156+
if got["X-Api-Key"] != "abc" || got["X-Region"] != "eu" {
157+
t.Errorf("got %v", got)
158+
}
159+
})
160+
t.Run("nil for no flags", func(t *testing.T) {
161+
if got, err := parseUpstreamHeaders(nil); err != nil || got != nil {
162+
t.Errorf("got %v, %v; want nil, nil", got, err)
163+
}
164+
})
165+
t.Run("rejects malformed pair", func(t *testing.T) {
166+
if _, err := parseUpstreamHeaders([]string{"no-colon"}); err == nil {
167+
t.Error("expected error for header without a colon")
168+
}
169+
})
170+
}
171+
150172
func TestSellCommand_Structure(t *testing.T) {
151173
cfg := newTestConfig(t)
152174
cmd := sellCommand(cfg)

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ require (
1414
github.com/hf/nitrite v0.0.0-20241225144000-c2d5d3c4f303
1515
github.com/hf/nsm v0.0.0-20220930140112-cd181bd646b9
1616
github.com/mattn/go-isatty v0.0.20
17+
github.com/modelcontextprotocol/go-sdk v1.3.0
1718
github.com/prometheus/client_golang v1.19.1
1819
github.com/prometheus/client_model v0.6.1
1920
github.com/prometheus/common v0.55.0
@@ -70,6 +71,7 @@ require (
7071
github.com/google/gnostic-models v0.7.0 // indirect
7172
github.com/google/go-cmp v0.7.0 // indirect
7273
github.com/google/go-configfs-tsm v0.2.2 // indirect
74+
github.com/google/jsonschema-go v0.4.2 // indirect
7375
github.com/google/logger v1.1.1 // indirect
7476
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
7577
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
@@ -102,6 +104,7 @@ require (
102104
github.com/x448/float16 v0.8.4 // indirect
103105
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
104106
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
107+
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
105108
go.uber.org/multierr v1.11.0 // indirect
106109
go.yaml.in/yaml/v2 v2.4.3 // indirect
107110
go.yaml.in/yaml/v3 v3.0.4 // indirect

go.sum

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
127127
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
128128
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
129129
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
130+
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
131+
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
130132
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
131133
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
132134
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
@@ -142,6 +144,8 @@ github.com/google/go-tdx-guest v0.3.1/go.mod h1:/rc3d7rnPykOPuY8U9saMyEps0PZDThL
142144
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
143145
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
144146
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
147+
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
148+
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
145149
github.com/google/logger v1.1.1 h1:+6Z2geNxc9G+4D4oDO9njjjn2d0wN5d7uOo0vOIW1NQ=
146150
github.com/google/logger v1.1.1/go.mod h1:BkeJZ+1FhQ+/d087r4dzojEg1u2ZX+ZqG1jTUrLM+zQ=
147151
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
@@ -224,6 +228,8 @@ github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxd
224228
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
225229
github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A=
226230
github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4=
231+
github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs=
232+
github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE=
227233
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
228234
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
229235
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -322,10 +328,18 @@ github.com/x402-foundation/x402/go v0.0.0-20260529172747-45d81d46e5bd h1:Bb+VbLs
322328
github.com/x402-foundation/x402/go v0.0.0-20260529172747-45d81d46e5bd/go.mod h1:58Cdk20g83eAI3QvxAiQJze7qWUgkjCj9uZlPb4M4HM=
323329
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
324330
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
331+
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
332+
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
333+
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
334+
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
335+
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
336+
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
325337
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
326338
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
327339
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
328340
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
341+
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
342+
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
329343
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
330344
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
331345
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=

0 commit comments

Comments
 (0)