@url2md-io/mcp
The official package is @url2md-io/mcp — with the @url2md-io scope. The unscoped name
url2md-mcp on npm is someone else's package, published by an unrelated person in May 2026.
It is not ours, we have no control over it, and we cannot see what it does with what it is given.
Running npx url2md-mcp runs their code — and this tool is set up by handing it a wallet private
key. Give the key only to @url2md-io/mcp. If the name you are about to install has no scope in
front of it, it is the wrong one.
An MCP server that gives your agent one tool: PDFs and pages that charge for access, as clean
Markdown.
One conversion engine handles every kind, so the output is the same shape whichever it was: PDFs,
pages that only appear once JavaScript has run (a headless browser is used only when the page needs
one), and ordinary HTML. robots.txt is honoured per RFC 9309. There is no account, no sign-up and no API
key. Each call is paid for on-chain, from a wallet you control, at $0.005 — half a cent — in USDC
on Base, and the settlement receipt comes back with the Markdown. When the page itself demands an
x402 payment before it will hand anything over, url2md pays that for you and the cost is included in
what you pay.
What you need before you start
- Node 22.18 or newer. Check with
node --version. Nothing else: npx fetches the package
and the published files are plain JavaScript, so there is nothing for you to build.
- A wallet private key, holding USDC on Base. This is the part that is different from a normal
MCP server, so it is worth being precise:
- It must be a raw private key:
0x followed by 64 hex characters. Not a seed phrase.
- The wallet needs USDC only — no ETH. The payment facilitator submits the transfer and pays
the gas. A wallet with $2 of USDC and nothing else works.
- Use a fresh wallet with a small balance. Anything with this key can spend the USDC in it.
Do not use a wallet you keep anything else in.
- Get USDC on Base by bridging, or by buying on an exchange that withdraws to Base directly
(Coinbase does). The USDC contract on Base is
0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913.
If you would rather try it without spending real money, see Trying it on
testnet below — the test network's USDC is free.
Install
Nothing to install. Every example below runs the server with npx, which fetches it on first use
and caches it:
npx @url2md-io/mcp
Mind the scope — @url2md-io/mcp, never the unscoped url2md-mcp, which is someone else's
package. Run it once by hand like that and it will refuse to start, with CONFIG_MISSING_KEY.
That is the answer you want: the package is there and the only thing missing is the key, which the
next step sets up.
To pin a version, or to keep it off the network at start-up, install it instead:
npm install -g @url2md-io/mcp
Put the key in a file, not in a config
You can pass the key in URL2MD_PRIVATE_KEY, and the examples below show both ways. Prefer the
file:
umask 077
printf '0xYOUR_PRIVATE_KEY' > ~/.url2md-key
chmod 600 ~/.url2md-key
Then set URL2MD_PRIVATE_KEY_FILE=/Users/you/.url2md-key instead of the key itself. A path is
not a secret. The key itself, put anywhere else, ends up somewhere you did not intend:
claude mcp add … --env URL2MD_PRIVATE_KEY=0x… puts it in your shell history, and then in
~/.claude.json in plaintext.
- Any
-e URL2MD_PRIVATE_KEY=0x… on a command line is visible to every process on the machine
through ps aux for as long as it runs.
URL2MD_PRIVATE_KEY_FILE wins if both are set.
Add it to your MCP client
Claude Code
claude mcp add url2md \
--env URL2MD_PRIVATE_KEY_FILE=/Users/you/.url2md-key \
--env URL2MD_BASE_URL=https://url2md.io \
-- npx -y @url2md-io/mcp
Claude Desktop, Cursor, Windsurf, and anything else that reads a JSON config
Add this to the mcpServers object of your client's config file (Claude Desktop:
~/Library/Application Support/Claude/claude_desktop_config.json on macOS,
%APPDATA%\Claude\claude_desktop_config.json on Windows):
{
"mcpServers": {
"url2md": {
"command": "npx",
"args": ["-y", "@url2md-io/mcp"],
"env": {
"URL2MD_PRIVATE_KEY_FILE": "/Users/you/.url2md-key",
"URL2MD_BASE_URL": "https://url2md.io"
}
}
}
}
If the server never appears, the cause is almost always PATH. Desktop clients on macOS are
launched by the window manager with a minimal PATH that usually does not include a
version-managed Node, so a bare "npx" resolves to nothing. Use an absolute path for command —
which npx prints it — and keep args as they are. Restart the client after editing the file.
Check it before you wire it up
You can talk to the server yourself with the reference MCP inspector, which needs nothing installed:
npx @modelcontextprotocol/inspector --cli npx -y @url2md-io/mcp \
-e URL2MD_PRIVATE_KEY_FILE=/Users/you/.url2md-key \
--method tools/list
The server prints one line to stderr when it starts, naming the deployment, the network and
whether it is real money, the wallet address it will pay from, and the per-call limit:
@url2md-io/mcp 0.1.4 → https://url2md.io; eip155:8453 (real money); paying from 0x0972…7a36, at most $0.10 per call and $1.00 in total
Your private key is never printed, logged, or sent anywhere except as the signature on a payment.
The tool
fetch_markdown
url | Required. The absolute http(s) URL to convert. |
render | Optional, default auto. auto: use a headless browser only if the page looks client-rendered. static: never render, take the HTML as served. browser: always render. |
render=browser costs the same but is slower, and can fail on pages a plain fetch would have
handled. auto is right almost always.
What comes back — the Markdown as the tool's text, and this as structured content:
{
"markdown": "# Example Domain\n\nThis domain is for use in documentation examples…",
"url": "https://example.com/",
"finalUrl": "https://example.com/",
"title": "Example Domain",
"source": "static",
"words": 24,
"tokensEstimate": 42,
"warnings": [],
"payment": {
"amountUsd": "0.005",
"amountAtomic": "5000",
"network": "eip155:8453",
"payTo": "0xa09aE66Ec8664475697FfC0eb45aa3Fe52Aa6030",
"settled": true,
"payer": "0x…your wallet",
"transaction": "0x3bd94a…",
"explorerUrl": "https://basescan.org/tx/0x3bd94a…",
"ledgerId": "e17a62c0-0801-4043-a58a-da135c1343ae"
},
"paymentRounds": 1,
"committedUsd": "0.005",
"signed": [
{
"amountUsd": "0.005",
"amountAtomic": "5000",
"network": "eip155:8453",
"payTo": "0xa09aE66Ec8664475697FfC0eb45aa3Fe52Aa6030",
"payer": "0x…your wallet",
"nonce": "0xdccc5af7…",
"validBefore": "2026-09-07T05:17:26.000Z"
}
]
}
source says how the Markdown was produced: static, browser, pdf, origin-markdown or
text. Fields that do not apply are left out rather than set to null.
payment is there whenever you were charged, even if the service answered without confirming
the settlement. The amountUsd, network, payTo and payer are always what this client
signed — never what the service's receipt claims. If the receipt disagrees with any of them,
warnings names both figures and the signed one stands. settled: false means no usable
settlement came back at all; transaction is then absent and warnings tells you to check the
wallet on-chain.
committedUsd is everything the call authorized, and signed lists each authorization with the
nonce that identifies it on-chain. On an ordinary call committedUsd equals payment.amountUsd.
On a tolled call it is higher: the first authorization is replaced by the quote, only the quote
is settled, and the service still holds the first one — warnings says so, with both numbers.
Read warnings. It carries the service's own notes too — a browser render that failed and fell
back to the static result, for instance. Anything you must not miss is also written above the
Markdown in the tool's text, because many MCP clients show a model only the text and drop the
structured content: an unconfirmed payment appears there as a PAYMENT NOT CONFIRMED line, and
every warning as a NOTE: line. A clean, settled call has no preamble at all.
What a call costs
$0.005 per call, whatever the page. The wallet is debited once, when the call succeeds — or, for
a page that needs a browser, just before the browser starts.
Which failures are free, exactly. A malformed URL, an unsupported scheme and a blocked host are
refused on the unpaid request: nothing is signed and details is empty. A missing URL is the
one exception, and it costs nothing either: the bare path answers a payment challenge like every
other x402 endpoint, so this client signs a payment and is then told INVALID_URL — the service
settles nothing for a call it answered with an error, so the authorization is wasted, not spent.
Everything else —
ROBOTS_DISALLOWED, ROBOTS_UNREACHABLE (which is where a mistyped domain lands), a page that
times out, a render that fails — is discovered after the payment gate, so a signed
authorization has already left this process. url2md does not settle a payment for a call it
answered with an error, and the ledger row would say needs_refund if it did; but this client
cannot see the chain and does not claim the money is safe. Every such failure therefore comes back
with details.committedUsd, and details.signed[] carrying each authorization's amount,
recipient, EIP-3009 nonce and expiry — that nonce is what USDC emits as AuthorizationUsed when a
payment is actually redeemed, so you can check for yourself. An agent that retries a mistyped
domain in a loop signs $0.005 each time until URL2MD_MAX_SPEND_USD stops it.
Pages that charge their own toll. Some pages are themselves behind an x402 paywall. url2md can
pay them for you: it quotes you the base price plus the page's toll plus 25 %, you pay that, and it
pays the origin from its own wallet. Those calls come back with toll filled in and
paymentRounds: 2. Tolls above $0.05 are refused rather than passed on.
Two ceilings. One call will not commit more than $0.10 in total, however many rounds it
takes — comfortably above the most a legitimate call can cost (base + the $0.05 toll cap + margin
≈ $0.068), and low enough that a surprising price is refused instead of paid. And the whole process
will not commit more than $1.00 for as long as it runs, which is what bounds an agent stuck in a
retry loop; restarting the server resets it. Raise either with URL2MD_MAX_PRICE_USD and
URL2MD_MAX_SPEND_USD. Neither check can be turned off, though setting the numbers absurdly high
has the same effect, so the server says so on startup if you do.
What is checked before anything is signed. A 402 is written by whoever answers your request,
so the terms in it are not evidence of anything on their own. Before signing, this client fetches
the service's own published terms from GET / (free, unpaid) and refuses the payment unless the
402 matches them:
The service publishes a payTo, an asset and a network at all | UNPUBLISHED_TERMS |
The recipient is the payTo the service publishes | PAYTO_MISMATCH |
| The token is the asset the service publishes | UNSUPPORTED_PAYMENT_TERMS |
| The network is the one the service publishes | UNSUPPORTED_PAYMENT_TERMS |
The scheme is exact | UNSUPPORTED_PAYMENT_TERMS |
| The authorization is redeemable for at most an hour | UNSUPPORTED_PAYMENT_TERMS |
| The whole call stays at or under your per-call ceiling | PRICE_ABOVE_LIMIT |
| This process has not spent its session budget | SPEND_LIMIT_REACHED |
These fail closed: a service that publishes no receiving address gets no signature, rather than
an unchecked one. The amount is not compared with the published price for the toll round, because
a quote is legitimately higher than the base price — but a first 402 above the published price
is reported in warnings even when it is inside your ceiling, since that is a service charging
something other than what it advertises.
URL2MD_BASE_URL is the whole trust anchor. Every check above compares the 402 with what that
URL published moments earlier. It stops a service contradicting itself; it cannot stop a service
that lies consistently, because an impostor writes both halves. So: point it at a deployment you
trust, and nothing else. It must be https:// (only a loopback address may be plain http), and
the paid request is pinned to that origin — a redirect to another host is refused
(REDIRECT_REFUSED) rather than followed, because following it would hand your signed
authorization to whoever the redirect names.
The ceiling bounds the whole call, so a two-round tolled call cannot commit twice the number you
set — and what a call committed in total is always reported, as committedUsd on success and
details.committedUsd on failure.
If a call fails after a payment was signed, the error carries details.signedButUnconfirmed,
details.committedUsd, and details.signed — one entry per authorization, with the amount, the
recipient, the network, the EIP-3009 nonce and when it stops being redeemable. A signature that has
left this process is money you have committed whether or not the service ever redeems it, so it is
never omitted from a failure.
Configuration
One of the two key variables is required. Everything else has a default.
URL2MD_PRIVATE_KEY_FILE
Path to a file containing the key. Preferred — a path is not a secret, and a key on a command
line is visible to every process on the machine. Wins if both key variables are set.
URL2MD_PRIVATE_KEY
The key itself: 0x followed by 64 hex characters. Use the file instead where you can.
URL2MD_MAX_PRICE_USD — default 0.10
The most this server will commit for one call, in USD, across every round of that call. A
tolled page takes two rounds; this ceiling covers both.
URL2MD_MAX_SPEND_USD — default 1.00
The most this server will commit in total, for as long as the process runs. Restarting resets
it.
URL2MD_BASE_URL — default https://url2md.io
The url2md deployment to call. Must be https:// (only a loopback address may be plain http),
because this is the origin every 402 is checked against.
URL2MD_TIMEOUT_MS — default 90000
How long to wait for one conversion, in milliseconds. 90 s is the default because that is what the
service's own bounds add up to: 20 s for the fetch (robots.txt, every redirect hop and the body run
on one deadline) and, when the page needs a headless browser, 55 s more for the render (30 s of
navigation plus 25 s of action and slack) — 75 s in the worst case, plus room for the network and
the payment round trips.
Do not set it to 30 s because you read that a render gets 30 s: that is the navigation timeout
inside the browser, not the wall clock. Giving up early does not cancel the call, and on a page
that needed a browser your payment has already settled by the time the render starts.
When something goes wrong
Failures come back as MCP error results (isError: true) whose text is a JSON object, so an agent
can branch on the code rather than read prose:
{ "error": { "code": "BLOCKED_HOST", "message": "localhost is not a public host", "retryable": false, "httpStatus": 400 } }
CONFIG_MISSING_KEY, CONFIG_BAD_KEY, CONFIG_BAD_KEY_FILE | No key, not a private key, or a key file that could not be read. The server refuses to start (exit 2). Set URL2MD_PRIVATE_KEY_FILE to a readable file holding 0x + 64 hex characters. |
CONFIG_BAD_LIMIT, CONFIG_BAD_BASE_URL, CONFIG_BAD_TIMEOUT | A setting the server will not guess at — including a URL2MD_BASE_URL that is not https://. It refuses to start. Fix the value named in the message. |
INVALID_URL, UNSUPPORTED_SCHEME | The URL is missing, or is not an absolute http(s) URL. Fix the URL. |
BLOCKED_HOST | Private, loopback, link-local or internal host. Public URLs only — this is deliberate. |
ROBOTS_DISALLOWED | The origin's robots.txt forbids it, and url2md obeys robots.txt — so there is nothing to retry. Checked after the payment gate, so an authorization was signed: read details.committedUsd. |
PRICE_ABOVE_LIMIT | The call, in total, would cost more than URL2MD_MAX_PRICE_USD. Read details.totalUsd; raise the limit if it is reasonable. |
SPEND_LIMIT_REACHED | This server has committed its whole URL2MD_MAX_SPEND_USD session budget. Nothing was signed. Raise it and restart, or look at why the agent is spending. |
UNPUBLISHED_TERMS | The service asks for payment but publishes no receiving address, asset or network to check its 402 against. Nothing was signed. Check URL2MD_BASE_URL. A real url2md deployment publishes all three at GET /. |
PAYTO_MISMATCH | The 402 asked you to pay an address the service does not publish. Nothing was signed. Do not raise any limit. Check you are pointed at the right URL2MD_BASE_URL; if you are, stop and investigate. |
UNSUPPORTED_PAYMENT_TERMS | The 402's network, asset or scheme is not the one the service publishes, or this client cannot pay it. Nothing was signed. As above. |
PAYMENT_NOT_SIGNED | The payment could not be signed — usually the x402 client's own spend controls refusing an unrecognised token. Read the message; nothing was sent. |
ROBOTS_UNREACHABLE | The origin's robots.txt could not be fetched. RFC 9309 requires treating that as disallow. Checked after the payment gate, so an authorization was signed — read details.committedUsd. Retryable, but each retry signs another payment: a nonexistent domain lands here too, so check the URL before looping. |
SERVICE_UNREACHABLE, TIMEOUT | url2md itself did not answer, or not in time. Retryable. Check URL2MD_BASE_URL. |
BAD_RESPONSE, PAYMENT_NOT_ACCEPTED | url2md answered something this client could not use, or kept asking for payment after two rounds. Nothing further was signed. Report it. |
REDIRECT_REFUSED | The service tried to redirect the request to another host. The request — and the signed authorization it carried — was not followed there. Check URL2MD_BASE_URL. If it is right, stop and investigate: an honest deployment does not do this. |
TOO_MANY_REDIRECTS | More than three redirects inside the service's own origin. Check URL2MD_BASE_URL. |
UPSTREAM_PAYMENT_REQUIRED, UPSTREAM_PAYMENT_FAILED | The page charges its own toll and url2md could not pass it through, or the origin refused url2md's payment. If details.callerCharged is true you were charged; quote details.ledgerId. |
INTERNAL | An unexpected failure inside this server. Report it. |
UPSTREAM_TOLL_TOO_HIGH | The page charges a toll above url2md's $0.05 cap, so it is not available through url2md. Nothing to do. |
PAYMENT_REJECTED | The service refused the payment when it tried to settle it. Usually an empty wallet. Check the wallet has USDC on the network the startup line names. |
PAYMENT_REQUIRED | The service asked for payment but does not publish x402 pricing, so this client will not guess at terms. Check URL2MD_BASE_URL. |
UPSTREAM_STATUS | The origin answered a status url2md cannot convert. details.status is the origin's own: 404 or 5xx means the page is broken or gone, but 403 usually means the origin refused url2md — a bot wall, not a page failure — and retrying changes nothing. On a 429 the origin is asking for a slower pace: details.retryAfter carries its Retry-After when it sent one, and that is the one case where waiting and trying again is worth a second payment. |
RENDER_FAILED, UPSTREAM_TIMEOUT | The page itself failed to load or render in time. retryable says whether trying again is worth it. |
RATE_LIMITED | 60 calls a minute, 6 browser renders a minute. Wait. |
url2md's own error codes are documented in full in the service's OpenAPI document,
https://url2md.io/openapi.json; any of them can appear here unchanged.
If you were charged and got nothing, the error carries details.callerCharged: true and
details.ledgerId. url2md keeps a row for every settled payment and reconciles it against the chain;
quote the ledger id to hello@url2md.io.
Trying it on testnet
Base Sepolia USDC is free, so you can exercise the whole thing without spending anything. Note that
this changes the payment network to Base Sepolia (eip155:84532): a wallet funded with USDC on
Base mainnet cannot pay the testnet deployment, and vice versa. The startup line names the network
it will pay on, so you can check before you spend.
npx @modelcontextprotocol/inspector --cli npx -y @url2md-io/mcp \
-e URL2MD_PRIVATE_KEY_FILE=/Users/you/.url2md-testnet-key \
-e URL2MD_BASE_URL=https://url2md-testnet.url2md.workers.dev \
--method tools/call --tool-name fetch_markdown --tool-arg url=https://example.com
Fund a throwaway wallet from the Coinbase Base Sepolia faucet.
The testnet deployment is the same code as production, on the test network. Set the same
URL2MD_BASE_URL in your client config to point it at testnet permanently.
This local server, or the hosted connector
This package is the local lane: it runs beside your client, holds your wallet key, and pays each call
itself, which is what lets it pay a page's own x402 toll too (quoted up front, settled after your
payment). Running it beside your client keeps the key on your machine and the payment yours.
The service also serves a hosted connector on its own origin — POST /mcp, Streamable HTTP behind
OAuth 2.1 with a GitHub sign-in — for clients that hold no wallet, such as claude.ai's custom
connectors. It is free to the signed-in account within a published allowance, and it never pays a
toll: a page that charges one comes back as an error pointing at this package and at the paid HTTP
endpoint. Where a deployment has it switched on, GET / lists it under clients.mcp.remote with the
URL and the allowance. Use this package when you want a toll paid, when you want no account in the
loop, or when you need more than the allowance.
The key is read once at startup and used for one thing: signing
EIP-3009 USDC transfer authorizations for the exact
amount the 402 asks for. It is never written to stdout (which is the MCP wire), to stderr, to an
error message, or into a tool result. The only thing derived from it that ever leaves the process is
the public address.
Checking what you are about to run
The service's repository is private, so this README and the published tarball are the whole public
record — which is the reason the README documents every check, every error code and everything the
key is used for, rather than pointing at code you cannot open. The tarball is small and readable,
and you can have it without installing anything:
npm pack @url2md-io/mcp
dist/ is plain JavaScript compiled from TypeScript, a few hundred lines. dist/client.js is
where the key is used and where every 402 is checked against what the service publishes.
About url2md
https://url2md.io — the service itself, with the same facts as JSON (GET /), Markdown
(/llms.txt) and OpenAPI (/openapi.json). Questions: hello@url2md.io.
MIT.