
Security News
Lovable’s OJ Rewrites Vite’s Dev Server in Rust as AI Lowers the Cost of Forking Open Source
Lovable’s OJ rewrites Vite’s dev server in Rust, reducing memory use and preview times as AI lowers the cost of open source reimplementation.
@url2md-io/mcp
Advanced tools
MCP server for url2md: PDFs and x402-tolled pages as clean Markdown, JavaScript rendering included, paid per call from your own wallet.
The official package is
@url2md-io/mcp, with the@url2md-ioscope. The unscoped nameurl2md-mcpon npm is someone else's package, published by an unrelated person in May 2026. It isn't ours, we have no control over it, and we can't see what it does with what it is given. Runningnpx url2md-mcpruns 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's the wrong one.
One tool for your agent: Markdown from PDFs and tolled pages.
The same conversion engine handles all of it, so the output has the same shape whatever went in. PDFs, pages that only appear once JavaScript has run, and ordinary HTML. A headless browser runs only when the page needs one. robots.txt is followed per RFC 9309. There is no account, no sign-up and no API key. Each call is paid on-chain from a wallet you control, at $0.005 in USDC on Base, and the settlement receipt comes back with the Markdown. When the page charges an x402 toll of its own, url2md pays that for you and the cost is included in what you pay.
node --version. Nothing else: npx fetches the package
and the published files are plain JavaScript, so there is nothing for you to build.0x followed by 64 hex characters. Not a seed phrase.0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913.If you would rather try it without spending real money, see Trying it on testnet below. The test network's USDC is free.
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. It's @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 # then the command is: url2md
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 # no trailing newline needed; one is fine
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.-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.
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
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 which npx prints, and keep args as they are. Restart the client after editing the file.
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.5 → 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.
fetch_markdown| Parameter | What it does |
|---|---|
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, such as a browser render that failed and
fell back to the static result. 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.
$0.005 per call, whatever the page. The wallet is debited once, when the call succeeds. On a page that needs a browser, that happens 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 is discovered after the payment gate:
ROBOTS_DISALLOWED, ROBOTS_UNREACHABLE (which is where a mistyped domain lands), a page that
times out, a render that fails. By then a signed
authorization has already left this process. url2md doesn't 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
can't see the chain and doesn't 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. That is 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. 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:
| Check | Refused with |
|---|---|
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 can't 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 can't commit twice the number you
set. 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.
One of the two key variables is required. Everything else has a default.
URL2MD_PRIVATE_KEY_FILEPath to a file containing the key. Preferred, because 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_KEYThe 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, and 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). That is 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.
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 } }
| Code | What it means, and what to do |
|---|---|
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, deliberately. |
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, and 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 unrecognized 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, and 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, so 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 rather than the page, which is a bot wall, 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, and 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.
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 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, at 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.
The service's repository is private, so this README and the published tarball are the whole public record. That is why this README documents every check, every error code and everything the key is used for, rather than pointing at code you can't open. The tarball is small and readable, and you can have it without installing anything:
npm pack @url2md-io/mcp # downloads the tarball; tar -xzf it and read dist/
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.
https://url2md.io is the service itself, with the same facts as JSON (GET /), Markdown
(/llms.txt) and OpenAPI (/openapi.json). Questions: hello@url2md.io.
MIT.
FAQs
MCP server for url2md: PDFs and x402-tolled pages as clean Markdown, JavaScript rendering included, paid per call from your own wallet.
We found that @url2md-io/mcp demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
Lovable’s OJ rewrites Vite’s dev server in Rust, reducing memory use and preview times as AI lowers the cost of open source reimplementation.

Security News
It has been one year since Shai-Hulud made its first appearance on npm.

Research
/Security News
Operators behind PolinRider used a compromised GitHub account to plant malware in four development versions of a Packagist package with 700,000+ downloads.