sandermuller / laravel-x402-mcp
Bridge between laravel/mcp and laravel-x402 — gate MCP tools behind x402 stablecoin payments.
Requires
- php: ^8.2
- illuminate/support: ^11.0|^12.0
- laravel/mcp: ^0.6 || ^0.7
- sandermuller/laravel-x402: ^0.2
Requires (Dev)
- dg/bypass-finals: ^1.9
- driftingly/rector-laravel: ^2.3
- larastan/larastan: ^3.0
- laravel/pint: ^1.29
- mockery/mockery: ^1.6
- mrpunyapal/rector-pest: ^0.2
- nunomaduro/collision: ^8.0
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-arch: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.0
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- rector/rector: ^2.0
- rector/type-perfect: ^2.1
- sandermuller/package-boost: ^0.14
- spaze/phpstan-disallowed-calls: ^4.10
- symplify/phpstan-extensions: ^12.0
- tomasvotruba/cognitive-complexity: ^1.1
- tomasvotruba/type-coverage: ^2.1
This package is auto-updated.
Last update: 2026-05-09 11:29:30 UTC
README
Gate laravel/mcp tools behind x402 stablecoin payments. Conformant with the x402 v2 MCP transport spec (specs/transports-v2/mcp.md).
Bridge between sandermuller/laravel-x402 (^0.2) and laravel/mcp (^0.6 || ^0.7). Annotate paid tools with the #[X402Price] attribute. Agents include the signed payment payload in params._meta["x402/payment"] (JSON-RPC level — not an HTTP header). The advertised price travels back on tools/list via _meta["x402/price"].
Install
composer require sandermuller/laravel-x402-mcp
The bridge inherits its facilitator wiring, recipient address, and asset config from laravel-x402. Run that package's installer first:
php artisan x402:install
This sets X402_RECIPIENT and (optionally) X402_PRIVATE_KEY in .env and publishes the config/x402.php file. Verify with php artisan x402:verify-config.
Usage
1. Annotate a paid tool
use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; use X402\Laravel\Mcp\Attributes\X402Price; #[X402Price(amount: '0.01', asset: 'USDC', network: 'base')] final class FetchPremiumData extends Tool { public function description(): string { return 'Premium dataset. Costs $0.01 USDC on Base.'; } public function handle(Request $request): Response { // Runs only after payment is settled. return Response::json(['data' => '...']); } }
payTo overrides the global x402.recipient for a specific tool:
#[X402Price(amount: '5.00', asset: 'USDC', network: 'base', payTo: '0xa11ce...')]
2. Wire the gating method handlers on your Server
use Laravel\Mcp\Server\Server; use X402\Laravel\Mcp\Server\Concerns\WithX402Payment; final class MyMcpServer extends Server { use WithX402Payment; protected array $tools = [ FetchPremiumData::class, ]; }
The WithX402Payment trait registers two method handlers when the server starts:
tools/list→X402ListTools— advertises priced tools as_meta["x402/price"]so agents know the price before invoking.tools/call→X402CallTool— gates priced tools behind a verified + settled payment; passes free tools through unchanged.
The trait hooks on start(), not boot(), so any subclass overriding boot() still gets x402 gating without having to know about this trait. If you want to opt out of trait defaults — for example to register your own tools/call handler — use addMethod() inside boot(); explicit registrations made there win over the trait. If you also override start(), call parent::start() so the trait runs.
3. What X402CallTool does
- Looks up the invoked tool, checks for
#[X402Price]. - If unpriced — passes through to the standard
CallTool. - If priced — reads
params._meta["x402/payment"]for the signed payload, verifies + settles via the boundFacilitatorClient, then runs the tool. - On success, injects
result._meta["x402/payment-response"]with the settlement receipt. - On any failure, returns a tool result with
isError: true+structuredContent: PaymentRequired+content[0].text(JSON-stringified).
The replay store from laravel-x402 is reused — concurrent requests with the same authorization are rejected before hitting the facilitator.
Wire format
Per specs/transports-v2/mcp.md:
| Direction | Location | Shape |
|---|---|---|
| Client → Server (payment) | params._meta["x402/payment"] |
PaymentPayload v2 envelope |
| Server → Client (settled) | result._meta["x402/payment-response"] |
{success, transaction, network, payer} |
| Server → Client (required) | result.structuredContent + result.content[0].text + result.isError = true |
PaymentRequired |
| Server → Client (advertised) | tools[i]._meta["x402/price"] on tools/list |
{amount, asset, network[, payTo]} |
The HTTP-level PAYMENT-SIGNATURE / PAYMENT-RESPONSE headers used by the x402 HTTP transport are NOT used in MCP — payment travels at the JSON-RPC layer, inside the request/response body.
Stdio transport
Stdio MCP servers can also receive _meta["x402/payment"] because _meta is a JSON-RPC field, not an HTTP envelope. Paid tools work on stdio as well as HTTP.
Operator commands
php artisan x402-mcp:list-tools "App\\Mcp\\Servers\\MyMcpServer"
Lists every tool on the given Server class, marking gated tools with their amount, asset, network, and payTo (or (default) when not overridden). Free tools render as (free). Mirrors x402:list-routes from laravel-x402 for the JSON-RPC transport.
Testing
laravel-x402 ships a recording fake. Swap it in once at the top of a test and the bridge picks it up automatically:
use X402\Laravel\Facades\X402; $fake = X402::fake(); // Drive the MCP server however you normally would (HTTP, stdio, in-process). $this->postJson('/mcp', $jsonRpcCall)->assertOk(); $fake->assertVerified('mcp://tool/fetch-premium-data'); $fake->assertSettled('mcp://tool/fetch-premium-data');
PaymentSettled / PaymentRejected events still fire through DispatchingFacilitator, so Event::fake([PaymentSettled::class]) composes alongside.
License
MIT.