Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- New `mcpc connect --x402-max-amount <usd>` option that caps every single x402 payment, e.g. `--x402-max-amount 0.50`. A payment above the cap fails instead of being signed, whatever the server asks for. The cap is stored with the session and reused on restart.
- New [REFERENCE.md](docs/REFERENCE.md) with the full `--help` output of every mcpc command, generated from the CLI itself so it always matches the release.
- `mcpc @session` and `server-discover` now show the description and website URL a server advertises about itself, right below its name.

Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,24 @@ mcpc @apify restart
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. Your scheme preference is persisted in `sessions.json` and reused on every reconnect or restart.

### Limiting how much a session can spend

Without a limit, a session pays whatever a tool call turns out to cost. `--x402-max-amount <usd>`
caps every single payment — the price the server asks for, or for the `upto` scheme the maximum
authorization you sign:

```bash
# Never sign a single payment above $0.50
mcpc connect mcp.apify.com @apify --x402 --x402-max-amount 0.50
```

A payment above the cap is refused locally, before anything is signed, and the tool call fails with
the amount that was asked for. The check covers all three payment paths: proactively priced tools,
HTTP 402 challenges, and payment-required tool results.

The cap is stored in `sessions.json` and reused on every reconnect and restart, so a crashed
session comes back capped. To change it, close the session and connect again.

### Supported networks

| Network | Status |
Expand Down
5 changes: 5 additions & 0 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ Options:
--stdio Launch all local stdio servers from selected config files
--protocol-version <version> Pin the MCP protocol version (see below)
--x402 [scheme] Enable x402 auto-payment (see below)
--x402-max-amount <usd> Refuse any single x402 payment above this amount
--json Output in JSON format

Server formats:
Expand Down Expand Up @@ -152,6 +153,10 @@ Protocol version:
x402 payments (experimental):
--x402 pays for paid tool calls from the wallet set up with mcpc x402.
Schemes: auto (default, prefers upto), upto, exact.
--x402-max-amount <usd> caps every single payment, e.g. --x402-max-amount 0.50.
A payment above the cap fails instead of being signed, whatever the server asks
for. The cap is stored with the session and reused on restart; close the session
to change it.

Output:
For a single server, shows session, server info, capabilities, and tools.
Expand Down
2 changes: 2 additions & 0 deletions skills/mcpc/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@ mcpc @apify skills-get <name> --raw # print the SKILL.md markdown (pipe to a
(`--no-profile`, `--stdio`, `--proxy`, and `-H` are options of `connect`, not global flags.)

`mcpc` also has experimental `--x402` auto-payment for paid MCP tools — see `mcpc help x402`.
When spending unattended, add `--x402-max-amount <usd>` (e.g. `--x402-max-amount 0.50`): any single
payment above it is refused instead of signed, and the cap survives session restarts.

## Debugging

Expand Down
36 changes: 34 additions & 2 deletions src/bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ import type { ProxyConfig } from '../lib/types.js';
// only here and load the implementations lazily at the x402-gated call sites.
import type { X402PaymentCache } from '../lib/x402/fetch-middleware.js';
import type { SignerWallet } from '../lib/x402/signer.js';
// Spend-limit helpers are deliberately dependency-free, so they stay a static import.
import { X402PaymentLimitError, parseMaxAmountUsd, usdToAtomicUnits } from '../lib/x402/limits.js';
import type { FetchLike } from '@modelcontextprotocol/client';

// HTTP proxy and TLS settings are configured in main() after parsing --insecure flag
Expand All @@ -96,6 +98,8 @@ interface BridgeOptions {
protocolVersion?: string; // Protocol version negotiated by the resumed session (only set with mcpSessionId)
/** x402 scheme preference; presence enables x402 auto-payment, absence disables. */
x402?: X402SchemePreference;
/** Local spend limit in USD applied to every single x402 payment. */
x402MaxAmountUsd?: number;
insecure?: boolean; // Skip TLS certificate verification
}

Expand Down Expand Up @@ -678,11 +682,13 @@ class BridgeProcess {
return this.client?.getCachedTools()?.find((t: Tool) => t.name === name);
};
const { createX402FetchMiddleware } = await import('../lib/x402/fetch-middleware.js');
const maxAmountAtomicUnits = this.x402MaxAmountAtomicUnits();
customFetch = createX402FetchMiddleware(proxyFetch, {
wallet,
getToolByName,
paymentCache: this.x402PaymentCache,
...(this.options.x402 && { schemePreference: this.options.x402 }),
...(maxAmountAtomicUnits !== undefined && { maxAmountAtomicUnits }),
});
}

Expand Down Expand Up @@ -1301,6 +1307,12 @@ class BridgeProcess {
}
}

/** The session's `--x402-max-amount` in atomic units, or undefined when uncapped. */
private x402MaxAmountAtomicUnits(): bigint | undefined {
const maxAmountUsd = this.options.x402MaxAmountUsd;
return maxAmountUsd === undefined ? undefined : usdToAtomicUnits(maxAmountUsd);
}

/**
* Handle a tool result that contains x402 payment-required data.
* Signs a fresh payment, caches it, and retries the tool call once.
Expand Down Expand Up @@ -1337,18 +1349,23 @@ class BridgeProcess {

// Invalidate cache and sign fresh
this.x402PaymentCache.signature = null;
const { signPayment } = await import('../lib/x402/signer.js');
const maxAmountAtomicUnits = this.x402MaxAmountAtomicUnits();
try {
const { signPayment } = await import('../lib/x402/signer.js');
const signed = await signPayment({
wallet: this.x402Wallet,
accept: parsed.accept,
resource: parsed.resource,
...(maxAmountAtomicUnits !== undefined && { maxAmountAtomicUnits }),
});
this.x402PaymentCache.signature = signed.paymentSignatureBase64;
logger.debug(
`Fresh payment signed for retry: $${signed.amountUsd.toFixed(6)} to ${signed.to} on ${signed.networkLabel}`
);
} catch (signError) {
// Refusing to exceed the spend limit must reach the caller — returning the
// payment-required result instead would read as "the server wants payment".
if (signError instanceof X402PaymentLimitError) throw signError;
logger.warn('Failed to sign fresh payment for 402 retry:', signError);
return { handled: false };
}
Expand Down Expand Up @@ -1899,7 +1916,7 @@ async function main(): Promise<void> {

if (args.length < 2) {
console.error(
'Usage: mcpc-bridge <sessionName> <transportConfigJson> [--verbose] [--profile <name>] [--proxy-host <host>] [--proxy-port <port>] [--mcp-session-id <id>] [--protocol-version <version>] [--x402 <auto|upto|exact>] [--insecure]'
'Usage: mcpc-bridge <sessionName> <transportConfigJson> [--verbose] [--profile <name>] [--proxy-host <host>] [--proxy-port <port>] [--mcp-session-id <id>] [--protocol-version <version>] [--x402 <auto|upto|exact>] [--x402-max-amount <usd>] [--insecure]'
);
process.exit(1);
}
Expand Down Expand Up @@ -1955,6 +1972,18 @@ async function main(): Promise<void> {
x402 = value as X402SchemePreference;
}

// Parse `--x402-max-amount <usd>` (local spend limit for every single payment).
let x402MaxAmountUsd: number | undefined;
const x402MaxAmountIndex = args.indexOf('--x402-max-amount');
if (x402MaxAmountIndex !== -1) {
try {
x402MaxAmountUsd = parseMaxAmountUsd(args[x402MaxAmountIndex + 1] ?? '');
} catch (error) {
console.error((error as Error).message);
process.exit(1);
}
}

// Parse --insecure flag (skip TLS certificate verification)
const insecure = args.includes('--insecure');

Expand Down Expand Up @@ -1983,6 +2012,9 @@ async function main(): Promise<void> {
if (x402) {
bridgeOptions.x402 = x402;
}
if (x402MaxAmountUsd !== undefined) {
bridgeOptions.x402MaxAmountUsd = x402MaxAmountUsd;
}
if (insecure) {
bridgeOptions.insecure = true;
}
Expand Down
34 changes: 34 additions & 0 deletions src/cli/commands/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
storeKeychainProxyBearerToken,
} from '../../lib/auth/keychain.js';
import { getWallet } from '../../lib/wallets.js';
import { formatUsdAmount, usdToAtomicUnits } from '../../lib/x402/limits.js';
import chalk from 'chalk';
// ora is loaded lazily at the spinner call site — it is only needed for
// human-mode bulk connects and costs ~50 ms at import.
Expand Down Expand Up @@ -145,6 +146,7 @@
proxyBearerToken?: string;
protocolVersion?: string;
x402?: X402SchemePreference;
x402MaxAmountUsd?: number;
insecure?: boolean;
skipDetails?: boolean;
quiet?: boolean;
Expand Down Expand Up @@ -292,12 +294,32 @@
// Validate --protocol-version (if provided)
assertSupportedProtocolVersion(options.protocolVersion);

if (options.x402MaxAmountUsd !== undefined && !options.x402) {
throw new ClientError('--x402-max-amount requires --x402');
}

/** Render a stored or requested spend limit for error messages. */
const formatLimit = (maxAmountUsd: number | undefined): string =>
maxAmountUsd === undefined ? 'no limit' : formatUsdAmount(usdToAtomicUnits(maxAmountUsd));

// Check if session already exists
const existingSession = await getSession(name);
if (existingSession) {
const bridgeStatus = getBridgeStatus(existingSession);

if (bridgeStatus === 'live') {
// A live bridge keeps the limit it was started with, so silently ignoring a
// different --x402-max-amount would leave the session spending on the old one.
if (
options.x402MaxAmountUsd !== undefined &&
options.x402MaxAmountUsd !== existingSession.x402MaxAmountUsd
) {
throw new ClientError(
`Session ${name} is already active with a different x402 spend limit ` +
`(${formatLimit(existingSession.x402MaxAmountUsd)}, requested ${formatLimit(options.x402MaxAmountUsd)}). ` +
`To change it, run: mcpc ${name} close`
);
}
// Session exists and bridge is running - just show server info
if (options.outputMode === 'human' && !options.quiet) {
console.log(formatSuccess(`Session ${name} is already active`));
Expand Down Expand Up @@ -389,6 +411,11 @@
logger.debug(`Using x402 wallet: ${wallet.address}`);
}

// A bare reconnect (`mcpc connect <server> @name` after a crash) must not drop the
// spend limit the session was created with — restoring it keeps the guard on until
// the session is explicitly closed.
const effectiveX402MaxAmountUsd = options.x402MaxAmountUsd ?? existingSession?.x402MaxAmountUsd;

// Create or update session record (without pid - that comes from startBridge)
// Store serverConfig with headers redacted (actual values in keychain)
const isReconnect = !!existingSession;
Expand All @@ -403,6 +430,9 @@
...(profileName && { profileName }),
...(proxyConfig && { proxy: proxyConfig }),
...(options.x402 && { x402: options.x402 }),
...(effectiveX402MaxAmountUsd !== undefined && {
x402MaxAmountUsd: effectiveX402MaxAmountUsd,
}),
...(options.insecure && { insecure: true }),
// Clear any previous error status (unauthorized, expired) when reconnecting
...(isReconnect && { status: 'active' }),
Expand Down Expand Up @@ -432,6 +462,9 @@
...(profileName && { profileName }),
...(proxyConfig && { proxyConfig }),
...(options.x402 && { x402: options.x402 }),
...(effectiveX402MaxAmountUsd !== undefined && {
x402MaxAmountUsd: effectiveX402MaxAmountUsd,
}),
...(options.insecure && { insecure: true }),
};

Expand Down Expand Up @@ -646,6 +679,7 @@
stdio?: boolean;
protocolVersion?: string;
x402?: X402SchemePreference;
x402MaxAmountUsd?: number;
insecure?: boolean;
};

Expand Down Expand Up @@ -772,7 +806,7 @@
);

let results: BulkConnectResult[] = settled.map((outcome, i) => {
const base = entries[i]!;

Check warning on line 809 in src/cli/commands/connect.ts

View workflow job for this annotation

GitHub Actions / Node.js 26

Forbidden non-null assertion

Check warning on line 809 in src/cli/commands/connect.ts

View workflow job for this annotation

GitHub Actions / Node.js 24

Forbidden non-null assertion

Check warning on line 809 in src/cli/commands/connect.ts

View workflow job for this annotation

GitHub Actions / Node.js 22

Forbidden non-null assertion
if (outcome.status === 'fulfilled') {
return { ...base, status: liveSet.has(base.sessionName) ? 'active' : 'created' };
}
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ export async function restartSession(
...(profileName && { profileName }),
...(session.proxy && { proxyConfig: session.proxy }),
...(session.x402 && { x402: session.x402 }),
...(session.x402MaxAmountUsd !== undefined && { x402MaxAmountUsd: session.x402MaxAmountUsd }),
...(session.insecure && { insecure: session.insecure }),
};

Expand Down
24 changes: 24 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { clean } from './commands/clean.js';
import { MCPC_OAUTH_CALLBACK_HOSTS, MCPC_OAUTH_CALLBACK_PORTS } from '../lib/auth/oauth-utils.js';
import type { OutputMode, X402SchemePreference } from '../lib/index.js';
import { X402_SCHEME_PREFERENCES } from '../lib/index.js';
import { parseMaxAmountUsd } from '../lib/x402/limits.js';
import {
extractOptions,
preProcessSkillArgv,
Expand Down Expand Up @@ -89,6 +90,8 @@ interface HandlerOptions {
* `--x402` (no value) resolves to `'auto'` (prefer upto, fall back to exact).
*/
x402?: X402SchemePreference;
/** Local spend limit in USD applied to every single x402 payment. */
x402MaxAmountUsd?: number;
insecure?: boolean;
schema?: string;
schemaMode?: 'strict' | 'compatible' | 'ignore';
Expand Down Expand Up @@ -146,6 +149,10 @@ function getOptionsFromCommand(command: Command): HandlerOptions {
}
options.x402 = opts.x402 as X402SchemePreference;
}
if (opts.x402MaxAmount !== undefined) {
options.x402MaxAmountUsd = parseMaxAmountUsd(opts.x402MaxAmount as string);
if (!options.x402) throw new ClientError('--x402-max-amount requires --x402.');
}
if (opts.insecure) options.insecure = true;
if (opts.schema) options.schema = opts.schema;
if (opts.schemaMode) {
Expand Down Expand Up @@ -489,6 +496,7 @@ Full docs: ${docsUrl}`
.option('--stdio', 'Launch all local stdio servers from selected config files')
.option('--protocol-version <version>', 'Pin the MCP protocol version (see below)')
.option('--x402 [scheme]', 'Enable x402 auto-payment (see below)')
.option('--x402-max-amount <usd>', 'Refuse any single x402 payment above this amount')
.addHelpText(
'after',
`
Expand Down Expand Up @@ -522,6 +530,10 @@ ${chalk.bold('Protocol version:')}
${chalk.bold('x402 payments (experimental):')}
--x402 pays for paid tool calls from the wallet set up with mcpc x402.
Schemes: auto (default, prefers upto), upto, exact.
--x402-max-amount <usd> caps every single payment, e.g. --x402-max-amount 0.50.
A payment above the cap fails instead of being signed, whatever the server asks
for. The cap is stored with the session and reused on restart; close the session
to change it.
${outputHelp([
'For a single server, shows session, server info, capabilities, and tools.',
'Bulk connects list every session with its state, then a summary.',
Expand Down Expand Up @@ -553,6 +565,9 @@ ${outputHelp([
...(opts.stdio && { stdio: true }),
...(opts.protocolVersion && { protocolVersion: opts.protocolVersion as string }),
...(globalOpts.x402 && { x402: globalOpts.x402 }),
...(globalOpts.x402MaxAmountUsd !== undefined && {
x402MaxAmountUsd: globalOpts.x402MaxAmountUsd,
}),
...(globalOpts.insecure && { insecure: true }),
});
// Trailing blank line to match the spacing of other commands (human mode only).
Expand Down Expand Up @@ -585,6 +600,9 @@ ${outputHelp([
...(opts.stdio && { stdio: true }),
...(opts.protocolVersion && { protocolVersion: opts.protocolVersion as string }),
...(globalOpts.x402 && { x402: globalOpts.x402 }),
...(globalOpts.x402MaxAmountUsd !== undefined && {
x402MaxAmountUsd: globalOpts.x402MaxAmountUsd,
}),
...(globalOpts.insecure && { insecure: true }),
});
return;
Expand All @@ -610,6 +628,9 @@ ${outputHelp([
proxyBearerToken: opts.proxyBearerToken,
...(opts.protocolVersion && { protocolVersion: opts.protocolVersion as string }),
...(globalOpts.x402 && { x402: globalOpts.x402 }),
...(globalOpts.x402MaxAmountUsd !== undefined && {
x402MaxAmountUsd: globalOpts.x402MaxAmountUsd,
}),
...(globalOpts.insecure && { insecure: true }),
});
} else {
Expand All @@ -620,6 +641,9 @@ ${outputHelp([
proxyBearerToken: opts.proxyBearerToken,
...(opts.protocolVersion && { protocolVersion: opts.protocolVersion as string }),
...(globalOpts.x402 && { x402: globalOpts.x402 }),
...(globalOpts.x402MaxAmountUsd !== undefined && {
x402MaxAmountUsd: globalOpts.x402MaxAmountUsd,
}),
...(globalOpts.insecure && { insecure: true }),
});
}
Expand Down
8 changes: 7 additions & 1 deletion src/cli/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import chalk from 'chalk';
import { formatUsdAmount, usdToAtomicUnits } from '../lib/x402/limits.js';
import type {
DiscoverResult,
GetPromptResult,
Expand Down Expand Up @@ -1545,7 +1546,12 @@ export function formatSessionLine(session: SessionData): string {
// x402 takes precedence when both happen to be present on the session record.
let infoStr = '';
if (session.x402) {
infoStr = theme.yellow('[x402]');
// Show the spend limit — it is the difference between a capped wallet and an open one.
infoStr = theme.yellow(
session.x402MaxAmountUsd === undefined
? '[x402]'
: `[x402 max ${formatUsdAmount(usdToAtomicUnits(session.x402MaxAmountUsd))}]`
);
} else if (!session.server.command && session.profileName) {
infoStr = chalk.dim('(OAuth: ') + theme.magenta(session.profileName) + chalk.dim(')');
}
Expand Down
Loading
Loading