@url2md-io/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { McpServer } from "@modelcontextprotocol/server";
4
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
5
+ import * as z from "zod/v4";
6
+ import { Budget, ceilingLooksWrong, CEILING_WORTH_MENTIONING_USD, convert, discoverPricing, payerAddress, readConfig, RENDER_MODES, Url2mdToolError } from "./client.js";
7
+ /**
8
+ * url2md as an MCP server: one tool, `fetch_markdown`, that turns any URL into clean Markdown and
9
+ * pays for the call from the user's own wallet over x402.
10
+ *
11
+ * Local stdio, not a hosted remote MCP. A remote server has no wallet of the caller's to pay with:
12
+ * it would have to pay from its own and then bill the caller some other way — an account, a key, an
13
+ * invoice — which is the exact thing x402 exists to remove. Running it beside the client keeps the
14
+ * key on the user's machine and the payment the user's own.
15
+ *
16
+ * The key is read once, at startup, and the only thing ever derived from it that leaves this process
17
+ * is the public address. Nothing writes it to stdout (which is the MCP wire), to stderr, to an error
18
+ * message or into a tool result.
19
+ */
20
+ export const SERVER_NAME = "url2md";
21
+ export const SERVER_VERSION = "0.1.0";
22
+ export const TOOL_NAME = "fetch_markdown";
23
+ export const inputSchema = z.object({
24
+ url: z.string().describe("The absolute http(s) URL to convert. Public hosts only: private, loopback and link-local addresses are refused, and the origin's robots.txt is obeyed."),
25
+ render: z
26
+ .enum(RENDER_MODES)
27
+ .default("auto")
28
+ .describe("auto (default): render with a headless browser only if the page looks client-rendered. static: never render, fetch the HTML as served. browser: always render — costs the same but takes longer and can fail on pages a plain fetch would have handled."),
29
+ });
30
+ /**
31
+ * Everything a call may not have is **absent**, never `null`. Zod emits a nullable field as
32
+ * `type: ["string","null"]`, and the reference inspector's `--strict` mode reports that several MCP
33
+ * clients read `type` as a single string and either reject the tool or drop the constraint. Absent
34
+ * is also the truer contract here: there is nothing a model can do with a `null` title that it
35
+ * cannot do with no title at all.
36
+ */
37
+ const receiptSchema = z
38
+ .object({
39
+ amountUsd: z.string().describe("What this call cost, in USD. This is what the client signed, not what the service's receipt claims; a disagreement is reported in warnings."),
40
+ amountAtomic: z.string().describe("The same amount in atomic units of the asset (USDC has 6 decimals)."),
41
+ network: z.string(),
42
+ payTo: z.string(),
43
+ settled: z.boolean().describe("True when the service returned a settlement receipt naming an on-chain transaction. That is the service's word, not proof: follow explorerUrl to verify. False means the payment was authorized and the service answered without confirming it — see warnings."),
44
+ payer: z.string().optional().describe("The wallet that paid: yours."),
45
+ transaction: z.string().optional(),
46
+ explorerUrl: z.string().optional().describe("The settlement on a block explorer."),
47
+ ledgerId: z.string().optional().describe("url2md's own row for this payment, quotable if anything needs chasing."),
48
+ })
49
+ .optional()
50
+ .describe("What this call cost. Present whenever a payment was authorized — absent only when nothing was paid at all (a free deployment). Check `settled`.");
51
+ export const outputSchema = z.object({
52
+ markdown: z.string().describe("The page as Markdown."),
53
+ url: z.string(),
54
+ finalUrl: z.string().optional().describe("Where the request ended up after redirects."),
55
+ title: z.string().optional(),
56
+ source: z.string().describe("static | browser | pdf | origin-markdown | text — how the Markdown was produced."),
57
+ words: z.number(),
58
+ tokensEstimate: z.number(),
59
+ warnings: z.array(z.string()).describe("Non-fatal notes you should read: a browser render that fell back to the static result, or a payment the service did not confirm."),
60
+ payment: receiptSchema,
61
+ toll: z
62
+ .object({
63
+ amountUsd: z.string(),
64
+ payTo: z.string().optional(),
65
+ transaction: z.string().optional(),
66
+ explorerUrl: z.string().optional(),
67
+ })
68
+ .optional()
69
+ .describe("Present when the page itself charged an x402 toll that url2md paid on your behalf; the amount is already included in what you paid."),
70
+ paymentRounds: z.number().describe("402s answered: 1 for an ordinary page, 2 when the page charged a toll of its own."),
71
+ committedUsd: z.string().describe("Everything this call authorized, summed. Above `payment.amountUsd` when a toll quote replaced an earlier authorization the service can still redeem."),
72
+ signed: z
73
+ .array(z.object({
74
+ amountUsd: z.string(),
75
+ amountAtomic: z.string(),
76
+ network: z.string(),
77
+ payTo: z.string(),
78
+ payer: z.string(),
79
+ nonce: z.string().optional().describe("The EIP-3009 nonce. USDC emits AuthorizationUsed(payer, nonce) when it is redeemed, so this is how you find it on-chain."),
80
+ validBefore: z.string().optional().describe("After this instant the authorization can no longer be redeemed."),
81
+ }))
82
+ .describe("Every payment authorization this call put on the wire, in order. Empty on a free deployment."),
83
+ });
84
+ /** The tool result for a failure: `isError`, with the machine-readable cause as the text, never a sentence that reads like success. */
85
+ export function errorResult(e) {
86
+ const err = e instanceof Url2mdToolError
87
+ ? e
88
+ : new Url2mdToolError("INTERNAL", e instanceof Error ? e.message : String(e), { retryable: false });
89
+ const payload = {
90
+ error: {
91
+ code: err.code,
92
+ message: err.message,
93
+ retryable: err.retryable,
94
+ ...(err.httpStatus === null ? {} : { httpStatus: err.httpStatus }),
95
+ ...(Object.keys(err.details).length ? { details: err.details } : {}),
96
+ },
97
+ };
98
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], isError: true };
99
+ }
100
+ /** The successful result: the Markdown as the text a model reads, the whole record as structured content. */
101
+ export function successResult(c) {
102
+ const structured = {
103
+ markdown: c.result.markdown ?? "",
104
+ url: c.result.url,
105
+ ...present("finalUrl", c.result.finalUrl),
106
+ ...present("title", c.result.title),
107
+ source: c.result.source,
108
+ words: c.result.words ?? 0,
109
+ tokensEstimate: c.result.tokensEstimate ?? 0,
110
+ warnings: c.warnings,
111
+ ...present("payment", c.payment && dropNulls(c.payment)),
112
+ ...present("toll", c.toll && dropNulls(c.toll)),
113
+ paymentRounds: c.paymentRounds,
114
+ committedUsd: c.committedUsd,
115
+ signed: (c.signed ?? []).map((a) => dropNulls(a)),
116
+ };
117
+ // Half the MCP clients in use render `content` and drop `structuredContent`. Everything about the
118
+ // money lives in the structured half, so a call whose payment was never confirmed came back as
119
+ // clean Markdown and nothing else — the model saw a success. Anything the caller must act on is
120
+ // written above the Markdown, in words, where every client shows it.
121
+ const banner = paymentBanner(c);
122
+ return { content: [{ type: "text", text: banner ? `${banner}\n\n${structured.markdown}` : structured.markdown }], structuredContent: structured };
123
+ }
124
+ /**
125
+ * The line that goes above the Markdown when there is something about this call's money that a
126
+ * reader must not miss. Silent on the ordinary case: a settled payment with no warnings needs no
127
+ * preamble, and one on every call trains a reader to skip it.
128
+ */
129
+ export function paymentBanner(c) {
130
+ const parts = [];
131
+ if (c.payment && !c.payment.settled) {
132
+ parts.push(`PAYMENT NOT CONFIRMED: $${c.payment.amountUsd} was authorized to ${c.payment.payTo} on ${c.payment.network}, and the service returned no usable settlement receipt. The content below may still be correct; the payment is not confirmed.`);
133
+ }
134
+ if (c.warnings.length)
135
+ parts.push(...c.warnings.map((w) => `NOTE: ${w}`));
136
+ return parts.length ? parts.join("\n") : null;
137
+ }
138
+ /** `{ key: value }` when there is a value, `{}` when there is not: absent, never null (see outputSchema). */
139
+ function present(key, value) {
140
+ return value === null || value === undefined ? {} : { [key]: value };
141
+ }
142
+ /** The same rule one level down: the client fills unknown fields with null, the wire leaves them out. */
143
+ function dropNulls(o) {
144
+ return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== null && v !== undefined));
145
+ }
146
+ /**
147
+ * What the tool says about itself. The price and the network are the ones this deployment actually
148
+ * publishes, read once at startup — a model deciding whether to spend needs the real number and,
149
+ * above all, needs to be able to tell a test network from one that costs real money. When `GET /`
150
+ * could not be reached the description says where the price is published instead of inventing one.
151
+ */
152
+ export function toolDescription(config, pricing) {
153
+ const cost = pricing?.model === "x402" && pricing.priceAtomic
154
+ ? `Each call costs ${usdLabel(pricing.priceAtomic)} in ${pricing.asset ?? "USDC"} on ${pricing.network}${isTestNetwork(pricing.network) ? " — a TEST network, so the money is not real" : ""}, paid on-chain from your own wallet; the settlement receipt comes back with the result.`
155
+ : pricing?.model === "free"
156
+ ? `This deployment (${config.baseUrl}) is not charging: calls are free.`
157
+ : `Calls are paid per call over x402 from your own wallet; the live price is published at ${config.baseUrl}/.`;
158
+ return ("Fetch any web page or PDF and return it as clean Markdown, ready to read. Handles JavaScript-rendered pages (a headless browser is used when the page needs one) and PDFs. " +
159
+ `${cost} This server refuses to sign more than $${config.maxPriceUsd} for one call, or $${config.maxSpendUsd} in total for as long as it is running. ` +
160
+ "If the page itself charges an x402 toll, url2md pays it and the cost is included in what you pay. " +
161
+ "Errors, including a payment that could not be made, come back as an error result whose text is a JSON object with a stable `code`.");
162
+ }
163
+ const isTestNetwork = (network) => network === "eip155:84532" || (network ?? "").includes("sepolia");
164
+ /** Atomic units of a 6-decimal asset as a short USD label. */
165
+ function usdLabel(atomic) {
166
+ try {
167
+ const n = Number(BigInt(atomic)) / 1e6;
168
+ return n < 0.01 ? `$${n} (${(n * 100).toFixed(1)} cents)` : `$${n}`;
169
+ }
170
+ catch {
171
+ return `${atomic} atomic units`;
172
+ }
173
+ }
174
+ export function createServer(deps) {
175
+ const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
176
+ server.registerTool(TOOL_NAME, {
177
+ title: "Fetch a URL as Markdown",
178
+ description: toolDescription(deps.config, deps.pricingSnapshot ?? null),
179
+ inputSchema,
180
+ outputSchema,
181
+ // Not read-only: this tool signs a USDC transfer from the caller's wallet. Clients use
182
+ // readOnlyHint to decide what may run without asking a human, and a tool that spends money
183
+ // must never be in that set — it was, until review pointed at it.
184
+ annotations: { readOnlyHint: false, openWorldHint: true, idempotentHint: false },
185
+ }, async ({ url, render }) => {
186
+ try {
187
+ const run = deps.convertImpl ?? convert;
188
+ return successResult(await run({ url, render }, deps.config, await deps.pricing(), deps.fetchImpl, deps.budget));
189
+ }
190
+ catch (e) {
191
+ return errorResult(e);
192
+ }
193
+ });
194
+ return server;
195
+ }
196
+ /** stderr, never stdout: stdout is the MCP wire and anything written there corrupts the protocol. */
197
+ function note(line) {
198
+ process.stderr.write(`${line}\n`);
199
+ }
200
+ /**
201
+ * The only thing a fatal error is allowed to say. Our own errors carry wording we wrote; anything
202
+ * else is named by its type and nothing more — a library that rejects a bad private key quotes the
203
+ * number it was given, which is the key, and stderr is what MCP clients spool to a log file
204
+ * (rule 12 of the service repository's CLAUDE.md).
205
+ */
206
+ export function fatalLine(e) {
207
+ if (e instanceof Url2mdToolError)
208
+ return `@url2md-io/mcp: ${e.code}: ${e.message}`;
209
+ return `@url2md-io/mcp: INTERNAL: an unexpected ${e instanceof Error ? e.name : typeof e} was thrown during startup. Its message is withheld because it may quote a value that was given to this process.`;
210
+ }
211
+ export async function main() {
212
+ let config;
213
+ try {
214
+ config = readConfig(process.env);
215
+ }
216
+ catch (e) {
217
+ // A configuration failure is fatal and must say what to fix, without ever echoing what was given.
218
+ note(fatalLine(e));
219
+ process.exit(2);
220
+ }
221
+ let cached = null;
222
+ const deps = {
223
+ config,
224
+ pricing: () => {
225
+ // Re-asked after a failure, so a service that was down at startup is not down forever.
226
+ if (!cached) {
227
+ cached = discoverPricing(config, deps.fetchImpl)
228
+ .then((p) => {
229
+ // serveStdio builds a server per connection, so a price learned late reaches the next one.
230
+ deps.pricingSnapshot = p;
231
+ return p;
232
+ })
233
+ .catch((e) => ((cached = null), Promise.reject(e)));
234
+ }
235
+ return cached;
236
+ },
237
+ fetchImpl: (input, init) => fetch(input, init),
238
+ budget: new Budget(config.maxSpendUsd),
239
+ };
240
+ // The price and the network go into the tool's own description, so a model can tell what a call
241
+ // costs — and whether it is a test network — before it decides to call. `GET /` is free and this
242
+ // is one round trip at startup; if it fails the description says where the price is published and
243
+ // the first tool call asks again.
244
+ let snapshot = null;
245
+ try {
246
+ snapshot = await deps.pricing();
247
+ }
248
+ catch (e) {
249
+ note(`@url2md-io/mcp: could not read the price from ${config.baseUrl} at startup (${e instanceof Url2mdToolError ? e.code : "error"}); it will be read again on the first call`);
250
+ }
251
+ deps.pricingSnapshot = snapshot;
252
+ note(`@url2md-io/mcp ${SERVER_VERSION} → ${config.baseUrl}` +
253
+ (snapshot
254
+ ? `; ${snapshot.model === "x402" ? `${snapshot.network}${isTestNetwork(snapshot.network) ? " (TEST network)" : " (real money)"}` : snapshot.model}`
255
+ : "; network unknown — the service did not answer, so nothing has been checked yet") +
256
+ `; paying from ${payerAddress(config)}, at most $${config.maxPriceUsd} per call and $${config.maxSpendUsd} in total`);
257
+ if (ceilingLooksWrong(config.maxPriceUsd)) {
258
+ note(`@url2md-io/mcp: WARNING — URL2MD_MAX_PRICE_USD is $${config.maxPriceUsd}. A call to this service costs a fraction of a cent; a ceiling above $${CEILING_WORTH_MENTIONING_USD} is usually a typo.`);
259
+ }
260
+ serveStdio(() => createServer(deps), { onerror: (err) => note(`@url2md-io/mcp: ${err.message}`) });
261
+ }
262
+ /**
263
+ * Whether this file is the program being run, rather than a module something imported (the tests
264
+ * import it).
265
+ *
266
+ * Both paths are resolved through the filesystem before they are compared, because when the package
267
+ * is installed the thing that starts is `node_modules/.bin/url2md` — a **symlink** to this file.
268
+ * `process.argv[1]` is then the symlink and `import.meta.filename` the file it points at, a plain
269
+ * `===` is false, and the server exits 0 having done nothing at all: no startup line, no tool, no
270
+ * error. The client sees only its connection close. It cannot happen when the file is run from a
271
+ * clone by its own path, which is why it survived to a cold install of the tarball to find.
272
+ */
273
+ export function runningAsProgram(argv1 = process.argv[1], self = import.meta.filename) {
274
+ if (!argv1)
275
+ return false;
276
+ try {
277
+ return realpathSync(argv1) === realpathSync(self);
278
+ }
279
+ catch {
280
+ return false; // a path that cannot be resolved is not this file
281
+ }
282
+ }
283
+ if (runningAsProgram()) {
284
+ main().catch((e) => {
285
+ note(fatalLine(e));
286
+ process.exit(1);
287
+ });
288
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@url2md-io/mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for url2md — any URL in, clean Markdown out. Pays per call over x402 from your own wallet.",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "markdown",
9
+ "x402",
10
+ "scraping",
11
+ "url-to-markdown"
12
+ ],
13
+ "homepage": "https://url2md.io",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/glassyeah/url2md.git",
17
+ "directory": "mcp"
18
+ },
19
+ "bugs": {
20
+ "url": "https://url2md.io"
21
+ },
22
+ "license": "MIT",
23
+ "type": "module",
24
+ "engines": {
25
+ "node": ">=22.18"
26
+ },
27
+ "bin": {
28
+ "url2md": "./dist/index.js"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc -p tsconfig.build.json",
40
+ "prepack": "npm run build",
41
+ "start": "node src/index.ts",
42
+ "typecheck": "tsc --noEmit -p tsconfig.json",
43
+ "test": "vitest run"
44
+ },
45
+ "dependencies": {
46
+ "@modelcontextprotocol/server": "^2.0.0",
47
+ "@x402/core": "^2.25.0",
48
+ "@x402/evm": "^2.25.0",
49
+ "viem": "^2.56.3",
50
+ "zod": "^4.1.13"
51
+ },
52
+ "devDependencies": {
53
+ "@modelcontextprotocol/client": "^2.0.0",
54
+ "typescript": "^5.9.3",
55
+ "@types/node": "^24.13.3",
56
+ "vitest": "^4.1.11"
57
+ }
58
+ }