task/migrate-x402-v2 #1
6 changed files with 472 additions and 214 deletions
add x402 unit test
All checks were successful
CI/CD Go Verification / build_and_test (pull_request) Successful in 59s
All checks were successful
CI/CD Go Verification / build_and_test (pull_request) Successful in 59s
add test workflow
commit
252eede995
36
.forgejo/workflows/test.yaml
Normal file
36
.forgejo/workflows/test.yaml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
name: CI/CD Go Verification
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, dev ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build_and_test:
|
||||
runs-on: node20
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Check code formatting
|
||||
run: |
|
||||
if [ -n "$(gofmt -l .)" ]; then
|
||||
echo "Code is not formatted correctly. Run 'gofmt -w .'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build Application
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Run Tests (with Mocked Boundaries)
|
||||
run: go test -v -cover ./...
|
||||
2
go.mod
2
go.mod
|
|
@ -10,7 +10,7 @@ require (
|
|||
github.com/lib/pq v1.10.9
|
||||
github.com/mark3labs/x402-go v0.12.1
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1
|
||||
github.com/nacorid/logger v0.0.0-20251221003040-586da7a03fa0
|
||||
github.com/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80
|
||||
github.com/ollama/ollama v0.12.10
|
||||
github.com/x402-foundation/x402/go/v2 v2.19.0
|
||||
golang.org/x/time v0.14.0
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -248,8 +248,8 @@ github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7B
|
|||
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
|
||||
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
|
||||
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
|
||||
github.com/nacorid/logger v0.0.0-20251221003040-586da7a03fa0 h1:vKVU0JbEOiG7xI+UNwb8M72mo98uHCiPNyPUd9oBC9c=
|
||||
github.com/nacorid/logger v0.0.0-20251221003040-586da7a03fa0/go.mod h1:hd9h4KEPU1f/Vv2qP0x/fZUwz2clFvOV/uIF0pkGo+s=
|
||||
github.com/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80 h1:nZwGInvJNQzPSFYpstVR/TLumqBEZt0+yUXchdyBZiI=
|
||||
github.com/nacorid/logger v0.0.0-20251221013547-5fc8fd94ed80/go.mod h1:hd9h4KEPU1f/Vv2qP0x/fZUwz2clFvOV/uIF0pkGo+s=
|
||||
github.com/ollama/ollama v0.12.10 h1:Dd0/SeCc+nv+FffxmWuQTGiRreib7Gt3nBhIIFuKwZA=
|
||||
github.com/ollama/ollama v0.12.10/go.mod h1:RUSmYywUWx/YZMaHrqtnT1ZChu+iSz/7jx2aO9+Mgfg=
|
||||
github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE=
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import (
|
|||
"github.com/nacorid/naco-api/internal/utils"
|
||||
"github.com/nacorid/naco-api/internal/x402"
|
||||
x402v2 "github.com/x402-foundation/x402/go/v2"
|
||||
x402http "github.com/x402-foundation/x402/go/v2/http/nethttp"
|
||||
"github.com/x402-foundation/x402/go/v2/types"
|
||||
)
|
||||
|
||||
|
|
@ -58,69 +59,30 @@ func New(ctx context.Context, db *store.Queries, ap *activitypub.APClient, bsky
|
|||
cfg.X402FacilitatorURL,
|
||||
auth,
|
||||
toolChain,
|
||||
func(w http.ResponseWriter, r *http.Request, resp *x402v2.SettleResponse) {
|
||||
requirements, ok := x402http.RequirementsFromContext(r.Context())
|
||||
if !ok {
|
||||
log.WarnWithContext(r.Context(), "failed to extract payment requirements from context during settlement hook")
|
||||
return
|
||||
}
|
||||
|
||||
amount := requirements.Amount
|
||||
asset := requirements.Asset
|
||||
to := requirements.PayTo
|
||||
_ = settleHook(r.Context(), db, resp.Transaction, amount, asset, string(resp.Network), to, resp.Payer)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.FatalWithContext(ctx, "failed to create x402 server", "error", err)
|
||||
}
|
||||
|
||||
srv.OnAfterSettle(func(settleCtx x402v2.SettleResultContext) error {
|
||||
var amount, to, from, asset string
|
||||
amount := settleCtx.Requirements.GetAmount()
|
||||
asset := settleCtx.Requirements.GetAsset()
|
||||
to := settleCtx.Requirements.GetPayTo()
|
||||
from := settleCtx.Result.Payer
|
||||
|
||||
var payloadMap map[string]any
|
||||
if err := json.Unmarshal(settleCtx.PayloadBytes, &payloadMap); err == nil {
|
||||
if payload, ok := payloadMap["payload"].(map[string]interface{}); ok {
|
||||
if auth, ok := payload["Authorization"].(map[string]interface{}); ok {
|
||||
if value, ok := auth["Value"].(string); ok {
|
||||
amount = value
|
||||
}
|
||||
if value, ok := auth["To"].(string); ok {
|
||||
to = value
|
||||
}
|
||||
if value, ok := auth["From"].(string); ok {
|
||||
from = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var reqMap map[string]interface{}
|
||||
if err := json.Unmarshal(settleCtx.RequirementsBytes, &reqMap); err == nil {
|
||||
if value, ok := reqMap["asset"].(string); ok {
|
||||
asset = value
|
||||
}
|
||||
}
|
||||
|
||||
ret, err := db.SaveTransaction(settleCtx.Ctx, store.SaveTransactionParams{
|
||||
X402TransactionHash: settleCtx.Result.Transaction,
|
||||
Amount: amount,
|
||||
Asset: asset,
|
||||
Network: string(settleCtx.Result.Network),
|
||||
Recipient: to,
|
||||
Sender: from,
|
||||
})
|
||||
|
||||
var id int64 = 0
|
||||
if len(ret) > 0 {
|
||||
id = ret[0].ID
|
||||
}
|
||||
|
||||
fields := log.Fields{
|
||||
"transaction_id": id,
|
||||
"X402_transaction_hash": settleCtx.Result.Transaction,
|
||||
"amount": amount,
|
||||
"asset": asset,
|
||||
"network": settleCtx.Result.Network,
|
||||
"recipient": to,
|
||||
"sender": from,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.WithFields(fields).ErrorWithContext(settleCtx.Ctx, "Failed to save transaction", "value", ret, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.WithFields(fields).DebugWithContext(settleCtx.Ctx, "Saved transaction")
|
||||
return nil
|
||||
return settleHook(settleCtx.Ctx, db, settleCtx.Result.Transaction, amount, asset, string(settleCtx.Result.Network), to, from)
|
||||
})
|
||||
|
||||
s := &Server{
|
||||
|
|
@ -140,6 +102,43 @@ func New(ctx context.Context, db *store.Queries, ap *activitypub.APClient, bsky
|
|||
}
|
||||
|
||||
func (s *Server) registerTools() {
|
||||
extra := map[string]any{
|
||||
"OutputSchema": Schema{
|
||||
Input: InputSchema{
|
||||
Type: "HTTP",
|
||||
Method: "POST",
|
||||
Discoverable: true,
|
||||
BodyFields: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did or handle of an actor",
|
||||
Required: true,
|
||||
},
|
||||
"rkey": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Record key of the post",
|
||||
Required: true,
|
||||
},
|
||||
"collection": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Collection the post belongs to. Defaults to 'app.bsky.feed.post'",
|
||||
Required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
Output: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did of the actor",
|
||||
},
|
||||
"post": Bodyfield{
|
||||
Type: "object",
|
||||
Description: "The post object. Includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
requirement := types.PaymentRequirements{
|
||||
Scheme: "exact",
|
||||
Network: "eip155:8453",
|
||||
|
|
@ -147,42 +146,7 @@ func (s *Server) registerTools() {
|
|||
Amount: "10000",
|
||||
PayTo: s.x402Wallet,
|
||||
MaxTimeoutSeconds: 60,
|
||||
Extra: map[string]any{
|
||||
"OutputSchema": Schema{
|
||||
Input: InputSchema{
|
||||
Type: "HTTP",
|
||||
Method: "POST",
|
||||
Discoverable: true,
|
||||
BodyFields: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did or handle of an actor",
|
||||
Required: true,
|
||||
},
|
||||
"rkey": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Record key of the post",
|
||||
Required: true,
|
||||
},
|
||||
"collection": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Collection the post belongs to. Defaults to 'app.bsky.feed.post'",
|
||||
Required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
Output: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did of the actor",
|
||||
},
|
||||
"post": Bodyfield{
|
||||
Type: "object",
|
||||
Description: "The post object. Includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Extra: extra,
|
||||
}
|
||||
requirements := []types.PaymentRequirements{requirement}
|
||||
s.mcpHandler.AddPayableTool(
|
||||
|
|
@ -195,6 +159,38 @@ func (s *Server) registerTools() {
|
|||
requirements...,
|
||||
)
|
||||
|
||||
extra = map[string]any{
|
||||
"OutputSchema": Schema{
|
||||
Input: InputSchema{
|
||||
Type: "HTTP",
|
||||
Method: "POST",
|
||||
Discoverable: true,
|
||||
BodyFields: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did or handle of an actor",
|
||||
Required: true,
|
||||
},
|
||||
"n": Bodyfield{
|
||||
Type: "integer",
|
||||
Description: "Number of posts to retrieve, maximum 50",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Output: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did of an actor",
|
||||
},
|
||||
"posts": Bodyfield{
|
||||
Type: "array",
|
||||
Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
requirement = types.PaymentRequirements{
|
||||
Scheme: "exact",
|
||||
Network: "eip155:8453",
|
||||
|
|
@ -202,37 +198,7 @@ func (s *Server) registerTools() {
|
|||
Amount: "100000",
|
||||
PayTo: s.x402Wallet,
|
||||
MaxTimeoutSeconds: 60,
|
||||
Extra: map[string]any{
|
||||
"OutputSchema": Schema{
|
||||
Input: InputSchema{
|
||||
Type: "HTTP",
|
||||
Method: "POST",
|
||||
Discoverable: true,
|
||||
BodyFields: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did or handle of an actor",
|
||||
Required: true,
|
||||
},
|
||||
"n": Bodyfield{
|
||||
Type: "integer",
|
||||
Description: "Number of posts to retrieve, maximum 50",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Output: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did of an actor",
|
||||
},
|
||||
"posts": Bodyfield{
|
||||
Type: "array",
|
||||
Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Extra: extra,
|
||||
}
|
||||
|
||||
requirements = []types.PaymentRequirements{requirement}
|
||||
|
|
@ -247,6 +213,48 @@ func (s *Server) registerTools() {
|
|||
requirements...,
|
||||
)
|
||||
|
||||
extra = map[string]any{
|
||||
"OutputSchema": Schema{
|
||||
Input: InputSchema{
|
||||
Type: "HTTP",
|
||||
Method: "POST",
|
||||
Discoverable: true,
|
||||
BodyFields: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did or handle of an actor",
|
||||
Required: true,
|
||||
},
|
||||
"start": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Start time in RFC3339 format",
|
||||
Required: true,
|
||||
},
|
||||
"end": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "End time in RFC3339 format",
|
||||
Required: true,
|
||||
},
|
||||
"n": Bodyfield{
|
||||
Type: "integer",
|
||||
Description: "Maximum number of posts to retrieve, maximum 50",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Output: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did of an actor",
|
||||
},
|
||||
"posts": Bodyfield{
|
||||
Type: "array",
|
||||
Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
requirement = types.PaymentRequirements{
|
||||
Scheme: "exact",
|
||||
Network: "eip155:8453",
|
||||
|
|
@ -254,47 +262,7 @@ func (s *Server) registerTools() {
|
|||
Amount: "100000",
|
||||
PayTo: s.x402Wallet,
|
||||
MaxTimeoutSeconds: 60,
|
||||
Extra: map[string]any{
|
||||
"OutputSchema": Schema{
|
||||
Input: InputSchema{
|
||||
Type: "HTTP",
|
||||
Method: "POST",
|
||||
Discoverable: true,
|
||||
BodyFields: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did or handle of an actor",
|
||||
Required: true,
|
||||
},
|
||||
"start": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Start time in RFC3339 format",
|
||||
Required: true,
|
||||
},
|
||||
"end": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "End time in RFC3339 format",
|
||||
Required: true,
|
||||
},
|
||||
"n": Bodyfield{
|
||||
Type: "integer",
|
||||
Description: "Maximum number of posts to retrieve, maximum 50",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Output: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did of an actor",
|
||||
},
|
||||
"posts": Bodyfield{
|
||||
Type: "array",
|
||||
Description: "Array of post objects. Each includes the post content 'content', like 'likes', quote 'quotes', replies 'replies' and repost 'reposts' count",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Extra: extra,
|
||||
}
|
||||
|
||||
requirements = []types.PaymentRequirements{requirement}
|
||||
|
|
@ -309,6 +277,41 @@ func (s *Server) registerTools() {
|
|||
requirements...,
|
||||
)
|
||||
|
||||
extra = map[string]any{
|
||||
"OutputSchema": Schema{
|
||||
Input: InputSchema{
|
||||
Type: "HTTP",
|
||||
Method: "POST",
|
||||
Discoverable: true,
|
||||
BodyFields: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did or handle of an actor",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Output: map[string]any{
|
||||
"PersonaEmbedding": Bodyfield{
|
||||
Type: "array",
|
||||
Description: "Embedding vector representing the actor's persona",
|
||||
},
|
||||
"TopKeywords": Bodyfield{
|
||||
Type: "array",
|
||||
Description: "Top words used by the actor",
|
||||
},
|
||||
"StyleSummary": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Summary of the actor's writing style",
|
||||
},
|
||||
"PostCount": Bodyfield{
|
||||
Type: "integer",
|
||||
Description: "Number of posts analyzed",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
requirement = types.PaymentRequirements{
|
||||
Scheme: "exact",
|
||||
Network: "eip155:8453",
|
||||
|
|
@ -316,40 +319,7 @@ func (s *Server) registerTools() {
|
|||
Amount: "100000",
|
||||
PayTo: s.x402Wallet,
|
||||
MaxTimeoutSeconds: 60,
|
||||
Extra: map[string]any{
|
||||
"OutputSchema": Schema{
|
||||
Input: InputSchema{
|
||||
Type: "HTTP",
|
||||
Method: "POST",
|
||||
Discoverable: true,
|
||||
BodyFields: map[string]any{
|
||||
"actor": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Did or handle of an actor",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Output: map[string]any{
|
||||
"PersonaEmbedding": Bodyfield{
|
||||
Type: "array",
|
||||
Description: "Embedding vector representing the actor's persona",
|
||||
},
|
||||
"TopKeywords": Bodyfield{
|
||||
Type: "array",
|
||||
Description: "Top words used by the actor",
|
||||
},
|
||||
"StyleSummary": Bodyfield{
|
||||
Type: "string",
|
||||
Description: "Summary of the actor's writing style",
|
||||
},
|
||||
"PostCount": Bodyfield{
|
||||
Type: "integer",
|
||||
Description: "Number of posts analyzed",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Extra: extra,
|
||||
}
|
||||
|
||||
requirements = []types.PaymentRequirements{requirement}
|
||||
|
|
@ -729,6 +699,40 @@ func determinePlatform(author string) (platform, error) {
|
|||
return -1, fmt.Errorf("unknown author format: %s", author)
|
||||
}
|
||||
|
||||
func settleHook(ctx context.Context, db *store.Queries, transaction, amount, asset, network, to, from string) error {
|
||||
ret, err := db.SaveTransaction(ctx, store.SaveTransactionParams{
|
||||
X402TransactionHash: transaction,
|
||||
Amount: amount,
|
||||
Asset: asset,
|
||||
Network: network,
|
||||
Recipient: to,
|
||||
Sender: from,
|
||||
})
|
||||
|
||||
var id int64 = 0
|
||||
if len(ret) > 0 {
|
||||
id = ret[0].ID
|
||||
}
|
||||
|
||||
fields := log.Fields{
|
||||
"transaction_id": id,
|
||||
"X402_transaction_hash": transaction,
|
||||
"amount": amount,
|
||||
"asset": asset,
|
||||
"network": network,
|
||||
"recipient": to,
|
||||
"sender": from,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.WithFields(fields).ErrorWithContext(ctx, "Failed to save transaction", "value", ret, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.WithFields(fields).DebugWithContext(ctx, "Saved transaction")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
return s.mcpHandler.Start(s.listenAddress, s.permissions)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ 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
|
||||
|
|
@ -45,7 +46,9 @@ type X402Server struct {
|
|||
tools []ToolInfo
|
||||
}
|
||||
|
||||
func NewX402Server(name, version string, facilitatorURL string, auth x402http.AuthProvider, mws X402Middlewares) (*X402Server, error) {
|
||||
const VERSION1 = "v1/"
|
||||
|
||||
func NewX402Server(name, version string, facilitatorURL string, auth x402http.AuthProvider, mws X402Middlewares, settleHook func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse)) (*X402Server, error) {
|
||||
mcpServer := mcp.NewServer(
|
||||
&mcp.Implementation{
|
||||
Name: name,
|
||||
|
|
@ -75,6 +78,7 @@ func NewX402Server(name, version string, facilitatorURL string, auth x402http.Au
|
|||
mcpServer: mcpServer,
|
||||
x402ResourceServer: resourceServer,
|
||||
facilitatorClient: facilitator,
|
||||
httpSettlementHook: settleHook,
|
||||
mux: http.NewServeMux(),
|
||||
name: name,
|
||||
version: version,
|
||||
|
|
@ -112,7 +116,7 @@ func (s *X402Server) AddTool(tool mcp.Tool, handler mcp.ToolHandler) {
|
|||
restHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleRESTCall(w, r, tool.Name, handler)
|
||||
})
|
||||
s.mux.Handle("POST /v1/"+tool.Name, s.middlewareChain(restHandler))
|
||||
s.mux.Handle("POST /"+VERSION1+tool.Name, s.middlewareChain(restHandler))
|
||||
}
|
||||
|
||||
func (s *X402Server) AddPayableTool(
|
||||
|
|
@ -157,7 +161,7 @@ func (s *X402Server) AddPayableTool(
|
|||
|
||||
paymentMiddleware := nethttp.X402Payment(nethttp.Config{
|
||||
Routes: x402http.RoutesConfig{
|
||||
"POST /" + tool.Name: {
|
||||
"POST /" + VERSION1 + tool.Name: {
|
||||
Accepts: options,
|
||||
Description: tool.Description,
|
||||
},
|
||||
|
|
@ -166,13 +170,14 @@ func (s *X402Server) AddPayableTool(
|
|||
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)
|
||||
})
|
||||
chained := s.middlewareChain(restHandler)
|
||||
s.mux.Handle("POST /"+tool.Name, paymentMiddleware(chained))
|
||||
s.mux.Handle("POST /"+VERSION1+tool.Name, paymentMiddleware(chained))
|
||||
}
|
||||
|
||||
func (s *X402Server) handleRESTCall(w http.ResponseWriter, r *http.Request, toolName string, handler mcp.ToolHandler) {
|
||||
|
|
@ -182,6 +187,8 @@ func (s *X402Server) handleRESTCall(w http.ResponseWriter, r *http.Request, tool
|
|||
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,
|
||||
|
|
@ -215,15 +222,11 @@ func (s *X402Server) OnAfterSettle(hook func(ctx x402.SettleResultContext) error
|
|||
s.x402ResourceServer.OnAfterSettle(hook)
|
||||
}
|
||||
|
||||
func noOpMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
@ -320,25 +323,34 @@ func (s *X402Server) Start(addr string, permissions os.FileMode) error {
|
|||
|
||||
func toJSONSchema(val any) json.RawMessage {
|
||||
if val == nil {
|
||||
return 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 nil
|
||||
return json.RawMessage(`{"type": "object"}`)
|
||||
}
|
||||
bytes, err := json.Marshal(schema)
|
||||
if err != nil {
|
||||
return nil
|
||||
return json.RawMessage(`{"type": "object"}`)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
|
|
|||
206
internal/x402/x402_test.go
Normal file
206
internal/x402/x402_test.go
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
package x402
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
x402 "github.com/x402-foundation/x402/go/v2"
|
||||
x402http "github.com/x402-foundation/x402/go/v2/http"
|
||||
)
|
||||
|
||||
type DummyAuthProvider struct{}
|
||||
|
||||
func (d *DummyAuthProvider) GetAuthHeaders(ctx context.Context) (x402http.AuthHeaders, error) {
|
||||
return x402http.AuthHeaders{}, nil
|
||||
}
|
||||
|
||||
func TestX402Server_HTTPBoundaries(t *testing.T) {
|
||||
mockFacilitator := setupMockFacilitator()
|
||||
defer mockFacilitator.Close()
|
||||
|
||||
srv, err := NewX402Server(
|
||||
"Test Agent API",
|
||||
"1.0.0",
|
||||
mockFacilitator.URL,
|
||||
&DummyAuthProvider{},
|
||||
X402Middlewares{},
|
||||
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
|
||||
srv.AddPayableTool(
|
||||
mcp.Tool{
|
||||
Name: "getTestPost",
|
||||
Description: "Gated test endpoint",
|
||||
},
|
||||
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}},
|
||||
}, nil
|
||||
},
|
||||
x402.PaymentRequirements{
|
||||
Scheme: "exact",
|
||||
Network: "eip155:8453",
|
||||
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
||||
Amount: "1000",
|
||||
PayTo: "0x0000000000000000000000000000000000000000",
|
||||
},
|
||||
)
|
||||
|
||||
reqRec := httptest.NewRecorder()
|
||||
reqHealth, _ := http.NewRequest("GET", "/healthz", nil)
|
||||
srv.mux.ServeHTTP(reqRec, reqHealth)
|
||||
if reqRec.Code != http.StatusOK {
|
||||
t.Errorf("expected healthz status 200, got %d", reqRec.Code)
|
||||
}
|
||||
|
||||
reqRecOpenAPI := httptest.NewRecorder()
|
||||
reqOpenAPI, _ := http.NewRequest("GET", "/openapi.json", nil)
|
||||
srv.mux.ServeHTTP(reqRecOpenAPI, reqOpenAPI)
|
||||
if reqRecOpenAPI.Code != http.StatusOK {
|
||||
t.Errorf("expected OpenAPI status 200, got %d", reqRecOpenAPI.Code)
|
||||
}
|
||||
if !strings.Contains(reqRecOpenAPI.Body.String(), "getTestPost") {
|
||||
t.Error("OpenAPI spec did not render registered tool definitions")
|
||||
}
|
||||
|
||||
reqRecPayable := httptest.NewRecorder()
|
||||
reqPayable, _ := http.NewRequest("POST", "/v1/getTestPost", strings.NewReader(`{}`))
|
||||
srv.mux.ServeHTTP(reqRecPayable, reqPayable)
|
||||
|
||||
if reqRecPayable.Code != http.StatusPaymentRequired {
|
||||
t.Errorf("expected status 402 (Payment Required), got %d", reqRecPayable.Code)
|
||||
}
|
||||
|
||||
payHeader := reqRecPayable.Header().Get("PAYMENT-REQUIRED")
|
||||
if payHeader == "" {
|
||||
t.Error("missing PAYMENT-REQUIRED response header on 402 challenge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestX402Server_MCPBoundaries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
serverTransport, clientTransport := mcp.NewInMemoryTransports()
|
||||
|
||||
mockFacilitator := setupMockFacilitator()
|
||||
defer mockFacilitator.Close()
|
||||
|
||||
srv, err := NewX402Server(
|
||||
"Test Agent API",
|
||||
"1.0.0",
|
||||
mockFacilitator.URL,
|
||||
&DummyAuthProvider{},
|
||||
X402Middlewares{},
|
||||
func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse) {},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
|
||||
srv.AddPayableTool(
|
||||
mcp.Tool{
|
||||
Name: "getTestPost",
|
||||
Description: "Gated test endpoint",
|
||||
},
|
||||
func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: `{"status": "success"}`}},
|
||||
}, nil
|
||||
},
|
||||
x402.PaymentRequirements{
|
||||
Scheme: "exact",
|
||||
Network: "eip155:8453",
|
||||
Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
||||
Amount: "1000",
|
||||
PayTo: "0x0000000000000000000000000000000000000000",
|
||||
},
|
||||
)
|
||||
|
||||
serverSession, err := srv.mcpServer.Connect(ctx, serverTransport, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect server session: %v", err)
|
||||
}
|
||||
defer serverSession.Close()
|
||||
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil)
|
||||
clientSession, err := client.Connect(ctx, clientTransport, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect client session: %v", err)
|
||||
}
|
||||
defer clientSession.Close()
|
||||
|
||||
toolsResponse, err := clientSession.ListTools(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list tools over MCP: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, tool := range toolsResponse.Tools {
|
||||
if tool.Name == "getTestPost" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected tool 'getTestPost' to be discovered by the MCP client, but it was missing")
|
||||
}
|
||||
|
||||
result, err := clientSession.CallTool(ctx, &mcp.CallToolParams{
|
||||
Name: "getTestPost",
|
||||
Arguments: json.RawMessage(`{}`),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected protocol-level error: %v", err)
|
||||
} else {
|
||||
if !result.IsError {
|
||||
t.Error("expected CallTool to return a payment challenge, but it returned a successful, free result")
|
||||
} else {
|
||||
t.Logf("Intercepted expected application-level payment challenge.")
|
||||
t.Logf("Content: %v", result.Content)
|
||||
t.Logf("Meta fields: %v", result.Meta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupMockFacilitator() *httptest.Server {
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/supported":
|
||||
_, _ = w.Write([]byte(`{
|
||||
"kinds": [
|
||||
{
|
||||
"x402Version": 2,
|
||||
"scheme": "exact",
|
||||
"network": "eip155:8453"
|
||||
}
|
||||
]
|
||||
}`))
|
||||
case r.Method == "POST" && r.URL.Path == "/verify":
|
||||
_, _ = w.Write([]byte(`{
|
||||
"isValid": true,
|
||||
"invalidReason": ""
|
||||
}`))
|
||||
case r.Method == "POST" && r.URL.Path == "/settle":
|
||||
_, _ = w.Write([]byte(`{
|
||||
"success": true,
|
||||
"transaction": "0x1234abcd",
|
||||
"network": "eip155:8453",
|
||||
"payer": "0xabcd1234"
|
||||
}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
return s
|
||||
}
|
||||
Loading…
Reference in a new issue