@neuctra/notlix-mcp 1.0.0 → 2.0.1

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/src/content.js ADDED
@@ -0,0 +1,263 @@
1
+ import { documentToReadable, editDocument, markdownToDocument } from "./documents.js";
2
+ import { editDiagram, editKnowledge, editNote, editPresentation } from "./shared/aiEdits.js";
3
+ import { DIAGRAM_SHAPES, boundsOf, createEdge, createNode, diagramText, normalizeDiagram } from "./shared/diagrams.js";
4
+ import { markdownToSlides, normalizeSlides, slidesText } from "./shared/presentations.js";
5
+
6
+ // The five content types an AI can manage, and how each is read, created and
7
+ // edited. Records are shaped exactly as the Notlix services write them
8
+ // (neuctra-notlix-client/services/*.service.js). None of these are encrypted;
9
+ // the vault (passwords, secure notes, API keys, files) is never touched.
10
+
11
+ export const TYPES = ["note", "knowledge", "document", "presentation", "diagram"];
12
+
13
+ export const TYPE_LABELS = {
14
+ note: "Notes",
15
+ knowledge: "Knowledge",
16
+ document: "Documents",
17
+ presentation: "Presentations",
18
+ diagram: "Diagrams",
19
+ };
20
+
21
+ // From NOTE_CATEGORIES and DOCUMENT_CATEGORIES in the client services.
22
+ export const NOTE_CATEGORIES = ["general", "work", "personal", "learning", "ideas", "project", "meeting", "research", "journal"];
23
+ export const DOCUMENT_CATEGORIES = ["general", "work", "personal", "project", "meeting", "research"];
24
+
25
+ export const DIAGRAM_SHAPE_IDS = DIAGRAM_SHAPES.map((shape) => shape.id);
26
+
27
+ // Authix refuses saves over about 100 KB.
28
+ export const MAX_RECORD_BYTES = 100_000;
29
+
30
+ export class ContentError extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = "ContentError";
34
+ }
35
+ }
36
+
37
+ const now = () => new Date().toISOString();
38
+
39
+ // ---- Reading -----------------------------------------------------------------
40
+
41
+ function noteText(record) {
42
+ if (typeof record.content === "string") return record.content;
43
+ return (Array.isArray(record.content) ? record.content : [])
44
+ .map((block) => (typeof block?.content === "string" ? block.content : ""))
45
+ .filter(Boolean)
46
+ .join("\n\n");
47
+ }
48
+
49
+ function slidesToReadable(slides) {
50
+ return normalizeSlides(slides)
51
+ .map((slide, index) => {
52
+ const lines = [`<!-- slide ${index + 1} -->`];
53
+ for (const element of slide.elements) {
54
+ if (element.rows) lines.push(...element.rows.map((row) => `| ${row.join(" | ")} |`));
55
+ else if (typeof element.text === "string" && element.text.trim()) {
56
+ if (element.role === "title") lines.push(`# ${element.text}`);
57
+ else if (element.bullets) lines.push(...element.text.split("\n").map((line) => `- ${line}`));
58
+ else lines.push(element.text);
59
+ }
60
+ }
61
+ if (slide.notes) lines.push(`Notes: ${slide.notes}`);
62
+ return lines.join("\n");
63
+ })
64
+ .join("\n\n---\n\n");
65
+ }
66
+
67
+ function diagramToReadable(record) {
68
+ const { nodes, edges } = normalizeDiagram(record);
69
+ const textOf = new Map(nodes.map((node) => [node.id, node.text || "(no text)"]));
70
+ const lines = ["Nodes:"];
71
+ for (const node of nodes) lines.push(`- [${node.id}] ${node.text || "(no text)"} (${node.shape})`);
72
+ lines.push("", "Connectors:");
73
+ for (const edge of edges) {
74
+ const label = edge.label ? `: ${edge.label}` : "";
75
+ lines.push(`- [${edge.from}] ${textOf.get(edge.from) ?? "?"} -> [${edge.to}] ${textOf.get(edge.to) ?? "?"}${label}`);
76
+ }
77
+ return lines.join("\n");
78
+ }
79
+
80
+ /** The item's content as text an AI can read. */
81
+ export function readableContent(type, record) {
82
+ switch (type) {
83
+ case "note":
84
+ return noteText(record);
85
+ case "knowledge":
86
+ return typeof record.content === "string" ? record.content : "";
87
+ case "document":
88
+ return documentToReadable(record.html);
89
+ case "presentation":
90
+ return slidesToReadable(record.slides);
91
+ case "diagram":
92
+ return diagramToReadable(record);
93
+ default:
94
+ return "";
95
+ }
96
+ }
97
+
98
+ /** Text to search in: the title plus whatever the item says. */
99
+ export function searchableText(type, record) {
100
+ const body =
101
+ type === "note" ? noteText(record) : type === "knowledge" ? record.content : record.text;
102
+ return `${record.title || ""} ${typeof body === "string" ? body : ""}`.toLowerCase();
103
+ }
104
+
105
+ // ---- Diagrams from a simple description -------------------------------------
106
+
107
+ const GAP_X = 220;
108
+ const GAP_Y = 160;
109
+
110
+ /**
111
+ * Lays nodes out in rows, top to bottom, following the connectors. `nodes` use
112
+ * the AI's own keys; edges name those keys (or existing node ids). Returns the
113
+ * created nodes and edges, with keys mapped to real ids.
114
+ */
115
+ function layOut(newNodes, newEdges, { existing = [], top = 0 } = {}) {
116
+ const keys = newNodes.map((node) => String(node.id));
117
+ if (new Set(keys).size !== keys.length) throw new ContentError("Each node needs its own id.");
118
+
119
+ const existingIds = new Set(existing.map((node) => node.id));
120
+ const isNew = new Set(keys);
121
+ for (const edge of newEdges) {
122
+ for (const end of [edge.from, edge.to]) {
123
+ if (!isNew.has(String(end)) && !existingIds.has(String(end))) {
124
+ throw new ContentError(`A connector names "${end}", which isn't a node id.`);
125
+ }
126
+ }
127
+ }
128
+
129
+ // Each new node's row is one below the deepest new node pointing at it.
130
+ const level = new Map(keys.map((key) => [key, 0]));
131
+ for (let pass = 0; pass < keys.length; pass += 1) {
132
+ let changed = false;
133
+ for (const { from, to } of newEdges) {
134
+ const a = String(from);
135
+ const b = String(to);
136
+ if (isNew.has(a) && isNew.has(b) && level.get(b) < level.get(a) + 1 && level.get(a) + 1 < keys.length) {
137
+ level.set(b, level.get(a) + 1);
138
+ changed = true;
139
+ }
140
+ }
141
+ if (!changed) break;
142
+ }
143
+
144
+ const rows = [];
145
+ for (const node of newNodes) {
146
+ const row = level.get(String(node.id));
147
+ (rows[row] ||= []).push(node);
148
+ }
149
+
150
+ const ids = new Map();
151
+ const nodes = [];
152
+ rows.filter(Boolean).forEach((row, rowIndex) => {
153
+ row.forEach((node, index) => {
154
+ const x = (index - (row.length - 1) / 2) * GAP_X;
155
+ const created = createNode(node.shape || "rect", x, top + rowIndex * GAP_Y, { text: String(node.text || "") });
156
+ ids.set(String(node.id), created.id);
157
+ nodes.push(created);
158
+ });
159
+ });
160
+
161
+ const edges = newEdges.map((edge) =>
162
+ createEdge(ids.get(String(edge.from)) ?? String(edge.from), ids.get(String(edge.to)) ?? String(edge.to), {
163
+ label: String(edge.label || ""),
164
+ }),
165
+ );
166
+ return { nodes, edges };
167
+ }
168
+
169
+ // ---- Creating ----------------------------------------------------------------
170
+
171
+ /** The record to save for a new item. */
172
+ export function newRecord(type, { title, content = "", category, diagram }) {
173
+ const created = now();
174
+ const base = { title: title.trim(), isPinned: false, createdAt: created, updatedAt: created };
175
+
176
+ switch (type) {
177
+ case "note":
178
+ return {
179
+ ...base,
180
+ content: content.trim() ? [{ id: `blk_${Date.now()}`, type: "text", content: content.trim() }] : [],
181
+ category: NOTE_CATEGORIES.includes(category) ? category : "general",
182
+ };
183
+ case "knowledge":
184
+ return { ...base, content };
185
+ case "document":
186
+ return {
187
+ ...base,
188
+ ...markdownToDocument(content),
189
+ category: DOCUMENT_CATEGORIES.includes(category) ? category : "general",
190
+ };
191
+ case "presentation": {
192
+ const slides = markdownToSlides(content || `# ${title}`);
193
+ return { ...base, themeId: "paper", slides, text: slidesText(slides) };
194
+ }
195
+ case "diagram": {
196
+ if (!diagram?.nodes?.length) throw new ContentError("A diagram needs at least one node in diagram.nodes.");
197
+ const { nodes, edges } = layOut(diagram.nodes, diagram.edges || []);
198
+ return { ...base, themeId: "paper", nodes, edges, text: diagramText({ nodes, edges }) };
199
+ }
200
+ default:
201
+ throw new ContentError(`Unknown type "${type}".`);
202
+ }
203
+ }
204
+
205
+ // ---- Editing -----------------------------------------------------------------
206
+
207
+ /**
208
+ * The fields to change on an existing item. `edits` are find-and-replace pairs
209
+ * on its text, `append` is added at the end, `diagram` adds nodes and
210
+ * connectors. Returns `{ changes, missed }`; missed lists finds that didn't match.
211
+ */
212
+ export function editRecord(type, record, { title, edits = [], append = "", diagram }) {
213
+ const request = { edits, append: String(append || "") };
214
+ let result;
215
+
216
+ switch (type) {
217
+ case "note":
218
+ result = editNote(record, request);
219
+ break;
220
+ case "knowledge":
221
+ result = editKnowledge(record, request);
222
+ break;
223
+ case "document":
224
+ result = editDocument(record, request);
225
+ break;
226
+ case "presentation":
227
+ result = editPresentation(record, request);
228
+ result.changes.text = slidesText(result.changes.slides);
229
+ break;
230
+ case "diagram": {
231
+ if (request.append.trim()) {
232
+ throw new ContentError("Diagrams can't take appended text. Use diagram.nodes and diagram.edges to add to one.");
233
+ }
234
+ result = editDiagram(record, request);
235
+ if (diagram?.nodes?.length || diagram?.edges?.length) {
236
+ const { nodes, edges } = result.changes;
237
+ const bounds = boundsOf(nodes);
238
+ const top = nodes.length ? bounds.y + bounds.h + GAP_Y : 0;
239
+ const added = layOut(diagram.nodes || [], diagram.edges || [], { existing: nodes, top });
240
+ result.changes.nodes = [...nodes, ...added.nodes];
241
+ result.changes.edges = [...edges, ...added.edges];
242
+ }
243
+ result.changes.text = diagramText(result.changes);
244
+ break;
245
+ }
246
+ default:
247
+ throw new ContentError(`Unknown type "${type}".`);
248
+ }
249
+
250
+ if (title !== undefined) result.changes.title = title.trim();
251
+ result.changes.updatedAt = now();
252
+ return result;
253
+ }
254
+
255
+ /** Throws when a record is too big for Authix to accept. */
256
+ export function checkSize(record) {
257
+ const bytes = new TextEncoder().encode(JSON.stringify(record)).length;
258
+ if (bytes > MAX_RECORD_BYTES) {
259
+ throw new ContentError(
260
+ `That's too big for one Notlix item (${Math.round(bytes / 1000)} KB, the limit is about 100 KB). Split it into smaller items.`,
261
+ );
262
+ }
263
+ }
@@ -0,0 +1,8 @@
1
+ // Written by scripts/sync.mjs from the website's public settings.
2
+
3
+ export default {
4
+ "authixUrl": "https://server.authix.neuctra.com/api",
5
+ "publishableKey": "pk_live_d992acbc_94a61ccb2354550f205674733b5e77bb7e3e51774d4544fd",
6
+ "appId": "7870561b328f8fb3a5beec64fbb81443",
7
+ "origin": "https://notlix.neuctra.com"
8
+ };
@@ -0,0 +1,133 @@
1
+ import { Marked } from "marked";
2
+ import { parseHTML } from "linkedom";
3
+
4
+ // Documents are stored as HTML for the Notlix document editor. The website
5
+ // converts AI markdown in the browser (cms-core + DOMPurify); there is no DOM
6
+ // here, so this builds the HTML itself and never lets raw HTML through: tags an
7
+ // AI writes are shown as text, and only http(s) and mailto links are kept.
8
+
9
+ const escapeHtml = (text) =>
10
+ String(text).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
11
+
12
+ const SAFE_URL = /^(https?:|mailto:)/i;
13
+
14
+ const marked = new Marked({
15
+ gfm: true,
16
+ renderer: {
17
+ html({ text }) {
18
+ return escapeHtml(text);
19
+ },
20
+ link({ href, title, tokens }) {
21
+ const label = this.parser.parseInline(tokens);
22
+ if (!SAFE_URL.test(String(href).trim())) return label;
23
+ const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
24
+ return `<a href="${escapeHtml(href)}"${titleAttr} target="_blank" rel="noopener noreferrer">${label}</a>`;
25
+ },
26
+ image({ href, text }) {
27
+ if (!/^https?:/i.test(String(href).trim())) return escapeHtml(text || "");
28
+ return `<img src="${escapeHtml(href)}" alt="${escapeHtml(text || "")}">`;
29
+ },
30
+ },
31
+ });
32
+
33
+ function bodyOf(html) {
34
+ return parseHTML(`<!doctype html><html><body>${html}</body></html>`).document.body;
35
+ }
36
+
37
+ /** Plain text of some HTML, for card previews and search. */
38
+ export function htmlToText(html) {
39
+ return bodyOf(String(html || "")).textContent || "";
40
+ }
41
+
42
+ /** `{ html, text }` for a document, from markdown. */
43
+ export function markdownToDocument(markdown) {
44
+ const html = marked.parse(String(markdown || ""), { async: false });
45
+ return { html, text: htmlToText(html) };
46
+ }
47
+
48
+ const BLOCKS = new Set(["P", "DIV", "SECTION", "ARTICLE", "HEADER", "FOOTER", "FIGURE", "FIGCAPTION", "UL", "OL", "TABLE", "HR"]);
49
+
50
+ /**
51
+ * A readable, markdown-like view of a document for an AI. Headings get #,
52
+ * list items get -, tables become rows of |. Bold and links are left as plain
53
+ * words, which is also how a find must be written to match.
54
+ */
55
+ export function documentToReadable(html) {
56
+ const out = [];
57
+ const walk = (node, listIndex = null) => {
58
+ if (node.nodeType === 3) {
59
+ out.push(node.nodeValue.replace(/\s+/g, " "));
60
+ return;
61
+ }
62
+ if (node.nodeType !== 1) return;
63
+ const tag = node.tagName;
64
+ const children = (index) => node.childNodes.forEach((child) => walk(child, index));
65
+
66
+ if (/^H[1-6]$/.test(tag)) {
67
+ out.push(`\n\n${"#".repeat(Number(tag[1]))} `);
68
+ children();
69
+ out.push("\n\n");
70
+ } else if (tag === "BR") out.push("\n");
71
+ else if (tag === "LI") {
72
+ out.push(listIndex === null ? "\n- " : `\n${listIndex}. `);
73
+ children();
74
+ } else if (tag === "OL") {
75
+ out.push("\n");
76
+ let index = 0;
77
+ node.childNodes.forEach((child) => walk(child, child.tagName === "LI" ? (index += 1) : null));
78
+ out.push("\n");
79
+ } else if (tag === "PRE") out.push(`\n\n\`\`\`\n${node.textContent}\n\`\`\`\n\n`);
80
+ else if (tag === "BLOCKQUOTE") out.push(`\n\n> ${node.textContent.trim().replace(/\n+/g, "\n> ")}\n\n`);
81
+ else if (tag === "TR") {
82
+ out.push("\n|");
83
+ node.childNodes.forEach((cell) => {
84
+ if (cell.nodeType === 1) out.push(` ${cell.textContent.trim()} |`);
85
+ });
86
+ } else if (tag === "IMG") out.push(`[image${node.getAttribute("alt") ? `: ${node.getAttribute("alt")}` : ""}]`);
87
+ else if (tag === "HR") out.push("\n\n---\n\n");
88
+ else if (BLOCKS.has(tag)) {
89
+ out.push("\n\n");
90
+ children();
91
+ out.push("\n\n");
92
+ } else children();
93
+ };
94
+ bodyOf(String(html || "")).childNodes.forEach((node) => walk(node));
95
+ return out
96
+ .join("")
97
+ .replace(/[ \t]+\n/g, "\n")
98
+ .replace(/\n{3,}/g, "\n\n")
99
+ .trim();
100
+ }
101
+
102
+ const replaceAll = (text, find, replace) => text.split(find).join(replace);
103
+
104
+ /**
105
+ * Applies find-and-replace edits to a document's text nodes only, so edits
106
+ * can't add markup, and appends markdown at the end. Returns
107
+ * `{ changes: { html, text }, missed }`, like the website's editDocument.
108
+ */
109
+ export function editDocument(record, { edits = [], append = "" }) {
110
+ const body = bodyOf(String(record.html || ""));
111
+ const textNodes = [];
112
+ const collect = (node) => {
113
+ if (node.nodeType === 3) textNodes.push(node);
114
+ node.childNodes?.forEach(collect);
115
+ };
116
+ collect(body);
117
+
118
+ const missed = [];
119
+ for (const { find, replace } of edits) {
120
+ let found = false;
121
+ for (const node of textNodes) {
122
+ if (node.nodeValue.includes(find)) {
123
+ node.nodeValue = replaceAll(node.nodeValue, find, replace);
124
+ found = true;
125
+ }
126
+ }
127
+ if (!found) missed.push(find);
128
+ }
129
+
130
+ let html = body.innerHTML;
131
+ if (String(append).trim()) html += markdownToDocument(append).html;
132
+ return { changes: { html, text: htmlToText(html) }, missed };
133
+ }
package/src/http.js CHANGED
@@ -1,43 +1,14 @@
1
1
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
2
2
 
3
- import { parseConnection } from "./connection.js";
4
- import { InboxError, createInboxClient } from "./inbox.js";
5
3
  import { createNotlixServer } from "./server.js";
6
4
 
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.
5
+ // Remote MCP over Streamable HTTP with web-standard Request/Response, for
6
+ // Cloudflare Workers (neuctra-notlix-mcp-http). Signing the user in is the
7
+ // caller's job: pass an AuthixClient that already holds their session.
10
8
 
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);
9
+ /** Answers one MCP request. Stateless: a new server and transport each time. */
10
+ export async function handleMcpRequest(request, { authix }) {
11
+ const server = createNotlixServer(authix);
41
12
  const transport = new WebStandardStreamableHTTPServerTransport({
42
13
  sessionIdGenerator: undefined,
43
14
  enableJsonResponse: true,
package/src/index.js CHANGED
@@ -1,4 +1,8 @@
1
- export { DEFAULT_API_URL, parseConnection } from "./connection.js";
2
- export { InboxError, createInboxClient } from "./inbox.js";
3
- export { seal } from "./seal.js";
1
+ // Works in Node and on Cloudflare Workers. The sign-in commands and session
2
+ // file are Node-only and live in src/cli/.
3
+
4
+ export { AuthixClient, AuthixError, SESSION_COOKIE, authixConfig } from "./authix.js";
5
+ export { ContentError, TYPES, TYPE_LABELS } from "./content.js";
6
+ export { markdownToDocument, documentToReadable } from "./documents.js";
4
7
  export { VERSION, createNotlixServer } from "./server.js";
8
+ export { SHARE_TOKEN, sharedItemText } from "./sharedText.js";