@neuctra/notlix-mcp 0.0.0-stage → 1.0.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 +63 -2
- package/bin/notlix-mcp.js +29 -0
- package/package.json +28 -4
- package/src/connection.js +15 -0
- package/src/http.js +53 -0
- package/src/inbox.js +60 -0
- package/src/index.js +4 -0
- package/src/seal.js +32 -0
- package/src/server.js +141 -0
package/README.md
CHANGED
|
@@ -1,3 +1,64 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @neuctra/notlix-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Local MCP server for Neuctra Notlix. It lets Claude Code, Claude Desktop, Cursor and other MCP tools
|
|
4
|
+
save to a Notlix account. It runs over stdio on the user's computer.
|
|
5
|
+
|
|
6
|
+
Two tools:
|
|
7
|
+
|
|
8
|
+
- `save_to_notlix`: adds a Knowledge item, Document, Presentation or Note.
|
|
9
|
+
- `edit_in_notlix`: queues find-and-replace edits or appended text for one item the user turned
|
|
10
|
+
**Edit with AI** on for, addressed by a reference such as `ntx-doc-<16 hex>`.
|
|
11
|
+
|
|
12
|
+
Each item is encrypted here, to the user's public key, before it is sent. The Notlix server only
|
|
13
|
+
stores ciphertext, and only the user's browser can open it. The token is write-only: it can't read,
|
|
14
|
+
search or delete anything.
|
|
15
|
+
|
|
16
|
+
## Setup
|
|
17
|
+
|
|
18
|
+
In Notlix, open **Connect AI** and copy the connection token (`<id>.<secret>`).
|
|
19
|
+
|
|
20
|
+
Claude Code:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
claude mcp add notlix -e NOTLIX_TOKEN=<token> -- npx -y @neuctra/notlix-mcp
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Claude Desktop, Cursor and others (`mcp.json`):
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"mcpServers": {
|
|
31
|
+
"notlix": {
|
|
32
|
+
"command": "npx",
|
|
33
|
+
"args": ["-y", "@neuctra/notlix-mcp"],
|
|
34
|
+
"env": { "NOTLIX_TOKEN": "<token>" }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
| Variable | |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `NOTLIX_TOKEN` | Required. The connection token. An old URL ending in `/mcp/<id>.<secret>` works too. |
|
|
43
|
+
| `NOTLIX_API_URL` | Optional. The Notlix server. Defaults to `https://server.notlix.neuctra.com`. |
|
|
44
|
+
|
|
45
|
+
Requires Node.js 20 or later.
|
|
46
|
+
|
|
47
|
+
## As a library
|
|
48
|
+
|
|
49
|
+
The remote Worker (`neuctra-notlix-mcp-http`) uses this package, so the tools exist in one place:
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
import { createInboxClient, createNotlixServer, parseConnection } from "@neuctra/notlix-mcp";
|
|
53
|
+
import { handleMcpRequest } from "@neuctra/notlix-mcp/http"; // Streamable HTTP, web-standard Request/Response
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm install
|
|
60
|
+
npm test # runs the tools against the real neuctra-notlix-server app with fake bindings
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The tests import `../neuctra-notlix-server` and `../neuctra-notlix-client`, so run `npm install` in
|
|
64
|
+
the server folder first.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Local MCP server for Notlix over stdio. Configure it with:
|
|
3
|
+
// NOTLIX_TOKEN the connection token from Notlix, Connect AI (required)
|
|
4
|
+
// NOTLIX_API_URL the Notlix server (optional, defaults to production)
|
|
5
|
+
// The token can also be passed as the first argument.
|
|
6
|
+
// stdout carries the MCP protocol, so messages go to stderr.
|
|
7
|
+
|
|
8
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
|
+
|
|
10
|
+
import { DEFAULT_API_URL, createInboxClient, createNotlixServer, parseConnection } from "../src/index.js";
|
|
11
|
+
|
|
12
|
+
const token = process.env.NOTLIX_TOKEN || process.argv[2];
|
|
13
|
+
const connection = parseConnection(token);
|
|
14
|
+
if (!connection) {
|
|
15
|
+
console.error(
|
|
16
|
+
token
|
|
17
|
+
? "notlix-mcp: NOTLIX_TOKEN isn't a Notlix connection token. Copy it again from Notlix, Connect AI."
|
|
18
|
+
: "notlix-mcp: set NOTLIX_TOKEN to the connection token from Notlix, Connect AI.",
|
|
19
|
+
);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const apiUrl = process.env.NOTLIX_API_URL || DEFAULT_API_URL;
|
|
24
|
+
const inbox = createInboxClient({ apiUrl, ...connection });
|
|
25
|
+
const server = createNotlixServer(inbox);
|
|
26
|
+
await server.connect(new StdioServerTransport());
|
|
27
|
+
|
|
28
|
+
// A bad token shouldn't stop the tools from listing, but say so early.
|
|
29
|
+
inbox.getPublicKey().catch((err) => console.error(`notlix-mcp: ${err.message}`));
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neuctra/notlix-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for Neuctra Notlix: lets Claude Code, Claude Desktop, Cursor and other MCP tools save to Notlix. Encrypts on your machine.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"notlix-mcp": "bin/notlix-mcp.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js",
|
|
11
|
+
"./http": "./src/http.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"src",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"start": "node bin/notlix-mcp.js",
|
|
20
|
+
"test": "node --test \"test/*.test.js\""
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
27
|
+
"zod": "^4.6.7"
|
|
28
|
+
},
|
|
29
|
+
"license": "UNLICENSED"
|
|
30
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The connection token the Notlix dashboard gives out under Connect AI:
|
|
2
|
+
// `<inbox id>.<write secret>`. Older connections handed out a full URL ending
|
|
3
|
+
// in /mcp/<id>.<secret>; that works too.
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_API_URL = "https://server.notlix.neuctra.com";
|
|
6
|
+
|
|
7
|
+
const TOKEN = /^([0-9a-f]{32})\.([0-9a-f]{64})$/;
|
|
8
|
+
|
|
9
|
+
/** `{ id, secret }` from a token or an old MCP URL, or null when it isn't one. */
|
|
10
|
+
export function parseConnection(value) {
|
|
11
|
+
const text = String(value || "").trim();
|
|
12
|
+
const token = text.includes("/mcp/") ? text.split("/mcp/").pop().split(/[/?#]/)[0] : text;
|
|
13
|
+
const match = TOKEN.exec(token);
|
|
14
|
+
return match ? { id: match[1], secret: match[2] } : null;
|
|
15
|
+
}
|
package/src/http.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
2
|
+
|
|
3
|
+
import { parseConnection } from "./connection.js";
|
|
4
|
+
import { InboxError, createInboxClient } from "./inbox.js";
|
|
5
|
+
import { createNotlixServer } from "./server.js";
|
|
6
|
+
|
|
7
|
+
// Remote MCP over Streamable HTTP, with web-standard Request/Response so it runs
|
|
8
|
+
// on Cloudflare Workers (neuctra-notlix-mcp-http). The token is part of the URL,
|
|
9
|
+
// /mcp/<id>.<secret>, because a URL is the one thing every MCP client can be given.
|
|
10
|
+
|
|
11
|
+
const json = (status, body) =>
|
|
12
|
+
new Response(JSON.stringify(body), {
|
|
13
|
+
status,
|
|
14
|
+
headers: { "Content-Type": "application/json", "X-Content-Type-Options": "nosniff" },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const invalid = () =>
|
|
18
|
+
json(401, { error: { code: "INVALID_TOKEN", message: "This connection isn't valid. Reconnect from Notlix." } });
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Answers one MCP request for `token`. Stateless: a new server and transport
|
|
22
|
+
* each time. `apiUrl` is the Notlix server; `fetch` is optional (a service binding).
|
|
23
|
+
*/
|
|
24
|
+
export async function handleMcpRequest(request, { token, apiUrl, fetch }) {
|
|
25
|
+
const connection = parseConnection(token);
|
|
26
|
+
if (!connection) return invalid();
|
|
27
|
+
|
|
28
|
+
const inbox = createInboxClient({ apiUrl, ...connection, fetch });
|
|
29
|
+
try {
|
|
30
|
+
// Checks the token, so a wrong URL fails when it's added, not on first save.
|
|
31
|
+
await inbox.getPublicKey();
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (err instanceof InboxError && err.status === 401) return invalid();
|
|
34
|
+
if (err instanceof InboxError && err.status === 429) {
|
|
35
|
+
return json(429, { error: { code: "RATE_LIMITED", message: err.message } });
|
|
36
|
+
}
|
|
37
|
+
return json(502, { error: { code: "UPSTREAM", message: err.message } });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const server = createNotlixServer(inbox);
|
|
41
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
42
|
+
sessionIdGenerator: undefined,
|
|
43
|
+
enableJsonResponse: true,
|
|
44
|
+
});
|
|
45
|
+
await server.connect(transport);
|
|
46
|
+
try {
|
|
47
|
+
return await transport.handleRequest(request);
|
|
48
|
+
} finally {
|
|
49
|
+
// JSON responses are complete once returned.
|
|
50
|
+
transport.close();
|
|
51
|
+
server.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/inbox.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { seal } from "./seal.js";
|
|
2
|
+
|
|
3
|
+
// Talks to the Notlix server's inbox API with the write token. It can fetch the
|
|
4
|
+
// inbox's public key and add encrypted items. It can't read anything.
|
|
5
|
+
|
|
6
|
+
/** An error whose message is safe to show the AI and the user. */
|
|
7
|
+
export class InboxError extends Error {
|
|
8
|
+
constructor(message, status) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "InboxError";
|
|
11
|
+
this.status = status;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const UNREACHABLE = "Couldn't reach Notlix. Check the connection and try again.";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `apiUrl`: the Notlix server. `fetch`: optional, for a Cloudflare service
|
|
19
|
+
* binding or tests. The public key is fetched once and kept.
|
|
20
|
+
*/
|
|
21
|
+
export function createInboxClient({ apiUrl, id, secret, fetch: doFetch = globalThis.fetch }) {
|
|
22
|
+
const base = String(apiUrl).replace(/\/+$/, "");
|
|
23
|
+
const headers = { Authorization: `Bearer ${secret}` };
|
|
24
|
+
let publicKey = null;
|
|
25
|
+
|
|
26
|
+
async function call(path, init = {}) {
|
|
27
|
+
let res;
|
|
28
|
+
try {
|
|
29
|
+
res = await doFetch(`${base}/v1/inboxes/${id}${path}`, {
|
|
30
|
+
...init,
|
|
31
|
+
headers: { ...headers, ...init.headers },
|
|
32
|
+
});
|
|
33
|
+
} catch {
|
|
34
|
+
throw new InboxError(UNREACHABLE, 0);
|
|
35
|
+
}
|
|
36
|
+
if (res.ok) return res.json();
|
|
37
|
+
const body = await res.json().catch(() => null);
|
|
38
|
+
if (res.status === 401) publicKey = null;
|
|
39
|
+
throw new InboxError(body?.error?.message || UNREACHABLE, res.status);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function getPublicKey() {
|
|
43
|
+
if (!publicKey) publicKey = (await call("/key")).publicKey;
|
|
44
|
+
return publicKey;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
getPublicKey,
|
|
49
|
+
/** Encrypts `item` here and adds it to the inbox. Returns the item id. */
|
|
50
|
+
async add(item) {
|
|
51
|
+
const sealed = await seal(await getPublicKey(), item);
|
|
52
|
+
const { id: itemId } = await call("/items", {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: { "Content-Type": "application/json" },
|
|
55
|
+
body: JSON.stringify(sealed),
|
|
56
|
+
});
|
|
57
|
+
return itemId;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
package/src/index.js
ADDED
package/src/seal.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Encrypts an item to the inbox's public key (ECDH P-256 + AES-GCM, a new key
|
|
2
|
+
// pair per item). Only the user's browser holds the private key; it opens
|
|
3
|
+
// items with openInboxItem in the Notlix client (lib/security/inboxCrypto.js).
|
|
4
|
+
|
|
5
|
+
const EC = { name: "ECDH", namedCurve: "P-256" };
|
|
6
|
+
|
|
7
|
+
function toBase64(bytes) {
|
|
8
|
+
let binary = "";
|
|
9
|
+
for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte);
|
|
10
|
+
return btoa(binary);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Returns `{ x, y, iv, cipher }`, the shape the Notlix server stores. */
|
|
14
|
+
export async function seal(publicKey, item) {
|
|
15
|
+
const recipient = await crypto.subtle.importKey("jwk", publicKey, EC, false, []);
|
|
16
|
+
const ephemeral = await crypto.subtle.generateKey(EC, true, ["deriveKey"]);
|
|
17
|
+
const key = await crypto.subtle.deriveKey(
|
|
18
|
+
{ name: "ECDH", public: recipient },
|
|
19
|
+
ephemeral.privateKey,
|
|
20
|
+
{ name: "AES-GCM", length: 256 },
|
|
21
|
+
false,
|
|
22
|
+
["encrypt"],
|
|
23
|
+
);
|
|
24
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
25
|
+
const cipher = await crypto.subtle.encrypt(
|
|
26
|
+
{ name: "AES-GCM", iv },
|
|
27
|
+
key,
|
|
28
|
+
new TextEncoder().encode(JSON.stringify(item)),
|
|
29
|
+
);
|
|
30
|
+
const { x, y } = await crypto.subtle.exportKey("jwk", ephemeral.publicKey);
|
|
31
|
+
return { x, y, iv: toBase64(iv), cipher: toBase64(cipher) };
|
|
32
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import { InboxError } from "./inbox.js";
|
|
5
|
+
|
|
6
|
+
// The Notlix MCP tools. Used by the local stdio command (bin/notlix-mcp.js) and
|
|
7
|
+
// by the remote Worker (neuctra-notlix-mcp-http). Items are encrypted here,
|
|
8
|
+
// before they leave, so the Notlix server never sees the text. The write token
|
|
9
|
+
// can add items and queue text edits for items the user turned AI editing on
|
|
10
|
+
// for. It can't read anything.
|
|
11
|
+
|
|
12
|
+
export const VERSION = "1.0.0";
|
|
13
|
+
|
|
14
|
+
const TOOL_DESCRIPTION = `Save something to the user's Neuctra Notlix account so it isn't lost when this chat ends.
|
|
15
|
+
|
|
16
|
+
Use this when the user asks you to save, remember or store something in Notlix. Write a clear title and a self-contained document that makes sense without this chat. Use type "knowledge" (markdown) for reference material, decisions and project context, type "document" (markdown) for finished, formatted writing the user will read, edit or export, such as reports, proposals and letters, type "presentation" for slide decks, and type "note" (plain text) for quick notes.
|
|
17
|
+
|
|
18
|
+
For a presentation, write markdown with --- on its own line between slides. Start each slide with a # heading, follow it with short - bullets, and add a line starting with Notes: at the end of a slide for speaker notes. Keep each slide to about six short bullets. Images are not supported.
|
|
19
|
+
|
|
20
|
+
Never save passwords, API keys, tokens or other secrets: Knowledge, Documents, Presentations and Notes are not encrypted in the account. This tool can only add new items. To change an item that already exists, use edit_in_notlix with the reference the user gives you. Nothing here can read or search Notlix.`;
|
|
21
|
+
|
|
22
|
+
const EDIT_DESCRIPTION = `Edit an item in the user's Neuctra Notlix account that the user chose to share with you: fix typos, reword, or add more content at the end, such as new research.
|
|
23
|
+
|
|
24
|
+
You can only edit an item the user gave you a Notlix reference for. It looks like ntx-doc-3f9a1c2b7d4e5f60, and the user copies it from Notlix together with the item's current text. Without a reference, ask the user to open the item in Notlix, choose "Edit with AI" and paste what it copies. Never make up a reference: edits with any other reference are refused.
|
|
25
|
+
|
|
26
|
+
- edits: each find is copied exactly from the item's current text and replaced with replace. Every occurrence is replaced, so include enough surrounding words to match only the place you mean. For documents, copy the words as they read, without markdown symbols such as ** or #, and keep each find inside one paragraph and one style (a find can't cross bold or a link). For presentations and diagrams, keep each find inside one bullet, table cell, notes field, shape or connector label. replace is plain text.
|
|
27
|
+
- append: new content added at the end. Markdown for knowledge (ntx-kn) and documents (ntx-doc), plain text for notes (ntx-note), and for presentations (ntx-ppt) new slides written as markdown with --- between slides, a # title and - bullets. Diagrams (ntx-dgm) can't take appended content.
|
|
28
|
+
|
|
29
|
+
You can't delete items, rename them, or edit the encrypted vault. Edits are applied the next time the user opens Notlix. If a find doesn't match, that edit is skipped and the user is told, so you won't see an error here.`;
|
|
30
|
+
|
|
31
|
+
// Matches newAiEditRef in the client. The prefix says what kind of item it is.
|
|
32
|
+
const EDIT_REF = /^ntx-(kn|doc|ppt|dgm|note)-[0-9a-f]{16}$/;
|
|
33
|
+
|
|
34
|
+
const DESTINATIONS = {
|
|
35
|
+
knowledge: "Knowledge",
|
|
36
|
+
document: "Documents",
|
|
37
|
+
presentation: "Presentations",
|
|
38
|
+
note: "Notes",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Sends an item. Returns a tool error result when it couldn't, or null. */
|
|
42
|
+
async function send(inbox, item) {
|
|
43
|
+
try {
|
|
44
|
+
await inbox.add(item);
|
|
45
|
+
return null;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (!(err instanceof InboxError)) throw err;
|
|
48
|
+
return { isError: true, content: [{ type: "text", text: err.message }] };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** An MCP server with save_to_notlix and edit_in_notlix. `inbox` comes from createInboxClient. */
|
|
53
|
+
export function createNotlixServer(inbox) {
|
|
54
|
+
const server = new McpServer({ name: "neuctra-notlix", version: VERSION });
|
|
55
|
+
|
|
56
|
+
server.registerTool(
|
|
57
|
+
"save_to_notlix",
|
|
58
|
+
{
|
|
59
|
+
title: "Save to Notlix",
|
|
60
|
+
description: TOOL_DESCRIPTION,
|
|
61
|
+
inputSchema: {
|
|
62
|
+
title: z.string().trim().min(1).max(200).describe("Short, descriptive title."),
|
|
63
|
+
content: z.string().min(1).max(200_000).describe("The full text to save."),
|
|
64
|
+
type: z
|
|
65
|
+
.enum(["knowledge", "document", "presentation", "note"])
|
|
66
|
+
.default("knowledge")
|
|
67
|
+
.describe('"knowledge" for markdown reference material, "document" for formatted markdown writing, "presentation" for slides written as markdown split by ---, "note" for plain text.'),
|
|
68
|
+
},
|
|
69
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
70
|
+
},
|
|
71
|
+
async ({ title, content, type }) => {
|
|
72
|
+
const failed = await send(inbox, { title, content, type });
|
|
73
|
+
if (failed) return failed;
|
|
74
|
+
return {
|
|
75
|
+
content: [
|
|
76
|
+
{
|
|
77
|
+
type: "text",
|
|
78
|
+
text: `Saved "${title}". It will appear in ${DESTINATIONS[type]} the next time the user opens Notlix.`,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
server.registerTool(
|
|
86
|
+
"edit_in_notlix",
|
|
87
|
+
{
|
|
88
|
+
title: "Edit in Notlix",
|
|
89
|
+
description: EDIT_DESCRIPTION,
|
|
90
|
+
inputSchema: {
|
|
91
|
+
ref: z
|
|
92
|
+
.string()
|
|
93
|
+
.trim()
|
|
94
|
+
.regex(EDIT_REF, "Not a Notlix reference. Ask the user to copy one with Edit with AI in Notlix.")
|
|
95
|
+
.describe("The Notlix reference the user gave you, such as ntx-doc-3f9a1c2b7d4e5f60."),
|
|
96
|
+
edits: z
|
|
97
|
+
.array(
|
|
98
|
+
z.object({
|
|
99
|
+
find: z.string().min(1).max(10_000).describe("Exact text currently in the item."),
|
|
100
|
+
replace: z.string().max(50_000).describe("The text to put in its place."),
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
.max(50)
|
|
104
|
+
.optional()
|
|
105
|
+
.describe("Find-and-replace changes, such as typo fixes or rewording."),
|
|
106
|
+
append: z.string().max(200_000).optional().describe("New content to add at the end."),
|
|
107
|
+
},
|
|
108
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
109
|
+
},
|
|
110
|
+
async ({ ref, edits = [], append = "" }) => {
|
|
111
|
+
const refuse = (text) => ({ isError: true, content: [{ type: "text", text }] });
|
|
112
|
+
|
|
113
|
+
if (edits.length === 0 && !append.trim()) {
|
|
114
|
+
return refuse("Nothing to change. Give at least one edit or some text to append.");
|
|
115
|
+
}
|
|
116
|
+
if (ref.startsWith("ntx-dgm-") && append.trim()) {
|
|
117
|
+
return refuse("Diagrams can't take appended content. Use edits to change the text in shapes and connector labels.");
|
|
118
|
+
}
|
|
119
|
+
if (edits.some(({ find, replace }) => find === replace)) {
|
|
120
|
+
return refuse("An edit's find and replace are the same, so it wouldn't change anything.");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const failed = await send(inbox, { action: "edit", ref, edits, append });
|
|
124
|
+
if (failed) return failed;
|
|
125
|
+
|
|
126
|
+
const parts = [];
|
|
127
|
+
if (edits.length) parts.push(`${edits.length} ${edits.length === 1 ? "edit" : "edits"}`);
|
|
128
|
+
if (append.trim()) parts.push("new content at the end");
|
|
129
|
+
return {
|
|
130
|
+
content: [
|
|
131
|
+
{
|
|
132
|
+
type: "text",
|
|
133
|
+
text: `Queued ${parts.join(" and ")} for ${ref}. They will be applied the next time the user opens Notlix, if that item still allows AI edits. Any edit whose text doesn't match is skipped, and the user is told.`,
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
return server;
|
|
141
|
+
}
|