Skip to content
Closed
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
42 changes: 42 additions & 0 deletions .changeset/mcp-http-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'lens': minor
---

The MCP server can now serve Streamable HTTP, selected with `MCP_TRANSPORT=http`.
Until then stdio was the only transport, so the server could only be used by a
client able to launch this process on the same machine — which rules out every
hosted agent, and means the agent's secret key has to be installed on each
machine that wants to pay.

HTTP reaches the network, so it is only allowed to start authenticated.
`resolveMcpTransportConfig` (`src/mcp/config.ts`) requires a non-empty
`MCP_HTTP_AUTH_TOKEN` before anything else happens, and `runHttp()` throws again
if handed a blank one, so an unauthenticated remote server with `paid_call` in
its tool list cannot be brought up by configuration or by an embedding caller.
The example entrypoint resolves the transport before it builds a signer: a
server that cannot authenticate callers never reaches the point where it can
spend. Every request must then present `Authorization: Bearer <token>`, compared
over SHA-256 digests with `timingSafeEqual`; a missing, malformed or wrong token
is refused with `401`, `www-authenticate: Bearer realm="lens-mcp"` and the new
`ERR_UNAUTHORIZED` code, before the body is read and before any tool runs. The
token is never echoed back into an error message or a log line.

It binds `127.0.0.1` unless `MCP_HTTP_HOST` says otherwise, answers MCP only on
`MCP_HTTP_PATH` (default `/mcp`), rejects anything else with `404` without
revealing whether the path exists, and requires `POST` — `GET` gets `405`, which
spec-compliant clients read as "no server-initiated stream" rather than an
error. Bodies are capped at 1 MiB and must be `application/json`, so a caller
cannot make the payment path stream an unbounded request.

Both transports register the same tools from the same code path, and share
one spend budget and payment ledger per process, so a remote client cannot spend
past a ceiling a local one already used. State is deliberately not shared
across requests: the SDK's stateless Streamable HTTP transport is single-use — a
second request on one instance throws, because message ids from different
clients would collide — so each `POST` gets its own protocol server and
transport.

stdio remains the default: with `MCP_TRANSPORT` unset, or set to `stdio`, the
server talks to stdin and stdout exactly as before. An unrecognised value throws
instead of quietly falling back, because a server that silently ignored the
transport it was told to serve would be hard to trust with a key.
55 changes: 55 additions & 0 deletions .changeset/mcp-payment-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
'lens': patch
---

Every `paid_call` now leaves a record of what it cost. A successful call used to
return the response body and nothing else: no log line, metric or persisted trace
of which URL was paid, how much, on which network, with which transaction. For a
tool that signs with a real key, the audit trail is not an enhancement — without
it there is no answer to "where did today's money go", and the cumulative ceiling
had no measurement of spend to read.

`PaymentLedger` (`src/mcp/ledger.ts`) writes one JSON line per attempt to
`MCP_PAYMENT_LEDGER_FILE` (default `~/.lens-mcp/payments.jsonl`): timestamp, full
URL, host, method, price, asset, network, transaction hash, the call's outcome,
what the ledger can vouch for about the money (`settled`, `possible` or `none`)
and the error code if the call failed. A failed call is recorded too, because a
payment can settle on a call that then errors — `outcome` and `spend` are separate
fields so "the call failed" never implies "nothing left the account".

The price is not on the `Response` the x402 client hands back; it is only visible
on the payment header the transport puts on the request. So the ledger wraps that
transport's fetch (`instrumentFetch`) and correlates the offer with the response
it produced. The settlement header is read for the transaction hash and for
whether the seller confirmed it.

`mcp_payment_attempts_total` (labels `host`, `network`, `outcome`) and
`mcp_payment_amount_usd_total` (labels `host`, `network`) export the same spend to
Prometheus on the shared registry, which `MCP_TRANSPORT=http` now serves from the
MCP process itself at `GET /metrics` — a counter nobody can scrape is not an
export. That route answers only to the same bearer token as the tools: what an
agent bought, on which host and for how much describes a key that moves money.
`MCP_HTTP_PATH=/metrics` is refused at boot rather than resolved by precedence, so
one path never has two meanings. Under stdio there is no listener, so the running
total there is what `payment_budget` reports and what the audit file holds.

Labels come from `MCP_METRIC_HOSTS` and `MCP_METRIC_NETWORKS` (default
`stellar:testnet,stellar:pubnet`, the networks this agent signs for): series are
permanent, so labelling by whatever URL an agent asks for would let one caller mint
unbounded time series, and anything outside an allow-list is reported as `unknown`.
The audit file always keeps the real host. Amounts that are not a USD money string
are recorded verbatim but cannot join the USD sum, so `mcp_payment_unquantified_total`
and `totalSpend().unquantifiedPayments` make that understatement visible instead of
silent.

No secret material is recorded. Only the seller's echoed `accepted` requirements are
read out of the payment header; the signed envelope alongside it never reaches a
record, a metric or the file, and every header field is length-capped so a seller
cannot forge extra lines into the trail.

Accounting fails closed in both directions: if the audit file cannot be created or a
record cannot be written, `paid_call` is refused with `ERR_INTERNAL_FAILURE` rather
than spending unrecorded. And `payment_budget` now reports `recordedSpend` beside the
budget's own worst-case reservations, so the ceiling has an actual measurement to
read: the budget holds the per-call maximum because it cannot see the price, while
the ledger reports the price it did see.
43 changes: 43 additions & 0 deletions .changeset/mcp-spend-ceiling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'lens': patch
---

The MCP server's spend cap is now cumulative, not just per call. `maxPrice`
bounded a single payment, so an agent in a retry loop — or one told to "try each
of these endpoints" — could pay that amount arbitrarily many times: every
payment individually inside the limit, the sum unbounded. A per-call limit
answers "how much can one mistake cost"; an operator's question is "how much can
today cost".

`SpendBudget` (`src/mcp/budget.ts`) adds a ceiling on the total paid by
`paid_call` within one UTC day, configured with `MCP_DAILY_SPEND_CEILING`
(default `$10.00`) alongside the per-call `MCP_MAX_PAYMENT_PRICE`. The day window
was chosen over a sliding 24 hours because it answers the operator's question
directly, needs one stored number rather than one entry per payment, and rolls
over from the wall clock — so a restarted server resumes the same day's total and
resets it after 00:00 UTC.

Before a call is made the budget reserves its worst case, the per-call maximum,
and refuses when that no longer fits what is left. It refuses with the new
`ERR_BUDGET_EXCEEDED` code carrying the spent, ceiling, remaining and reset time
in `details.budget`, so an agent can report being out of budget instead of
looking broken; the refusal happens before the payment fetch, so no money moves.
Both limits are in the `paid_call` description and the live totals are readable
without spending, through the new `payment_budget` tool. The running total
persists to `MCP_SPEND_STATE_FILE` (default `~/.lens-mcp/spend-state.json`) and is
read back at startup, because a restart does not refund what the previous process
already spent.

Accounting is deliberately conservative: the wrapped x402 transport exposes only
a plain `Response`, so a paid call that could have settled is booked at the
per-call maximum and a rejection that proves nothing settled is not booked at all.
Recorded spend is an upper bound, which means the ceiling is reached early rather
than late — and an unreadable or unwritable state file is treated as fully spent,
not as a clean slate.

Also fixed while wiring this up: `MCP_MAX_PAYMENT_PRICE` was passed to
`wrapFetchWithPaymentFromConfig` as `maxPrice`, which is not an option of the
x402 client config and was silently ignored — every payment was capped by the
library default of `$1` whatever the operator asked for. It is now passed as
`spendControls.maxAmountPerPayment`, so the per-call limit the budget reserves
against is the one actually enforced.
62 changes: 62 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,68 @@ FACILITATOR_SECRET_KEY=
FACILITATOR_FEE_STROOPS_TESTNET=50000
FACILITATOR_FEE_STROOPS_MAINNET=50000

# --- MCP server (`npm run mcp:server`) ---
# Stellar secret keys the MCP agent signs x402 payments with, one per network.
# Both are required to start the server. Leave these empty in a committed file —
# the mainnet key moves real money.
MCP_AGENT_SECRET_KEY_TESTNET=
MCP_AGENT_SECRET_KEY_MAINNET=
# Transport to serve: "stdio" (default) or "http". stdio speaks over the process's
# stdin/stdout and only works for a client that can launch this process on the
# same machine; "http" serves Streamable HTTP so remote/hosted clients can connect.
MCP_TRANSPORT=stdio
# Required when MCP_TRANSPORT=http, and refused without it. Every request must
# carry `Authorization: Bearer <token>`. Generate a strong random value
# (e.g. `openssl rand -hex 32`); it authorises paid_call spending, so treat it
# as a credential, and never put it in a URL.
MCP_HTTP_AUTH_TOKEN=
# Bind address for the HTTP transport (default: 127.0.0.1). Reachability beyond
# loopback is only safe behind a TLS-terminating reverse proxy.
MCP_HTTP_HOST=127.0.0.1
# Port for the HTTP transport (default: 3003; the REST API listens on PORT=3002).
MCP_HTTP_PORT=3003
# Path the HTTP transport answers MCP POSTs on (default: /mcp).
MCP_HTTP_PATH=/mcp
# Most a single payment may cost. Enforced as the x402 client's per-payment cap
# (spendControls.maxAmountPerPayment), so a call priced higher is refused before
# any money moves (default: $1.00).
MCP_MAX_PAYMENT_PRICE=$1.00
# Cumulative ceiling on what paid calls may cost in one UTC day. The per-call cap
# above only bounds one mistake; without this, an agent in a retry loop can pay
# that amount over and over. Once a further call's worst case no longer fits the
# remaining budget, paid_call is refused with ERR_BUDGET_EXCEEDED without
# attempting payment. Read live with the payment_budget tool (default: $10.00).
MCP_DAILY_SPEND_CEILING=$10.00
# Where the day's running total is kept, so a server restart does not refill the
# budget: the total is per UTC day and read back from this file at startup, and
# the window still resets at 00:00 UTC. If the file is missing it starts at zero;
# if it is unreadable or unwritable the budget is treated as spent and paid calls
# are refused until it is fixed (fail closed). Keep it off ephemeral filesystems.
MCP_SPEND_STATE_FILE=
# Where the payment audit trail is appended, one JSON record per paid call:
# timestamp, full URL, host, method, price, asset, network, transaction hash and
# the outcome. Never the secret key and never the signed payment envelope. A call
# that failed is recorded too, since a payment can settle on a call that errored.
# If this file cannot be created or written, paid calls are refused: a tool that
# moves money does not get to spend without a record (fail closed). Keep it off
# ephemeral filesystems (default: ~/.lens-mcp/payments.jsonl).
MCP_PAYMENT_LEDGER_FILE=
# Comma-separated hosts allowed to become the `host` label of the spend metrics.
# Prometheus series are permanent, so labelling by whatever URL an agent asks for
# lets one caller mint unbounded time series; hosts outside this list are reported
# as `unknown`, and an empty value reports every host as `unknown`. The audit file
# above always keeps the real host regardless (e.g. bazaar.example,price.feed).
MCP_METRIC_HOSTS=
# Comma-separated network ids allowed to become the `network` label of the spend
# metrics. Same bounded-series rule as above. Unset keeps the networks this agent
# signs for, stellar:testnet and stellar:pubnet; setting it to an empty value
# reports every network as `unknown`, which is why it is commented out rather
# than left blank.
# MCP_METRIC_NETWORKS=
# With MCP_TRANSPORT=http the registry above is scrapeable from the MCP server
# itself: GET /metrics on MCP_HTTP_PORT, requiring the same MCP_HTTP_AUTH_TOKEN
# bearer token. stdio starts no listener, so there the running total is what the
# payment_budget tool reports and what the audit file holds.
# --- 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
Expand Down
Loading