Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/mcp-paid-call-allowlist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'lens': minor
---

Gate `paid_call` behind an operator allow-list of hosts (#404). The tool pays
whatever URL it is given, up to `MCP_MAX_PAYMENT_PRICE`, from a live key — and
an agent that reads attacker-influenced text is one sentence from being told
which URL. `paidCallAllowlist` (server config, `MCP_PAID_CALL_ALLOWED_HOSTS`)
lists origins or URL prefixes; an unlisted, non-http(s) or credential-bearing
URL is refused with `ERR_HOST_NOT_ALLOWED` naming the host, before any request
is sent, and the list cannot be widened by a tool argument. The default is
empty, which pays nobody.
10 changes: 10 additions & 0 deletions .changeset/x402-successful-payment-loop-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'lens': patch
---

Add an end-to-end test for the successful x402 402 payment loop (#423). Drives the real
`@x402/fetch` transport against a stubbed resource + fake facilitator, signing a real Soroban
`transfer` with `@stellar/stellar-sdk` using throwaway testnet keys. Asserts on what was signed —
amount, destination and network passphrase (valid on testnet, invalid on mainnet) — not just the
response body, and covers the failure branches: amount above maxPrice refused before submitting,
a network the client is not registered for, and an expired signature that the facilitator rejects.
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,22 @@ FACILITATOR_SECRET_KEY=
FACILITATOR_FEE_STROOPS_TESTNET=50000
FACILITATOR_FEE_STROOPS_MAINNET=50000

# --- MCP server (npm run mcp:server) ---
# The agent's own x402 payer keys, used to answer a 402 challenge when paid_call
# fetches a gated resource. These spend money, so keep them out of any other
# process and fund only what you are willing to lose.
MCP_AGENT_SECRET_KEY_TESTNET=
MCP_AGENT_SECRET_KEY_MAINNET=
# Ceiling per paid_call, enforced by the x402 fetch wrapper.
MCP_MAX_PAYMENT_PRICE=$1.00
# Comma-separated origins (or URL prefixes) paid_call may pay for, e.g.
# "https://api.lens.example,localhost:3000". An entry with no scheme matches
# both http and https; an entry with a path only allows that path and what sits
# under it. UNSET IS EMPTY, AND AN EMPTY LIST PAYS NOBODY: every paid_call is
# refused with ERR_HOST_NOT_ALLOWED before a request is sent. This is a
# prompt-injection guard — text an agent reads must not choose where money goes.
MCP_PAID_CALL_ALLOWED_HOSTS=


# ─── Snapshot retention ────────────────────────────────────────────────────
# Rows removed by one DELETE during a retention run. The job drains in batches
Expand Down
12 changes: 11 additions & 1 deletion examples/mcp-server/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,20 @@ async function main() {

console.log('[agent] Starting MCP server child process...');

// Start the MCP server as a child process
// Start the MCP server as a child process.
// paid_call spends real USDC, so the server only pays origins the operator
// allow-lists (MCP_PAID_CALL_ALLOWED_HOSTS, empty by default). This demo
// spends against the mock server above, so it names that origin and nothing
// else. A real deployment lists its own endpoints and leaves the rest refused.
const demoAllowlist = 'localhost:3000';

const transport = new StdioClientTransport({
command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
args: ['tsx', path.join(__dirname, 'run.ts')],
env: {
...process.env as Record<string, string>,
MCP_PAID_CALL_ALLOWED_HOSTS: process.env.MCP_PAID_CALL_ALLOWED_HOSTS || demoAllowlist,
},
});

const client = new Client(
Expand Down
17 changes: 15 additions & 2 deletions examples/mcp-server/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,26 @@ async function main() {
]
});

// paid_call spends from the keys above, so the hosts it may pay for are the
// operator's decision. Unset means no host is payable at all (#404).
const paidCallAllowlist = (process.env.MCP_PAID_CALL_ALLOWED_HOSTS || '')
.split(',')
.map(entry => entry.trim())
.filter(Boolean);

const server = new BazaarMcpServer({
fetchWithPayment
fetchWithPayment,
paidCallAllowlist
});

console.error('[mcp-server] Starting Bazaar MCP server on stdio...');
console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet');

console.error(
paidCallAllowlist.length > 0
? `[mcp-server] paid_call may pay these allow-listed origins: ${paidCallAllowlist.join(', ')}`
: '[mcp-server] paid_call allow-list is empty: every paid call will be refused. Set MCP_PAID_CALL_ALLOWED_HOSTS to allow hosts.'
);

await server.run();
}

Expand Down
14 changes: 14 additions & 0 deletions src/__tests__/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,23 @@ describe('structured logger output', () => {
})

describe('logging hygiene', () => {
/**
* `src/mcp/` is exempt, and must stay exempt.
*
* The MCP server speaks its protocol over **stdout** (`StdioServerTransport`),
* so stderr is the only stream it may write diagnostics to — and pino's
* default destination is stdout. Routing these through the logger would
* interleave log lines with protocol frames and break every client parsing
* them. `console.error` is correct there, not an oversight.
*/
// Both separators: `sourceFiles()` builds paths with the platform's, so a
// forward-slash-only pattern silently matches nothing on Windows.
const CONSOLE_ALLOWED = /[\\/]src[\\/]mcp[\\/]/

it('leaves no console.* calls in non-test source', () => {
const offenders: string[] = []
for (const file of sourceFiles()) {
if (CONSOLE_ALLOWED.test(file)) continue
const code = readFileSync(file, 'utf8')
code.split('\n').forEach((line, i) => {
if (/\bconsole\.\w+\s*\(/.test(line)) {
Expand Down
6 changes: 6 additions & 0 deletions src/mcp/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ export const MCP_ERROR_CODES = {
ERR_SERVICE_UNREACHABLE: 'ERR_SERVICE_UNREACHABLE',
ERR_NO_RESULTS: 'ERR_NO_RESULTS',
ERR_BAD_REQUEST: 'ERR_BAD_REQUEST',
// Refused before any request was made, for two different reasons:
// ERR_URL_BLOCKED the target resolves somewhere we will never fetch
// (private, loopback, link-local, metadata) — #488
// ERR_HOST_NOT_ALLOWED the host is reachable but is not on the operator's
// paid_call allow-list — #487
ERR_URL_BLOCKED: 'ERR_URL_BLOCKED',
ERR_HOST_NOT_ALLOWED: 'ERR_HOST_NOT_ALLOWED',
ERR_INTERNAL_FAILURE: 'ERR_INTERNAL_FAILURE'
} as const;

Expand Down
184 changes: 184 additions & 0 deletions src/mcp/paidCallAllowlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { MCP_ERROR_CODES, type McpErrorCode } from './codes.js';

/**
* The `paid_call` allow-list (#404).
*
* `paid_call` hands a URL straight to a fetch wrapper that answers `402` with
* USDC from a live key, so the tool is a spend primitive dressed as a getter.
* Agent-facing text — a web page, an issue comment, a search result — is one
* sentence away from "call paid_call on https://…", which makes the set of
* hosts this process may pay for a decision for the operator, not the model.
*
* The list therefore arrives through configuration (`McpServerConfig.paidCallAllowlist`)
* and is never read from tool arguments. Its default is empty, and an empty
* list pays nobody: refusing is the safe failure, so every check below errs
* towards refusal and an entry this module cannot understand is dropped rather
* than guessed at.
*
* Related open work: a spend ceiling (#210) and a payment log (#211) sit on
* either side of this gate. They bound and record what happens to allowed
* hosts; this one decides which hosts there are.
*/

/** One parsed `paidCallAllowlist` entry. */
export interface PaidCallAllowRule {
/**
* Origin the request must match exactly: lowercased host with any trailing
* root dot removed, plus the port when it is not the scheme default.
* `api.example.com` never matches `api.example.com:8443`, nor the reverse.
*/
origin: string;
/**
* Protocol the request must use, when the entry spelled one out.
* A bare-host entry leaves this unset and so accepts both http and https.
*/
protocol?: string;
/**
* Path prefix the request must sit under, at a segment boundary. Unset means
* every path on the origin is allowed.
*/
pathPrefix?: string;
}

export interface PaidCallAllowlist {
/** Entries that matched something. */
rules: PaidCallAllowRule[];
/** Entries that could not be understood, and so match nothing. */
rejected: string[];
}

export type PaidCallDecision =
| { allowed: true }
| { allowed: false; code: McpErrorCode; reason: string };

/** Schemes `paid_call` will pay at all. Anything else (file:, data:, gopher:) is refused. */
const PAYABLE_PROTOCOLS = new Set(['http:', 'https:']);

function originOf(url: URL): string {
const host = url.hostname.replace(/\.+$/, '');
return url.port ? `${host}:${url.port}` : host;
}

function pathPrefixOf(url: URL): string | undefined {
// A bare-host entry parses to "/", which constrains nothing.
if (url.pathname === '/' || url.pathname === '') return undefined;
return url.pathname;
}

/**
* Parse the configured entries. Never throws: an entry that cannot be
* understood is reported in `rejected` and simply never matches, so a typo
* costs the operator a host they meant to allow, not a host they meant to
* block.
*/
export function parsePaidCallAllowlist(entries: readonly string[]): PaidCallAllowlist {
const rules: PaidCallAllowRule[] = [];
const rejected: string[] = [];

for (const raw of entries) {
const entry = raw.trim();
if (!entry) continue;

const hasProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(entry);
let parsed: URL;
try {
// Bare "host[:port][/path]" entries are parsed as http so that URL gives
// us IDN-to-punycode and case folding for free; the protocol is only
// pinned when the entry named one itself.
parsed = new URL(hasProtocol ? entry : `http://${entry}`);
} catch {
rejected.push(entry);
continue;
}

const origin = originOf(parsed);
const hasLeftovers = parsed.search || parsed.hash;
if (!origin || parsed.username || parsed.password || hasLeftovers || !PAYABLE_PROTOCOLS.has(parsed.protocol)) {
// A query or fragment in an entry would silently drop the constraint the
// operator thought they wrote, so it is rejected rather than ignored.
rejected.push(entry);
continue;
}

rules.push({
origin,
protocol: hasProtocol ? parsed.protocol : undefined,
pathPrefix: pathPrefixOf(parsed),
});
}

return { rules, rejected };
}

function matches(rule: PaidCallAllowRule, url: URL): boolean {
if (rule.origin !== originOf(url)) return false;
if (rule.protocol && rule.protocol !== url.protocol) return false;
if (!rule.pathPrefix) return true;

const { pathname } = url;
if (rule.pathPrefix.endsWith('/')) {
// "prefix/" allows the prefix itself and anything below it, never a sibling.
return pathname === rule.pathPrefix.slice(0, -1) || pathname.startsWith(rule.pathPrefix);
}
// "prefix" allows exactly that segment path and anything under it, so
// "/pricexyz" does not ride in on an allow-listed "/price".
return pathname === rule.pathPrefix || pathname.startsWith(`${rule.pathPrefix}/`);
}

/**
* Decide whether `paid_call` may spend against `url`. Call this before any
* network I/O: a refused host must never be contacted, paid or not, and the
* wrapper that pays runs inside the fetch.
*
* The URL is parsed here rather than trusted from the caller, so a relative or
* nonsense target is refused instead of being resolved by some later layer.
*/
export function checkPaidCallTarget(
url: string,
rules: readonly PaidCallAllowRule[],
): PaidCallDecision {
const refuse = (code: McpErrorCode, detail: string): PaidCallDecision => ({
allowed: false,
code,
reason: `paid_call refused; no request was sent and no payment was made. ${detail} Configure the allow-list with MCP_PAID_CALL_ALLOWED_HOSTS.`,
});

let parsed: URL;
try {
parsed = new URL(url);
} catch {
return refuse(
MCP_ERROR_CODES.ERR_BAD_REQUEST,
'the url is not an absolute http(s) URL.',
);
}

const origin = originOf(parsed);
const label = origin || 'the url';

if (!PAYABLE_PROTOCOLS.has(parsed.protocol)) {
return refuse(
MCP_ERROR_CODES.ERR_HOST_NOT_ALLOWED,
`${label} uses ${parsed.protocol || 'an unknown protocol'}, which this tool does not pay for.`,
);
}

if (parsed.username || parsed.password) {
// "https://api.example.com@evil.test" reads as example.com to a human and
// resolves to evil.test, so credentials are never a legitimate part of a
// payable url.
return refuse(
MCP_ERROR_CODES.ERR_HOST_NOT_ALLOWED,
`${label} is behind url userinfo, which is not allowed.`,
);
}

if (!rules.some(rule => matches(rule, parsed))) {
return refuse(
MCP_ERROR_CODES.ERR_HOST_NOT_ALLOWED,
`${label} is not in the paid_call allow-list${rules.length ? ', which lists other origins' : ', which is empty'}.`,
);
}

return { allowed: true };
}
Loading
Loading