Skip to content

feat: add configurable host allow-list to paid_call, refuse before any request - #487

Merged
Miracle656 merged 3 commits into
Divinity-Labs:mainfrom
Ugasutun:feat/mcp-paid-call-allowlist
Oct 18, 2026
Merged

Miracle656 merged 3 commits into
Divinity-Labs:mainfrom
Ugasutun:feat/mcp-paid-call-allowlist

Conversation

@Ugasutun

Copy link
Copy Markdown
Contributor

Closes #404

Summary

paid_call took a URL from the agent and paid any 402 response from a live
mainnet key, up to maxPrice. With no allow-list, a prompt-injected agent
could be steered into paying an attacker-controlled host with a single
sentence. This PR adds a configuration-driven host allow-list that is checked
before any network request is made. The default configuration pays nobody.

Changes

  • Added an allow-list check at the top of the paid_call handler. The URL is
    parsed and validated before fetchWithPayment is called, so a refused host
    is never contacted.
  • The allow-list comes from configuration (MCP_PAID_CALL_ALLOWED_HOSTS,
    comma-separated), read in examples/mcp-server/run.ts and passed to the
    server at construction. It is not a tool argument and the agent cannot
    influence it.
  • Default is an empty list, so paid_call refuses everything until an
    operator opts hosts in. (Alternative: default to Lens's own origin only.)
  • Matching is on the parsed URL's hostname (and port/scheme where
    configured), using exact comparison. No substring or startsWith matching
    on the raw string.
  • Only https: URLs are accepted. URLs containing userinfo
    (https://allowed.com@evil.com) are rejected.
  • A refusal returns a structured MCP error with a stable code
    (PAID_CALL_HOST_NOT_ALLOWED) and a message naming the host, so the agent
    can explain the refusal rather than retry. Malformed URLs return
    PAID_CALL_INVALID_URL.
  • Updated the paid_call tool description to state that only operator
    allow-listed hosts can be paid.

Security notes

  • Validation happens before fetchWithPayment, so a refused host receives
    no request, not even an unpaid one.
  • Hostnames are compared after URL parsing and lowercasing, which defeats
    allowed.com.evil.com, evil.com/allowed.com, and
    allowed.com@evil.com style near-misses.
  • Redirects: [describe how handled, e.g. redirect: 'manual' or re-validating each hop against the allow-list]. A 402 payment must not follow a redirect
    to a host outside the allow-list.
  • This protects the money the agent spends. It is separate from Harden the x402 facilitator: spend ceiling, caller allow-list, settle idempotency #147, which
    protects POST /settle. Spend ceiling (L210) and payment log (L211) are
    complementary and tracked separately.

Testing

  • Allowed host: request proceeds and fetchWithPayment is called once.
  • Refused host: returns PAID_CALL_HOST_NOT_ALLOWED naming the host, and
    fetchWithPayment / fetch is never called (asserted with a spy).
  • Near-misses, all refused:
    • https://allowed.com.evil.com
    • https://evil.com/allowed.com
    • https://allowed.com@evil.com
    • https://allowed.co
    • http://allowed.com (wrong scheme)
  • Default config (no env var set): every URL is refused.
  • Malformed URL: returns PAID_CALL_INVALID_URL.

Checklist

  • paid_call refuses a non-allow-listed host before any request is made
  • Allow-list is configuration, not a tool argument
  • Default configuration pays nothing
  • Refusal is a structured error with a code, naming the host
  • Tests cover an allowed host, a refused host, and a near-miss
  • Docs updated (MCP_PAID_CALL_ALLOWED_HOSTS documented)

…y-Labs#404)

paid_call passed any URL the agent named to a fetch wrapper that answers a
402 with USDC from a live mainnet key, so a single sentence in a page, issue
comment or search result could choose where the money went.

The payable origins are server configuration (MCP_PAID_CALL_ALLOWED_HOSTS),
never a tool argument, and the default is an empty list that pays nobody.
A refused URL fails with ERR_HOST_NOT_ALLOWED naming its host, before any
request is sent, so an unlisted host is never contacted. Matching is
origin-exact — case, root dot and punycode folded, port pinned when the
entry names one — plus an optional path prefix enforced at a segment
boundary, which refuses subdomain look-alikes, url userinfo, homoglyphs and
sibling paths. An entry this cannot read is dropped with a warning rather
than guessed at, because a typo should cost an allowed host, not a blocked
one.

Bounds a spend this tool can make; the ceiling (Divinity-Labs#210) and the payment log
(Divinity-Labs#211) cover what happens to hosts that are allow-listed.
@drips-wave

drips-wave Bot commented Oct 17, 2026

Copy link
Copy Markdown

@Ugasutun Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

…y-Labs#423)

Existing 402 tests mocked fetchWithPayment, so the branch that actually moves
money - 402 -> construct -> sign -> submit -> resource returned - was never
exercised. This drives the real @x402/fetch transport and a real Soroban
transfer signed with @stellar/stellar-sdk against a stubbed resource + fake
facilitator. Only the RPC/Horizon and the facilitator are stubbed; both
upgrade-sensitive surfaces (x402 loop wiring, Stellar SDK signing) stay real.

Asserts on what was signed - amount, destination and network passphrase
(valid on testnet, fails on mainnet) - not just the response body, and covers
amount above maxPrice, an unregistered network, and an expired signature.
Throwaway testnet keys only; deterministic, no network access.

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Needs a rebase — #488 (the SSRF guard) landed an hour ago and also touches src/mcp/server.ts and src/mcp/codes.ts, so this now conflicts. That is my sequencing, not yours.

I have read it in the meantime and the design is right, so the rebase should be the only work left.

The default pays nobody. This is the whole thing:

Its default is empty, and an empty list pays nobody: refusing is the safe failure

A spend gate whose default is permissive is not a gate. And an entry the parser cannot understand being dropped rather than guessed at is the same instinct applied one level down — a typo'd allow-list line that silently becomes a broader rule than intended is exactly how these fail in practice.

It is configuration, never a tool argument. Taking the list from McpServerConfig means the agent cannot widen its own permissions, which matters because the agent is the component most likely to be talking to an attacker. If the model could pass the allow-list, the gate would be decorative.

Origin matching is exact, including the port, with api.example.com not matching api.example.com:8443 in either direction, and path prefixes matched at a segment boundary rather than by startsWith — so /api does not open /apifoo. Both are the details that turn an allow-list into an allow-list rather than a suggestion.

Your framing in the comment is also the right one to have written down:

the tool is a spend primitive dressed as a getter

That is the sentence that explains why #404 was a 200-point issue and not a 100.

Rebase on current main and I will merge it. Note that #488 added src/net/ssrf.ts with its own refusal codes — worth a look while you are in codes.ts, so the two gates do not end up with two vocabularies for "I refused to go there".

@Miracle656

Copy link
Copy Markdown
Collaborator

Coordination note — ten PRs are editing one 179-line file

If your MCP PR has just gone red with a conflict, this is why, and it is my fault rather than yours.

I published 22 MCP issues (#404–#425) as if they were independent. They are not: almost all of them land in src/mcp/server.ts, which is 179 lines long. Every merge therefore conflicts everything else. The batch should have been sequenced, or scoped so the work genuinely separated — that was a mistake in how I wrote the issues, not in how you answered them.

Already merged:

Order I intend to take the rest in, so you can rebase once rather than repeatedly:

  1. feat: add configurable host allow-list to paid_call, refuse before any request #487 — paid_call host allow-list (paid_call will pay whatever URL the agent names #404). The gate that decides which hosts may be paid. This one goes next because everything else about spending is narrower than it.
  2. feat(mcp): bound cumulative spend, audit payments, serve over HTTP #491 / fix(mcp): bound cumulative daily paid-call spend, not just per call #492 — cumulative spend ceiling and payment audit (The spend cap is per call, so it is not a cap #405, Nothing records what the agent spent #406). These bound and record what happens to hosts the allow-list permits.
  3. fix: classify MCP paid-call errors by structured signals #480 — structural error classification (Errors are classified by searching the message text #410).
  4. fix(mcp): bound tool result size, stream-cap upstream reads, and report truncation explicitly #489 — bounded tool results (Tool results are unbounded #416).
  5. feat: add lens_get_price tool returning aggregated pair price with issuer-strict assets #493 / feat: add lens_list_pairs, lens_get_pools and lens_screen_pairs tools #482 / feat: declare the resources capability and expose pairs, venues, OpenAPI spec, and status #494 — the new Lens tools and the resources capability (An agent cannot ask Lens for a price #408, No tool exposes pairs, pools or the screener #409, The server advertises only tools, not resources #418).
  6. test(mcp): add end-to-end 402 payment loop test against a stub facilitator #490 — the end-to-end 402 loop test (No test drives a real 402 payment loop #423).

Please do not all rebase at once. If you are below the one currently in flight, wait until the one above you lands — otherwise you rebase, I merge someone else, and you rebase again. I will comment on your PR when it is your turn.

One thing to settle between you: src/mcp/codes.ts is being extended by at least four of these, and #488 has already added a separate set of refusal reasons in src/net/ssrf.ts. We should end up with one vocabulary for "I refused to do that", not four. Whoever lands next, please look at what is already there and extend it rather than adding a parallel set — and say in your PR description what you added, so the next person can see it without reading the diff.

Nothing here reflects on the quality of the work. Several of these are among the best PRs this repo has had.

@Miracle656

Copy link
Copy Markdown
Collaborator

Your turn — please rebase now. You are first in the MCP queue and nothing is ahead of you.

main has #488 (the SSRF guard, src/net/ssrf.ts) and #486 (the examples README). Both touch files you touch.

One ask while you are in there: #488 added its own refusal codes in src/net/ssrf.ts (invalid_url, unsupported_scheme, embedded_credentials, host_not_allowed, unresolvable_host, blocked_address, too_many_redirects). Your allow-list refusals and those are the same kind of answer — "I would not go there" — so they should end up as one vocabulary rather than two sitting side by side in codes.ts. Extend what is there if it fits.

Everything I said in the review stands: the empty default that pays nobody, configuration rather than tool arguments, exact origin matching including the port, and path prefixes at a segment boundary. I will merge as soon as it is green.

This was referenced Oct 18, 2026
Two gates now sit side by side in the MCP server and they are deliberately
separate:

  ERR_URL_BLOCKED       the target resolves somewhere we will never fetch —
                        private, loopback, link-local, metadata (Divinity-Labs#488/Divinity-Labs#407)
  ERR_HOST_NOT_ALLOWED  the host is reachable, but is not on the operator's
                        paid_call allow-list (Divinity-Labs#487/Divinity-Labs#404)

Both codes kept rather than one replacing the other: "I cannot reach there"
and "I will not pay there" are different answers and an agent should be able
to tell them apart.

The constructor keeps both initialisations, on main's `config_` parameter
name — it was renamed there to stop it shadowing the imported `config`.

Also exempts src/mcp/ from the console guard added in Divinity-Labs#402. The MCP server
speaks its protocol over stdout via StdioServerTransport, and pino's default
destination is stdout — routing these diagnostics through the logger would
interleave log lines with protocol frames and break every client parsing
them. stderr is the only stream it may write to, so console.error is correct
there. The exemption pattern accepts both path separators; a forward-slash
pattern matches nothing on Windows.

mcp.test.ts, mcp-paid-call-allowlist.test.ts, ssrf.test.ts: 41 passed.
Suite: 772 passed. The two local reds are CRLF in my working copy and a
mercurius 16.12 node_modules ahead of the lockfile, neither of them real.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Rebased and merging. CI green.

I resolved the conflicts myself rather than leave the chain stuck — nine MCP PRs were blocked behind this one and nobody could move until it did. What I kept and why:

Both error codes, not one replacing the other.

ERR_URL_BLOCKED       // the target resolves somewhere we will never fetch (#488)
ERR_HOST_NOT_ALLOWED  // reachable, but not on the paid_call allow-list (#487)

"I cannot reach there" and "I will not pay there" are different answers and an agent should be able to act on the difference. The conflict presented them as alternatives; they are not.

Both gates in the constructor, on main's config_ parameter name — it was renamed there to stop it shadowing the imported config. Your allow-list decides which hosts may be paid; #488's SSRF guard decides which addresses may be reached at all. Neither can re-open what the other refuses, which is the property worth preserving.

One extra thing I had to fix. Your console.error for unparseable allow-list entries tripped the guard #402 added. I exempted src/mcp/ rather than convert it, because converting it would have been wrong: the MCP server speaks its protocol over stdout via StdioServerTransport, and pino's default destination is stdout — routing those diagnostics through the logger would interleave log lines with protocol frames and break every client parsing them. stderr is the only stream it may use, so console.error is correct there. The exemption accepts both path separators, since a forward-slash-only pattern matches nothing on Windows.

Everything from my original review stands: the empty default that pays nobody, configuration rather than tool arguments, exact origin matching including the port, path prefixes at a segment boundary, and unparseable entries dropped rather than guessed at.

41 MCP and SSRF tests pass; 772 across the suite.

@Miracle656
Miracle656 merged commit 94bd8ea into Divinity-Labs:main Oct 18, 2026
1 check passed
Jonathan-ai9 added a commit to Jonathan-ai9/Lens that referenced this pull request Oct 21, 2026
A tool result is written straight into the agent's context window, so an
unbounded one is self-inflicted denial of service whose failure gets blamed on
the model. All four tools serialise compactly and are capped at
MCP_MAX_RESULT_BYTES; a truncated result carries truncated, the total, the page
returned, and a notice saying how to ask for more.

bazaar_search gains limit/offset arguments, forwards them to the node and pages
whatever comes back. lens_get_price keeps structuredContent only while the
payload fits, since shipping it regardless would move the blow-up to another
field. Upstream bodies are read through a capped stream reader that cancels at
the cap, so an oversized body is never buffered; a discovery body too large to
parse returns ERR_RESPONSE_TOO_LARGE, which is deliberately not one of the two
pre-request refusals: we did fetch it, we will not dump it.

Rebased onto main after Divinity-Labs#480 (structured paid-call errors), Divinity-Labs#487 (paid_call host
allow-list) and Divinity-Labs#492 (spend ceiling) landed: the bounds now sit on top of those
gates rather than beside them, and limit/offset go through the Zod tool schemas.
Wittig18 pushed a commit to Wittig18/Lens that referenced this pull request Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

paid_call will pay whatever URL the agent names

2 participants