Files
Abhishek Krishna 6313934e12 feat(mcp/go): propagate x402 in the Go HTTP layer
The unified fetch tool surfaced HTTP 402 + X-Payment plumbing in PR #5
(TypeScript). The Go HTTP layer mounted in routes.go (HIP-0106) did not
propagate the same envelope: a 402 payment-required tool result was
returned as an opaque HTTP 200 carrying an in-band JSON marker, and the
incoming X-Payment header was never forwarded into the tool params, so
agents could not replay a payment quote via the Go surface.

This change wires the same two pieces through routes.go:

  - Request path: X-Payment header is plumbed into the tool params under
    the payment key before invoker dispatch. Caller-supplied payment
    objects (the structured shape) are preserved unchanged.
  - Response path: a tool result envelope of the shape
    {"status":402,"payment_required":{...}} is mapped to HTTP 402 with
    WWW-Authenticate: x402 on the wire. The body is forwarded verbatim
    so the caller still sees the accepts[] payment requirements.

Both handleCallTool (POST /v1/mcp/tools/:name) and handleFetch
(POST /v1/mcp/fetch shortcut) get the same treatment, so the agent path
is identical whether the model talks to the generic tool route or the
fetch shortcut.

No public type or interface change. The 200 happy path remains unchanged
when no X-Payment header is sent and the tool result is not a 402
envelope.

Tests
-----
go test ./... -run "TestCallTool|TestFetchShortcut|TestMount" -count=1 -v

12 PASS:
  - 5 existing TestMount* (no regression)
  - TestCallTool_XPaymentHeaderPlumbedIntoToolParams
  - TestCallTool_XPaymentMissing_NoOp
  - TestCallTool_XPaymentDoesNotOverwriteExisting
  - TestCallTool_PaymentRequiredEnvelopeMappedTo402
  - TestCallTool_RegularEnvelopeStays200
  - TestCallTool_StatusFieldAloneIsNotEnough
  - TestFetchShortcut_XPaymentPlumbedAndPropagated

Note: hanzomcp/ CGO link failure (missing prebuilt rust lib hanzo_mcp)
is environmental, present on main, and unrelated to this change. The
pure-Go route layer this PR modifies builds and tests cleanly.
2026-06-01 11:01:59 +00:00

48 lines
1.5 KiB
Go

package mcp
import "encoding/json"
// injectPayment merges the X-Payment header value into the JSON params body
// under the "payment" key so the unified fetch tool can replay the payment
// per x402. If the body is empty or not a JSON object, returns a minimal
// {"payment": v} payload. If the caller already supplied a payment field
// (e.g. a structured payment object), it is preserved unchanged. Returns
// body verbatim when v is empty.
func injectPayment(body []byte, v string) []byte {
if v == "" {
return body
}
var m map[string]any
if len(body) == 0 || json.Unmarshal(body, &m) != nil {
out, _ := json.Marshal(map[string]any{"payment": v})
return out
}
if _, exists := m["payment"]; exists {
out, _ := json.Marshal(m)
return out
}
m["payment"] = v
out, _ := json.Marshal(m)
return out
}
// paymentRequiredFromEnvelope returns true iff raw is a JSON object emitted
// by the unified fetch tool to surface an x402 402 Payment Required
// response, of the shape:
//
// { "status": 402, "payment_required": { ... } }
//
// The HTTP layer maps this to a real 402 on the wire (with
// WWW-Authenticate: x402) so downstream agents see a Payment-Required
// response instead of an opaque 200 carrying an in-band marker.
func paymentRequiredFromEnvelope(raw []byte) bool {
var env struct {
Status int `json:"status"`
PaymentRequired json.RawMessage `json:"payment_required"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return false
}
return env.Status == 402 && len(env.PaymentRequired) > 0
}