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.
- package/README.md +157 -48
- package/dist/index.js +450 -79
- 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,
|
|
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
|
[](https://www.npmjs.com/package/skim-mcp)
|
|
6
6
|
[](https://registry.modelcontextprotocol.io/v0/servers?search=skim402)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
|
|
9
|
-
`skim-mcp` is the official Model Context Protocol server for [Skim](https://skim402.com) — the
|
|
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
|
|
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
|
-
|
|
13
|
+

|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
17
17
|
## Quickstart (60 seconds)
|
|
18
18
|
|
|
19
|
-
###
|
|
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
|
-
"
|
|
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
|
-
|
|
45
|
+
**3.** Restart your client and ask it to read something:
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
```
|
|
48
|
+
Claude, read https://en.wikipedia.org/wiki/HTTP_402 and summarize it.
|
|
49
|
+
```
|
|
44
50
|
|
|
45
|
-
|
|
51
|
+
---
|
|
46
52
|
|
|
47
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
106
|
-
| ------------------------- |
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
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 wallet — never 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
|
-
|
|
121
|
-
|
|
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
|
|
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.
|
|
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
|
-
- **
|
|
144
|
-
- **
|
|
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
|
-
**"
|
|
155
|
-
Your
|
|
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
|
-
**"
|
|
158
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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).
|
|
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 (!
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"mcpName": "io.github.JessieJanie/skim402",
|
|
5
|
-
"description": "MCP server for Skim — clean web reader for AI agents.
|
|
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
|
+
}
|