nectr-mcp 0.1.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/LICENSE +21 -0
- package/README.md +87 -0
- package/dist/client.js +149 -0
- package/dist/config.js +20 -0
- package/dist/index.js +16 -0
- package/dist/server.js +8 -0
- package/dist/tools.js +187 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Genc Kastrati
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# nectr-mcp
|
|
2
|
+
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server for [nectr](https://nectr.ch), the markdown library for your LLMs.
|
|
4
|
+
|
|
5
|
+
It lets a model **search** your library, **read** any saved page as clean markdown, **save** new pages, and **list** your projects. You don't need to paste links into the chat.
|
|
6
|
+
|
|
7
|
+
- **Search:** a model can't guess your permalinks, but it can call `search_library("pricing page")`.
|
|
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.
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
1. In nectr, open **Settings → API keys** and create a key. It starts with `nct_`.
|
|
15
|
+
2. Add the server to your MCP client's configuration. Most desktop and editor clients use an `mcpServers` block like this:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"mcpServers": {
|
|
20
|
+
"nectr": {
|
|
21
|
+
"command": "npx",
|
|
22
|
+
"args": ["-y", "nectr-mcp"],
|
|
23
|
+
"env": {
|
|
24
|
+
"NECTR_API_KEY": "nct_your_key_here"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
3. Restart the client. The four tools below should appear.
|
|
32
|
+
|
|
33
|
+
Node.js 20 or newer is required.
|
|
34
|
+
|
|
35
|
+
| Variable | Required | Default | Purpose |
|
|
36
|
+
|---|---|---|---|
|
|
37
|
+
| `NECTR_API_KEY` | yes | — | Your `nct_` API key |
|
|
38
|
+
| `NECTR_URL` | no | `https://nectr.ch` | The nectr site to talk to, for a self-hosted instance |
|
|
39
|
+
|
|
40
|
+
## Tools
|
|
41
|
+
|
|
42
|
+
| Tool | What it does | Writes? |
|
|
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 |
|
|
48
|
+
|
|
49
|
+
There is deliberately **no delete**. A model that could delete your library by misreading a request isn't worth the convenience.
|
|
50
|
+
|
|
51
|
+
Search returns summaries rather than bodies so that looking around your library doesn't fill the model's context. It reads a full page only when it chooses to.
|
|
52
|
+
|
|
53
|
+
## Limits and errors
|
|
54
|
+
|
|
55
|
+
The server uses the same developer API as any other script, so your plan's limits apply unchanged. Errors are worded so a model knows whether to retry:
|
|
56
|
+
|
|
57
|
+
| nectr says | The model is told |
|
|
58
|
+
|---|---|
|
|
59
|
+
| 401: the key is invalid or revoked | Don't retry; you need a new key |
|
|
60
|
+
| 402: over the monthly quota | Don't retry until the quota resets |
|
|
61
|
+
| 422: the page can't be read (for example, it's behind a login) | nectr's reason, as given |
|
|
62
|
+
| 429: rate limited | How many seconds to wait |
|
|
63
|
+
| 503: the converter is busy | Retry in a few seconds |
|
|
64
|
+
|
|
65
|
+
## Security
|
|
66
|
+
|
|
67
|
+
**Your API key is stored in plain text in your MCP client's config file.** That's normal for MCP servers, but anyone who can read that file can use the key.
|
|
68
|
+
|
|
69
|
+
- Create a dedicated key for each client, so you can revoke one without breaking the others.
|
|
70
|
+
- If a key leaks, delete it under **Settings → API keys** in nectr. It stops working immediately.
|
|
71
|
+
- This server only ever reads and adds pages. **The key itself is broader:** it has the same access to the nectr API as your account, deletion included, so treat it like a password.
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm install
|
|
77
|
+
npm run typecheck
|
|
78
|
+
npm test
|
|
79
|
+
npm run build
|
|
80
|
+
NECTR_API_KEY=nct_... node dist/index.js # speaks MCP over stdio
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
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
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A thin client over the public nectr API (`/api/v1`), authenticated with an
|
|
3
|
+
* `nct_` API key. Every endpoint used here is the same one the developer API
|
|
4
|
+
* documents; this package adds no new server-side surface.
|
|
5
|
+
*/
|
|
6
|
+
/** An HTTP error from nectr, carrying what a caller needs to explain it. */
|
|
7
|
+
export class NectrApiError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
detail;
|
|
10
|
+
retryAfterSeconds;
|
|
11
|
+
constructor(status, detail, retryAfterSeconds = null) {
|
|
12
|
+
super(`nectr API ${status}: ${detail}`);
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.detail = detail;
|
|
15
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
16
|
+
this.name = "NectrApiError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** nectr could not be reached at all (DNS, TLS, connection refused, timeout). */
|
|
20
|
+
export class NectrUnreachableError extends Error {
|
|
21
|
+
siteUrl;
|
|
22
|
+
constructor(siteUrl, cause) {
|
|
23
|
+
super(`Could not reach nectr at ${siteUrl}`, { cause });
|
|
24
|
+
this.siteUrl = siteUrl;
|
|
25
|
+
this.name = "NectrUnreachableError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const USER_AGENT = "nectr-mcp/0.1.0";
|
|
29
|
+
const TIMEOUT_MS = 60_000; // a real crawl can take a while; reads are fast
|
|
30
|
+
export class NectrClient {
|
|
31
|
+
apiKey;
|
|
32
|
+
siteUrl;
|
|
33
|
+
fetchImpl;
|
|
34
|
+
api;
|
|
35
|
+
constructor(apiKey, siteUrl, fetchImpl = fetch) {
|
|
36
|
+
this.apiKey = apiKey;
|
|
37
|
+
this.siteUrl = siteUrl;
|
|
38
|
+
this.fetchImpl = fetchImpl;
|
|
39
|
+
this.api = `${siteUrl}/api/v1`;
|
|
40
|
+
}
|
|
41
|
+
search(query, limit) {
|
|
42
|
+
const params = new URLSearchParams({ page: "1", limit: String(limit) });
|
|
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");
|
|
63
|
+
}
|
|
64
|
+
async request(method, path, body) {
|
|
65
|
+
let res;
|
|
66
|
+
try {
|
|
67
|
+
res = await this.fetchImpl(`${this.api}${path}`, {
|
|
68
|
+
method,
|
|
69
|
+
headers: {
|
|
70
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
71
|
+
Accept: "application/json",
|
|
72
|
+
"User-Agent": USER_AGENT,
|
|
73
|
+
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
74
|
+
},
|
|
75
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
76
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
throw new NectrUnreachableError(this.siteUrl, err);
|
|
81
|
+
}
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
throw new NectrApiError(res.status, await readDetail(res), parseRetryAfter(res));
|
|
84
|
+
}
|
|
85
|
+
if (res.status === 204)
|
|
86
|
+
return undefined;
|
|
87
|
+
return (await res.json());
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** FastAPI puts the message in `detail`: a string, or a list of validation errors. */
|
|
91
|
+
async function readDetail(res) {
|
|
92
|
+
try {
|
|
93
|
+
const body = (await res.json());
|
|
94
|
+
if (typeof body.detail === "string")
|
|
95
|
+
return body.detail;
|
|
96
|
+
if (Array.isArray(body.detail)) {
|
|
97
|
+
return body.detail
|
|
98
|
+
.map((e) => (e && typeof e === "object" && "msg" in e ? String(e.msg) : String(e)))
|
|
99
|
+
.join("; ");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Not JSON (a proxy error page, say) — fall through to the status text.
|
|
104
|
+
}
|
|
105
|
+
return res.statusText || `HTTP ${res.status}`;
|
|
106
|
+
}
|
|
107
|
+
function parseRetryAfter(res) {
|
|
108
|
+
const raw = res.headers.get("retry-after");
|
|
109
|
+
if (!raw)
|
|
110
|
+
return null;
|
|
111
|
+
const seconds = Number(raw);
|
|
112
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Turn a failure into a sentence a model can act on.
|
|
116
|
+
*
|
|
117
|
+
* The distinction that matters is retry-or-not: a model that sees an opaque
|
|
118
|
+
* failure retries it, and retrying a quota error or a revoked key only burns
|
|
119
|
+
* the caller's rate limit.
|
|
120
|
+
*/
|
|
121
|
+
export function describeError(err) {
|
|
122
|
+
if (err instanceof NectrUnreachableError) {
|
|
123
|
+
return `Could not reach nectr at ${err.siteUrl}. Check the network or NECTR_URL; retrying immediately is unlikely to help.`;
|
|
124
|
+
}
|
|
125
|
+
if (!(err instanceof NectrApiError)) {
|
|
126
|
+
return `Unexpected error: ${err instanceof Error ? err.message : String(err)}`;
|
|
127
|
+
}
|
|
128
|
+
const wait = err.retryAfterSeconds;
|
|
129
|
+
switch (err.status) {
|
|
130
|
+
case 401:
|
|
131
|
+
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
|
+
case 403:
|
|
135
|
+
return `nectr refused this request: ${err.detail}`;
|
|
136
|
+
case 404:
|
|
137
|
+
return "Not found in this nectr library. It may have been deleted, or the id is wrong.";
|
|
138
|
+
case 422:
|
|
139
|
+
return `nectr could not convert this page: ${err.detail}`;
|
|
140
|
+
case 429:
|
|
141
|
+
return `Rate limited by nectr. Wait ${wait ?? 60} seconds before trying again.`;
|
|
142
|
+
case 503:
|
|
143
|
+
return `nectr is busy converting other pages. Retry in ${wait ?? 5} seconds.`;
|
|
144
|
+
default:
|
|
145
|
+
return err.status >= 500
|
|
146
|
+
? `nectr had a server error (${err.status}). Try again later.`
|
|
147
|
+
: `nectr returned ${err.status}: ${err.detail}`;
|
|
148
|
+
}
|
|
149
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const DEFAULT_SITE_URL = "https://nectr.ch";
|
|
2
|
+
/**
|
|
3
|
+
* Read configuration from the environment.
|
|
4
|
+
*
|
|
5
|
+
* Throws with a message meant for a person reading the MCP client's log: a
|
|
6
|
+
* server that starts without a key would fail every single tool call instead,
|
|
7
|
+
* and the model would be the one left guessing why.
|
|
8
|
+
*/
|
|
9
|
+
export function loadConfig(env = process.env) {
|
|
10
|
+
const apiKey = env.NECTR_API_KEY?.trim();
|
|
11
|
+
if (!apiKey) {
|
|
12
|
+
throw new Error("NECTR_API_KEY is not set. Create a key in nectr under Settings → API keys " +
|
|
13
|
+
"and add it to this server's env in your MCP client configuration.");
|
|
14
|
+
}
|
|
15
|
+
if (!apiKey.startsWith("nct_")) {
|
|
16
|
+
throw new Error("NECTR_API_KEY does not look like a nectr API key (they start with nct_).");
|
|
17
|
+
}
|
|
18
|
+
const siteUrl = (env.NECTR_URL?.trim() || DEFAULT_SITE_URL).replace(/\/+$/, "");
|
|
19
|
+
return { apiKey, siteUrl };
|
|
20
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { NectrClient } from "./client.js";
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
import { createServer } from "./server.js";
|
|
6
|
+
let config;
|
|
7
|
+
try {
|
|
8
|
+
config = loadConfig();
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
// stdout is the MCP channel; anything meant for a person goes to stderr.
|
|
12
|
+
console.error(`nectr-mcp: ${err instanceof Error ? err.message : String(err)}`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
const server = createServer(new NectrClient(config.apiKey, config.siteUrl));
|
|
16
|
+
await server.connect(new StdioServerTransport());
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { registerTools } from "./tools.js";
|
|
3
|
+
export const VERSION = "0.1.0";
|
|
4
|
+
export function createServer(client) {
|
|
5
|
+
const server = new McpServer({ name: "nectr", version: VERSION });
|
|
6
|
+
registerTools(server, client);
|
|
7
|
+
return server;
|
|
8
|
+
}
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { describeError } from "./client.js";
|
|
3
|
+
/** Default cap on a page body returned to a model: roughly 10k tokens. */
|
|
4
|
+
export const DEFAULT_MAX_CHARS = 40_000;
|
|
5
|
+
const SEARCH_MAX = 25;
|
|
6
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
7
|
+
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
8
|
+
const failure = (err) => ({
|
|
9
|
+
content: [{ type: "text", text: describeError(err) }],
|
|
10
|
+
isError: true,
|
|
11
|
+
});
|
|
12
|
+
function titleOf(p) {
|
|
13
|
+
return p.custom_title || p.title || p.url;
|
|
14
|
+
}
|
|
15
|
+
function statusNote(status) {
|
|
16
|
+
if (status === "completed")
|
|
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);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export async function getPage(client, args) {
|
|
66
|
+
const maxChars = Math.max(args.max_chars ?? DEFAULT_MAX_CHARS, 1_000);
|
|
67
|
+
try {
|
|
68
|
+
let id = args.id?.trim();
|
|
69
|
+
if (!id) {
|
|
70
|
+
const url = args.url?.trim();
|
|
71
|
+
if (!url)
|
|
72
|
+
return failure(new Error("Pass either id or url."));
|
|
73
|
+
// By id rather than by public link: a private page has no working
|
|
74
|
+
// public link, but its owner's key can still read it by id.
|
|
75
|
+
const term = url.replace(/^https?:\/\//i, "").replace(/\/+$/, "");
|
|
76
|
+
const list = await client.search(term, SEARCH_MAX);
|
|
77
|
+
const hit = list.items.find((p) => sameUrl(p.url, url));
|
|
78
|
+
if (!hit) {
|
|
79
|
+
return text(`No saved page for ${url}. Save it first with convert_url.`);
|
|
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);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export async function convertUrl(client, args) {
|
|
99
|
+
try {
|
|
100
|
+
const saved = await client.convert(args.url, args.force_refresh ?? false);
|
|
101
|
+
let projectLine = "";
|
|
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}").`);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
return failure(err);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export async function listProjects(client) {
|
|
124
|
+
try {
|
|
125
|
+
const [projects, me] = await Promise.all([client.listProjects(), client.me()]);
|
|
126
|
+
if (projects.length === 0)
|
|
127
|
+
return text("No projects yet. Projects are created in the nectr dashboard.");
|
|
128
|
+
const rows = projects.map((p) => `- ${p.name} (${plural(p.conversion_count, "page")})\n` +
|
|
129
|
+
` id: ${p.id}\n` +
|
|
130
|
+
` llms.txt: ${client.siteUrl}/p/${me.link_hash}/${p.slug}/llms.txt`);
|
|
131
|
+
return text(`${plural(projects.length, "project")}:\n\n${rows.join("\n")}\n\n` +
|
|
132
|
+
"Each llms.txt is public and lists only the project's shareable pages.");
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
return failure(err);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// ── Registration ────────────────────────────────────────────────────────────
|
|
139
|
+
export function registerTools(server, client) {
|
|
140
|
+
server.registerTool("search_library", {
|
|
141
|
+
title: "Search the nectr library",
|
|
142
|
+
description: "Search the user's saved nectr pages by title or URL. Returns ids, URLs, freshness and a " +
|
|
143
|
+
"short summary per page, not the page bodies. Omit query to list the most recently refreshed pages.",
|
|
144
|
+
inputSchema: {
|
|
145
|
+
query: z.string().max(500).optional().describe("Words from the title or URL. Omit to list recent pages."),
|
|
146
|
+
limit: z.number().int().min(1).max(SEARCH_MAX).optional().describe("Max results (default 10)."),
|
|
147
|
+
},
|
|
148
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
149
|
+
}, (args) => searchLibrary(client, args));
|
|
150
|
+
server.registerTool("get_page", {
|
|
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."),
|
|
157
|
+
max_chars: z
|
|
158
|
+
.number()
|
|
159
|
+
.int()
|
|
160
|
+
.min(1000)
|
|
161
|
+
.optional()
|
|
162
|
+
.describe(`Truncate the body after this many characters (default ${DEFAULT_MAX_CHARS}).`),
|
|
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)."),
|
|
178
|
+
},
|
|
179
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
180
|
+
}, (args) => convertUrl(client, 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));
|
|
187
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nectr-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for nectr: search, read and add to your nectr markdown library from any MCP client.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"nectr-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc -p tsconfig.build.json",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
25
|
+
"zod": "^4.6.5"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^24.0.0",
|
|
29
|
+
"typescript": "^7.0.2",
|
|
30
|
+
"vitest": "^5.0.1"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"mcp",
|
|
34
|
+
"model-context-protocol",
|
|
35
|
+
"nectr",
|
|
36
|
+
"markdown",
|
|
37
|
+
"llm"
|
|
38
|
+
],
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/genckastrati/nectr-mcp.git"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/genckastrati/nectr-mcp#readme",
|
|
44
|
+
"bugs": "https://github.com/genckastrati/nectr-mcp/issues",
|
|
45
|
+
"license": "MIT"
|
|
46
|
+
}
|