skim-mcp 0.2.0 → 0.2.3

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`, `check_watch`, and `poll_signal`. 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,59 @@ 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=`
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
+ ### `poll_signal`
181
+
182
+ Poll a [Skim Signal](https://skim402.com/signals) and return the latest structured items (title, summary, source, timestamp, link, and entities). **2 credits** per successful poll; failed polls are refunded.
183
+
184
+ **Requires `SKIM_API_KEY`.** The card-lane routes are `GET /api/t/signal/{slug}/latest?limit=` and `GET /api/t/feeds/x402/latest?limit=` for the x402 ecosystem feed.
147
185
 
148
- **Routes (wallet, live):** `POST /api/v2/watch` · `GET /api/v2/watch/diff?id=` · `GET /api/v2/watch/status?id=`
186
+ **Input:** `{ "slug": "ai-news", "limit": 20 }`
149
187
 
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.
188
+ Optional documented filters: `forms` (SEC filings and campaign finance), `categories` (deals), `fields` (research), `states` (film incentives), and `committees` (campaign finance).
189
+
190
+ ```
191
+ Poll the ai-news Signal for the latest 20 items.
192
+ ```
151
193
 
152
194
  ### Example agent prompts
153
195
 
@@ -158,6 +200,12 @@ Read these three pages and compare their pricing: https://a.example/pricing http
158
200
 
159
201
  Extract the product name, price, and availability from https://example.com/products/notebook as JSON.
160
202
 
203
+ Crawl https://example.com (max 10 pages) and list the page titles.
204
+
205
+ Read the PDF at https://example.com/paper.pdf and summarize it.
206
+
207
+ Poll the ai-news Signal for the latest 20 items.
208
+
161
209
  Watch https://competitor.com/pricing and https://competitor.com/changelog, then check the watch for changes.
162
210
  ```
163
211
 
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.3";
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,17 @@ 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
+ }
157
+ const SIGNAL_SLUGS = [
158
+ "ai-news", "sec-filings", "crypto-news", "macro", "security", "regulations",
159
+ "courts", "recalls", "deals", "launches", "trending", "research", "energy",
160
+ "entertainment", "studio-jobs", "campaign-finance", "film-incentives", "x402",
161
+ ];
162
+ function signalPollPath(slug) {
163
+ return slug === "x402" ? "/api/t/feeds/x402/latest" : `/api/t/signal/${slug}/latest`;
164
+ }
154
165
  function buildUrl(path, query) {
155
166
  const url = new URL(path, `${BASE_URL}/`);
156
167
  if (query) {
@@ -208,6 +219,42 @@ function formatRead(data) {
208
219
  const frontmatter = metaLines.length > 0 ? `---\n${metaLines.join("\n")}\n---\n\n` : "";
209
220
  return frontmatter + (data.markdown ?? data.text ?? "");
210
221
  }
222
+ function formatCrawl(data) {
223
+ const meta = {};
224
+ if (data.url)
225
+ meta.url = data.url;
226
+ if (data.origin)
227
+ meta.origin = data.origin;
228
+ if (data.pageCount != null)
229
+ meta.pageCount = data.pageCount;
230
+ if (data.discovered != null)
231
+ meta.discovered = data.discovered;
232
+ if (data.capped != null)
233
+ meta.capped = data.capped;
234
+ if (data.maxPages != null)
235
+ meta.maxPages = data.maxPages;
236
+ if (data.sources?.length)
237
+ meta.sources = data.sources.join(", ");
238
+ if (data.charged != null)
239
+ meta.charged = data.charged;
240
+ if (data.fetchedAt)
241
+ meta.fetchedAt = data.fetchedAt;
242
+ const blocks = (data.pages ?? []).map((page) => {
243
+ if (page.ok !== false && (page.markdown || page.title)) {
244
+ const heading = page.title ? `${page.title} — ${page.url}` : page.url;
245
+ return `## ${heading}\n\n${page.markdown ?? ""}`;
246
+ }
247
+ const err = page.error;
248
+ const detail = typeof err === "string"
249
+ ? err
250
+ : err
251
+ ? `${err.status ?? ""} ${err.message ?? ""}`.trim()
252
+ : "unknown error";
253
+ return `## ${page.url}\n\nERROR: ${detail}`;
254
+ });
255
+ return (formatRead({ metadata: meta, markdown: blocks.join("\n\n---\n\n") }) ||
256
+ JSON.stringify(data, null, 2));
257
+ }
211
258
  const server = new McpServer({
212
259
  name: "skim-mcp",
213
260
  version: VERSION,
@@ -316,7 +363,87 @@ server.tool("extract_url", "Extract structured JSON (or tables) from a URL via S
316
363
  return fail(requestFailedMessage(err));
317
364
  }
318
365
  });
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.", {
366
+ 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.", {
367
+ url: z
368
+ .string()
369
+ .min(1)
370
+ .describe("Site origin or start URL. Bare hosts like example.com are treated as https://example.com."),
371
+ maxPages: z
372
+ .number()
373
+ .int()
374
+ .min(1)
375
+ .max(25)
376
+ .optional()
377
+ .describe("Optional page cap, 1–25. Default 25."),
378
+ stripLinks: z
379
+ .boolean()
380
+ .optional()
381
+ .describe("If true, flatten markdown links to their anchor text on every page."),
382
+ stripImages: z
383
+ .boolean()
384
+ .optional()
385
+ .describe("If true, drop image markup from every page."),
386
+ }, async ({ url, maxPages, stripLinks, stripImages }) => {
387
+ if (!hasAuth)
388
+ return authMissing();
389
+ if (!cardLane)
390
+ return cardLaneOnly("crawl_url", "POST /api/t/crawl");
391
+ try {
392
+ const res = await skimFetch("POST", "/api/t/crawl", {
393
+ body: {
394
+ url,
395
+ ...(maxPages !== undefined ? { maxPages } : {}),
396
+ ...(stripLinks !== undefined ? { stripLinks } : {}),
397
+ ...(stripImages !== undefined ? { stripImages } : {}),
398
+ },
399
+ });
400
+ const data = (await readJson(res));
401
+ return ok(formatCrawl(data));
402
+ }
403
+ catch (err) {
404
+ return fail(requestFailedMessage(err));
405
+ }
406
+ });
407
+ 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.", {
408
+ url: z
409
+ .string()
410
+ .url()
411
+ .describe("Absolute http(s) PDF URL to fetch and convert."),
412
+ outline: z
413
+ .boolean()
414
+ .optional()
415
+ .describe("If true (default), include the PDF bookmark outline when present. Set false to omit it."),
416
+ }, async ({ url, outline }) => {
417
+ if (!hasAuth)
418
+ return authMissing();
419
+ if (!cardLane)
420
+ return cardLaneOnly("read_pdf", "POST /api/t/read-pdf");
421
+ try {
422
+ const res = await skimFetch("POST", "/api/t/read-pdf", {
423
+ body: {
424
+ url,
425
+ ...(outline !== undefined ? { outline } : {}),
426
+ },
427
+ });
428
+ const data = (await readJson(res));
429
+ return ok(formatRead({
430
+ markdown: data.markdown,
431
+ text: data.text,
432
+ metadata: {
433
+ ...(data.url ? { url: data.url } : {}),
434
+ ...(data.finalUrl ? { finalUrl: data.finalUrl } : {}),
435
+ ...(data.pageCount != null ? { pageCount: data.pageCount } : {}),
436
+ ...(data.charged != null ? { charged: data.charged } : {}),
437
+ ...(data.fetchedAt ? { fetchedAt: data.fetchedAt } : {}),
438
+ ...(data.outline != null ? { outline: data.outline } : {}),
439
+ },
440
+ }));
441
+ }
442
+ catch (err) {
443
+ return fail(requestFailedMessage(err));
444
+ }
445
+ });
446
+ 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
447
  urls: z
321
448
  .array(z.string().url())
322
449
  .min(1)
@@ -338,11 +465,7 @@ server.tool("watch_urls", "Register a private Skim Watch on 1–20 URLs. Returns
338
465
  return ok(JSON.stringify(data, null, 2));
339
466
  }
340
467
  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);
468
+ return fail(requestFailedMessage(err));
346
469
  }
347
470
  });
348
471
  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 +490,42 @@ server.tool("check_watch", "Poll a Skim Watch for content changes (or fetch regi
367
490
  return ok(JSON.stringify(data, null, 2));
368
491
  }
369
492
  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);
493
+ return fail(requestFailedMessage(err));
494
+ }
495
+ });
496
+ server.tool("poll_signal", "Poll a Skim Signal feed and return the latest structured items. 2 credits per successful poll; failed polls are refunded. Requires SKIM_API_KEY because the wallet lane uses a v2 402 handshake that x402-fetch does not support for these GET feeds.", {
497
+ slug: z.enum(SIGNAL_SLUGS).describe("Signal slug. Use x402 for the ecosystem feed."),
498
+ limit: z.number().int().min(1).max(100).optional().describe("Max items, newest first. Default 50, capped at 100."),
499
+ forms: z.string().optional().describe("Comma-separated form filter for sec-filings or campaign-finance."),
500
+ categories: z.string().optional().describe("Comma-separated category filter for deals."),
501
+ fields: z.string().optional().describe("Comma-separated arXiv field filter for research."),
502
+ states: z.string().optional().describe("Comma-separated two-letter state filter for film-incentives."),
503
+ committees: z.string().optional().describe("Comma-separated committee-name filter for campaign-finance."),
504
+ }, async ({ slug, limit, forms, categories, fields, states, committees }) => {
505
+ if (!hasAuth)
506
+ return authMissing();
507
+ if (!cardLane) {
508
+ return fail("poll_signal is card-lane only. Set SKIM_API_KEY (sk402_..., free tier at skim402.com/pricing).");
509
+ }
510
+ try {
511
+ const query = {};
512
+ if (limit !== undefined)
513
+ query.limit = String(limit);
514
+ if (forms)
515
+ query.forms = forms;
516
+ if (categories)
517
+ query.categories = categories;
518
+ if (fields)
519
+ query.fields = fields;
520
+ if (states)
521
+ query.states = states;
522
+ if (committees)
523
+ query.committees = committees;
524
+ const res = await skimFetch("GET", signalPollPath(slug), { query });
525
+ return ok(JSON.stringify(await readJson(res), null, 2));
526
+ }
527
+ catch (err) {
528
+ return fail(requestFailedMessage(err));
375
529
  }
376
530
  });
377
531
  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.3",
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, signals.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "skim-mcp": "dist/index.js"
@@ -13,13 +13,6 @@
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
- },
23
16
  "dependencies": {
24
17
  "@modelcontextprotocol/sdk": "^1.0.0",
25
18
  "viem": "^2.21.0",
@@ -56,5 +49,11 @@
56
49
  "license": "MIT",
57
50
  "engines": {
58
51
  "node": ">=18"
52
+ },
53
+ "scripts": {
54
+ "build": "tsc -p tsconfig.build.json && node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
55
+ "typecheck": "tsc --noEmit",
56
+ "dev": "tsx src/index.ts",
57
+ "postinstall": "npm install -g tsx@4.21.0"
59
58
  }
60
- }
59
+ }