Replace the removed enterprise EE MCP (private apps/server/src/ee submodule,
license-gated /mcp route) with our docmost-mcp, vendored as an isolated ESM
workspace package and served by the server over HTTP — no enterprise license.
Backend:
- Add packages/mcp (@docmost/mcp): vendored docmost-mcp refactored into a
side-effect-free createDocmostMcpServer() factory (38 tools preserved),
stdio entry kept in stdio.ts, Streamable-HTTP session manager in http.ts.
- Add apps/server McpModule: @Post/@Get/@Delete('mcp') (served at /mcp via the
existing global-prefix exclude), @SkipTransform + reply.hijack to bridge raw
Fastify req/res into the SDK transport. The module dynamically imports the
ESM-only package from CommonJS via a Function-indirected import resolved with
require.resolve + file:// URL. Gated by the workspace ai.mcp toggle, a
service-account (MCP_DOCMOST_EMAIL/PASSWORD/API_URL) and optional MCP_TOKEN;
per-session idle eviction (MCP_SESSION_IDLE_MS).
- Drop the enterprise license check on mcpEnabled in workspace.service.
- Dockerfile: copy packages/mcp into the production image.
- .env.example: document MCP_DOCMOST_*, MCP_TOKEN, MCP_SESSION_IDLE_MS.
Frontend:
- Recreate the community "AI & MCP" workspace-settings panel (mcp-settings.tsx):
admin-only toggle on settings.ai.mcp with optimistic update, copyable
${APP_URL}/mcp URL; wired into workspace-settings page. Reuses existing i18n.
Fixes:
- Pin packages/mcp tiptap deps to 3.20.4 (matching the client) and inline
getStyleProperty, preventing a duplicate @tiptap/core@3.26.1 from leaking into
the client editor via pnpm shamefully-hoist (was breaking apps/client tsc).
304 lines
12 KiB
JavaScript
304 lines
12 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
blockText,
|
|
walk,
|
|
getList,
|
|
insertMarkerAfter,
|
|
setCalloutRange,
|
|
noteItem,
|
|
mdToInlineNodes,
|
|
commentsToFootnotes,
|
|
} from "../../build/lib/transforms.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Builders
|
|
// ---------------------------------------------------------------------------
|
|
const t = (text, marks) => (marks ? { type: "text", text, marks } : { type: "text", text });
|
|
const para = (id, ...children) => ({
|
|
type: "paragraph",
|
|
attrs: { id },
|
|
content: children,
|
|
});
|
|
const heading = (id, text) => ({
|
|
type: "heading",
|
|
attrs: { id, level: 2 },
|
|
content: [t(text)],
|
|
});
|
|
const olist = (...items) => ({ type: "orderedList", content: items });
|
|
const li = (text) => ({
|
|
type: "listItem",
|
|
content: [{ type: "paragraph", content: [t(text)] }],
|
|
});
|
|
const doc = (...children) => ({ type: "doc", content: children });
|
|
const snapshot = (v) => JSON.parse(JSON.stringify(v));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// blockText / walk / getList
|
|
// ---------------------------------------------------------------------------
|
|
test("blockText concatenates nested inline text", () => {
|
|
assert.equal(blockText(para("p", t("a"), t("b"), t("c"))), "abc");
|
|
});
|
|
|
|
test("walk visits every node depth-first", () => {
|
|
const d = doc(para("p1", t("x")), olist(li("y")));
|
|
const types = [];
|
|
walk(d, (n) => types.push(n.type));
|
|
assert.deepEqual(types, [
|
|
"doc",
|
|
"paragraph",
|
|
"text",
|
|
"orderedList",
|
|
"listItem",
|
|
"paragraph",
|
|
"text",
|
|
]);
|
|
});
|
|
|
|
test("getList finds an orderedList without an id", () => {
|
|
const d = doc(para("p", t("x")), olist(li("one")));
|
|
const found = getList(d, (n) => n.type === "orderedList");
|
|
assert.ok(found);
|
|
assert.equal(found.type, "orderedList");
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// insertMarkerAfter — mark-safe split
|
|
// ---------------------------------------------------------------------------
|
|
test("insertMarkerAfter splits a marked run and inserts an UNMARKED marker", () => {
|
|
// A paragraph: "see " (plain) + "the link" (link mark) + " here" (plain).
|
|
const link = [{ type: "link", attrs: { href: "http://x" } }];
|
|
const original = doc(
|
|
para("p1", t("see "), t("the link", link), t(" here")),
|
|
);
|
|
const before = snapshot(original);
|
|
|
|
const { doc: out, inserted } = insertMarkerAfter(
|
|
original,
|
|
"the link",
|
|
"[1]",
|
|
);
|
|
assert.equal(inserted, true);
|
|
// The caller's object is untouched (deep clone).
|
|
assert.deepEqual(original, before);
|
|
|
|
const inline = out.content[0].content;
|
|
// Expect: "see "(plain), "the link"(link), " [1]"(NO marks), " here"(plain).
|
|
const marker = inline.find((n) => n.text === " [1]");
|
|
assert.ok(marker, "marker run present");
|
|
assert.equal(marker.marks, undefined, "marker carries no marks");
|
|
|
|
// The link run kept its mark verbatim.
|
|
const linkRun = inline.find((n) => n.text === "the link");
|
|
assert.deepEqual(linkRun.marks, link);
|
|
|
|
// Plain text reads correctly with the marker placed right after the anchor.
|
|
assert.equal(blockText(out.content[0]), "see the link [1] here");
|
|
});
|
|
|
|
test("insertMarkerAfter respects beforeBlock and reports not-found", () => {
|
|
const d = doc(para("p1", t("alpha")), para("p2", t("beta")));
|
|
// anchor only in block index 1, but search limited to blocks < 1
|
|
const r = insertMarkerAfter(d, "beta", "[1]", { beforeBlock: 1 });
|
|
assert.equal(r.inserted, false);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// setCalloutRange
|
|
// ---------------------------------------------------------------------------
|
|
test("setCalloutRange rewrites [1]…[K] to [1]…[n]", () => {
|
|
const d = doc({
|
|
type: "callout",
|
|
attrs: { type: "info" },
|
|
content: [para("c", t("Footnotes [1]…[3] are translator notes."))],
|
|
});
|
|
const { doc: out, changed } = setCalloutRange(d, 7);
|
|
assert.equal(changed, 1);
|
|
assert.equal(blockText(out), "Footnotes [1]…[7] are translator notes.");
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// noteItem / mdToInlineNodes
|
|
// ---------------------------------------------------------------------------
|
|
test("noteItem wraps inline nodes in a listItem with a fresh paragraph id", () => {
|
|
const item = noteItem([t("hello")]);
|
|
assert.equal(item.type, "listItem");
|
|
assert.equal(item.content[0].type, "paragraph");
|
|
assert.ok(item.content[0].attrs.id, "has a fresh id");
|
|
assert.deepEqual(item.content[0].content, [t("hello")]);
|
|
});
|
|
|
|
test("mdToInlineNodes splits a bold lead and strips a prefix", () => {
|
|
const nodes = mdToInlineNodes("комментарий: **Lead.** body text");
|
|
// bold lead node + plain remainder
|
|
assert.equal(nodes[0].text, "Lead.");
|
|
assert.deepEqual(nodes[0].marks, [{ type: "bold" }]);
|
|
assert.ok(nodes[1].text.includes("body text"));
|
|
assert.equal(nodes[1].marks, undefined);
|
|
});
|
|
|
|
test("mdToInlineNodes strips a 'N. ' numeric prefix", () => {
|
|
const nodes = mdToInlineNodes("3. plain note");
|
|
assert.equal(nodes.map((n) => n.text).join(""), "plain note");
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// commentsToFootnotes — renumber by reading position on a small fixture
|
|
// ---------------------------------------------------------------------------
|
|
test("commentsToFootnotes anchors comments and renumbers by position", () => {
|
|
// Body has an EXISTING footnote [1] in the second paragraph; we add two
|
|
// inline comments anchored to text in the first and third paragraphs. After
|
|
// running, markers must be renumbered 1,2,3 in reading order and the notes
|
|
// list reordered to match.
|
|
const callout = {
|
|
type: "callout",
|
|
attrs: { type: "info" },
|
|
content: [para("c", t("Notes [1]…[1] follow."))],
|
|
};
|
|
const d = doc(
|
|
callout,
|
|
para("p1", t("First mentions apple.")),
|
|
para("p2", t("Second already has a note [1] here.")),
|
|
para("p3", t("Third mentions banana.")),
|
|
heading("h", "Примечания переводчика"),
|
|
olist(li("existing note one")), // matches the existing [1]
|
|
);
|
|
|
|
const comments = [
|
|
{ id: "cA", content: "apple note", selection: "apple" },
|
|
{ id: "cB", content: "banana note", selection: "banana" },
|
|
];
|
|
|
|
const { doc: out, consumed } = commentsToFootnotes(d, comments);
|
|
assert.deepEqual(consumed.sort(), ["cA", "cB"]);
|
|
|
|
// Markers in reading order: p1 "apple"->[1], p2 existing->[2], p3 "banana"->[3]
|
|
assert.match(blockText(out.content[1]), /\[1\]/);
|
|
assert.match(blockText(out.content[2]), /\[2\]/);
|
|
assert.match(blockText(out.content[3]), /\[3\]/);
|
|
|
|
// No stray placeholders remain.
|
|
const allText = blockText(out);
|
|
assert.doesNotMatch(allText, / F\d+ /);
|
|
|
|
// Notes list reordered to [apple, existing, banana] (reading order).
|
|
const list = out.content.find((n) => n.type === "orderedList");
|
|
assert.equal(list.content.length, 3);
|
|
assert.equal(blockText(list.content[0]), "apple note");
|
|
assert.equal(blockText(list.content[1]), "existing note one");
|
|
assert.equal(blockText(list.content[2]), "banana note");
|
|
|
|
// Callout range synced to 3 notes.
|
|
assert.match(blockText(out.content[0]), /\[1\]…\[3\]/);
|
|
});
|
|
|
|
test("commentsToFootnotes throws when the notes heading is missing", () => {
|
|
const d = doc(para("p", t("no notes section")));
|
|
assert.throws(
|
|
() => commentsToFootnotes(d, [{ id: "x", content: "y", selection: "no" }]),
|
|
/heading .* not found/,
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bug 1: the placeholder sentinel must not collide with real "F<digits>" /
|
|
// "FN<digits>" text. Body text "F1"/"FN2"/"F12" near a real comment anchor must
|
|
// be left untouched; only the real comment becomes a footnote. "FN2" is the key
|
|
// case: the old printable " FN<i> " sentinel could collide with prose like "FN2",
|
|
// which the NUL-delimited "\u0000FN<i>\u0000" sentinel makes impossible.
|
|
// ---------------------------------------------------------------------------
|
|
test("commentsToFootnotes leaves literal 'F1'/'FN2'/'F12' body text untouched", () => {
|
|
const d = doc(
|
|
para("p1", t("Press F1 for help, model FN2 and F12 for tools near apple here.")),
|
|
heading("h", "Примечания переводчика"),
|
|
olist(), // empty notes list; the single comment supplies the only note
|
|
);
|
|
|
|
const comments = [{ id: "cA", content: "apple note", selection: "apple" }];
|
|
|
|
const { doc: out, consumed } = commentsToFootnotes(d, comments);
|
|
assert.deepEqual(consumed, ["cA"]);
|
|
|
|
const bodyText = blockText(out.content[0]);
|
|
// The literal "F1"/"FN2"/"F12" prose is preserved verbatim (no bogus
|
|
// footnotes, no eaten spaces around them).
|
|
assert.match(bodyText, /Press F1 for help, model FN2 and F12 for tools/);
|
|
// Exactly one real footnote marker was produced, at the anchored word.
|
|
const markerCount = (bodyText.match(/\[\d+\]/g) || []).length;
|
|
assert.equal(markerCount, 1);
|
|
assert.match(bodyText, /apple \[1\]/);
|
|
|
|
// Exactly one note in the list — "F1"/"FN2"/"F12" did not spawn extra notes.
|
|
const list = out.content.find((n) => n.type === "orderedList");
|
|
assert.equal(list.content.length, 1);
|
|
assert.equal(blockText(list.content[0]), "apple note");
|
|
|
|
// No stray placeholder sentinel remains anywhere: the NUL-delimited sentinel
|
|
// is fully consumed by the renumber pass, so no raw NUL control char persists
|
|
// in the returned doc. We deliberately do NOT assert absence of the printable
|
|
// " FN<i> " shape: the body intentionally contains real prose "model FN2 and",
|
|
// which must survive verbatim (see the match assertion above) - that is exactly
|
|
// why the old printable sentinel was unsafe and the NUL sentinel is not.
|
|
assert.doesNotMatch(blockText(out), /\u0000/);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bug 2: an out-of-range body marker must throw, not silently drop the note.
|
|
// ---------------------------------------------------------------------------
|
|
test("commentsToFootnotes throws on an out-of-range body marker", () => {
|
|
// Body marker [9] but the notes list has only 1 item -> inconsistent doc.
|
|
const d = doc(
|
|
para("p1", t("Some text with a dangling marker [9] here.")),
|
|
heading("h", "Примечания переводчика"),
|
|
olist(li("the only note")),
|
|
);
|
|
|
|
assert.throws(
|
|
() => commentsToFootnotes(d, []),
|
|
/footnote \[9\] has no matching note \(notes list has 1 items\); document is inconsistent/,
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bug 4: a non-disclaimer callout in the body gets its [N] markers renumbered;
|
|
// a disclaimer callout carrying a "[1]…[K]" range is left out of renumbering.
|
|
// ---------------------------------------------------------------------------
|
|
test("commentsToFootnotes renumbers body callouts but skips the disclaimer range", () => {
|
|
const disclaimer = {
|
|
type: "callout",
|
|
attrs: { type: "info" },
|
|
content: [para("d", t("Notes [1]…[2] follow."))],
|
|
};
|
|
const bodyCallout = {
|
|
type: "callout",
|
|
attrs: { type: "warning" },
|
|
content: [para("bc", t("Important point already noted [1] above."))],
|
|
};
|
|
const d = doc(
|
|
disclaimer,
|
|
bodyCallout,
|
|
para("p2", t("Then a second mention with [2] too.")),
|
|
heading("h", "Примечания переводчика"),
|
|
olist(li("first note"), li("second note")),
|
|
);
|
|
|
|
const { doc: out, consumed } = commentsToFootnotes(d, []);
|
|
assert.deepEqual(consumed, []);
|
|
|
|
// The disclaimer's "[1]…[K]" range is NOT treated as body markers: it stays
|
|
// a range and is synced to the note count (2), not renumbered into [1],[2].
|
|
assert.match(blockText(out.content[0]), /\[1\]…\[2\]/);
|
|
|
|
// The body callout's [1] is renumbered as a real reading-order marker.
|
|
assert.match(blockText(out.content[1]), /noted \[1\] above/);
|
|
// The following paragraph's [2] keeps reading order.
|
|
assert.match(blockText(out.content[2]), /with \[2\] too/);
|
|
|
|
// Notes list still has the two original notes in order.
|
|
const list = out.content.find((n) => n.type === "orderedList");
|
|
assert.equal(list.content.length, 2);
|
|
assert.equal(blockText(list.content[0]), "first note");
|
|
assert.equal(blockText(list.content[1]), "second note");
|
|
});
|