nectr-mcp 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -22
- package/dist/client.js +12 -33
- package/dist/server.js +1 -1
- package/dist/tools.js +99 -155
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
# nectr-mcp
|
|
2
2
|
|
|
3
|
-
An [MCP](https://modelcontextprotocol.io) server for [nectr](https://nectr.ch),
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server for [nectr](https://nectr.ch), which shows how AI reads a web page.
|
|
4
4
|
|
|
5
|
-
It
|
|
5
|
+
It gives a model one tool, `scan_page`. Given a public page's address, it returns the page as clean markdown and what an AI agent gets from it: the size in words and estimated tokens, which AI crawlers the site's robots.txt allows, linked PDFs and whether they hold text, and the text that only appears after JavaScript runs.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- **Private pages:** pages you haven't shared have no working public link, but your key can still read them.
|
|
9
|
-
- **Saving:** "save these docs and keep them current" becomes one step, and nectr re-crawls them on your plan's schedule.
|
|
10
|
-
- **Freshness:** every read says when the page was last fetched.
|
|
7
|
+
> **Coming from 0.2?** The library tools (`search_library`, `get_page`, `convert_url`) are gone. Your existing `nct_` key works with `scan_page`.
|
|
11
8
|
|
|
12
9
|
## Setup
|
|
13
10
|
|
|
@@ -28,7 +25,7 @@ It lets a model **search** your library, **read** any saved page as clean markdo
|
|
|
28
25
|
}
|
|
29
26
|
```
|
|
30
27
|
|
|
31
|
-
3. Restart the client. The
|
|
28
|
+
3. Restart the client. The `scan_page` tool should appear.
|
|
32
29
|
|
|
33
30
|
Node.js 20 or newer is required.
|
|
34
31
|
|
|
@@ -37,30 +34,33 @@ Node.js 20 or newer is required.
|
|
|
37
34
|
| `NECTR_API_KEY` | yes | — | Your `nct_` API key |
|
|
38
35
|
| `NECTR_URL` | no | `https://nectr.ch` | The nectr site to talk to, for a self-hosted instance |
|
|
39
36
|
|
|
40
|
-
##
|
|
37
|
+
## The tool
|
|
41
38
|
|
|
42
|
-
|
|
43
|
-
|---|---|---|
|
|
44
|
-
| `search_library(query?, limit?)` | Finds saved pages by title or URL. Returns ids, URLs, freshness and a two-sentence summary per page, but **not** the bodies. Leave out `query` to list the most recently refreshed pages. | no |
|
|
45
|
-
| `get_page(id \| url, max_chars?)` | Returns the clean markdown of one page, with when it was last fetched. Long pages are cut off at 40,000 characters by default; ask for more with `max_chars`. | no |
|
|
46
|
-
| `convert_url(url, project_id?, force_refresh?)` | Saves a web page, PDF or DOCX to your library, optionally filed under a project. Counts against your monthly conversion quota unless the page is already saved. | yes |
|
|
47
|
-
| `list_projects()` | Lists your projects with page counts and each project's public `llms.txt` URL. | no |
|
|
39
|
+
`scan_page(url, markdown?, max_chars?)` fetches the page twice, with a browser and as plain HTTP, the way AI crawlers do, and returns:
|
|
48
40
|
|
|
49
|
-
|
|
41
|
+
| Section | What it says |
|
|
42
|
+
|---|---|
|
|
43
|
+
| Size | Words and estimated tokens of the whole page as markdown, against the raw HTML an agent receives, and the words left without JavaScript |
|
|
44
|
+
| robots.txt | Whether GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, PerplexityBot, Google-Extended and CCBot may fetch the page |
|
|
45
|
+
| Linked PDFs | Up to 3 PDFs on the same site: pages, words, and whether there's a text layer at all |
|
|
46
|
+
| Only with JavaScript | The share of words an agent misses without JavaScript, and the first 10 of those blocks |
|
|
47
|
+
| Markdown | The page as markdown, last. `markdown` picks `"full"` (after JavaScript runs, the default), `"without_javascript"` or `"none"`. It's cut at 40,000 characters unless you raise `max_chars` |
|
|
48
|
+
|
|
49
|
+
The tool is read-only: nectr stores nothing from a scan. nectr honours the site's robots.txt for itself, so a page whose robots.txt keeps nectr out isn't fetched.
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
The full response, field by field, is in the [scan API documentation](https://nectr.ch/docs#scan).
|
|
52
52
|
|
|
53
53
|
## Limits and errors
|
|
54
54
|
|
|
55
|
-
The server uses the same developer API as any other script, so
|
|
55
|
+
The server uses the same developer API as any other script, so the API's limits apply: 10 scans a minute from one IP address. Errors are worded so a model knows whether to retry:
|
|
56
56
|
|
|
57
57
|
| nectr says | The model is told |
|
|
58
58
|
|---|---|
|
|
59
59
|
| 401: the key is invalid or revoked | Don't retry; you need a new key |
|
|
60
|
-
|
|
|
61
|
-
| 422: the page can't be
|
|
60
|
+
| 403: robots.txt keeps nectr out, or scans aren't open to the account | nectr's reason; don't retry |
|
|
61
|
+
| 422: the page can't be scanned (not public, not found, a PDF) | nectr's reason, as given |
|
|
62
62
|
| 429: rate limited | How many seconds to wait |
|
|
63
|
-
| 503:
|
|
63
|
+
| 503: nectr is busy | Retry in a few seconds |
|
|
64
64
|
|
|
65
65
|
## Security
|
|
66
66
|
|
|
@@ -68,12 +68,14 @@ The server uses the same developer API as any other script, so your plan's limit
|
|
|
68
68
|
|
|
69
69
|
- Create a dedicated key for each client, so you can revoke one without breaking the others.
|
|
70
70
|
- If a key leaks, delete it under **Settings → API keys** in nectr. It stops working immediately.
|
|
71
|
-
- This server only ever
|
|
71
|
+
- This server only ever scans. **The key itself is broader:** it has the same access to the nectr API as your account, so treat it like a password.
|
|
72
72
|
|
|
73
73
|
## Development
|
|
74
74
|
|
|
75
|
+
Running the server needs Node 20+, but **developing needs Node 22.12 or newer** (Vitest and the MCP Inspector require it). CI uses Node 24.
|
|
76
|
+
|
|
75
77
|
```bash
|
|
76
|
-
npm install
|
|
78
|
+
npm ci # not `npm install`: installs exactly the lockfile and never rewrites it
|
|
77
79
|
npm run typecheck
|
|
78
80
|
npm test
|
|
79
81
|
npm run build
|
|
@@ -82,6 +84,36 @@ NECTR_API_KEY=nct_... node dist/index.js # speaks MCP over stdio
|
|
|
82
84
|
|
|
83
85
|
The tests run against a fake nectr API (`test/fake-api.ts`) and through a real MCP client over an in-memory transport (`test/server.test.ts`).
|
|
84
86
|
|
|
87
|
+
If `npm test` fails with **"Cannot find native binding"**, the `node_modules` folder was installed on another platform, for example inside a Linux container. `npm install` on top of it won't fix it. Delete `node_modules` and run `npm ci` again.
|
|
88
|
+
|
|
89
|
+
### Trying it by hand with the MCP Inspector
|
|
90
|
+
|
|
91
|
+
The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) calls the tools directly, without a model, and shows exactly what a model would receive. Build first (`npm run build`), then use its CLI mode. **Put the server command before the options.** With the options first, the Inspector reports "No servers found in config file".
|
|
92
|
+
|
|
93
|
+
```powershell
|
|
94
|
+
# PowerShell; in bash, use $NECTR_API_KEY instead of $k
|
|
95
|
+
$k = "nct_your_key"
|
|
96
|
+
|
|
97
|
+
npx @modelcontextprotocol/inspector --cli node dist/index.js -e NECTR_API_KEY=$k --method tools/list
|
|
98
|
+
npx @modelcontextprotocol/inspector --cli node dist/index.js -e NECTR_API_KEY=$k --method tools/call --tool-name scan_page --tool-arg url=https://example.com/
|
|
99
|
+
npx @modelcontextprotocol/inspector --cli node dist/index.js -e NECTR_API_KEY=$k --method tools/call --tool-name scan_page --tool-arg url=https://example.com/ --tool-arg markdown=none
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Add `-e NECTR_URL=http://localhost:8000` to test against a local nectr backend. There, scans are open to admins only unless the backend runs with `SCANS_ENABLED=true`; other accounts get a 403. Leave out `--cli` for the web interface; the argument order is the same.
|
|
103
|
+
|
|
104
|
+
The Inspector's own dependencies need a newer Node than this server does. If it fails with **"Cannot find native binding"** after you upgrade Node, delete its cached copy from the npx cache (`%LocalAppData%\npm-cache\_npx` on Windows, `~/.npm/_npx` elsewhere) and run it again.
|
|
105
|
+
|
|
106
|
+
The quickest sanity check needs no key at all: `npx -y nectr-mcp` should print "NECTR_API_KEY is not set" and exit.
|
|
107
|
+
|
|
108
|
+
### Publishing
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npm version patch # or minor / major; also bump VERSION in src/server.ts and USER_AGENT in src/client.ts
|
|
112
|
+
npm publish # prepublishOnly runs typecheck, tests and build first
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
npm refuses to publish from an account without two-factor authentication (error `E403`). Enable it under your npm account settings; `npm publish` then asks you to confirm in the browser or with `--otp=<code>`.
|
|
116
|
+
|
|
85
117
|
## License
|
|
86
118
|
|
|
87
119
|
MIT
|
package/dist/client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A thin client over the public nectr API (`/api/v1`), authenticated with an
|
|
3
|
-
* `nct_` API key.
|
|
4
|
-
*
|
|
3
|
+
* `nct_` API key. It uses the scan endpoint the developer API documents
|
|
4
|
+
* (nectr.ch/docs#scan); this package adds no new server-side surface.
|
|
5
5
|
*/
|
|
6
6
|
/** An HTTP error from nectr, carrying what a caller needs to explain it. */
|
|
7
7
|
export class NectrApiError extends Error {
|
|
@@ -25,8 +25,10 @@ export class NectrUnreachableError extends Error {
|
|
|
25
25
|
this.name = "NectrUnreachableError";
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
-
const USER_AGENT = "nectr-mcp/0.
|
|
29
|
-
|
|
28
|
+
const USER_AGENT = "nectr-mcp/0.3.0";
|
|
29
|
+
// A scan fetches the page twice (a browser and plain HTTP) and checks linked
|
|
30
|
+
// PDFs, so it can take a while.
|
|
31
|
+
const TIMEOUT_MS = 90_000;
|
|
30
32
|
export class NectrClient {
|
|
31
33
|
apiKey;
|
|
32
34
|
siteUrl;
|
|
@@ -38,28 +40,8 @@ export class NectrClient {
|
|
|
38
40
|
this.fetchImpl = fetchImpl;
|
|
39
41
|
this.api = `${siteUrl}/api/v1`;
|
|
40
42
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (query)
|
|
44
|
-
params.set("search", query);
|
|
45
|
-
return this.request("GET", `/conversions?${params}`);
|
|
46
|
-
}
|
|
47
|
-
getPage(id) {
|
|
48
|
-
return this.request("GET", `/conversions/${encodeURIComponent(id)}`);
|
|
49
|
-
}
|
|
50
|
-
convert(url, forceRefresh) {
|
|
51
|
-
return this.request("POST", "/conversions", { url, force_refresh: forceRefresh });
|
|
52
|
-
}
|
|
53
|
-
assignProject(id, projectId) {
|
|
54
|
-
return this.request("PATCH", `/conversions/${encodeURIComponent(id)}/project`, {
|
|
55
|
-
project_id: projectId,
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
listProjects() {
|
|
59
|
-
return this.request("GET", "/projects");
|
|
60
|
-
}
|
|
61
|
-
me() {
|
|
62
|
-
return this.request("GET", "/auth/me");
|
|
43
|
+
scan(url) {
|
|
44
|
+
return this.request("POST", "/scans", { url });
|
|
63
45
|
}
|
|
64
46
|
async request(method, path, body) {
|
|
65
47
|
let res;
|
|
@@ -129,18 +111,15 @@ export function describeError(err) {
|
|
|
129
111
|
switch (err.status) {
|
|
130
112
|
case 401:
|
|
131
113
|
return "The nectr API key was rejected — it is invalid or has been revoked. Do not retry; the user needs to create a new key in nectr under Settings → API keys.";
|
|
132
|
-
case 402:
|
|
133
|
-
return `Over the nectr plan's quota: ${err.detail} Do not retry until the quota resets or the plan is upgraded.`;
|
|
134
114
|
case 403:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return "Not found in this nectr library. It may have been deleted, or the id is wrong.";
|
|
115
|
+
// A robots.txt that keeps nectr out, or scans closed to this account.
|
|
116
|
+
return `nectr will not scan this page: ${err.detail} Do not retry.`;
|
|
138
117
|
case 422:
|
|
139
|
-
return `nectr could not
|
|
118
|
+
return `nectr could not scan this page: ${err.detail}`;
|
|
140
119
|
case 429:
|
|
141
120
|
return `Rate limited by nectr. Wait ${wait ?? 60} seconds before trying again.`;
|
|
142
121
|
case 503:
|
|
143
|
-
return `nectr is busy
|
|
122
|
+
return `nectr is busy scanning other pages. Retry in ${wait ?? 5} seconds.`;
|
|
144
123
|
default:
|
|
145
124
|
return err.status >= 500
|
|
146
125
|
? `nectr had a server error (${err.status}). Try again later.`
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { registerTools } from "./tools.js";
|
|
3
|
-
export const VERSION = "0.
|
|
3
|
+
export const VERSION = "0.3.0";
|
|
4
4
|
export function createServer(client) {
|
|
5
5
|
const server = new McpServer({ name: "nectr", version: VERSION });
|
|
6
6
|
registerTools(server, client);
|
package/dist/tools.js
CHANGED
|
@@ -1,135 +1,104 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { describeError } from "./client.js";
|
|
3
|
-
/** Default cap on
|
|
3
|
+
/** Default cap on the markdown returned to a model: roughly 10k tokens. */
|
|
4
4
|
export const DEFAULT_MAX_CHARS = 40_000;
|
|
5
|
-
|
|
6
|
-
const
|
|
5
|
+
/** How many "only with JavaScript" blocks to list; nectr returns up to 50. */
|
|
6
|
+
export const JS_BLOCKS_SHOWN = 10;
|
|
7
|
+
export const MARKDOWN_CHOICES = ["full", "without_javascript", "none"];
|
|
8
|
+
const plural = (n, word) => `${n.toLocaleString("en")} ${word}${n === 1 ? "" : "s"}`;
|
|
7
9
|
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
8
10
|
const failure = (err) => ({
|
|
9
11
|
content: [{ type: "text", text: describeError(err) }],
|
|
10
12
|
isError: true,
|
|
11
13
|
});
|
|
12
|
-
function
|
|
13
|
-
return
|
|
14
|
+
function size(scan) {
|
|
15
|
+
return [
|
|
16
|
+
"## Size",
|
|
17
|
+
`- The whole page as markdown (after JavaScript runs): ${plural(scan.rendered_words, "word")}, ≈ ${plural(scan.markdown_tokens, "token")}.`,
|
|
18
|
+
`- The raw HTML an agent receives when it fetches the URL: ≈ ${plural(scan.html_tokens, "token")}.`,
|
|
19
|
+
`- Without JavaScript: ${plural(scan.plain_words, "word")} as markdown.`,
|
|
20
|
+
];
|
|
14
21
|
}
|
|
15
|
-
function
|
|
16
|
-
if (
|
|
17
|
-
return "";
|
|
18
|
-
if (status === "unreadable")
|
|
19
|
-
return " · UNREADABLE (login wall or placeholder; no content)";
|
|
20
|
-
return ` · ${status.toUpperCase()} (no content)`;
|
|
21
|
-
}
|
|
22
|
-
/** Compare URLs the way a person would: scheme/host case, fragment and trailing slash ignored. */
|
|
23
|
-
export function sameUrl(a, b) {
|
|
24
|
-
const norm = (u) => {
|
|
25
|
-
try {
|
|
26
|
-
const p = new URL(u.trim());
|
|
27
|
-
p.hash = "";
|
|
28
|
-
return `${p.protocol}//${p.host}${p.pathname.replace(/\/+$/, "")}${p.search}`.toLowerCase();
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
return u.trim().replace(/\/+$/, "").toLowerCase();
|
|
32
|
-
}
|
|
33
|
-
};
|
|
34
|
-
return norm(a) === norm(b);
|
|
35
|
-
}
|
|
36
|
-
// ── Handlers (exported for tests) ───────────────────────────────────────────
|
|
37
|
-
export async function searchLibrary(client, args) {
|
|
38
|
-
const query = args.query?.trim() || undefined;
|
|
39
|
-
const limit = Math.min(Math.max(args.limit ?? 10, 1), SEARCH_MAX);
|
|
40
|
-
try {
|
|
41
|
-
const list = await client.search(query, limit);
|
|
42
|
-
if (list.items.length === 0) {
|
|
43
|
-
return text(query
|
|
44
|
-
? `No saved pages match "${query}". Use convert_url to add one.`
|
|
45
|
-
: "The library is empty. Use convert_url to add a page.");
|
|
46
|
-
}
|
|
47
|
-
const heading = query
|
|
48
|
-
? `${list.items.length} of ${plural(list.total, "saved page")} matching "${query}":`
|
|
49
|
-
: `${list.items.length} most recently refreshed of ${plural(list.total, "saved page")}:`;
|
|
50
|
-
// Summaries and ids only. Bodies are fetched deliberately with get_page:
|
|
51
|
-
// returning ten full pages here would flood the context on every search.
|
|
52
|
-
const rows = list.items.map((p, i) => [
|
|
53
|
-
`${i + 1}. ${titleOf(p)}`,
|
|
54
|
-
` id: ${p.id}`,
|
|
55
|
-
` url: ${p.url}`,
|
|
56
|
-
` fetched: ${p.fetched_at} · ${plural(p.word_count, "word")}${statusNote(p.status)}`,
|
|
57
|
-
...(p.summary ? [` summary: ${p.summary}`] : []),
|
|
58
|
-
].join("\n"));
|
|
59
|
-
return text(`${heading}\n\n${rows.join("\n\n")}\n\nRead one with get_page(id).`);
|
|
60
|
-
}
|
|
61
|
-
catch (err) {
|
|
62
|
-
return failure(err);
|
|
22
|
+
function robots(scan) {
|
|
23
|
+
if (!scan.robots_found) {
|
|
24
|
+
return ["## robots.txt", "The site has no robots.txt, so every crawler may fetch this page."];
|
|
63
25
|
}
|
|
26
|
+
return [
|
|
27
|
+
"## robots.txt",
|
|
28
|
+
...scan.robots.map((r) => `- ${r.agent}: ${r.allowed ? "allowed" : "blocked"}`),
|
|
29
|
+
];
|
|
64
30
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
id = hit.id;
|
|
82
|
-
}
|
|
83
|
-
const page = await client.getPage(id);
|
|
84
|
-
if (page.status !== "completed") {
|
|
85
|
-
return text(`"${titleOf(page)}" (${page.url}) has no readable content${statusNote(page.status)}.`);
|
|
86
|
-
}
|
|
87
|
-
const body = page.markdown_content;
|
|
88
|
-
const meta = `id: ${page.id} · url: ${page.url} · fetched: ${page.fetched_at} · ${page.word_count} words`;
|
|
89
|
-
if (body.length <= maxChars)
|
|
90
|
-
return text(`${meta}\n\n${body}`);
|
|
91
|
-
return text(`${meta}\n\n${body.slice(0, maxChars)}\n\n[Truncated: showing ${maxChars} of ${body.length} characters. ` +
|
|
92
|
-
`Call get_page again with a larger max_chars for the rest.]`);
|
|
93
|
-
}
|
|
94
|
-
catch (err) {
|
|
95
|
-
return failure(err);
|
|
31
|
+
function pdfs(scan) {
|
|
32
|
+
if (scan.pdfs.length === 0 && scan.pdfs_not_checked === 0)
|
|
33
|
+
return [];
|
|
34
|
+
const lines = scan.pdfs.map((p) => {
|
|
35
|
+
if (p.has_text === null)
|
|
36
|
+
return `- ${p.url}: not checked${p.note ? ` (${p.note})` : ""}`;
|
|
37
|
+
const facts = [
|
|
38
|
+
p.pages !== null ? plural(p.pages, "page") : null,
|
|
39
|
+
p.words !== null ? `${plural(p.words, "word")} in the first pages` : null,
|
|
40
|
+
p.has_text ? "has text" : "no text layer (a scanned image; AI reads nothing)",
|
|
41
|
+
].filter(Boolean);
|
|
42
|
+
return `- ${p.url}: ${facts.join(", ")}`;
|
|
43
|
+
});
|
|
44
|
+
if (scan.pdfs_not_checked > 0) {
|
|
45
|
+
lines.push(`- ${plural(scan.pdfs_not_checked, "more linked PDF")} not checked (past the first 3, or on another site).`);
|
|
96
46
|
}
|
|
47
|
+
return ["## Linked PDFs", ...lines];
|
|
97
48
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (args.project_id) {
|
|
103
|
-
try {
|
|
104
|
-
await client.assignProject(saved.id, args.project_id);
|
|
105
|
-
projectLine = `\nAdded to project ${args.project_id}.`;
|
|
106
|
-
}
|
|
107
|
-
catch (err) {
|
|
108
|
-
// The page IS saved; say so rather than reporting the whole call as failed.
|
|
109
|
-
projectLine = `\nSaved, but could not add it to project ${args.project_id}: ${describeError(err)}`;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
const how = saved.cached ? "Already in the library (served the saved copy)" : "Saved";
|
|
113
|
-
return text(`${how}: ${titleOf(saved)}\n` +
|
|
114
|
-
`id: ${saved.id} · ${saved.word_count} words · fetched: ${saved.fetched_at}` +
|
|
115
|
-
(saved.summary ? `\nsummary: ${saved.summary}` : "") +
|
|
116
|
-
projectLine +
|
|
117
|
-
`\n\nnectr keeps this page current on the plan's refresh schedule. Read it with get_page("${saved.id}").`);
|
|
49
|
+
function onlyWithJavaScript(scan) {
|
|
50
|
+
const blocks = scan.only_with_javascript;
|
|
51
|
+
if (blocks.length === 0) {
|
|
52
|
+
return ["## Only with JavaScript", "Nothing: an agent that doesn't run JavaScript gets the same text."];
|
|
118
53
|
}
|
|
119
|
-
|
|
120
|
-
|
|
54
|
+
const share = Math.round(scan.missing_word_share * 100);
|
|
55
|
+
const shown = blocks.slice(0, JS_BLOCKS_SHOWN).map((b) => `- ${b.replace(/\s+/g, " ").trim()}`);
|
|
56
|
+
const more = blocks.length > JS_BLOCKS_SHOWN ? [`- …and ${blocks.length - JS_BLOCKS_SHOWN} more.`] : [];
|
|
57
|
+
return [
|
|
58
|
+
"## Only with JavaScript",
|
|
59
|
+
`${share}% of the page's words appear only after JavaScript runs, in ${plural(blocks.length, "block")}. ` +
|
|
60
|
+
"An agent that doesn't run JavaScript misses them:",
|
|
61
|
+
...shown,
|
|
62
|
+
...more,
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
function markdown(scan, choice, maxChars) {
|
|
66
|
+
if (choice === "none")
|
|
67
|
+
return [];
|
|
68
|
+
if (!scan.markdown_included) {
|
|
69
|
+
return ["## Markdown", "nectr didn't include the markdown with this scan."];
|
|
121
70
|
}
|
|
71
|
+
const full = choice === "full";
|
|
72
|
+
const body = (full ? scan.rendered_markdown : scan.plain_markdown) ?? "";
|
|
73
|
+
const heading = full
|
|
74
|
+
? "## The page as markdown (after JavaScript runs)"
|
|
75
|
+
: "## The page as markdown, without JavaScript";
|
|
76
|
+
if (body.length <= maxChars)
|
|
77
|
+
return [heading, body];
|
|
78
|
+
return [
|
|
79
|
+
heading,
|
|
80
|
+
body.slice(0, maxChars),
|
|
81
|
+
`[Truncated: showing ${maxChars.toLocaleString("en")} of ${body.length.toLocaleString("en")} characters. ` +
|
|
82
|
+
"Call scan_page again with a larger max_chars for the rest.]",
|
|
83
|
+
];
|
|
122
84
|
}
|
|
123
|
-
|
|
85
|
+
// ── Handler (exported for tests) ────────────────────────────────────────────
|
|
86
|
+
export async function scanPage(client, args) {
|
|
87
|
+
const choice = args.markdown ?? "full";
|
|
88
|
+
const maxChars = Math.max(args.max_chars ?? DEFAULT_MAX_CHARS, 1_000);
|
|
124
89
|
try {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
`
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
90
|
+
const scan = await client.scan(args.url);
|
|
91
|
+
// Short findings first, the long markdown last: a body of 40,000
|
|
92
|
+
// characters at the top would push the findings out of view.
|
|
93
|
+
const sections = [
|
|
94
|
+
[`# ${scan.title || scan.url}`, `url: ${scan.url} · scanned: ${scan.scanned_at}`],
|
|
95
|
+
size(scan),
|
|
96
|
+
robots(scan),
|
|
97
|
+
pdfs(scan),
|
|
98
|
+
onlyWithJavaScript(scan),
|
|
99
|
+
markdown(scan, choice, maxChars),
|
|
100
|
+
].filter((s) => s.length > 0);
|
|
101
|
+
return text(sections.map((s) => s.join("\n")).join("\n\n"));
|
|
133
102
|
}
|
|
134
103
|
catch (err) {
|
|
135
104
|
return failure(err);
|
|
@@ -137,51 +106,26 @@ export async function listProjects(client) {
|
|
|
137
106
|
}
|
|
138
107
|
// ── Registration ────────────────────────────────────────────────────────────
|
|
139
108
|
export function registerTools(server, client) {
|
|
140
|
-
server.registerTool("
|
|
141
|
-
title: "
|
|
142
|
-
description: "
|
|
143
|
-
"
|
|
109
|
+
server.registerTool("scan_page", {
|
|
110
|
+
title: "Scan a page the way AI reads it",
|
|
111
|
+
description: "Fetch one public web page with nectr and return how AI agents see it: the page as clean markdown, " +
|
|
112
|
+
"its size in words and estimated tokens (against the raw HTML), which AI crawlers its robots.txt " +
|
|
113
|
+
"allows, linked PDFs and whether they have text, and the text that only appears after JavaScript " +
|
|
114
|
+
"runs. Nothing is stored. nectr honours the site's robots.txt for itself.",
|
|
144
115
|
inputSchema: {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
title: "Read a saved page",
|
|
152
|
-
description: "Get the clean markdown of one saved nectr page, by id (from search_library) or by its " +
|
|
153
|
-
"original URL. Includes when it was last fetched. Works for private pages too.",
|
|
154
|
-
inputSchema: {
|
|
155
|
-
id: z.string().optional().describe("Page id from search_library or convert_url."),
|
|
156
|
-
url: z.string().optional().describe("The page's original URL, if the id is not known."),
|
|
116
|
+
url: z.url().describe("The http(s) address of a public web page."),
|
|
117
|
+
markdown: z
|
|
118
|
+
.enum(MARKDOWN_CHOICES)
|
|
119
|
+
.optional()
|
|
120
|
+
.describe('Which markdown to include: "full" (after JavaScript runs, the default), ' +
|
|
121
|
+
'"without_javascript" (what an agent that runs no JavaScript gets) or "none".'),
|
|
157
122
|
max_chars: z
|
|
158
123
|
.number()
|
|
159
124
|
.int()
|
|
160
125
|
.min(1000)
|
|
161
126
|
.optional()
|
|
162
|
-
.describe(`Truncate the
|
|
163
|
-
},
|
|
164
|
-
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
165
|
-
}, (args) => getPage(client, args));
|
|
166
|
-
server.registerTool("convert_url", {
|
|
167
|
-
title: "Save a page to nectr",
|
|
168
|
-
description: "Convert a web page, PDF or DOCX to clean markdown and save it to the user's nectr library, " +
|
|
169
|
-
"where it is re-crawled automatically to stay current. Returns the new page's id and summary. " +
|
|
170
|
-
"Counts against the user's monthly conversion quota unless the page is already saved.",
|
|
171
|
-
inputSchema: {
|
|
172
|
-
url: z.url().describe("The http(s) URL to save."),
|
|
173
|
-
project_id: z.string().optional().describe("Optional project id (from list_projects) to file it under."),
|
|
174
|
-
force_refresh: z
|
|
175
|
-
.boolean()
|
|
176
|
-
.optional()
|
|
177
|
-
.describe("Re-crawl now even if the page is already saved (counts against the quota)."),
|
|
127
|
+
.describe(`Truncate the markdown after this many characters (default ${DEFAULT_MAX_CHARS}).`),
|
|
178
128
|
},
|
|
179
|
-
annotations: { readOnlyHint:
|
|
180
|
-
}, (args) =>
|
|
181
|
-
server.registerTool("list_projects", {
|
|
182
|
-
title: "List nectr projects",
|
|
183
|
-
description: "List the user's nectr projects with their page counts and public llms.txt URLs.",
|
|
184
|
-
inputSchema: {},
|
|
185
|
-
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
186
|
-
}, () => listProjects(client));
|
|
129
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
130
|
+
}, (args) => scanPage(client, args));
|
|
187
131
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nectr-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server for nectr:
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "MCP server for nectr: scan any public page the way AI reads it, from any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"nectr-mcp": "dist/index.js"
|
|
@@ -34,7 +34,9 @@
|
|
|
34
34
|
"model-context-protocol",
|
|
35
35
|
"nectr",
|
|
36
36
|
"markdown",
|
|
37
|
-
"llm"
|
|
37
|
+
"llm",
|
|
38
|
+
"ai-crawlers",
|
|
39
|
+
"robots.txt"
|
|
38
40
|
],
|
|
39
41
|
"repository": {
|
|
40
42
|
"type": "git",
|