diff --git a/README.md b/README.md index e26bba8..a773063 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ After all, UNIX-compatible shell script is THE most universal coding language. ![mcpc screenshot](https://raw.githubusercontent.com/apify/mcpc/main/docs/images/mcpc-demo.gif) **Key features:** + - 🌎 **Compatible** - Works with any MCP server over Streamable HTTP or stdio. - 🔄 **Persistent sessions** - Keep multiple server connections alive simultaneously. - 🔧 **Strong MCP support** - Instructions, tools, resources, prompts, dynamic discovery. @@ -17,7 +18,7 @@ After all, UNIX-compatible shell script is THE most universal coding language. - ðŸĪ– **AI sandboxing** - MCP proxy server to securely access authenticated sessions from AI-generated code. - 🔒 **Secure** - Full OAuth 2.1 support, OS keychain for credentials storage. - ðŸŠķ **Lightweight** - Minimal dependencies, works on Mac/Win/Linux, doesn't use LLMs on its own. - +- ðŸ’ļ **[Agentic payments (x402)](#agentic-payments-x402)** - Experimental support for the [x402](https://www.x402.org/) payment protocol, enabling AI agents to pay for MCP tool calls with USDC on [Base](https://www.base.org/). ## Table of contents @@ -31,6 +32,7 @@ After all, UNIX-compatible shell script is THE most universal coding language. - [Authentication](#authentication) - [MCP proxy](#mcp-proxy) - [AI agents](#ai-agents) +- [Agentic payments (x402)](#agentic-payments-x402) - [MCP support](#mcp-support) - [Configuration](#configuration) - [Security](#security) @@ -51,6 +53,7 @@ npm install -g @apify/mcpc [Secret Service API](https://specifications.freedesktop.org/secret-service/). Two things are required: 1. **`libsecret`** — the shared library (client side): + ```bash # Debian/Ubuntu sudo apt-get install libsecret-1-0 @@ -64,6 +67,7 @@ npm install -g @apify/mcpc 2. **A running secret service daemon** — on desktop systems (GNOME, KDE) this is already provided by gnome-keyring or KWallet. On headless/server/CI environments you need to install and start one: + ```bash # Debian/Ubuntu sudo apt-get install gnome-keyring @@ -117,6 +121,7 @@ Options: --timeout Request timeout in seconds (default: 300) --proxy <[host:]port> Start proxy MCP server for session (with "connect" command) --proxy-bearer-token Require authentication for access to proxy server + --x402 Enable x402 auto-payment using the configured wallet --clean[=types] Clean up mcpc data (types: sessions, logs, profiles, all) -h, --help Display general help @@ -148,7 +153,14 @@ MCP server commands: resources-templates-list logging-set-level ping - + +x402 payment commands (no target needed): + x402 init Create a new x402 wallet + x402 import Import wallet from private key + x402 info Show wallet info + x402 sign -r Sign payment from PAYMENT-REQUIRED header + x402 remove Remove the wallet + Run "mcpc" without to show available sessions and profiles. ``` @@ -181,6 +193,7 @@ To connect and interact with an MCP server, you need to specify a ``, wh connects, and enables you to interact with it. **URL handling:** + - URLs without a scheme (e.g. `mcp.apify.com`) default to `https://` - `localhost` and `127.0.0.1` addresses without a scheme default to `http://` (for local dev/proxy servers) - To override the default, specify the scheme explicitly (e.g. `http://example.com`) @@ -229,6 +242,7 @@ cat args.json | mcpc tools-call ``` **Rules:** + - All arguments use `:=` syntax: `key:=value` - Values are auto-parsed: valid JSON becomes that type, otherwise treated as string - `count:=10` → number `10` @@ -257,6 +271,7 @@ echo "{\"query\": \"${QUERY}\", \"limit\": 10}" | mcpc @server tools-call search ``` **Common pitfall:** Don't put spaces around `:=` - it won't work: + ```bash # Wrong - spaces around := mcpc @server tools-call search query := "hello world" @@ -329,7 +344,7 @@ Still, sessions can fail due to network disconnects, bridge process crash, or se **Session states:** | State | Meaning | -|------------------|-----------------------------------------------------------------------------------------------| +| ---------------- | --------------------------------------------------------------------------------------------- | | ðŸŸĒ **`live`** | Bridge process is running; server might or might not be operational | | ðŸŸĄ **`crashed`** | Bridge process crashed or was killed; will auto-restart on next use | | ðŸ”ī **`expired`** | Server rejected the session (auth failed, session ID invalid); requires `close` and reconnect | @@ -365,7 +380,6 @@ and opens new connection with new `MCP-Session-Id`, by running: mcpc @apify restart ``` - ## Authentication `mcpc` supports all standard [MCP authorization methods](https://modelcontextprotocol.io/specification/latest/basic/authorization). @@ -404,8 +418,8 @@ mcpc @apify tools-list ### OAuth profiles -For OAuth-enabled remote MCP servers, `mcpc` implements the full OAuth 2.1 flow with PKCE, -including `WWW-Authenticate` header discovery, server metadata discovery, client ID metadata documents, +For OAuth-enabled remote MCP servers, `mcpc` implements the full OAuth 2.1 flow with PKCE, +including `WWW-Authenticate` header discovery, server metadata discovery, client ID metadata documents, dynamic client registration, and automatic token refresh. The OAuth authentication **always** needs to be initiated by the user calling the `login` command, @@ -413,11 +427,13 @@ which opens a web browser with login screen. `mcpc` never opens the web browser The OAuth credentials to specific servers are securely stored as **authentication profiles** - reusable credentials that allow you to: + - Authenticate once, use credentials across multiple commands or sessions - Use different accounts (profiles) with the same server - Manage credentials independently from sessions Key concepts: + - **Authentication profile**: Named set of OAuth credentials for a specific server (stored in `~/.mcpc/profiles.json` + OS keychain) - **Session**: Active connection to a server that may reference an authentication profile (stored in `~/.mcpc/sessions.json`) - **Default profile**: When `--profile` is not specified, `mcpc` uses the authentication profile named `default` @@ -456,7 +472,6 @@ When multiple authentication methods are available, `mcpc` uses this precedence 3. **Config file headers** - Headers from `--config` file for the server 4. **No authentication** - Attempts unauthenticated connection - `mcpc` automatically handles authentication based on whether you specify a profile: **When `--profile ` is specified:** @@ -478,6 +493,7 @@ When multiple authentication methods are available, `mcpc` uses this precedence On failure, the error message includes instructions on how to login and save the profile, so you know what to do. This flow ensures: + - You only authenticate when necessary - Credentials are never silently mixed up (personal → work) or downgraded (authenticated → unauthenticated) - You can mix authenticated sessions (with named profiles) and public access on the same server @@ -500,7 +516,6 @@ mcpc mcp.apify.com connect @apify-personal mcpc mcp.apify.com\?tools=docs tools-list ``` - ## MCP proxy For stronger isolation, `mcpc` can expose an MCP session under a new local proxy MCP server using the `--proxy` option. @@ -534,7 +549,7 @@ mcpc localhost:8081 connect @sandboxed2 --header "Authorization: Bearer secret12 **Proxy options for `connect` command:** | Option | Description | -|--------------------------------|--------------------------------------------------------------------------------| +| ------------------------------ | ------------------------------------------------------------------------------ | | `--proxy [host:]port` | Start proxy MCP server. Default host: `127.0.0.1` (localhost only) | | `--proxy-bearer-token ` | Requires `Authorization: Bearer ` header to access the proxy MCP server | @@ -565,7 +580,6 @@ mcpc # @relay → https://mcp.apify.com (HTTP, OAuth: default) [proxy: 127.0.0.1:8080] ``` - ## AI agents `mcpc` is designed for CLI-enabled AI agents like Claude Code or Codex CLI, supporting both @@ -635,6 +649,7 @@ mcpc @apify tools-call search-actors --schema expected.json keywords:="test" ``` Available schema validation modes (`--schema-mode`): + - `compatible` (default) - Input schema: new optional fields OK, required fields must have the same type. - Output schema: new fields OK, removed required fields cause error. @@ -668,6 +683,73 @@ To help Claude Code use `mcpc`, you can install this [Claude skill](./docs/claud +## Agentic payments (x402) + +> ⚠ïļ **Experimental.** This feature is under active development and may change. + +`mcpc` has experimental support for the [x402 payment protocol](https://www.x402.org/), +which enables AI agents to autonomously pay for MCP tool calls using cryptocurrency. +When an MCP server charges for a tool call (HTTP 402), `mcpc` automatically signs a USDC payment +on the [Base](https://base.org/) blockchain and retries the request — no human intervention needed. + +This is entirely **opt-in**: existing functionality is unaffected unless you explicitly pass the `--x402` flag. + +### How it works + +1. **Server returns HTTP 402** with a `PAYMENT-REQUIRED` header describing the price and payment details. +2. `mcpc` parses the header, signs an [EIP-3009](https://eips.ethereum.org/EIPS/eip-3009) `TransferWithAuthorization` using your local wallet. +3. `mcpc` retries the request with a `PAYMENT-SIGNATURE` header containing the signed payment. +4. The server verifies the signature and fulfills the request. + +For tools that advertise pricing in their `_meta.x402` metadata, `mcpc` can **proactively sign** payments +on the first request, avoiding the 402 round-trip entirely. + +### Wallet setup + +`mcpc` stores a single wallet in `~/.mcpc/wallets.json` (file permissions `0600`). +You need to create or import a wallet before using x402 payments. + +```bash +# Create a new wallet (generates a random private key) +mcpc x402 init + +# Or import an existing wallet from a private key +mcpc x402 import + +# Show wallet address and creation date +mcpc x402 info + +# Remove the wallet +mcpc x402 remove +``` + +After creating a wallet, **fund it with USDC on Base** (mainnet or Sepolia testnet) to enable payments. + +### Using x402 with MCP servers + +Pass the `--x402` flag when connecting to a session or running direct commands: + +```bash +# Create a session with x402 payment support +mcpc mcp.apify.com connect @apify --x402 + +# The session now automatically handles 402 responses +mcpc @apify tools-call expensive-tool query:="hello" + +# Restart a session with x402 enabled +mcpc @apify restart --x402 +``` + +When `--x402` is active, a fetch middleware wraps all HTTP requests to the MCP server. +If any request returns HTTP 402, the middleware transparently signs and retries. + +### Supported networks + +| Network | Status | +| -------------------- | ------------ | +| Base Mainnet | ✅ Supported | +| Base Sepolia testnet | ✅ Supported | + ## MCP support `mcpc` is built on the official [MCP SDK for TypeScript](https://github.com/modelcontextprotocol/typescript-sdk) and supports most [MCP protocol features](https://modelcontextprotocol.io/specification/latest). @@ -688,6 +770,7 @@ To help Claude Code use `mcpc`, you can install this [Claude skill](./docs/claud ### MCP session The bridge process manages the full MCP session lifecycle: + - Performs initialization handshake (`initialize` → `initialized`) - Negotiates protocol version and capabilities - Fetches server-provided `instructions` @@ -698,21 +781,21 @@ The bridge process manages the full MCP session lifecycle: ### MCP feature support -| **Feature** | **Status** | -|:---------------------------------------------------|:-----------------------------------| -| 📖 [**Instructions**](#server-instructions) | ✅ Supported | -| 🔧 [**Tools**](#tools) | ✅ Supported | -| 💎 [**Prompts**](#prompts) | ✅ Supported | -| ðŸ“Ķ [**Resources**](#resources) | ✅ Supported | -| 📝 [**Logging**](#server-logs) | ✅ Supported | -| 🔔 [**Notifications**](#list-change-notifications) | ✅ Supported | -| 📄 [**Pagination**](#pagination) | ✅ Supported | -| 🏓 [**Ping**](#ping) | ✅ Supported | -| âģ **Async tasks** | 🚧 Planned | -| 📁 **Roots** | 🚧 Planned | -| ❓ **Elicitation** | 🚧 Planned | -| ðŸ”Ī **Completion** | 🚧 Planned | -| ðŸĪ– **Sampling** | ❌ Not applicable (no LLM access) | +| **Feature** | **Status** | +| :------------------------------------------------- | :-------------------------------- | +| 📖 [**Instructions**](#server-instructions) | ✅ Supported | +| 🔧 [**Tools**](#tools) | ✅ Supported | +| 💎 [**Prompts**](#prompts) | ✅ Supported | +| ðŸ“Ķ [**Resources**](#resources) | ✅ Supported | +| 📝 [**Logging**](#server-logs) | ✅ Supported | +| 🔔 [**Notifications**](#list-change-notifications) | ✅ Supported | +| 📄 [**Pagination**](#pagination) | ✅ Supported | +| 🏓 [**Ping**](#ping) | ✅ Supported | +| âģ **Async tasks** | 🚧 Planned | +| 📁 **Roots** | 🚧 Planned | +| ❓ **Elicitation** | 🚧 Planned | +| ðŸ”Ī **Completion** | 🚧 Planned | +| ðŸĪ– **Sampling** | ❌ Not applicable (no LLM access) | #### Server instructions @@ -865,6 +948,7 @@ mcpc @apify ping --json You can configure `mcpc` using a config file, environment variables, or command-line flags. **Precedence** (highest to lowest): + 1. Command-line flags (including `--config` option) 2. Environment variables 3. Built-in defaults @@ -912,11 +996,13 @@ mcpc --config .vscode/mcp.json apify connect @my-apify **Server configuration properties:** For **Streamable HTTP servers:** + - `url` (required) - MCP server endpoint URL - `headers` (optional) - HTTP headers to include with requests - `timeout` (optional) - Request timeout in seconds For **stdio servers:** + - `command` (required) - Command to execute (e.g., `node`, `npx`, `python`) - `args` (optional) - Array of command arguments - `env` (optional) - Environment variables for the process @@ -958,6 +1044,7 @@ Config files support environment variable substitution using `${VAR_NAME}` synta - `~/.mcpc/sessions.json` - Active sessions with references to authentication profiles (file-locked for concurrent access) - `~/.mcpc/profiles.json` - Authentication profiles (OAuth metadata, scopes, expiry) +- `~/.mcpc/wallets.json` - x402 wallet data (file permissions `0600`) - `~/.mcpc/bridges/` - Unix domain socket files for each bridge process - `~/.mcpc/logs/bridge-*.log` - Log files for each bridge process - OS keychain - Sensitive credentials (OAuth tokens, bearer tokens, client secrets) @@ -1000,11 +1087,12 @@ MCP enables arbitrary tool execution and data access - treat servers like you tr ### Credential protection | What | How | -|------------------------|-------------------------------------------------| +| ---------------------- | ----------------------------------------------- | | **OAuth tokens** | Stored in OS keychain, never on disk | | **HTTP headers** | Stored in OS keychain per-session | | **Bridge credentials** | Passed via Unix socket IPC, kept in memory only | | **Process arguments** | No secrets visible in `ps aux` | +| **x402 private key** | Stored in `wallets.json` (`0600` permissions) | | **Config files** | Contain only metadata, never tokens | | **File permissions** | `0600` (user-only) for all config files | @@ -1055,20 +1143,22 @@ The main `mcpc` process doesn't save log files, but supports [verbose mode](#ver ### Troubleshooting **"Cannot connect to bridge"** + - Bridge may have crashed. Try: `mcpc @ tools-list` to restart the bridge - Check bridge is running: `ps aux | grep -e 'mcpc-bridge' -e '[m]cpc/dist/bridge'` - Check socket exists: `ls ~/.mcpc/bridges/` **"Session not found"** + - List existing sessions: `mcpc` - Create new session if expired: `mcpc @ close` and `mcpc connect @` **"Authentication failed"** + - List saved OAuth profiles: `mcpc` - Re-authenticate: `mcpc login [--profile ]` - For bearer tokens: provide `--header "Authorization: Bearer ${TOKEN}"` again - ## Development The initial version of `mcpc` was developed and [launched by Jan Curn](https://x.com/jancurn/status/2007144080959291756) of [Apify](https://apify.com) @@ -1099,8 +1189,6 @@ See [CONTRIBUTING](./CONTRIBUTING.md) for development setup, architecture overvi - Other - https://github.com/TeamSparkAI/mcpGraph - ## License Apache-2.0 - see [LICENSE](./LICENSE) for details. - diff --git a/package-lock.json b/package-lock.json index 9466432..cf75a7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "ora": "^9.0.0", "proper-lockfile": "^4.1.2", "undici": "^7.22.0", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "viem": "^2.46.3" }, "bin": { "mcpc": "bin/mcpc", @@ -47,6 +48,12 @@ "node": ">=20.0.0" } }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -2390,6 +2397,45 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2504,6 +2550,42 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.48", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", @@ -3247,6 +3329,27 @@ "win32" ] }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -5647,6 +5750,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -7414,6 +7523,21 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -10109,6 +10233,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ox": { + "version": "0.12.4", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.12.4.tgz", + "integrity": "sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -12382,7 +12536,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "peer": true, "bin": { @@ -12679,6 +12833,36 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/viem": { + "version": "2.46.3", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.46.3.tgz", + "integrity": "sha512-2LJS+Hyh2sYjHXQtzfv1kU9pZx9dxFzvoU/ZKIcn0FNtOU0HQuIICuYdWtUDFHaGXbAdVo8J1eCvmjkL9JVGwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.12.4", + "ws": "8.18.3" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -12991,6 +13175,28 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xmlbuilder2": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-4.0.3.tgz", diff --git a/package.json b/package.json index e4ecfb8..3992ec1 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,8 @@ "ora": "^9.0.0", "proper-lockfile": "^4.1.2", "undici": "^7.22.0", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "viem": "^2.46.3" }, "devDependencies": { "@types/jest": "^30.0.0", diff --git a/src/bridge/index.ts b/src/bridge/index.ts index 4440f83..ddb1e1c 100644 --- a/src/bridge/index.ts +++ b/src/bridge/index.ts @@ -22,7 +22,7 @@ import { } from '../lib/index.js'; import { ClientError, NetworkError, isAuthenticationError } from '../lib/index.js'; import { loadSessions, updateSession } from '../lib/sessions.js'; -import type { AuthCredentials } from '../lib/types.js'; +import type { AuthCredentials, X402WalletCredentials } from '../lib/types.js'; import { OAuthTokenManager } from '../lib/auth/oauth-token-manager.js'; import { OAuthProvider } from '../lib/auth/oauth-provider.js'; import { storeKeychainOAuthTokenInfo, readKeychainOAuthTokenInfo } from '../lib/auth/keychain.js'; @@ -35,6 +35,9 @@ const { version: mcpcVersion } = createRequire(import.meta.url)('../../package.j }; import { ProxyServer } from './proxy-server.js'; import type { ProxyConfig } from '../lib/types.js'; +import { createX402FetchMiddleware } from '../lib/x402/fetch-middleware.js'; +import type { SignerWallet } from '../lib/x402/signer.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; // Set up HTTP proxy from environment variables (HTTPS_PROXY, HTTP_PROXY, NO_PROXY, and lowercase variants) setGlobalDispatcher(new EnvHttpProxyAgent()); @@ -51,6 +54,7 @@ interface BridgeOptions { profileName?: string; // Auth profile name for token refresh proxyConfig?: ProxyConfig; // Proxy server configuration mcpSessionId?: string; // MCP session ID for resumption (Streamable HTTP only) + x402?: boolean; // Enable x402 auto-payment } /** @@ -76,10 +80,20 @@ class BridgeProcess { // HTTP headers (received via IPC, stored in memory only) private headers: Record | null = null; + // x402 wallet for automatic payment signing (received via IPC, stored in memory only) + private x402Wallet: SignerWallet | null = null; + + // Cached tools list for x402 proactive signing (updated via listChanged notifications) + private cachedTools: Tool[] | null = null; + // Promise to track when auth credentials are received (for startup sequencing) private authCredentialsReceived: Promise | null = null; private authCredentialsResolver: (() => void) | null = null; + // Promise to track when x402 wallet is received (for startup sequencing) + private x402WalletReceived: Promise | null = null; + private x402WalletResolver: (() => void) | null = null; + // Promise to track when MCP client is connected (for blocking requests until ready) private mcpClientReady: Promise; private mcpClientReadyResolver!: () => void; @@ -204,6 +218,25 @@ class BridgeProcess { } } + /** + * Set x402 wallet received from CLI via IPC + * The wallet private key is used for automatic payment signing + */ + setX402Wallet(credentials: X402WalletCredentials): void { + logger.info(`Received x402 wallet: ${credentials.address}`); + this.x402Wallet = { + privateKey: credentials.privateKey, + address: credentials.address, + }; + logger.debug('x402 wallet stored in memory'); + + // Signal that wallet has been received (unblocks startup) + if (this.x402WalletResolver) { + this.x402WalletResolver(); + this.x402WalletResolver = null; + } + } + /** * Get a valid access token, refreshing if necessary, * and update transport config with headers @@ -298,23 +331,33 @@ class BridgeProcess { // 3. Create Unix socket server FIRST (so CLI can send auth credentials) await this.createSocketServer(); - // 4. Wait for auth credentials from CLI if auth profile is specified - // The CLI sends credentials via IPC immediately after detecting the socket file + // 4. Wait for auth credentials and/or x402 wallet from CLI via IPC + // The CLI sends these immediately after detecting the socket file + const ipcWaiters: Promise[] = []; + if (this.options.profileName) { logger.debug(`Waiting for auth credentials (profile: ${this.options.profileName})...`); - - // Create a promise that resolves when credentials are received this.authCredentialsReceived = new Promise((resolve) => { this.authCredentialsResolver = resolve; }); + ipcWaiters.push(this.authCredentialsReceived); + } + + if (this.options.x402) { + logger.debug('Waiting for x402 wallet...'); + this.x402WalletReceived = new Promise((resolve) => { + this.x402WalletResolver = resolve; + }); + ipcWaiters.push(this.x402WalletReceived); + } + if (ipcWaiters.length > 0) { // Wait with timeout (5 seconds should be plenty for local IPC) const timeout = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Timeout waiting for auth credentials')), 5000); + setTimeout(() => reject(new Error('Timeout waiting for IPC credentials')), 5000); }); - - await Promise.race([this.authCredentialsReceived, timeout]); - logger.debug('Auth credentials received, proceeding with MCP connection'); + await Promise.race([Promise.all(ipcWaiters), timeout]); + logger.debug('IPC credentials received, proceeding with MCP connection'); } // 5. Connect to MCP server (now with auth credentials if provided) @@ -445,10 +488,25 @@ class BridgeProcess { logger.debug('Building MCP client config...'); logger.debug(` this.authProvider is set: ${!!this.authProvider}`); + logger.debug(` this.x402Wallet is set: ${!!this.x402Wallet}`); if (this.authProvider) { logger.debug(` authProvider type: ${this.authProvider.constructor.name}`); } + // Build x402 fetch middleware if wallet is configured + let customFetch: FetchLike | undefined; + if (this.x402Wallet && serverConfig.url) { + logger.debug('Creating x402 fetch middleware for payment signing'); + // We use a closure that defers tool lookup to the connected client + // The client may not have tools cached yet at this point, but will + // after it connects and receives the auto-refreshed tools list + const wallet = this.x402Wallet; + const getToolByName = (name: string): Tool | undefined => { + return this.cachedTools?.find((t: Tool) => t.name === name); + }; + customFetch = createX402FetchMiddleware(fetch, { wallet, getToolByName }); + } + const clientConfig: CreateMcpClientOptions = { clientInfo: { name: 'mcpc', version: mcpcVersion }, serverConfig, @@ -460,6 +518,8 @@ class BridgeProcess { ...(this.authProvider && { authProvider: this.authProvider }), // Pass session ID for resumption (HTTP transport only) ...(this.options.mcpSessionId && { mcpSessionId: this.options.mcpSessionId }), + // Pass x402 fetch middleware (HTTP transport only) + ...(customFetch && { customFetch }), listChanged: { tools: { // Let SDK auto-fetch the tools on list changed notification. @@ -467,6 +527,11 @@ class BridgeProcess { autoRefresh: true, onChanged: (error: Error | null, tools: Tool[] | null) => { logger.debug('Tools list changed', { error, count: tools?.length }); + // Update local tools cache (used by x402 middleware for proactive signing) + if (tools) { + this.cachedTools = tools; + logger.debug(`Updated cached tools list (${tools.length} tools)`); + } // Broadcast notification to all connected clients this.broadcastNotification('tools/list_changed'); // Update session with notification timestamp @@ -539,6 +604,20 @@ class BridgeProcess { // Note: Token refresh is handled automatically by the SDK // The SDK calls authProvider.tokens() before each request, // which triggers OAuthTokenManager.getValidAccessToken() to refresh if needed + + // Pre-populate tools cache for x402 proactive signing + if (this.x402Wallet) { + try { + const toolsResult = await this.client.listTools(); + if (toolsResult.tools) { + this.cachedTools = toolsResult.tools; + logger.debug(`Pre-populated tools cache (${this.cachedTools.length} tools) for x402`); + } + } catch (error) { + // Non-fatal: 402 fallback will still work without proactive signing + logger.warn('Failed to pre-populate tools cache for x402:', error); + } + } } /** @@ -775,6 +854,21 @@ class BridgeProcess { } break; + case 'set-x402-wallet': + if (message.x402Wallet) { + this.setX402Wallet(message.x402Wallet); + if (message.id) { + this.sendResponse(socket, { + type: 'response', + id: message.id, + result: { success: true }, + }); + } + } else { + throw new ClientError('Missing x402Wallet in set-x402-wallet message'); + } + break; + default: throw new ClientError(`Unknown message type: ${message.type}`); } @@ -1043,7 +1137,7 @@ async function main(): Promise { if (args.length < 2) { console.error( - 'Usage: mcpc-bridge [--verbose] [--profile ] [--proxy-host ] [--proxy-port ] [--mcp-session-id ]' + 'Usage: mcpc-bridge [--verbose] [--profile ] [--proxy-host ] [--proxy-port ] [--mcp-session-id ] [--x402]' ); process.exit(1); } @@ -1078,6 +1172,9 @@ async function main(): Promise { mcpSessionId = args[mcpSessionIdIndex + 1]; } + // Parse --x402 flag (for x402 payment signing) + const x402 = args.includes('--x402'); + try { const bridgeOptions: BridgeOptions = { sessionName, @@ -1093,6 +1190,9 @@ async function main(): Promise { if (mcpSessionId) { bridgeOptions.mcpSessionId = mcpSessionId; } + if (x402) { + bridgeOptions.x402 = true; + } const bridge = new BridgeProcess(bridgeOptions); diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index 37de83d..ba3759b 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -10,3 +10,4 @@ export * from './sessions.js'; export * from './logging.js'; export * from './utilities.js'; export * from './auth.js'; +export * from './x402.js'; diff --git a/src/cli/commands/sessions.ts b/src/cli/commands/sessions.ts index 2ef3293..5d70264 100644 --- a/src/cli/commands/sessions.ts +++ b/src/cli/commands/sessions.ts @@ -35,6 +35,7 @@ import { storeKeychainProxyBearerToken, } from '../../lib/auth/keychain.js'; import { AuthError, ClientError } from '../../lib/index.js'; +import { getWallet } from '../../lib/wallets.js'; import chalk from 'chalk'; import { createLogger } from '../../lib/logger.js'; import { parseProxyArg } from '../parser.js'; @@ -83,6 +84,7 @@ export async function connectSession( profile?: string; proxy?: string; proxyBearerToken?: string; + x402?: boolean; } ): Promise { try { @@ -193,6 +195,15 @@ export async function connectSession( await storeKeychainProxyBearerToken(name, options.proxyBearerToken); } + // Validate x402 wallet (if provided) + if (options.x402) { + const wallet = await getWallet(); + if (!wallet) { + throw new ClientError('x402 wallet not found. Create one with: mcpc x402 init'); + } + logger.debug(`Using x402 wallet: ${wallet.address}`); + } + // Create or update session record (without pid - that comes from startBridge) // Store serverConfig with headers redacted (actual values in keychain) const isReconnect = !!existingSession; @@ -206,6 +217,7 @@ export async function connectSession( server: sessionTransportConfig, ...(profileName && { profileName }), ...(proxyConfig && { proxy: proxyConfig }), + ...(options.x402 && { x402: true }), }; if (isReconnect) { @@ -236,6 +248,9 @@ export async function connectSession( if (proxyConfig) { bridgeOptions.proxyConfig = proxyConfig; } + if (options.x402) { + bridgeOptions.x402 = true; + } const { pid } = await startBridge(bridgeOptions); @@ -592,6 +607,10 @@ export async function restartSession( bridgeOptions.proxyConfig = session.proxy; } + if (session.x402) { + bridgeOptions.x402 = session.x402; + } + // NOTE: Do NOT pass mcpSessionId on explicit restart. // Explicit restart should create a fresh session, not try to resume the old one. // Session resumption is only attempted on automatic bridge restart (when bridge crashes diff --git a/src/cli/commands/x402.ts b/src/cli/commands/x402.ts new file mode 100644 index 0000000..df4e5cb --- /dev/null +++ b/src/cli/commands/x402.ts @@ -0,0 +1,303 @@ +/** + * x402 wallet management and payment signing commands + */ + +import { Command } from 'commander'; +import chalk from 'chalk'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import type { Hex } from 'viem'; +import { formatSuccess, formatError, formatInfo, formatJson } from '../output.js'; +import { getWallet, saveWallet, removeWallet } from '../../lib/wallets.js'; +import { ClientError } from '../../lib/errors.js'; +import type { OutputMode } from '../../lib/types.js'; +import { signPayment, parsePaymentRequired } from '../../lib/x402/signer.js'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const USDC_DECIMALS = 6; + +// --------------------------------------------------------------------------- +// Command: init +// --------------------------------------------------------------------------- + +async function initWallet(options: { outputMode: OutputMode }): Promise { + const existing = await getWallet(); + if (existing) { + throw new ClientError( + `Wallet already exists (address: ${existing.address}). Use "mcpc x402 remove" first.` + ); + } + + const privateKey = generatePrivateKey(); + const account = privateKeyToAccount(privateKey); + + await saveWallet({ + address: account.address, + privateKey, + createdAt: new Date().toISOString(), + }); + + if (options.outputMode === 'json') { + console.log(formatJson({ address: account.address })); + } else { + console.log(formatSuccess('Wallet created')); + console.log(formatInfo(`Address: ${chalk.cyan(account.address)}`)); + console.log(formatInfo('Fund this address with USDC on Base to use x402 payments.')); + } +} + +// --------------------------------------------------------------------------- +// Command: import +// --------------------------------------------------------------------------- + +async function importWallet(options: { + privateKey: string; + outputMode: OutputMode; +}): Promise { + const existing = await getWallet(); + if (existing) { + throw new ClientError( + `Wallet already exists (address: ${existing.address}). Use "mcpc x402 remove" first.` + ); + } + + let key = options.privateKey.trim(); + if (!key.startsWith('0x')) key = `0x${key}`; + + let account; + try { + account = privateKeyToAccount(key as Hex); + } catch { + throw new ClientError( + 'Invalid private key. Must be a 64-character hex string (with or without 0x prefix).' + ); + } + + await saveWallet({ + address: account.address, + privateKey: key, + createdAt: new Date().toISOString(), + }); + + if (options.outputMode === 'json') { + console.log(formatJson({ address: account.address })); + } else { + console.log(formatSuccess('Wallet imported')); + console.log(formatInfo(`Address: ${chalk.cyan(account.address)}`)); + } +} + +// --------------------------------------------------------------------------- +// Command: info +// --------------------------------------------------------------------------- + +async function walletInfo(options: { outputMode: OutputMode }): Promise { + const wallet = await getWallet(); + + if (options.outputMode === 'json') { + console.log( + formatJson(wallet ? { address: wallet.address, createdAt: wallet.createdAt } : null) + ); + return; + } + + if (!wallet) { + console.log(formatInfo('No wallet configured. Create one with: mcpc x402 init')); + return; + } + + console.log(` ${chalk.bold('Address')} ${chalk.cyan(wallet.address)}`); + console.log(` ${chalk.bold('Created')} ${wallet.createdAt}`); +} + +// --------------------------------------------------------------------------- +// Command: remove +// --------------------------------------------------------------------------- + +async function removeWalletCmd(options: { outputMode: OutputMode }): Promise { + const removed = await removeWallet(); + if (!removed) { + throw new ClientError('No wallet configured.'); + } + + if (options.outputMode === 'json') { + console.log(formatJson({ removed: true })); + } else { + console.log(formatSuccess('Wallet removed.')); + } +} + +// --------------------------------------------------------------------------- +// Command: sign +// --------------------------------------------------------------------------- + +interface SignOptions { + paymentRequired: string; + amount?: string; + expiry?: string; + outputMode: OutputMode; +} + +async function signPaymentCommand(options: SignOptions): Promise { + const wallet = await getWallet(); + if (!wallet) { + throw new ClientError('No wallet configured. Create one with: mcpc x402 init'); + } + + // Parse PAYMENT-REQUIRED header + const { header, accept } = parsePaymentRequired(options.paymentRequired); + + // Resolve overrides + let amountOverride: bigint | undefined; + if (options.amount) { + const amountUsd = parseFloat(options.amount); + if (isNaN(amountUsd) || amountUsd <= 0) + throw new ClientError('--amount must be a positive number.'); + amountOverride = BigInt(Math.round(amountUsd * 10 ** USDC_DECIMALS)); + } + + const expiryOverride = options.expiry ? parseInt(options.expiry, 10) : undefined; + + // Sign using shared signer + const result = await signPayment({ + wallet: { privateKey: wallet.privateKey, address: wallet.address }, + accept, + resource: header.resource, + ...(amountOverride !== undefined && { amountOverride }), + ...(expiryOverride !== undefined && { expiryOverride }), + }); + + if (options.outputMode === 'json') { + console.log( + formatJson({ + paymentSignature: result.paymentSignatureBase64, + from: result.from, + to: result.to, + amount: result.amountUsd, + amountAtomicUnits: result.amountAtomicUnits.toString(), + network: result.networkLabel, + expiresAt: result.expiresAt.toISOString(), + }) + ); + return; + } + + // Human output + const resourceUrl = (header.resource?.url ?? 'https://mcp.apify.com/mcp').replace(/\?.*$/, ''); + + console.log(formatSuccess('Payment signed')); + console.log(formatInfo(`Wallet : ${result.from}`)); + console.log(formatInfo(`Network : ${result.networkLabel}`)); + console.log(formatInfo(`To : ${result.to}`)); + console.log( + formatInfo( + `Amount : $${result.amountUsd.toFixed(2)} (${result.amountAtomicUnits.toString()} atomic units)` + ) + ); + console.log(formatInfo(`Expires : ${result.expiresAt.toISOString()}`)); + console.log(''); + console.log(chalk.bold(' PAYMENT-SIGNATURE header:')); + console.log(` ${result.paymentSignatureBase64}`); + console.log(''); + console.log(chalk.bold(' MCP config snippet:')); + console.log( + JSON.stringify( + { + mcp: { + 'apify-x402': { + type: 'remote', + url: `${resourceUrl}?payment=x402`, + headers: { 'PAYMENT-SIGNATURE': result.paymentSignatureBase64 }, + }, + }, + }, + null, + 2 + ) + .split('\n') + .map((l) => ` ${l}`) + .join('\n') + ); + console.log(''); +} + +// --------------------------------------------------------------------------- +// Top-level x402 command router +// --------------------------------------------------------------------------- + +export async function handleX402Command(args: string[]): Promise { + const program = new Command(); + program.name('mcpc x402').description('x402 wallet management and payment signing'); + + // Inherit global options so they parse correctly + program.option('-j, --json', 'Output in JSON format').option('--verbose', 'Enable debug logging'); + + const resolveOutputMode = (cmd: Command): OutputMode => { + const opts = cmd.optsWithGlobals(); + return opts.json ? 'json' : 'human'; + }; + + program + .command('init') + .description('Create a new x402 wallet') + .action(async (_opts, cmd) => { + await initWallet({ outputMode: resolveOutputMode(cmd) }); + }); + + program + .command('import ') + .description('Import an existing wallet from a private key') + .action(async (privateKey, _opts, cmd) => { + await importWallet({ privateKey, outputMode: resolveOutputMode(cmd) }); + }); + + program + .command('info') + .description('Show wallet info') + .action(async (_opts, cmd) => { + await walletInfo({ outputMode: resolveOutputMode(cmd) }); + }); + + program + .command('remove') + .description('Remove the wallet') + .action(async (_opts, cmd) => { + await removeWalletCmd({ outputMode: resolveOutputMode(cmd) }); + }); + + program + .command('sign') + .description('Sign a payment using the wallet') + .requiredOption( + '-r, --payment-required ', + 'PAYMENT-REQUIRED header from a 402 response' + ) + .option('--amount ', 'Override amount in USD') + .option('--expiry ', 'Override expiry in seconds') + .action(async (opts, cmd) => { + await signPaymentCommand({ + paymentRequired: opts.paymentRequired, + amount: opts.amount, + expiry: opts.expiry, + outputMode: resolveOutputMode(cmd), + }); + }); + + // Show help if no subcommand + if (args.length === 0) { + program.outputHelp(); + return; + } + + try { + await program.parseAsync(['node', 'mcpc-x402', ...args]); + } catch (error) { + if (error instanceof ClientError) { + console.error(formatError(error.message)); + process.exit(1); + } + throw error; + } +} diff --git a/src/cli/helpers.ts b/src/cli/helpers.ts index 8316b74..f2d6b86 100644 --- a/src/cli/helpers.ts +++ b/src/cli/helpers.ts @@ -21,6 +21,8 @@ import { OAuthTokenManager } from '../lib/auth/oauth-token-manager.js'; import { getAuthProfile, listAuthProfiles } from '../lib/auth/profiles.js'; import { readKeychainOAuthTokenInfo, readKeychainOAuthClientInfo } from '../lib/auth/keychain.js'; import { logTarget } from './output.js'; +import { getWallet } from '../lib/wallets.js'; +import { createX402FetchMiddleware } from '../lib/x402/fetch-middleware.js'; import { createRequire } from 'module'; const { version: mcpcVersion } = createRequire(import.meta.url)('../../package.json') as { version: string; @@ -256,6 +258,7 @@ export async function withMcpClient( verbose?: boolean; hideTarget?: boolean; profile?: string; + x402?: boolean; }, callback: (client: IMcpClient, context: McpClientContext) => Promise ): Promise { @@ -319,6 +322,21 @@ export async function withMcpClient( clientConfig.authProvider = authProvider; logger.debug(`Using auth profile: ${profileName}`); } + + // Set up x402 fetch middleware for automatic payment signing + if (options.x402) { + const wallet = await getWallet(); + if (!wallet) { + throw new ClientError('x402 wallet not found. Create one with: mcpc x402 init'); + } + logger.debug(`Using x402 wallet: ${wallet.address}`); + clientConfig.customFetch = createX402FetchMiddleware(fetch, { + wallet: { privateKey: wallet.privateKey, address: wallet.address }, + // No getToolByName for direct connections — proactive signing requires + // a tools list cache which direct connections don't maintain. + // The 402 fallback will still work. + }); + } } let client: IMcpClient; diff --git a/src/cli/index.ts b/src/cli/index.ts index 9f5b583..515e803 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -22,6 +22,7 @@ import * as sessions from './commands/sessions.js'; import * as logging from './commands/logging.js'; import * as utilities from './commands/utilities.js'; import * as auth from './commands/auth.js'; +import { handleX402Command } from './commands/x402.js'; import { clean } from './commands/clean.js'; import type { OutputMode } from '../lib/index.js'; import { @@ -53,6 +54,7 @@ interface HandlerOptions { timeout?: number; verbose?: boolean; profile?: string; + x402?: boolean; schema?: string; schemaMode?: 'strict' | 'compatible' | 'ignore'; full?: boolean; @@ -88,6 +90,7 @@ function getOptionsFromCommand(command: Command): HandlerOptions { if (opts.timeout) options.timeout = parseInt(opts.timeout, 10); if (opts.profile) options.profile = opts.profile; if (verbose) options.verbose = verbose; + if (opts.x402) options.x402 = true; if (opts.schema) options.schema = opts.schema; if (opts.schemaMode) { const mode = opts.schemaMode as string; @@ -203,6 +206,14 @@ async function main(): Promise { ...args.slice(targetIndex + 1), ]; + // Handle x402 as a top-level command (not a server target) + if (target === 'x402') { + const x402Args = args.slice(targetIndex + 1); + await handleX402Command(x402Args); + await closeFileLogger(); + return; + } + // Handle commands try { await handleCommands(target, modifiedArgs); @@ -272,6 +283,7 @@ function createProgram(): Command { .option('--timeout ', 'Request timeout in seconds (default: 300)') .option('--proxy <[host:]port>', 'Start proxy MCP server for session (with "connect" command)') .option('--proxy-bearer-token ', 'Require authentication for access to proxy server') + .option('--x402', 'Enable x402 auto-payment using the configured wallet') .option('--clean[=types]', 'Clean up mcpc data (types: sessions, logs, profiles, all)'); // Add help text to match README @@ -311,6 +323,13 @@ MCP server commands: resources-templates-list logging-set-level ping + +x402 payment commands (no target needed): + x402 init Create a new x402 wallet + x402 import Import wallet from private key + x402 info Show wallet info + x402 sign -r Sign payment from PAYMENT-REQUIRED header + x402 remove Remove the wallet Run "mcpc" without to show available sessions and profiles. @@ -383,6 +402,7 @@ async function handleCommands(target: string, args: string[]): Promise { ...getOptionsFromCommand(command), proxy: opts.proxy, proxyBearerToken: opts.proxyBearerToken, + x402: opts.x402, }); }); diff --git a/src/cli/parser.ts b/src/cli/parser.ts index 93c55e1..f74fc71 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -56,6 +56,7 @@ const KNOWN_OPTIONS = [ '--verbose', '--clean', '--full', + '--x402', ]; // Valid --clean types @@ -87,13 +88,14 @@ export const KNOWN_COMMANDS = [ 'prompts-get', 'logging-set-level', 'ping', + 'x402', ]; // Valid --schema-mode values const VALID_SCHEMA_MODES = ['strict', 'compatible', 'ignore']; /** - * Check if an option takes a value + * Check if an option always takes a value */ export function optionTakesValue(arg: string): boolean { const optionName = arg.includes('=') ? arg.substring(0, arg.indexOf('=')) : arg; @@ -235,6 +237,7 @@ export function extractOptions(args: string[]): { headers?: string[]; timeout?: number; profile?: string; + x402?: boolean; verbose: boolean; json: boolean; } { @@ -269,12 +272,16 @@ export function extractOptions(args: string[]): { const profile = profileIndex >= 0 && profileIndex + 1 < args.length ? args[profileIndex + 1] : undefined; + // Extract --x402 (boolean flag) + const x402 = args.includes('--x402') || undefined; + return { ...options, ...(config && { config }), ...(headers.length > 0 && { headers }), ...(timeout !== undefined && { timeout }), ...(profile && { profile }), + ...(x402 && { x402 }), }; } diff --git a/src/core/factory.ts b/src/core/factory.ts index 97e2c20..6614f81 100644 --- a/src/core/factory.ts +++ b/src/core/factory.ts @@ -4,6 +4,7 @@ import type { ClientCapabilities, ListChangedHandlers } from '@modelcontextprotocol/sdk/types.js'; import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; import { McpClient, type McpClientOptions } from './mcp-client.js'; import { createTransportFromConfig } from './transports.js'; import { type ServerConfig } from '../lib/types.js'; @@ -51,6 +52,12 @@ export interface CreateMcpClientOptions { */ mcpSessionId?: string; + /** + * Custom fetch function for the transport (HTTP transport only) + * Used by x402 payment middleware + */ + customFetch?: FetchLike; + /** * Whether to automatically connect after creation * @default true @@ -120,13 +127,21 @@ export async function createMcpClient(options: CreateMcpClientOptions): Promise< if (autoConnect) { factoryLogger.debug('Creating transport with authProvider:', !!options.authProvider); factoryLogger.debug('Creating transport with mcpSessionId:', options.mcpSessionId || '(none)'); - const transportOptions: { authProvider?: OAuthClientProvider; mcpSessionId?: string } = {}; + factoryLogger.debug('Creating transport with customFetch:', !!options.customFetch); + const transportOptions: { + authProvider?: OAuthClientProvider; + mcpSessionId?: string; + customFetch?: FetchLike; + } = {}; if (options.authProvider) { transportOptions.authProvider = options.authProvider; } if (options.mcpSessionId) { transportOptions.mcpSessionId = options.mcpSessionId; } + if (options.customFetch) { + transportOptions.customFetch = options.customFetch; + } const transport = createTransportFromConfig(options.serverConfig, transportOptions); await client.connect(transport); } diff --git a/src/core/transports.ts b/src/core/transports.ts index 4fdb4b5..df2b93f 100644 --- a/src/core/transports.ts +++ b/src/core/transports.ts @@ -26,7 +26,7 @@ export { // Re-export auth-related types if needed export type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; -import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { Transport, FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; import { StdioClientTransport, @@ -62,7 +62,7 @@ export function createStdioTransport(config: StdioServerParameters): Transport { */ export function createStreamableHttpTransport( url: string, - options: Omit = {} + options: StreamableHTTPClientTransportOptions = {} ): Transport { const logger = createLogger('StreamableHttpTransport'); logger.debug('Creating Streamable HTTP transport', { url }); @@ -119,6 +119,12 @@ export interface CreateTransportOptions { * If provided, the transport will include this in the MCP-Session-Id header */ mcpSessionId?: string; + + /** + * Custom fetch function (HTTP transport only) + * Used by x402 middleware to intercept and modify requests + */ + customFetch?: FetchLike; } /** @@ -180,6 +186,12 @@ export function createTransportFromConfig( }; } + // Set custom fetch function (e.g., x402 payment middleware) + if (options.customFetch) { + transportOptions.fetch = options.customFetch; + logger.debug('Setting custom fetch function on transport'); + } + return createStreamableHttpTransport(config.url, transportOptions); } diff --git a/src/lib/auth/profiles.ts b/src/lib/auth/profiles.ts index b66807a..c8c5c5f 100644 --- a/src/lib/auth/profiles.ts +++ b/src/lib/auth/profiles.ts @@ -62,7 +62,6 @@ async function saveAuthProfilesInternal(storage: AuthProfilesStorage): Promise; // Headers to send via IPC (caller stores in keychain) proxyConfig?: ProxyConfig; // Proxy server configuration mcpSessionId?: string; // MCP session ID for resumption (Streamable HTTP only) + x402?: boolean; // Enable x402 auto-payment using the wallet } export interface StartBridgeResult { @@ -77,8 +79,16 @@ export interface StartBridgeResult { * @returns Bridge process PID */ export async function startBridge(options: StartBridgeOptions): Promise { - const { sessionName, serverConfig, verbose, profileName, headers, proxyConfig, mcpSessionId } = - options; + const { + sessionName, + serverConfig, + verbose, + profileName, + headers, + proxyConfig, + mcpSessionId, + x402, + } = options; logger.debug(`Launching bridge for session: ${sessionName}`); @@ -128,6 +138,12 @@ export async function startBridge(options: StartBridgeOptions): Promise { + const wallet = await getWallet(); + + if (!wallet) { + throw new ClientError('x402 wallet not found. Create one with: mcpc x402 init'); + } + + logger.debug(`Sending x402 wallet (${wallet.address}) to bridge`); + + const credentials: X402WalletCredentials = { + address: wallet.address, + privateKey: wallet.privateKey, + }; + + const client = new BridgeClient(socketPath); + try { + await client.connect(); + client.sendX402Wallet(credentials); + logger.debug('x402 wallet sent to bridge successfully'); + } finally { + await client.close(); + } +} + /** * Result of bridge health check */ diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index b75d28f..44006e6 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -60,7 +60,6 @@ async function saveSessionsInternal(storage: SessionsStorage): Promise { // Ensure the directory exists await ensureDir(getMcpcHome()); - // Write to a temp file first (atomic operation) // Write temp file in the same directory as the target to avoid EXDEV on Linux // (rename() fails across filesystem boundaries, e.g. /tmp vs ~/.mcpc) const tempFile = join(getMcpcHome(), `.sessions-${Date.now()}-${process.pid}.tmp`); diff --git a/src/lib/types.ts b/src/lib/types.ts index 711ff42..d98b6ed 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -119,6 +119,7 @@ export interface SessionData { name: string; server: ServerConfig; // Transport configuration (header values redacted to "") profileName?: string; // Name of auth profile (for OAuth servers) + x402?: boolean; // x402 auto-payment enabled for this session pid?: number; // Bridge process PID protocolVersion?: string; // Negotiated MCP version mcpSessionId?: string; // Server-assigned MCP session ID for resumption (Streamable HTTP only) @@ -178,7 +179,8 @@ export type IpcMessageType = | 'response' | 'shutdown' | 'notification' - | 'set-auth-credentials'; + | 'set-auth-credentials' + | 'set-x402-wallet'; /** * Auth credentials sent from CLI to bridge via IPC @@ -194,6 +196,14 @@ export interface AuthCredentials { headers?: Record; } +/** + * x402 wallet credentials sent from CLI to bridge via IPC + */ +export interface X402WalletCredentials { + address: string; + privateKey: string; // Hex with 0x prefix +} + /** * Notification types from MCP server */ @@ -224,6 +234,7 @@ export interface IpcMessage { result?: unknown; // Response result notification?: NotificationData; // Notification data (for type='notification') authCredentials?: AuthCredentials; // Auth credentials (for type='set-auth-credentials') + x402Wallet?: X402WalletCredentials; // x402 wallet (for type='set-x402-wallet') error?: { code: number; message: string; @@ -262,6 +273,25 @@ export interface McpConfig { mcpServers: Record; } +/** + * x402 wallet data stored in ~/.mcpc/wallets.json + * Only a single wallet is supported (no names needed) + */ +export interface WalletData { + address: string; + privateKey: string; // Hex string starting with 0x + createdAt: string; // ISO 8601 +} + +/** + * Wallets storage structure (~/.mcpc/wallets.json) + * Versioned for future migration (e.g. multi-wallet support) + */ +export interface WalletsStorage { + version: 1; + wallet?: WalletData; +} + /** * Combined server details returned by getServerDetails() * Structure matches MCP InitializeResult for consistency diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 1a20e14..f9035e6 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -92,6 +92,13 @@ export function getAuthProfilesFilePath(): string { return join(getMcpcHome(), 'profiles.json'); } +/** + * Get the wallets file path (~/.mcpc/wallets.json) + */ +export function getWalletsFilePath(): string { + return join(getMcpcHome(), 'wallets.json'); +} + /** * Ensure a directory exists, creating it if necessary */ diff --git a/src/lib/wallets.ts b/src/lib/wallets.ts new file mode 100644 index 0000000..f4ff438 --- /dev/null +++ b/src/lib/wallets.ts @@ -0,0 +1,97 @@ +/** + * Wallet management for x402 payments + * Stores a single wallet in ~/.mcpc/wallets.json + */ + +import { readFile, writeFile, rename, unlink } from 'fs/promises'; +import { join } from 'path'; +import type { WalletData, WalletsStorage } from './types.js'; +import { getWalletsFilePath, fileExists, ensureDir, getMcpcHome } from './utils.js'; +import { withFileLock } from './file-lock.js'; +import { ClientError } from './errors.js'; + +const WALLETS_DEFAULT_CONTENT = JSON.stringify({ version: 1 }, null, 2); + +// --------------------------------------------------------------------------- +// Internal read/write (called under file lock) +// --------------------------------------------------------------------------- + +async function loadStorageInternal(): Promise { + const filePath = getWalletsFilePath(); + + if (!(await fileExists(filePath))) { + return { version: 1 }; + } + + try { + const content = await readFile(filePath, 'utf-8'); + const storage = JSON.parse(content) as WalletsStorage; + if (!storage.version) { + return { version: 1 }; + } + return storage; + } catch { + return { version: 1 }; + } +} + +async function saveStorageInternal(storage: WalletsStorage): Promise { + const filePath = getWalletsFilePath(); + await ensureDir(getMcpcHome()); + + const tempFile = join(getMcpcHome(), `.wallets-${Date.now()}-${process.pid}.tmp`); + try { + await writeFile(tempFile, JSON.stringify(storage, null, 2), { encoding: 'utf-8', mode: 0o600 }); + await rename(tempFile, filePath); + } catch (error) { + try { + await unlink(tempFile); + } catch { + /* ignore */ + } + throw new ClientError(`Failed to save wallet: ${(error as Error).message}`); + } +} + +// --------------------------------------------------------------------------- +// Public API (all operations hold file lock) +// --------------------------------------------------------------------------- + +export async function getWallet(): Promise { + return withFileLock( + getWalletsFilePath(), + async () => { + const storage = await loadStorageInternal(); + return storage.wallet; + }, + WALLETS_DEFAULT_CONTENT + ); +} + +export async function saveWallet(wallet: WalletData): Promise { + const filePath = getWalletsFilePath(); + await withFileLock( + filePath, + async () => { + const storage = await loadStorageInternal(); + storage.wallet = wallet; + await saveStorageInternal(storage); + }, + WALLETS_DEFAULT_CONTENT + ); +} + +export async function removeWallet(): Promise { + const filePath = getWalletsFilePath(); + return withFileLock( + filePath, + async () => { + const storage = await loadStorageInternal(); + if (!storage.wallet) return false; + delete storage.wallet; + await saveStorageInternal(storage); + return true; + }, + WALLETS_DEFAULT_CONTENT + ); +} diff --git a/src/lib/x402/fetch-middleware.ts b/src/lib/x402/fetch-middleware.ts new file mode 100644 index 0000000..0c21d9d --- /dev/null +++ b/src/lib/x402/fetch-middleware.ts @@ -0,0 +1,270 @@ +/** + * x402 fetch middleware for MCP transport + * + * Wraps the fetch function used by StreamableHTTPClientTransport to: + * 1. Proactively sign payments when tool metadata includes _meta.x402 + * 2. Handle HTTP 402 responses by parsing PAYMENT-REQUIRED, signing, and retrying once + * + * This middleware is injected into the transport via the SDK's `fetch` option. + */ + +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { + signPayment, + parsePaymentRequired, + type SignerWallet, + type PaymentRequiredAccept, + type PaymentRequiredHeader, +} from './signer.js'; +import { createLogger } from '../logger.js'; + +const logger = createLogger('x402-middleware'); + +/** Payment information from tool's _meta.x402 */ +interface ToolPaymentMeta { + paymentRequired: boolean; + scheme?: string; + network?: string; + amount?: string; + asset?: string; + payTo?: string; + maxTimeoutSeconds?: number; + extra?: { name?: string; version?: string }; +} + +/** Parsed JSON-RPC request body (enough to identify tools/call) */ +interface JsonRpcRequest { + method?: string; + params?: { + name?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +/** + * Options for creating the x402 fetch middleware + */ +export interface X402FetchMiddlewareOptions { + /** The wallet to sign payments with */ + wallet: SignerWallet; + + /** + * Callback to look up a tool by name to check _meta.x402. + * Returns the tool if found, undefined otherwise. + * This is called per tools/call request for proactive signing. + */ + getToolByName?: (name: string) => Tool | undefined; +} + +/** + * Create a fetch middleware that handles x402 payments. + * + * Returns a FetchLike function that wraps the original fetch: + * - For tools/call POST requests: proactively sign if tool has _meta.x402 + * - For any request returning 402: parse, sign, retry once + * - All other requests: pass through unchanged + */ +export function createX402FetchMiddleware( + baseFetch: FetchLike, + options: X402FetchMiddlewareOptions +): FetchLike { + const { wallet, getToolByName } = options; + + return async (url: string | URL, init?: RequestInit): Promise => { + // Try proactive signing for tools/call requests + const proactiveHeader = await tryProactiveSigning(init, wallet, getToolByName); + if (proactiveHeader) { + logger.debug('Proactively signing x402 payment for tools/call'); + const enhancedInit = injectPaymentHeader(init, proactiveHeader); + const response = await baseFetch(url, enhancedInit); + + // If proactive signing succeeded (not 402), return immediately + if (response.status !== 402) { + return response; + } + + // Proactive signing failed with 402 — fall through to 402 fallback + logger.debug('Proactive payment rejected (402), falling back to 402 handler'); + return handle402Fallback(url, init, response, baseFetch, wallet); + } + + // No proactive signing — make request normally + const response = await baseFetch(url, init); + + // Check for 402 fallback + if (response.status === 402) { + return handle402Fallback(url, init, response, baseFetch, wallet); + } + + return response; + }; +} + +/** + * Try to proactively sign a payment based on tool metadata. + * Returns the base64-encoded PAYMENT-SIGNATURE header, or undefined if not applicable. + */ +async function tryProactiveSigning( + init: RequestInit | undefined, + wallet: SignerWallet, + getToolByName?: (name: string) => Tool | undefined +): Promise { + if (!getToolByName || !init?.body) { + return undefined; + } + + // Only handle POST requests (tools/call is always POST) + if (init.method && init.method.toUpperCase() !== 'POST') { + return undefined; + } + + // Parse the request body to find tools/call requests + const toolName = extractToolCallName(init.body); + if (!toolName) { + return undefined; + } + + // Look up tool metadata + const tool = getToolByName(toolName); + if (!tool) { + logger.debug(`Tool "${toolName}" not found in cache, skipping proactive signing`); + return undefined; + } + + // Check _meta.x402 + const meta = (tool as { _meta?: { x402?: ToolPaymentMeta } })._meta; + const x402 = meta?.x402; + if (!x402 || !x402.paymentRequired) { + return undefined; + } + + // Check if we have enough info to sign proactively + if (!x402.scheme || !x402.network || !x402.amount || !x402.asset || !x402.payTo) { + logger.debug( + `Tool "${toolName}" has x402 metadata but missing fields, skipping proactive signing` + ); + return undefined; + } + + // Build accept from tool metadata + const accept: PaymentRequiredAccept = { + scheme: x402.scheme, + network: x402.network, + amount: x402.amount, + asset: x402.asset, + payTo: x402.payTo, + maxTimeoutSeconds: x402.maxTimeoutSeconds || 3600, + ...(x402.extra && { extra: x402.extra }), + }; + + try { + const result = await signPayment({ wallet, accept }); + logger.debug( + `Proactive payment signed: $${result.amountUsd.toFixed(4)} to ${result.to} on ${result.networkLabel}` + ); + return result.paymentSignatureBase64; + } catch (error) { + logger.warn(`Proactive signing failed for tool "${toolName}":`, error); + return undefined; + } +} + +/** + * Handle a 402 response by parsing PAYMENT-REQUIRED, signing, and retrying once. + */ +async function handle402Fallback( + url: string | URL, + originalInit: RequestInit | undefined, + response402: Response, + baseFetch: FetchLike, + wallet: SignerWallet +): Promise { + // Extract PAYMENT-REQUIRED header (case-insensitive) + const paymentRequiredBase64 = + response402.headers.get('PAYMENT-REQUIRED') || response402.headers.get('payment-required'); + + if (!paymentRequiredBase64) { + logger.debug('402 response has no PAYMENT-REQUIRED header, passing through'); + return response402; + } + + logger.debug('Received 402 with PAYMENT-REQUIRED header, signing payment...'); + + let header: PaymentRequiredHeader; + let accept: PaymentRequiredAccept; + try { + ({ header, accept } = parsePaymentRequired(paymentRequiredBase64)); + } catch (error) { + logger.warn('Failed to parse PAYMENT-REQUIRED header:', error); + return response402; + } + + // Sign the payment + try { + const result = await signPayment({ + wallet, + accept, + resource: header.resource, + }); + + logger.debug( + `402 fallback payment signed: $${result.amountUsd.toFixed(4)} to ${result.to} on ${result.networkLabel}` + ); + + // Retry with payment signature (once only) + const retryInit = injectPaymentHeader(originalInit, result.paymentSignatureBase64); + return await baseFetch(url, retryInit); + } catch (error) { + logger.warn('402 fallback signing failed:', error); + return response402; + } +} + +/** + * Extract the tool name from a JSON-RPC tools/call request body. + * Returns undefined if the body isn't a tools/call request. + */ +function extractToolCallName(body: RequestInit['body'] | undefined): string | undefined { + if (!body || typeof body !== 'string') { + return undefined; + } + + try { + const parsed: unknown = JSON.parse(body); + + // Handle single request + if (!Array.isArray(parsed)) { + const req = parsed as JsonRpcRequest; + if (req.method === 'tools/call' && req.params?.name) { + return req.params.name; + } + return undefined; + } + + // Handle batch — find first tools/call + for (const item of parsed) { + const req = item as JsonRpcRequest; + if (req.method === 'tools/call' && req.params?.name) { + return req.params.name; + } + } + return undefined; + } catch { + return undefined; + } +} + +/** + * Clone a RequestInit and add/overwrite the PAYMENT-SIGNATURE header. + */ +function injectPaymentHeader(init: RequestInit | undefined, paymentSignature: string): RequestInit { + const headers = new Headers(init?.headers); + headers.set('PAYMENT-SIGNATURE', paymentSignature); + + return { + ...init, + headers, + }; +} diff --git a/src/lib/x402/signer.ts b/src/lib/x402/signer.ts new file mode 100644 index 0000000..4217033 --- /dev/null +++ b/src/lib/x402/signer.ts @@ -0,0 +1,265 @@ +/** + * x402 payment signing logic + * Reusable module for signing EIP-3009 TransferWithAuthorization payments. + * Used by both the CLI `x402 sign` command and the fetch middleware. + */ + +import { privateKeyToAccount } from 'viem/accounts'; +import { createWalletClient, http, type Hex } from 'viem'; +import { base, baseSepolia } from 'viem/chains'; +import { ClientError } from '../errors.js'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const X402_VERSION = 2; +const USDC_DECIMALS = 6; + +const TRANSFER_WITH_AUTHORIZATION_TYPES = { + TransferWithAuthorization: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'validAfter', type: 'uint256' }, + { name: 'validBefore', type: 'uint256' }, + { name: 'nonce', type: 'bytes32' }, + ], +} as const; + +// --------------------------------------------------------------------------- +// Network configs +// --------------------------------------------------------------------------- + +interface NetworkConfig { + chain: typeof base | typeof baseSepolia; + networkId: string; + rpcUrl: string; + label: string; +} + +const NETWORKS: Record = { + [`eip155:${base.id}`]: { + chain: base, + networkId: `eip155:${base.id}`, + rpcUrl: 'https://mainnet.base.org', + label: 'Base Mainnet', + }, + [`eip155:${baseSepolia.id}`]: { + chain: baseSepolia, + networkId: `eip155:${baseSepolia.id}`, + rpcUrl: 'https://sepolia.base.org', + label: 'Base Sepolia (testnet)', + }, +}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface PaymentRequiredAccept { + scheme: string; + network: string; + amount: string; + asset: string; + payTo: string; + maxTimeoutSeconds: number; + extra?: { name?: string; version?: string }; +} + +export interface PaymentRequiredHeader { + x402Version: number; + resource?: { url?: string; description?: string; mimeType?: string }; + accepts: PaymentRequiredAccept[]; +} + +/** Minimal wallet info needed for signing */ +export interface SignerWallet { + privateKey: string; // Hex with 0x prefix + address: string; +} + +export interface SignPaymentInput { + wallet: SignerWallet; + accept: PaymentRequiredAccept; + resource?: PaymentRequiredHeader['resource']; + /** Override amount in atomic units (default: from accept.amount) */ + amountOverride?: bigint; + /** Override expiry in seconds (default: from accept.maxTimeoutSeconds or 3600) */ + expiryOverride?: number; +} + +export interface SignPaymentResult { + /** Base64-encoded payment signature header value */ + paymentSignatureBase64: string; + /** Signer address */ + from: string; + /** Recipient address */ + to: string; + /** Amount in USD */ + amountUsd: number; + /** Amount in atomic units */ + amountAtomicUnits: bigint; + /** Network label */ + networkLabel: string; + /** Expiry timestamp */ + expiresAt: Date; +} + +// --------------------------------------------------------------------------- +// Crypto helpers +// --------------------------------------------------------------------------- + +function randomBytes32(): Hex { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return ('0x' + [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')) as Hex; +} + +// --------------------------------------------------------------------------- +// PAYMENT-REQUIRED header parsing +// --------------------------------------------------------------------------- + +/** + * Parse a base64-encoded PAYMENT-REQUIRED header value + * Returns the parsed header and the first "exact" scheme accept entry + */ +export function parsePaymentRequired(base64Value: string): { + header: PaymentRequiredHeader; + accept: PaymentRequiredAccept; +} { + let decoded: string; + try { + decoded = Buffer.from(base64Value, 'base64').toString('utf-8'); + } catch { + throw new ClientError('Failed to base64-decode the PAYMENT-REQUIRED value.'); + } + + let header: PaymentRequiredHeader; + try { + header = JSON.parse(decoded) as PaymentRequiredHeader; + } catch { + throw new ClientError('PAYMENT-REQUIRED header is not valid JSON after base64 decoding.'); + } + + if (!header.accepts || !Array.isArray(header.accepts) || header.accepts.length === 0) { + throw new ClientError('PAYMENT-REQUIRED header has no "accepts" entries.'); + } + + const accept = header.accepts.find((a) => a.scheme === 'exact'); + if (!accept) { + throw new ClientError( + `No "exact" scheme found in PAYMENT-REQUIRED accepts. Available: ${header.accepts.map((a) => a.scheme).join(', ')}` + ); + } + + if (!accept.payTo || !accept.amount || !accept.network || !accept.asset) { + throw new ClientError( + 'PAYMENT-REQUIRED accept entry is missing required fields (payTo, amount, network, asset).' + ); + } + + return { header, accept }; +} + +// --------------------------------------------------------------------------- +// Signing +// --------------------------------------------------------------------------- + +/** + * Sign an x402 payment and return a base64-encoded PAYMENT-SIGNATURE header value + */ +export async function signPayment(input: SignPaymentInput): Promise { + const { wallet, accept, resource } = input; + + // Resolve network + const networkConfig = NETWORKS[accept.network]; + if (!networkConfig) { + throw new ClientError( + `Unknown network "${accept.network}" in payment requirements. Supported: ${Object.keys(NETWORKS).join(', ')}` + ); + } + + // Resolve amount + const amountAtomicUnits = input.amountOverride ?? BigInt(accept.amount); + const amountUsd = Number(amountAtomicUnits) / 10 ** USDC_DECIMALS; + + // Resolve expiry + const expirySeconds = (input.expiryOverride ?? accept.maxTimeoutSeconds) || 3600; + + // EIP-3009 domain + const eip3009Name = accept.extra?.name ?? 'USDC'; + const eip3009Version = accept.extra?.version ?? '2'; + + // Sign + const account = privateKeyToAccount(wallet.privateKey as Hex); + const walletClient = createWalletClient({ + account, + chain: networkConfig.chain, + transport: http(networkConfig.rpcUrl), + }); + + const nonce = randomBytes32(); + const validBefore = BigInt(Math.floor(Date.now() / 1000) + expirySeconds); + + const signature = await walletClient.signTypedData({ + domain: { + name: eip3009Name, + version: eip3009Version, + chainId: networkConfig.chain.id, + verifyingContract: accept.asset as Hex, + }, + types: TRANSFER_WITH_AUTHORIZATION_TYPES, + primaryType: 'TransferWithAuthorization', + message: { + from: account.address, + to: accept.payTo as Hex, + value: amountAtomicUnits, + validAfter: 0n, + validBefore, + nonce, + }, + }); + + // Build x402 payload + const paymentPayload = { + x402Version: X402_VERSION, + resource: resource ?? { + url: 'https://mcp.apify.com/mcp', + description: 'MCP Server', + mimeType: 'application/json', + }, + payload: { + signature, + authorization: { + from: account.address, + to: accept.payTo, + value: amountAtomicUnits.toString(), + validAfter: '0', + validBefore: validBefore.toString(), + nonce, + }, + }, + accepted: { + scheme: 'exact', + network: networkConfig.networkId, + asset: accept.asset, + amount: amountAtomicUnits.toString(), + payTo: accept.payTo, + maxTimeoutSeconds: expirySeconds, + extra: { name: eip3009Name, version: eip3009Version }, + }, + }; + + const paymentSignatureBase64 = Buffer.from(JSON.stringify(paymentPayload)).toString('base64'); + + return { + paymentSignatureBase64, + from: account.address, + to: accept.payTo, + amountUsd, + amountAtomicUnits, + networkLabel: networkConfig.label, + expiresAt: new Date(Number(validBefore) * 1000), + }; +}