skim-mcp 0.1.9 → 0.2.2

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.
Files changed (3) hide show
  1. package/README.md +157 -48
  2. package/dist/index.js +450 -79
  3. package/package.json +11 -13
package/README.md CHANGED
@@ -1,22 +1,26 @@
1
1
  # skim-mcp
2
2
 
3
- **Give your AI agent the ability to read any URL — clean Markdown, no ads, no nav, no boilerplate. Pays itself per call. No signup, no API key.**
3
+ **Give your AI agent the ability to read any URL — clean Markdown, ~4x smaller than raw HTML. No ads, no nav, no boilerplate.**
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/skim-mcp.svg)](https://www.npmjs.com/package/skim-mcp)
6
6
  [![MCP Registry](https://img.shields.io/badge/MCP-Registry-blue)](https://registry.modelcontextprotocol.io/v0/servers?search=skim402)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
8
8
 
9
- `skim-mcp` is the official Model Context Protocol server for [Skim](https://skim402.com) — the canonical [x402](https://x402.org) clean reader API. It exposes one tool, `read_url`, that your agent can call to fetch any web page as agent-ready Markdown plus structured metadata (title, byline, published date, language, excerpt). Each call costs **$0.002 in USDC on Base**, paid automatically by your local wallet over HTTP 402.
9
+ `skim-mcp` is the official Model Context Protocol server for [Skim](https://skim402.com) — the clean reader API for AI agents. It exposes `read_url`, `read_urls` (batch), `extract_url` (structured / table), `crawl_url`, `read_pdf`, `watch_urls`, and `check_watch`. The default path is a card-plan API key (`SKIM_API_KEY`); x402 wallet pay stays optional.
10
10
 
11
- > **See it before you wire it:** [try Skim free in your browser](https://freeskims.skim402.com) — 10 free skims a day, no wallet, no signup. Paste a URL, see exactly what your agent gets back.
11
+ > **See it before you wire it:** [try Skim free in your browser](https://freeskims.skim402.com) — 10 free skims a day, no signup. Paste a URL, see exactly what your agent gets back.
12
12
 
13
- > _A 30-second demo GIF coming here soon._
13
+ ![Skim in action one URL in, clean Markdown out](https://raw.githubusercontent.com/JessieJanie/skim402/main/demo.gif)
14
14
 
15
15
  ---
16
16
 
17
17
  ## Quickstart (60 seconds)
18
18
 
19
- ### 1. Add to your MCP-compatible client
19
+ ### Option A Card API key (recommended)
20
+
21
+ **1.** Get a free key at **[skim402.com/pricing](https://skim402.com/pricing)** — 1,000 reads/month, card required at signup.
22
+
23
+ **2.** Add to your MCP client config:
20
24
 
21
25
  **Claude Desktop** — edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
22
26
 
@@ -27,7 +31,7 @@
27
31
  "command": "npx",
28
32
  "args": ["-y", "skim-mcp"],
29
33
  "env": {
30
- "SKIM_WALLET_PRIVATE_KEY": "0xYOUR_BASE_WALLET_PRIVATE_KEY"
34
+ "SKIM_API_KEY": "sk402_your_key_here"
31
35
  }
32
36
  }
33
37
  }
@@ -38,25 +42,48 @@
38
42
 
39
43
  **Cline, Continue, Zed, or any other MCP client** — same shape; the binary is `npx skim-mcp` with one env var.
40
44
 
41
- ### 2. Fund a Base wallet with $1 of USDC
45
+ **3.** Restart your client and ask it to read something:
42
46
 
43
- A dollar funds roughly 500 reads. Full step-by-step (with screenshots, for non-crypto-native devs): **<https://skim402.com/wallet>**.
47
+ ```
48
+ Claude, read https://en.wikipedia.org/wiki/HTTP_402 and summarize it.
49
+ ```
44
50
 
45
- > **Use a fresh wallet, not your personal one.** This wallet's private key lives in a plaintext config file on your machine — treat it like a hot-wallet for paying $0.002 tolls, not a savings account.
51
+ ---
46
52
 
47
- ### 3. Restart your client and ask it to read something
53
+ ### Option B Pay per call with a crypto wallet
48
54
 
55
+ If you prefer x402 pay-per-call ($0.002 USDC on Base, no monthly plan):
56
+
57
+ ```json
58
+ {
59
+ "mcpServers": {
60
+ "skim": {
61
+ "command": "npx",
62
+ "args": ["-y", "skim-mcp"],
63
+ "env": {
64
+ "SKIM_WALLET_PRIVATE_KEY": "0xYOUR_BASE_WALLET_PRIVATE_KEY"
65
+ }
66
+ }
67
+ }
68
+ }
49
69
  ```
50
- Claude, read https://en.wikipedia.org/wiki/HTTP_402 and summarize it.
51
- ```
52
70
 
53
- The agent will call `read_url`, your local wallet will sign an EIP-3009 USDC authorization for $0.002, Skim returns clean Markdown, and Claude summarizes. You'll see the payment receipt in your wallet's transaction history on [BaseScan](https://basescan.org/).
71
+ Fund a dedicated Base wallet with a small USDC balance ($1 500 reads). Full setup guide with screenshots: **<https://skim402.com/wallet>**.
72
+
73
+ > **Use a fresh wallet, not your personal one.** This wallet's private key lives in a plaintext config file on your machine — treat it like a hot-wallet for paying $0.002 tolls, not a savings account.
54
74
 
55
75
  ---
56
76
 
57
77
  ## Try it without an agent
58
78
 
59
- Skeptical? Test the upstream endpoint directly it'll return a 402 challenge so you can see the protocol in action:
79
+ Test the endpoint directly. With a card key:
80
+
81
+ ```bash
82
+ curl -H 'Authorization: Bearer sk402_your_key_here' \
83
+ 'https://skim402.com/api/t/read?url=https://en.wikipedia.org/wiki/HTTP_402'
84
+ ```
85
+
86
+ Or without a key (returns a 402 challenge so you can see the x402 protocol):
60
87
 
61
88
  ```bash
62
89
  curl -i -X POST https://skim402.com/api/v1/read \
@@ -64,21 +91,17 @@ curl -i -X POST https://skim402.com/api/v1/read \
64
91
  -d '{"url":"https://en.wikipedia.org/wiki/HTTP_402"}'
65
92
  ```
66
93
 
67
- You'll get back `HTTP/1.1 402 Payment Required` with the x402 challenge in the response body. To then *pay* the challenge from a script (not an agent), see [x402-fetch](https://www.npmjs.com/package/x402-fetch).
68
-
69
94
  ---
70
95
 
71
- ## The tool
96
+ ## The tools
72
97
 
73
98
  ### `read_url`
74
99
 
75
100
  Reads any URL and returns clean Markdown with a YAML frontmatter block.
76
101
 
77
- **Input:**
102
+ **Input:** `{ "url": "https://example.com/article" }`
78
103
 
79
- ```json
80
- { "url": "https://example.com/article" }
81
- ```
104
+ **Routes:** `GET /api/t/read?url=` (API key) · `POST /api/v1/read` (wallet)
82
105
 
83
106
  **Output:**
84
107
 
@@ -96,52 +119,137 @@ excerpt: A short summary...
96
119
  The cleaned article body in Markdown...
97
120
  ```
98
121
 
99
- That's it. One tool, one input, one shape of output. Designed to drop into any agent's tool-calling loop with zero ceremony.
122
+ ### `read_urls`
123
+
124
+ Batch-read 1–10 URLs in one call. Optional `stripLinks` / `stripImages`.
125
+
126
+ **Input:** `{ "urls": ["https://a.example", "https://b.example"] }`
127
+
128
+ **Routes:** `POST /api/t/read/batch` · `POST /api/v1/read/batch`
129
+
130
+ ### `extract_url`
131
+
132
+ Structured JSON from a page. Pass a JSON Schema, or a preset: `article`, `product`, `job`, `review`, `event`, `table`.
133
+
134
+ **Input:** `{ "url": "https://example.com/product", "preset": "product" }`
135
+
136
+ **Routes:** `POST /api/t/extract` · `POST /api/v1/extract`
137
+
138
+ Presets are sent as schemas on those extract routes (card lane has no live `/api/t/extract/{preset}` today). Align with skim402-web if that splits later.
139
+
140
+ ### `crawl_url`
141
+
142
+ Crawl a site (origin or start URL) and return clean Markdown per page. Discovers `sitemap.xml` / `robots.txt` sitemaps plus same-origin links. Cap 25 pages. **1 credit per successful page**; failed pages are not charged.
143
+
144
+ **Input:** `{ "url": "https://example.com", "maxPages": 10 }`
145
+
146
+ Optional: `stripLinks`, `stripImages` (passed through to each page read). Bare hosts like `example.com` are treated as `https://example.com`.
147
+
148
+ **Route:** `POST /api/t/crawl` (API key). No x402 `/v1` twin — wallet-only configs get a clear error; set `SKIM_API_KEY`.
149
+
150
+ ```
151
+ Crawl https://example.com (max 10 pages) and list the page titles.
152
+ ```
153
+
154
+ ### `read_pdf`
155
+
156
+ Fetch a public PDF URL and return clean Markdown plus an optional bookmark outline. Text comes only from the file. Image-only scans return **422** (no OCR). Files larger than **8 MB** return **413**. **3 credits**; failed conversions are not charged.
157
+
158
+ **Input:** `{ "url": "https://example.com/paper.pdf" }`
159
+
160
+ Optional: `outline` (default `true`).
161
+
162
+ **Route:** `POST /api/t/read-pdf` (API key). No x402 `/v1` twin — set `SKIM_API_KEY`.
163
+
164
+ ```
165
+ Read the PDF at https://example.com/paper.pdf and summarize the outline.
166
+ ```
167
+
168
+ ### `watch_urls` / `check_watch`
169
+
170
+ Register 1–20 URLs, then poll for content diffs. `watch_id` is a secret.
171
+
172
+ **Input:** `{ "urls": ["https://competitor.com/pricing"], "note": "pricing" }` then `{ "watch_id": "w_…" }` (optional `status_only: true`)
173
+
174
+ **Routes (API key):** `POST /api/t/watch` · `GET /api/t/watch/diff?id=` · `GET /api/t/watch/status?id=`
175
+
176
+ **Routes (wallet):** `POST /api/v2/watch` · `GET /api/v2/watch/diff?id=` · `GET /api/v2/watch/status?id=`
177
+
178
+ Card-lane `/api/t/watch*` is live (POST without a key returns `401`). Optional HTTPS `webhookUrl` is supported by the API; this MCP tool still sends `{ urls, note? }`.
179
+
180
+ ### Example agent prompts
181
+
182
+ ```
183
+ Read https://en.wikipedia.org/wiki/HTTP_402 and summarize it.
184
+
185
+ Read these three pages and compare their pricing: https://a.example/pricing https://b.example/pricing https://c.example/pricing
186
+
187
+ Extract the product name, price, and availability from https://example.com/products/notebook as JSON.
188
+
189
+ Crawl https://example.com (max 10 pages) and list the page titles.
190
+
191
+ Read the PDF at https://example.com/paper.pdf and summarize it.
192
+
193
+ Watch https://competitor.com/pricing and https://competitor.com/changelog, then check the watch for changes.
194
+ ```
100
195
 
101
196
  ---
102
197
 
103
198
  ## Configuration
104
199
 
105
- | Variable | Required | Default | Notes |
106
- | ------------------------- | -------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
107
- | `SKIM_WALLET_PRIVATE_KEY` | **yes** | — | Hex private key for the Base wallet that pays for reads. With or without `0x` prefix. Use a dedicated wallet — never your personal one. |
108
- | `SKIM_MAX_PRICE_USD` | no | `0.01` | Hard cap on per-call price in USD. The wallet refuses to sign for anything above this. Skim is currently `$0.002`/call, well under the default cap leave it alone unless tuning. |
109
- | `SKIM_API_URL` | no | `https://skim402.com` | Override the API base URL. For self-hosting or local development. |
110
- | `SKIM_TIMEOUT_MS` | no | `90000` | Hard deadline per call in milliseconds. Aborts stalled requests or payment steps so a single bad call can never hang your agent. Unsettled calls are never charged, so retry is safe. |
200
+ | Variable | Required | Default | Notes |
201
+ | ------------------------- | --------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
202
+ | `SKIM_API_KEY` | **yes** (or wallet) | — | Card-plan API key (`sk402_...`). Get one free at [skim402.com/pricing](https://skim402.com/pricing). Takes priority over `SKIM_WALLET_PRIVATE_KEY`. |
203
+ | `SKIM_WALLET_PRIVATE_KEY` | **yes** (or card key) | | Hex private key for the Base wallet that pays x402 reads ($0.002 USDC/call). Ignored when `SKIM_API_KEY` is set. Use a dedicated walletnever your personal one. |
204
+ | `SKIM_MAX_PRICE_USD` | no | `0.01` | Wallet lane only. Hard cap on per-call price in USD. Single reads are `$0.002`. Batch / extract / watch cost more — raise this (e.g. `0.05`) if the wallet lane rejects those calls. |
205
+ | `SKIM_API_URL` | no | `https://skim402.com` | Override the API base URL. For self-hosting or local development. |
206
+ | `SKIM_TIMEOUT_MS` | no | `90000` | Hard deadline per call in milliseconds. Aborts stalled requests so a single bad call can never hang your agent. Unsettled calls are never charged, so retry is safe. |
111
207
 
112
208
  ---
113
209
 
114
210
  ## How it actually works
115
211
 
212
+ **Card lane:**
213
+
214
+ ```
215
+ your agent ──► skim-mcp ──► GET https://skim402.com/api/t/read?url=…
216
+ Authorization: Bearer sk402_...
217
+
218
+
219
+ 200 OK + clean Markdown
220
+ ```
221
+
222
+ **Wallet lane (x402):**
223
+
116
224
  ```
117
225
  your agent ──► skim-mcp ──► POST https://skim402.com/api/v1/read
118
- ▲ │
119
- │ ▼
120
- │ 402 Payment Required
121
- │ (x402 challenge)
122
- │ │
123
- ▼ │
226
+ ▲ │
227
+ │ ▼
228
+ │ 402 Payment Required
229
+ │ (x402 challenge)
230
+ │ │
231
+ ▼ │
124
232
  x402-fetch signs EIP-3009 ◄────────────┘
125
233
  USDC transfer authorization
126
-
127
-
234
+
235
+
128
236
  retry POST with X-PAYMENT header
129
-
130
-
131
- Skim verifies + settles via Coinbase CDP facilitator
132
-
133
-
237
+
238
+
239
+ Skim verifies + settles via Coinbase CDP
240
+
241
+
134
242
  200 OK + clean Markdown
135
243
  ```
136
244
 
137
- End-to-end latency is typically **1.5–2 seconds** including settlement. Your private key never leaves your machine — it only signs authorizations locally.
245
+ End-to-end latency is typically **1.5–2 seconds** including settlement. Wallet-lane private keys never leave your machine — they only sign authorizations locally.
138
246
 
139
247
  ---
140
248
 
141
249
  ## Security
142
250
 
143
- - **Dedicated wallet, always.** Fund it with only as much USDC as you're willing to spend in a runaway loop. The `SKIM_MAX_PRICE_USD` cap catches accidental price escalations on the server side.
144
- - **Plaintext config caveat.** MCP clients read the private key from a JSON file on disk in plaintext. Anyone with read access to your home directory can drain the wallet. This is a property of every MCP server that needs credentials keep the wallet small.
251
+ - **Card key:** stored in your MCP client's JSON config. Anyone with read access to that file can use your key. Keep your key small-balance or use the free plan.
252
+ - **Wallet key:** lives in the same JSON config in plaintext. Anyone with access to your home directory can drain the wallet. Keep it funded with only as much USDC as you're willing to spend in a runaway loop.
145
253
  - **No outbound telemetry from this package.** `skim-mcp` only talks to `skim402.com` (or whatever you set as `SKIM_API_URL`). No analytics, no error reporting, no phone-home.
146
254
 
147
255
  ---
@@ -151,11 +259,11 @@ End-to-end latency is typically **1.5–2 seconds** including settlement. Your p
151
259
  **"No tool named `read_url` shows up in Claude/Cursor."**
152
260
  Restart the client fully (quit, don't just reload the window). MCP servers are spawned at client startup. If still missing, run `npx skim-mcp` directly in a terminal — if you get a stack trace, it's likely Node < 18.
153
261
 
154
- **"402 Payment Required loops forever."**
155
- Your wallet is out of USDC on Base mainnet (or you set `SKIM_API_URL` to a server expecting payment on a different network). Check the balance on [BaseScan](https://basescan.org/), top up if needed.
262
+ **"401 Unauthorized" with card key.**
263
+ Your `SKIM_API_KEY` is invalid or expired. Get a fresh one at [skim402.com/pricing](https://skim402.com/pricing).
156
264
 
157
- **"insufficient funds for gas"**
158
- Counter-intuitive but: x402 USDC transfers are gasless from your wallet's perspective (the facilitator pays gas, you only sign the authorization). If you see this error, you almost certainly funded the wrong wallet or the wrong network — verify the address in your config matches the one holding the USDC.
265
+ **"402 Payment Required loops forever" (wallet lane).**
266
+ Your wallet is out of USDC on Base mainnet. Check the balance on [BaseScan](https://basescan.org/), top up if needed.
159
267
 
160
268
  **"USDC is on Ethereum, not Base."**
161
269
  This is the most common funding mistake. USDC on Ethereum mainnet does not work — Skim only accepts USDC on Base. See <https://skim402.com/wallet> for the bridging walkthrough.
@@ -165,6 +273,7 @@ This is the most common funding mistake. USDC on Ethereum mainnet does not work
165
273
  ## Links
166
274
 
167
275
  - **Skim website** — <https://skim402.com>
276
+ - **Pricing & free key** — <https://skim402.com/pricing>
168
277
  - **Wallet setup guide** — <https://skim402.com/wallet>
169
278
  - **API docs** — <https://skim402.com/docs>
170
279
  - **x402 protocol** — <https://x402.org>
package/dist/index.js CHANGED
@@ -4,16 +4,29 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { privateKeyToAccount } from "viem/accounts";
5
5
  import { wrapFetchWithPayment } from "x402-fetch";
6
6
  import { z } from "zod";
7
+ const VERSION = "0.2.1";
7
8
  const BASE_URL = (process.env.SKIM_API_URL ?? "https://skim402.com").replace(/\/+$/, "");
8
- const PRIVATE_KEY = process.env.SKIM_WALLET_PRIVATE_KEY;
9
+ const API_KEY = process.env.SKIM_API_KEY ?? "";
10
+ const PRIVATE_KEY = process.env.SKIM_WALLET_PRIVATE_KEY ?? "";
9
11
  const MAX_PRICE_USD = process.env.SKIM_MAX_PRICE_USD ?? "0.01";
10
12
  const TIMEOUT_MS = (() => {
11
13
  const parsed = Number(process.env.SKIM_TIMEOUT_MS ?? "90000");
12
14
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 90_000;
13
15
  })();
16
+ const cardLane = API_KEY.length > 0;
14
17
  let payFetch = fetch;
15
18
  let walletAddress = null;
16
- if (PRIVATE_KEY) {
19
+ if (cardLane) {
20
+ const key = API_KEY;
21
+ payFetch = (input, init) => fetch(input, {
22
+ ...init,
23
+ headers: {
24
+ ...(init?.headers ?? {}),
25
+ Authorization: `Bearer ${key}`,
26
+ },
27
+ });
28
+ }
29
+ else if (PRIVATE_KEY) {
17
30
  const normalized = PRIVATE_KEY.startsWith("0x")
18
31
  ? PRIVATE_KEY.slice(2)
19
32
  : PRIVATE_KEY;
@@ -23,105 +36,463 @@ if (PRIVATE_KEY) {
23
36
  }
24
37
  const account = privateKeyToAccount(`0x${normalized}`);
25
38
  walletAddress = account.address;
26
- payFetch = wrapFetchWithPayment(fetch, account, BigInt(Math.round(Number(MAX_PRICE_USD) * 1_000_000)));
39
+ const maxPrice = Number(MAX_PRICE_USD);
40
+ if (!Number.isFinite(maxPrice) || maxPrice <= 0) {
41
+ process.stderr.write("skim-mcp: SKIM_MAX_PRICE_USD must be a positive number, e.g. 0.01.\n");
42
+ process.exit(1);
43
+ }
44
+ payFetch = wrapFetchWithPayment(fetch, account, BigInt(Math.round(maxPrice * 1_000_000)));
45
+ }
46
+ const hasAuth = cardLane || walletAddress !== null;
47
+ /**
48
+ * Convenience schemas for extract_url presets. Card lane has no live
49
+ * /api/t/extract/{preset} routes today, so presets are sent as `schema`
50
+ * to POST /api/t/extract (or POST /api/v1/extract on the wallet lane).
51
+ * Field lists match skim402.com/docs extraction presets.
52
+ */
53
+ const PRESET_SCHEMAS = {
54
+ article: {
55
+ type: "object",
56
+ properties: {
57
+ title: { type: "string" },
58
+ author: { type: "string" },
59
+ published: { type: "string" },
60
+ summary: { type: "string" },
61
+ key_points: { type: "array", items: { type: "string" } },
62
+ language: { type: "string" },
63
+ },
64
+ required: ["title"],
65
+ },
66
+ product: {
67
+ type: "object",
68
+ properties: {
69
+ name: { type: "string" },
70
+ brand: { type: "string" },
71
+ price: { type: "number" },
72
+ currency: { type: "string" },
73
+ availability: { type: "string" },
74
+ rating: { type: "number" },
75
+ reviewCount: { type: "number" },
76
+ description: { type: "string" },
77
+ },
78
+ required: ["name"],
79
+ },
80
+ job: {
81
+ type: "object",
82
+ properties: {
83
+ title: { type: "string" },
84
+ company: { type: "string" },
85
+ location: { type: "string" },
86
+ remote: { type: "boolean" },
87
+ employmentType: { type: "string" },
88
+ salaryMin: { type: "number" },
89
+ salaryMax: { type: "number" },
90
+ requirements: { type: "array", items: { type: "string" } },
91
+ },
92
+ required: ["title"],
93
+ },
94
+ review: {
95
+ type: "object",
96
+ properties: {
97
+ item: { type: "string" },
98
+ rating: { type: "number" },
99
+ scale: { type: "number" },
100
+ author: { type: "string" },
101
+ verdict: { type: "string" },
102
+ pros: { type: "array", items: { type: "string" } },
103
+ cons: { type: "array", items: { type: "string" } },
104
+ },
105
+ required: ["item"],
106
+ },
107
+ event: {
108
+ type: "object",
109
+ properties: {
110
+ name: { type: "string" },
111
+ startDate: { type: "string" },
112
+ endDate: { type: "string" },
113
+ venue: { type: "string" },
114
+ city: { type: "string" },
115
+ country: { type: "string" },
116
+ organizer: { type: "string" },
117
+ ticketPrice: { type: "string" },
118
+ },
119
+ required: ["name"],
120
+ },
121
+ table: {
122
+ type: "object",
123
+ properties: {
124
+ tables: {
125
+ type: "array",
126
+ items: {
127
+ type: "object",
128
+ properties: {
129
+ caption: { type: "string" },
130
+ headers: { type: "array", items: { type: "string" } },
131
+ rows: {
132
+ type: "array",
133
+ items: { type: "array", items: { type: "string" } },
134
+ },
135
+ },
136
+ },
137
+ },
138
+ },
139
+ required: ["tables"],
140
+ },
141
+ };
142
+ function fail(text) {
143
+ return {
144
+ isError: true,
145
+ content: [{ type: "text", text }],
146
+ };
147
+ }
148
+ function ok(text) {
149
+ return { content: [{ type: "text", text }] };
150
+ }
151
+ function authMissing() {
152
+ return fail("Skim needs a payment method. Set SKIM_API_KEY (card plan, free tier at skim402.com/pricing) or SKIM_WALLET_PRIVATE_KEY (Base wallet with USDC). Card is easier — no crypto setup required.");
153
+ }
154
+ function cardLaneOnly(tool, path) {
155
+ return fail(`${tool} is card-lane only (${path}) — there is no x402 /v1 twin. Set SKIM_API_KEY (sk402_..., free tier at skim402.com/pricing). Wallet pay still works for read_url, read_urls, extract_url, and watch.`);
156
+ }
157
+ function buildUrl(path, query) {
158
+ const url = new URL(path, `${BASE_URL}/`);
159
+ if (query) {
160
+ for (const [key, value] of Object.entries(query)) {
161
+ url.searchParams.set(key, value);
162
+ }
163
+ }
164
+ return url.toString();
165
+ }
166
+ async function skimFetch(method, path, opts = {}) {
167
+ const attempt = payFetch(buildUrl(path, opts.query), {
168
+ method,
169
+ headers: method === "POST" ? { "Content-Type": "application/json" } : undefined,
170
+ body: method === "POST" ? JSON.stringify(opts.body ?? {}) : undefined,
171
+ signal: AbortSignal.timeout(TIMEOUT_MS),
172
+ });
173
+ let watchdog;
174
+ const deadline = new Promise((_, reject) => {
175
+ watchdog = setTimeout(() => reject(new Error(`timed out after ${TIMEOUT_MS}ms (SKIM_TIMEOUT_MS) — the request or payment client stalled`)), TIMEOUT_MS + 5_000);
176
+ });
177
+ try {
178
+ return await Promise.race([attempt, deadline]);
179
+ }
180
+ finally {
181
+ clearTimeout(watchdog);
182
+ attempt.catch(() => { });
183
+ }
184
+ }
185
+ function requestFailedMessage(err) {
186
+ const msg = err instanceof Error ? err.message : String(err);
187
+ return cardLane
188
+ ? `Skim request failed: ${msg}. Check that your SKIM_API_KEY is valid (skim402.com/pricing).`
189
+ : `Skim request failed: ${msg}. Common causes: wallet has no USDC on Base, the price exceeds SKIM_MAX_PRICE_USD (${MAX_PRICE_USD}), or a stalled network/payment connection (retry is safe — you are not charged for unsettled calls). Wallet-lane batch/extract/watch cost more than a single read — raise SKIM_MAX_PRICE_USD (e.g. 0.05) if the cap is rejecting the call.`;
190
+ }
191
+ async function readJson(res) {
192
+ const body = await res.text();
193
+ if (!res.ok) {
194
+ throw new Error(`Skim returned ${res.status} ${res.statusText}: ${body || "(no body)"}`);
195
+ }
196
+ if (!body)
197
+ return {};
198
+ try {
199
+ return JSON.parse(body);
200
+ }
201
+ catch {
202
+ return body;
203
+ }
204
+ }
205
+ function formatRead(data) {
206
+ const metaLines = data.metadata
207
+ ? Object.entries(data.metadata)
208
+ .filter(([, v]) => v != null && v !== "")
209
+ .map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
210
+ : [];
211
+ const frontmatter = metaLines.length > 0 ? `---\n${metaLines.join("\n")}\n---\n\n` : "";
212
+ return frontmatter + (data.markdown ?? data.text ?? "");
213
+ }
214
+ function formatCrawl(data) {
215
+ const meta = {};
216
+ if (data.url)
217
+ meta.url = data.url;
218
+ if (data.origin)
219
+ meta.origin = data.origin;
220
+ if (data.pageCount != null)
221
+ meta.pageCount = data.pageCount;
222
+ if (data.discovered != null)
223
+ meta.discovered = data.discovered;
224
+ if (data.capped != null)
225
+ meta.capped = data.capped;
226
+ if (data.maxPages != null)
227
+ meta.maxPages = data.maxPages;
228
+ if (data.sources?.length)
229
+ meta.sources = data.sources.join(", ");
230
+ if (data.charged != null)
231
+ meta.charged = data.charged;
232
+ if (data.fetchedAt)
233
+ meta.fetchedAt = data.fetchedAt;
234
+ const blocks = (data.pages ?? []).map((page) => {
235
+ if (page.ok !== false && (page.markdown || page.title)) {
236
+ const heading = page.title ? `${page.title} — ${page.url}` : page.url;
237
+ return `## ${heading}\n\n${page.markdown ?? ""}`;
238
+ }
239
+ const err = page.error;
240
+ const detail = typeof err === "string"
241
+ ? err
242
+ : err
243
+ ? `${err.status ?? ""} ${err.message ?? ""}`.trim()
244
+ : "unknown error";
245
+ return `## ${page.url}\n\nERROR: ${detail}`;
246
+ });
247
+ return (formatRead({ metadata: meta, markdown: blocks.join("\n\n---\n\n") }) ||
248
+ JSON.stringify(data, null, 2));
27
249
  }
28
250
  const server = new McpServer({
29
251
  name: "skim-mcp",
30
- version: "0.1.6",
252
+ version: VERSION,
31
253
  });
32
- server.tool("read_url", "Fetch any URL and return clean, agent-ready Markdown via Skim (skim402.com). Strips nav, ads, and boilerplate; preserves the article body plus structured metadata (title, byline, published date, language, excerpt). Pays $0.002 per call in USDC on Base over the x402 protocol — no API keys, no signup. Use this whenever you need to read web content: articles, docs, blog posts, GitHub READMEs, research papers, etc.", {
254
+ server.tool("read_url", "Fetch any URL and return clean, agent-ready Markdown via Skim (skim402.com). Output is ~4x smaller than raw HTML — fewer tokens, faster processing. Strips nav, ads, and boilerplate; preserves the article body plus structured metadata (title, byline, published date, language, excerpt). Use this whenever you need to read web content: articles, docs, blog posts, GitHub READMEs, research papers, etc.", {
33
255
  url: z
34
256
  .string()
35
257
  .url()
36
258
  .describe("The fully-qualified URL to fetch and clean (https://...)."),
37
259
  }, async ({ url }) => {
38
- if (!PRIVATE_KEY) {
39
- return {
40
- isError: true,
41
- content: [
42
- {
43
- type: "text",
44
- text: "Skim requires payment via x402. Set the SKIM_WALLET_PRIVATE_KEY environment variable to a Base wallet funded with USDC. See https://skim402.com for details.",
45
- },
46
- ],
47
- };
260
+ if (!hasAuth)
261
+ return authMissing();
262
+ try {
263
+ const res = cardLane
264
+ ? await skimFetch("GET", "/api/t/read", { query: { url } })
265
+ : await skimFetch("POST", "/api/v1/read", {
266
+ body: { url, mode: "basic" },
267
+ });
268
+ const data = (await readJson(res));
269
+ return ok(formatRead(data));
270
+ }
271
+ catch (err) {
272
+ return fail(requestFailedMessage(err));
48
273
  }
49
- let res;
274
+ });
275
+ server.tool("read_urls", "Read up to 10 URLs in one Skim call and return per-URL clean Markdown plus metadata. Use this instead of calling read_url in a loop. Card lane: POST /api/t/read/batch. Wallet lane: POST /api/v1/read/batch.", {
276
+ urls: z
277
+ .array(z.string().url())
278
+ .min(1)
279
+ .max(10)
280
+ .describe("1–10 fully-qualified http(s) URLs to read."),
281
+ stripLinks: z
282
+ .boolean()
283
+ .optional()
284
+ .describe("If true, flatten markdown links to their anchor text."),
285
+ stripImages: z
286
+ .boolean()
287
+ .optional()
288
+ .describe("If true, drop image markup from the markdown."),
289
+ }, async ({ urls, stripLinks, stripImages }) => {
290
+ if (!hasAuth)
291
+ return authMissing();
50
292
  try {
51
- // Two layers of hang protection:
52
- // 1. AbortSignal.timeout aborts the underlying HTTP requests (both the
53
- // initial 402 handshake and the paid retry — x402-fetch reuses init).
54
- // 2. Promise.race is a hard watchdog for anything inside the payment
55
- // client that stalls without honoring the abort signal (e.g. a hung
56
- // RPC or signing step), so a single bad call can never freeze the
57
- // whole process.
58
- const attempt = payFetch(`${BASE_URL}/api/v1/read`, {
59
- method: "POST",
60
- headers: { "Content-Type": "application/json" },
61
- body: JSON.stringify({ url, mode: "basic" }),
62
- signal: AbortSignal.timeout(TIMEOUT_MS),
293
+ const path = cardLane ? "/api/t/read/batch" : "/api/v1/read/batch";
294
+ const res = await skimFetch("POST", path, {
295
+ body: {
296
+ urls,
297
+ ...(stripLinks !== undefined ? { stripLinks } : {}),
298
+ ...(stripImages !== undefined ? { stripImages } : {}),
299
+ },
63
300
  });
64
- let watchdog;
65
- const deadline = new Promise((_, reject) => {
66
- watchdog = setTimeout(() => reject(new Error(`timed out after ${TIMEOUT_MS}ms (SKIM_TIMEOUT_MS) — the request or payment client stalled`)), TIMEOUT_MS + 5_000);
301
+ const data = (await readJson(res));
302
+ const blocks = (data.results ?? []).map((item) => {
303
+ if (item.ok && item.data) {
304
+ return `## ${item.url}\n\n${formatRead(item.data)}`;
305
+ }
306
+ const err = item.error;
307
+ const detail = err
308
+ ? `${err.status ?? ""} ${err.message ?? ""}`.trim()
309
+ : "unknown error";
310
+ return `## ${item.url}\n\nERROR: ${detail}`;
67
311
  });
68
- try {
69
- res = await Promise.race([attempt, deadline]);
70
- }
71
- finally {
72
- clearTimeout(watchdog);
73
- // If the watchdog won, make sure the losing fetch promise can't
74
- // surface an unhandled rejection later.
75
- attempt.catch(() => { });
76
- }
312
+ return ok(blocks.join("\n\n---\n\n") || JSON.stringify(data, null, 2));
77
313
  }
78
314
  catch (err) {
79
- const msg = err instanceof Error ? err.message : String(err);
80
- return {
81
- isError: true,
82
- content: [
83
- {
84
- type: "text",
85
- text: `Skim request failed: ${msg}. Common causes: wallet has no USDC on Base, the price exceeds SKIM_MAX_PRICE_USD (${MAX_PRICE_USD}), or a stalled network/payment connection (the call was aborted after ${TIMEOUT_MS}ms; retry is safe — you are not charged for unsettled calls).`,
86
- },
87
- ],
88
- };
315
+ return fail(requestFailedMessage(err));
89
316
  }
90
- if (!res.ok) {
91
- const body = await res.text().catch(() => "");
92
- return {
93
- isError: true,
94
- content: [
95
- {
96
- type: "text",
97
- text: `Skim returned ${res.status} ${res.statusText}: ${body || "(no body)"}`,
98
- },
99
- ],
100
- };
317
+ });
318
+ server.tool("extract_url", "Extract structured JSON (or tables) from a URL via Skim. Pass a JSON Schema, or a preset (article, product, job, review, event, table). Values come only from the page — missing fields are omitted or null, never invented. Card lane: POST /api/t/extract. Wallet lane: POST /api/v1/extract.", {
319
+ url: z
320
+ .string()
321
+ .url()
322
+ .describe("The fully-qualified URL to fetch and extract from."),
323
+ schema: z
324
+ .record(z.unknown())
325
+ .optional()
326
+ .describe("JSON Schema (type: object) describing the output. Required unless preset is set. Wins if both are set."),
327
+ preset: z
328
+ .enum(["article", "product", "job", "review", "event", "table"])
329
+ .optional()
330
+ .describe("Built-in extraction shape. Use table for data tables on the page. Ignored when schema is set."),
331
+ instructions: z
332
+ .string()
333
+ .optional()
334
+ .describe("Optional natural-language hint (e.g. only quote prices from the article body)."),
335
+ }, async ({ url, schema, preset, instructions }) => {
336
+ if (!hasAuth)
337
+ return authMissing();
338
+ const resolved = schema ?? (preset ? PRESET_SCHEMAS[preset] : undefined);
339
+ if (!resolved) {
340
+ return fail("extract_url needs a JSON Schema (`schema`) or a `preset` (article, product, job, review, event, table).");
101
341
  }
102
- const data = (await res.json());
103
- const metaLines = data.metadata
104
- ? Object.entries(data.metadata)
105
- .filter(([, v]) => v != null && v !== "")
106
- .map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
107
- : [];
108
- const frontmatter = metaLines.length > 0 ? `---\n${metaLines.join("\n")}\n---\n\n` : "";
109
- return {
110
- content: [
111
- {
112
- type: "text",
113
- text: frontmatter + (data.markdown ?? data.text ?? ""),
342
+ try {
343
+ const path = cardLane ? "/api/t/extract" : "/api/v1/extract";
344
+ const res = await skimFetch("POST", path, {
345
+ body: {
346
+ url,
347
+ schema: resolved,
348
+ ...(instructions ? { instructions } : {}),
114
349
  },
115
- ],
116
- };
350
+ });
351
+ const data = await readJson(res);
352
+ return ok(JSON.stringify(data, null, 2));
353
+ }
354
+ catch (err) {
355
+ return fail(requestFailedMessage(err));
356
+ }
357
+ });
358
+ server.tool("crawl_url", "Crawl a site (origin or start URL) and return clean Markdown for the important pages. Discovers sitemap.xml / robots.txt sitemaps plus same-origin links. Cap 25 pages. 1 credit per successful page; failed pages are not charged. Card lane only: POST /api/t/crawl. No x402 /v1 twin.", {
359
+ url: z
360
+ .string()
361
+ .min(1)
362
+ .describe("Site origin or start URL. Bare hosts like example.com are treated as https://example.com."),
363
+ maxPages: z
364
+ .number()
365
+ .int()
366
+ .min(1)
367
+ .max(25)
368
+ .optional()
369
+ .describe("Optional page cap, 1–25. Default 25."),
370
+ stripLinks: z
371
+ .boolean()
372
+ .optional()
373
+ .describe("If true, flatten markdown links to their anchor text on every page."),
374
+ stripImages: z
375
+ .boolean()
376
+ .optional()
377
+ .describe("If true, drop image markup from every page."),
378
+ }, async ({ url, maxPages, stripLinks, stripImages }) => {
379
+ if (!hasAuth)
380
+ return authMissing();
381
+ if (!cardLane)
382
+ return cardLaneOnly("crawl_url", "POST /api/t/crawl");
383
+ try {
384
+ const res = await skimFetch("POST", "/api/t/crawl", {
385
+ body: {
386
+ url,
387
+ ...(maxPages !== undefined ? { maxPages } : {}),
388
+ ...(stripLinks !== undefined ? { stripLinks } : {}),
389
+ ...(stripImages !== undefined ? { stripImages } : {}),
390
+ },
391
+ });
392
+ const data = (await readJson(res));
393
+ return ok(formatCrawl(data));
394
+ }
395
+ catch (err) {
396
+ return fail(requestFailedMessage(err));
397
+ }
398
+ });
399
+ server.tool("read_pdf", "Fetch a public PDF URL and return clean Markdown plus an optional bookmark outline. Text comes only from the file — nothing is invented. Image-only scans return 422 (no OCR). Files larger than 8 MB return 413. 3 credits; failed conversions are not charged. Card lane only: POST /api/t/read-pdf. No x402 /v1 twin.", {
400
+ url: z
401
+ .string()
402
+ .url()
403
+ .describe("Absolute http(s) PDF URL to fetch and convert."),
404
+ outline: z
405
+ .boolean()
406
+ .optional()
407
+ .describe("If true (default), include the PDF bookmark outline when present. Set false to omit it."),
408
+ }, async ({ url, outline }) => {
409
+ if (!hasAuth)
410
+ return authMissing();
411
+ if (!cardLane)
412
+ return cardLaneOnly("read_pdf", "POST /api/t/read-pdf");
413
+ try {
414
+ const res = await skimFetch("POST", "/api/t/read-pdf", {
415
+ body: {
416
+ url,
417
+ ...(outline !== undefined ? { outline } : {}),
418
+ },
419
+ });
420
+ const data = (await readJson(res));
421
+ return ok(formatRead({
422
+ markdown: data.markdown,
423
+ text: data.text,
424
+ metadata: {
425
+ ...(data.url ? { url: data.url } : {}),
426
+ ...(data.finalUrl ? { finalUrl: data.finalUrl } : {}),
427
+ ...(data.pageCount != null ? { pageCount: data.pageCount } : {}),
428
+ ...(data.charged != null ? { charged: data.charged } : {}),
429
+ ...(data.fetchedAt ? { fetchedAt: data.fetchedAt } : {}),
430
+ ...(data.outline != null ? { outline: data.outline } : {}),
431
+ },
432
+ }));
433
+ }
434
+ catch (err) {
435
+ return fail(requestFailedMessage(err));
436
+ }
437
+ });
438
+ server.tool("watch_urls", "Register a private Skim Watch on 1–20 URLs. Returns a watch_id (treat it as a secret) used with check_watch. First check baselines each page; later checks report content diffs. Card lane: POST /api/t/watch. Wallet lane: POST /api/v2/watch.", {
439
+ urls: z
440
+ .array(z.string().url())
441
+ .min(1)
442
+ .max(20)
443
+ .describe("1–20 fully-qualified http(s) URLs to watch."),
444
+ note: z
445
+ .string()
446
+ .optional()
447
+ .describe("Optional label for this watch (e.g. competitor pricing)."),
448
+ }, async ({ urls, note }) => {
449
+ if (!hasAuth)
450
+ return authMissing();
451
+ try {
452
+ const path = cardLane ? "/api/t/watch" : "/api/v2/watch";
453
+ const res = await skimFetch("POST", path, {
454
+ body: { urls, ...(note ? { note } : {}) },
455
+ });
456
+ const data = await readJson(res);
457
+ return ok(JSON.stringify(data, null, 2));
458
+ }
459
+ catch (err) {
460
+ return fail(requestFailedMessage(err));
461
+ }
462
+ });
463
+ server.tool("check_watch", "Poll a Skim Watch for content changes (or fetch registration status). Pass the watch_id from watch_urls. Card lane: GET /api/t/watch/diff?id= (or /status). Wallet lane: GET /api/v2/watch/diff?id= (or /status, which is free).", {
464
+ watch_id: z
465
+ .string()
466
+ .min(1)
467
+ .describe("Watch id returned by watch_urls (treat as a secret)."),
468
+ status_only: z
469
+ .boolean()
470
+ .optional()
471
+ .describe("If true, return registration status only (no diff). Wallet-lane status is free."),
472
+ }, async ({ watch_id, status_only }) => {
473
+ if (!hasAuth)
474
+ return authMissing();
475
+ try {
476
+ const kind = status_only ? "status" : "diff";
477
+ const path = cardLane
478
+ ? `/api/t/watch/${kind}`
479
+ : `/api/v2/watch/${kind}`;
480
+ const res = await skimFetch("GET", path, { query: { id: watch_id } });
481
+ const data = await readJson(res);
482
+ return ok(JSON.stringify(data, null, 2));
483
+ }
484
+ catch (err) {
485
+ return fail(requestFailedMessage(err));
486
+ }
117
487
  });
118
488
  const transport = new StdioServerTransport();
119
489
  await server.connect(transport);
120
- // Surface startup status on stderr so MCP clients can show it in their logs
121
- // without interfering with the stdio JSON-RPC stream.
122
- if (walletAddress) {
490
+ if (cardLane) {
491
+ process.stderr.write(`skim-mcp ready card key (SKIM_API_KEY) ${BASE_URL}\n`);
492
+ }
493
+ else if (walletAddress) {
123
494
  process.stderr.write(`skim-mcp ready — paying from ${walletAddress} (max $${MAX_PRICE_USD}/call) → ${BASE_URL}\n`);
124
495
  }
125
496
  else {
126
- process.stderr.write(`skim-mcp ready (NO WALLET) — set SKIM_WALLET_PRIVATE_KEY to enable paid reads → ${BASE_URL}\n`);
497
+ process.stderr.write(`skim-mcp ready (NO AUTH) — set SKIM_API_KEY or SKIM_WALLET_PRIVATE_KEY to enable tools → ${BASE_URL}\n`);
127
498
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "skim-mcp",
3
- "version": "0.1.9",
3
+ "version": "0.2.2",
4
4
  "mcpName": "io.github.JessieJanie/skim402",
5
- "description": "MCP server for Skim — clean web reader for AI agents. Pays $0.002 per call in USDC over x402. No signup, no API keys.",
5
+ "description": "MCP server for Skim — clean web reader for AI agents. Card API key or x402 wallet. Batch, extract, crawl, PDF, watch.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "skim-mcp": "dist/index.js"
@@ -13,16 +13,21 @@
13
13
  "README.md",
14
14
  "LICENSE"
15
15
  ],
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.build.json && node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
18
+ "typecheck": "tsc --noEmit",
19
+ "dev": "tsx src/index.ts",
20
+ "prepublishOnly": "pnpm build",
21
+ "postinstall": "npm install -g tsx@4.21.0"
22
+ },
16
23
  "dependencies": {
17
24
  "@modelcontextprotocol/sdk": "^1.0.0",
18
25
  "viem": "^2.21.0",
19
26
  "x402-fetch": "^0.6.0",
20
- "zod": "3.25.76"
27
+ "zod": "^3.25.76"
21
28
  },
22
29
  "devDependencies": {
23
- "@anthropic-ai/mcpb": "^2.0.0",
24
30
  "@types/node": "^25.3.3",
25
- "esbuild": "^0.27.0",
26
31
  "tsx": "^4.21.0",
27
32
  "typescript": "^5.9.0"
28
33
  },
@@ -51,12 +56,5 @@
51
56
  "license": "MIT",
52
57
  "engines": {
53
58
  "node": ">=18"
54
- },
55
- "scripts": {
56
- "build": "tsc -p tsconfig.build.json && node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
57
- "typecheck": "tsc --build tsconfig.json",
58
- "dev": "tsx src/index.ts",
59
- "mcpb": "node mcpb-build.mjs",
60
- "audit:buyers": "node buyer-audit.mjs"
61
59
  }
62
- }
60
+ }