Skip to content

feat(payments): add MPP (Machine Payments Protocol) support - #620

Open
rajuans wants to merge 2 commits into
aws:mainfrom
rajuans:feature/mpp-payment-support
Open

feat(payments): add MPP (Machine Payments Protocol) support#620
rajuans wants to merge 2 commits into
aws:mainfrom
rajuans:feature/mpp-payment-support

Conversation

@rajuans

@rajuans rajuans commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Add MPP alongside the existing x402 protocol in ProcessPayment. MPP servers answer 402 with WWW-Authenticate: Payment challenges; the SDK selects the one challenge the payment instrument can satisfy and mints an Authorization header.

Core:

  • mpp.py: parse WWW-Authenticate challenges and select one (charge-intent, unexpired, method the instrument satisfies: evm/tempo->ETHEREUM, solana->SOLANA; ordered by NETWORK_PREFERENCES). The selected challenge is forwarded verbatim so the challenge HMAC stays valid.
  • generate_payment_header auto-detects protocol from the 402 and returns {"Authorization": "Payment "} for MPP; x402 path unchanged.
  • Optional buyer_pays_gas_fees passthrough (MPP buyerPaysGasFees).
  • _model_patch.py: inject MPP shapes into the loaded botocore model when the installed release predates them; idempotent, no-op once shipped natively, never mutates installed botocore.

Integrations:

  • Strands plugin and LangGraph middleware detect MPP 402s (WWW-Authenticate) and settle them; buyer_pays_gas_fees exposed on the shared config.

Tests: unit coverage for parsing, selection, model patch, manager routing, and both integrations; integ tests gated on TEST_* env vars.

Verified against the live mpp.dev 402 and prod ProcessPayment (request shape accepted; end-to-end settlement pending account MPP entitlement).

Issue #, if available:

Description of changes:

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Add MPP alongside the existing x402 protocol in ProcessPayment. MPP servers
answer 402 with WWW-Authenticate: Payment challenges; the SDK selects the one
challenge the payment instrument can satisfy and mints an Authorization header.

Core:
- mpp.py: parse WWW-Authenticate challenges and select one (charge-intent,
  unexpired, method the instrument satisfies: evm/tempo->ETHEREUM, solana->SOLANA;
  ordered by NETWORK_PREFERENCES). The selected challenge is forwarded verbatim so
  the challenge HMAC stays valid.
- generate_payment_header auto-detects protocol from the 402 and returns
  {"Authorization": "Payment <token>"} for MPP; x402 path unchanged.
- Optional buyer_pays_gas_fees passthrough (MPP buyerPaysGasFees).
- _model_patch.py: inject MPP shapes into the loaded botocore model when the
  installed release predates them; idempotent, no-op once shipped natively,
  never mutates installed botocore.

Integrations:
- Strands plugin and LangGraph middleware detect MPP 402s (WWW-Authenticate)
  and settle them; buyer_pays_gas_fees exposed on the shared config.

Tests: unit coverage for parsing, selection, model patch, manager routing, and
both integrations; integ tests gated on TEST_* env vars.

Verified against the live mpp.dev 402 and prod ProcessPayment (request shape
accepted; end-to-end settlement pending account MPP entitlement).
@rajuans
rajuans requested a review from a team August 4, 2026 22:21
@github-actions github-actions Bot added the size/xl PR size: XL label Aug 4, 2026
@codecov-commenter

codecov-commenter commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.38503% with 21 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@53b0b48). Learn more about missing BASE report.

Files with missing lines Patch % Lines
src/bedrock_agentcore/payments/mpp.py 93.05% 7 Missing and 8 partials ⚠️
src/bedrock_agentcore/payments/_model_patch.py 93.61% 2 Missing and 1 partial ⚠️
...edrock_agentcore/payments/integrations/handlers.py 92.85% 1 Missing and 1 partial ⚠️
src/bedrock_agentcore/payments/manager.py 98.43% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #620   +/-   ##
=======================================
  Coverage        ?   88.66%           
=======================================
  Files           ?      115           
  Lines           ?     9813           
  Branches        ?     1504           
=======================================
  Hits            ?     8701           
  Misses          ?      739           
  Partials        ?      373           
Flag Coverage Δ
unittests 88.66% <94.38%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

# leaves the protocol default in place rather than asserting a choice the
# caller did not make.
if buyer_pays_gas_fees is not None:
mpp_input["buyerPaysGasFees"] = bool(buyer_pays_gas_fees)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bool() treats every non-empty string as true, so buyer_pays_gas_fees="false" sends buyerPaysGasFees: true and authorizes additional wallet charges. Please require None or an actual bool and forward it unchanged, matching the strict integration-config validation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great finding!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 41da888 — good catch, this was a real money-moving bug.

bool() is gone. The value must now be None or an actual bool and is forwarded unchanged; anything else raises PaymentError rather than being coerced. This matches the strict integration-config validation as you suggested.

Added a regression test for the exact case you named (buyer_pays_gas_fees="false" must never authorize charges), plus a parametrized test covering "false", "true", "no", 1, 0, [], {} — all rejected, and process_payment is never called.

Comment on lines +1045 to +1055
if is_mpp_payment_required(payment_required_request):
logger.debug("Detected MPP payment required request")
return self._generate_mpp_payment_header(
payment_instrument_id=payment_instrument_id,
payment_session_id=payment_session_id,
payment_required_request=payment_required_request,
user_id=user_id,
network_preferences=network_preferences,
client_token=client_token,
buyer_pays_gas_fees=buyer_pays_gas_fees,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline MPP/x402 endpoints intentionally advertise both protocols. This unconditional branch chooses MPP whenever any Payment challenge exists, then fails if that challenge does not match the instrument without inspecting a satisfiable x402 option. Should we fall back to x402 when MPP selection is unsatisfiable and an x402 requirement is present?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and fixed in 41da888 — MPP now falls back to x402 when no advertised challenge is satisfiable and the same 402 carries a usable x402 requirement.

Behavior:

402 contents Outcome
Satisfiable MPP challenge (± x402) MPP → Authorization
Unsatisfiable MPP + usable x402 accepts Falls back to x402 → X-PAYMENT
Unsatisfiable MPP, no usable x402 PaymentError

One deliberate constraint worth flagging: the fallback catches only MppChallengeSelectionError, which is raised before anything is submitted. A failure after the payment reaches the service (insufficient budget, malformed output, verification failure) still propagates — falling back at that point could charge the buyer twice. There is a test asserting exactly one process_payment call in that case.

To make this work, _generate_mpp_payment_header now lets MppChallengeSelectionError propagate instead of converting it to PaymentError, so the dispatcher can tell a selection failure apart from a submission failure. Callers still see a PaymentError when no fallback exists.

_has_x402_payment_required is read-only and non-raising, so a malformed x402 payload reports "no fallback available" rather than masking the original MPP error. Empty accepts, missing x402Version, and absent body are all treated as no-fallback.

MPP_SOLANA_NETWORK_ALIASES = {
"mainnet": "solana-mainnet",
"devnet": "solana-devnet",
"localnet": "solana-testnet",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solana MPP uses localnet as a distinct local RPC/Surfpool environment, not Solana testnet. This alias lets a local-only challenge satisfy a solana-testnet preference and beat a payable devnet challenge. Should we keep localnet distinct or unranked unless explicitly supported?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, it should be just devnet/testnet.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 41da888 — the localnet alias is removed, so the map is now mainnet/devnet/testnet only (matching @rajuans' confirmation).

localnet is now left unranked rather than misranked: challenge_network() returns None, so it sorts last on preference order but stays selectable when it is the only advertised option. That keeps a local Surfpool setup usable for development without ever letting it outrank a payable public-network challenge.

Two tests cover the regression you identified:

  • a localnet + devnet pair must select devnet
  • localnet alone is still selectable

Three fixes from PR review, plus Strands plugin MPP integration tests.

1. buyer_pays_gas_fees is no longer coerced with bool(). Every non-empty string
   is truthy, so buyer_pays_gas_fees="false" sent buyerPaysGasFees: true and
   authorized additional wallet charges. Now requires None or an actual bool and
   forwards it unchanged, matching the integration-config validation.

2. Fall back to x402 when no advertised MPP challenge is satisfiable and the same
   402 carries a usable x402 requirement. Endpoints that advertise both protocols
   previously failed outright whenever the MPP challenge did not match the
   instrument, without considering a payable x402 option. The fallback is limited
   to selection failures (pre-submission); a failure after the payment was
   submitted still propagates, so a buyer can never be charged twice.

3. Drop the localnet -> solana-testnet alias. localnet is a distinct local
   RPC/Surfpool environment, and the alias let a local-only challenge satisfy a
   solana-testnet preference and outrank a payable devnet challenge. Unmapped
   networks are now unranked rather than misranked: still selectable when they are
   the only option, but never winning on preference order.

Also adds MPP coverage to tests_integ Strands plugin tests: live mpp.dev challenge
detection, verification that http_request preserves the WWW-Authenticate header,
and hook-flow tests for signing, post-payment rejection, selection failure, and
independent x402/MPP state.
@github-actions github-actions Bot added size/xl PR size: XL and removed size/xl PR size: XL labels Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/xl PR size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants