skim-mcp 0.2.0 → 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 CHANGED
@@ -6,7 +6,7 @@
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 clean reader API for AI agents. It exposes `read_url`, `read_urls` (batch), `extract_url` (structured / table), `watch_urls`, and `check_watch`. The default path is a card-plan API key (`SKIM_API_KEY`); x402 wallet pay stays optional.
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
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
 
@@ -137,17 +137,45 @@ Structured JSON from a page. Pass a JSON Schema, or a preset: `article`, `produc
137
137
 
138
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
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
+
140
168
  ### `watch_urls` / `check_watch`
141
169
 
142
170
  Register 1–20 URLs, then poll for content diffs. `watch_id` is a secret.
143
171
 
144
172
  **Input:** `{ "urls": ["https://competitor.com/pricing"], "note": "pricing" }` then `{ "watch_id": "w_…" }` (optional `status_only: true`)
145
173
 
146
- **Routes (API key, intended):** `POST /api/t/watch` · `GET /api/t/watch/diff?id=` · `GET /api/t/watch/status?id=`
174
+ **Routes (API key):** `POST /api/t/watch` · `GET /api/t/watch/diff?id=` · `GET /api/t/watch/status?id=`
147
175
 
148
- **Routes (wallet, live):** `POST /api/v2/watch` · `GET /api/v2/watch/diff?id=` · `GET /api/v2/watch/status?id=`
176
+ **Routes (wallet):** `POST /api/v2/watch` · `GET /api/v2/watch/diff?id=` · `GET /api/v2/watch/status?id=`
149
177
 
150
- `/api/t/watch*` is documented on skim402-web (Signals: `POST /t/watch`) but was not serving on skim402.com when this package was wired. The MCP client still calls those paths so it works the moment they land do not invent a different watch protocol.
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? }`.
151
179
 
152
180
  ### Example agent prompts
153
181
 
@@ -158,6 +186,10 @@ Read these three pages and compare their pricing: https://a.example/pricing http
158
186
 
159
187
  Extract the product name, price, and availability from https://example.com/products/notebook as JSON.
160
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
+
161
193
  Watch https://competitor.com/pricing and https://competitor.com/changelog, then check the watch for changes.
162
194
  ```
163
195
 
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ 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.1.7";
7
+ const VERSION = "0.2.1";
8
8
  const BASE_URL = (process.env.SKIM_API_URL ?? "https://skim402.com").replace(/\/+$/, "");
9
9
  const API_KEY = process.env.SKIM_API_KEY ?? "";
10
10
  const PRIVATE_KEY = process.env.SKIM_WALLET_PRIVATE_KEY ?? "";
@@ -151,6 +151,9 @@ function ok(text) {
151
151
  function authMissing() {
152
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
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
+ }
154
157
  function buildUrl(path, query) {
155
158
  const url = new URL(path, `${BASE_URL}/`);
156
159
  if (query) {
@@ -208,6 +211,42 @@ function formatRead(data) {
208
211
  const frontmatter = metaLines.length > 0 ? `---\n${metaLines.join("\n")}\n---\n\n` : "";
209
212
  return frontmatter + (data.markdown ?? data.text ?? "");
210
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));
249
+ }
211
250
  const server = new McpServer({
212
251
  name: "skim-mcp",
213
252
  version: VERSION,
@@ -316,7 +355,87 @@ server.tool("extract_url", "Extract structured JSON (or tables) from a URL via S
316
355
  return fail(requestFailedMessage(err));
317
356
  }
318
357
  });
319
- 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 (intended token path from skim402-web; may 404 until that route is live). Wallet lane: POST /api/v2/watch.", {
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.", {
320
439
  urls: z
321
440
  .array(z.string().url())
322
441
  .min(1)
@@ -338,11 +457,7 @@ server.tool("watch_urls", "Register a private Skim Watch on 1–20 URLs. Returns
338
457
  return ok(JSON.stringify(data, null, 2));
339
458
  }
340
459
  catch (err) {
341
- const msg = requestFailedMessage(err);
342
- if (cardLane && /404/.test(msg)) {
343
- return fail(`${msg}\n\nExpected card-lane path is POST /api/t/watch (see skim402-web Signals: POST /t/watch). That route is not live on skim402.com yet — align with skim402-web rather than inventing a different protocol. Wallet-lane POST /api/v2/watch is already live.`);
344
- }
345
- return fail(msg);
460
+ return fail(requestFailedMessage(err));
346
461
  }
347
462
  });
348
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).", {
@@ -367,11 +482,7 @@ server.tool("check_watch", "Poll a Skim Watch for content changes (or fetch regi
367
482
  return ok(JSON.stringify(data, null, 2));
368
483
  }
369
484
  catch (err) {
370
- const msg = requestFailedMessage(err);
371
- if (cardLane && /404/.test(msg)) {
372
- return fail(`${msg}\n\nExpected card-lane paths are GET /api/t/watch/diff?id= and GET /api/t/watch/status?id= (skim402-web). Those routes are not live on skim402.com yet. Wallet-lane GET /api/v2/watch/diff and /status are already live.`);
373
- }
374
- return fail(msg);
485
+ return fail(requestFailedMessage(err));
375
486
  }
376
487
  });
377
488
  const transport = new StdioServerTransport();
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "skim-mcp",
3
- "version": "0.2.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. Card API key or x402 wallet. Batch, extract, watch.",
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"