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).
302 lines
9.2 KiB
JavaScript
302 lines
9.2 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
insertNodeRelative,
|
|
sanitizeForYjs,
|
|
findUnstorableAttr,
|
|
} from "../../build/lib/node-ops.js";
|
|
|
|
// ProseMirror builders. Blocks carry a stable id in attrs.id.
|
|
const textNode = (text) => ({ type: "text", text });
|
|
const para = (id, ...children) => ({
|
|
type: "paragraph",
|
|
attrs: { id },
|
|
content: children,
|
|
});
|
|
const doc = (...children) => ({ type: "doc", content: children });
|
|
const snapshot = (v) => JSON.parse(JSON.stringify(v));
|
|
|
|
// A table cell holding a single paragraph.
|
|
const cell = (id, innerPara) => ({
|
|
type: "tableCell",
|
|
attrs: { id },
|
|
content: [innerPara],
|
|
});
|
|
const row = (id, ...cells) => ({
|
|
type: "tableRow",
|
|
attrs: { id },
|
|
content: cells,
|
|
});
|
|
const table = (id, ...rows) => ({
|
|
type: "table",
|
|
attrs: { id },
|
|
content: rows,
|
|
});
|
|
|
|
// A 2x2 table: rows r1/r2, cells c1..c4, each cell holds a paragraph p1..p4.
|
|
const make2x2Table = () =>
|
|
doc(
|
|
table(
|
|
"t1",
|
|
row("r1", cell("c1", para("p1", textNode("A1"))), cell("c2", para("p2", textNode("A2")))),
|
|
row("r2", cell("c3", para("p3", textNode("B1"))), cell("c4", para("p4", textNode("B2")))),
|
|
),
|
|
);
|
|
|
|
const freshRow = () => row("rNEW", cell("cNEW", para("pNEW", textNode("NEW"))));
|
|
const freshCell = () => cell("cNEW", para("pNEW", textNode("NEW")));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// sanitizeForYjs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("sanitizeForYjs strips undefined node-attr keys, preserves null/false/0/''", () => {
|
|
const input = doc({
|
|
type: "paragraph",
|
|
attrs: {
|
|
id: "p-1",
|
|
gone: undefined,
|
|
keptNull: null,
|
|
keptFalse: false,
|
|
keptZero: 0,
|
|
keptEmpty: "",
|
|
},
|
|
content: [textNode("x")],
|
|
});
|
|
const out = sanitizeForYjs(input);
|
|
const attrs = out.content[0].attrs;
|
|
assert.equal("gone" in attrs, false);
|
|
assert.equal("keptNull" in attrs, true);
|
|
assert.equal(attrs.keptNull, null);
|
|
assert.equal(attrs.keptFalse, false);
|
|
assert.equal(attrs.keptZero, 0);
|
|
assert.equal(attrs.keptEmpty, "");
|
|
// Input must not be mutated.
|
|
assert.equal("gone" in input.content[0].attrs, true);
|
|
});
|
|
|
|
test("sanitizeForYjs strips undefined mark-attr keys, preserves falsy values", () => {
|
|
const input = doc({
|
|
type: "paragraph",
|
|
attrs: { id: "p-1" },
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "x",
|
|
marks: [
|
|
{
|
|
type: "link",
|
|
attrs: { href: "", target: undefined, rel: null },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
const out = sanitizeForYjs(input);
|
|
const markAttrs = out.content[0].content[0].marks[0].attrs;
|
|
assert.equal("target" in markAttrs, false);
|
|
assert.equal(markAttrs.href, "");
|
|
assert.equal(markAttrs.rel, null);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// findUnstorableAttr
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("findUnstorableAttr returns a path for an undefined node attr", () => {
|
|
const input = doc(
|
|
para("p-0", textNode("ok")),
|
|
{
|
|
type: "paragraph",
|
|
attrs: { id: "p-1", indent: undefined },
|
|
content: [textNode("y")],
|
|
},
|
|
);
|
|
const hit = findUnstorableAttr(input);
|
|
assert.equal(hit, "content[1].attrs.indent (undefined)");
|
|
});
|
|
|
|
test("findUnstorableAttr finds an unstorable mark attr", () => {
|
|
const input = doc({
|
|
type: "paragraph",
|
|
attrs: { id: "p-1" },
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "x",
|
|
marks: [{ type: "link", attrs: { href: () => {} } }],
|
|
},
|
|
],
|
|
});
|
|
const hit = findUnstorableAttr(input);
|
|
assert.equal(hit, "content[0].content[0].marks[0].attrs.href (function)");
|
|
});
|
|
|
|
test("findUnstorableAttr returns null for a clean doc", () => {
|
|
const input = doc(para("p-1", textNode("clean")));
|
|
assert.equal(findUnstorableAttr(input), null);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// insertNodeRelative — table-structure-aware
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("insertNodeRelative inserting a tableRow anchored on a paragraph INSIDE a cell appends a sibling row to the table", () => {
|
|
const input = make2x2Table();
|
|
const { doc: out, inserted } = insertNodeRelative(input, freshRow(), {
|
|
position: "after",
|
|
anchorNodeId: "p4", // paragraph inside last cell of the last row
|
|
});
|
|
assert.equal(inserted, true);
|
|
const tbl = out.content[0];
|
|
// table.content length +1 (the row is a direct child of the table).
|
|
assert.equal(tbl.content.length, 3);
|
|
// The new row is a direct child of the table, NOT nested inside a cell.
|
|
const newRow = tbl.content[2];
|
|
assert.equal(newRow.type, "tableRow");
|
|
assert.equal(newRow.attrs.id, "rNEW");
|
|
// Existing rows' cells are intact.
|
|
assert.deepEqual(
|
|
tbl.content[0].content.map((c) => c.attrs.id),
|
|
["c1", "c2"],
|
|
);
|
|
assert.deepEqual(
|
|
tbl.content[1].content.map((c) => c.attrs.id),
|
|
["c3", "c4"],
|
|
);
|
|
// Assert the new row is NOT nested inside any existing cell.
|
|
for (const r of [tbl.content[0], tbl.content[1]]) {
|
|
for (const c of r.content) {
|
|
const ids = (c.content || []).map((n) => n.attrs?.id);
|
|
assert.equal(ids.includes("rNEW"), false);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("insertNodeRelative before/after place the new row at the correct index relative to the enclosing row", () => {
|
|
// "before" the first row.
|
|
{
|
|
const input = make2x2Table();
|
|
const { doc: out } = insertNodeRelative(input, freshRow(), {
|
|
position: "before",
|
|
anchorNodeId: "p1", // paragraph in first row
|
|
});
|
|
assert.deepEqual(
|
|
out.content[0].content.map((r) => r.attrs.id),
|
|
["rNEW", "r1", "r2"],
|
|
);
|
|
}
|
|
// "after" the first row.
|
|
{
|
|
const input = make2x2Table();
|
|
const { doc: out } = insertNodeRelative(input, freshRow(), {
|
|
position: "after",
|
|
anchorNodeId: "p1", // paragraph in first row
|
|
});
|
|
assert.deepEqual(
|
|
out.content[0].content.map((r) => r.attrs.id),
|
|
["r1", "rNEW", "r2"],
|
|
);
|
|
}
|
|
});
|
|
|
|
test("insertNodeRelative inserting a tableCell anchored inside a cell adds it to the enclosing row", () => {
|
|
const input = make2x2Table();
|
|
const { doc: out, inserted } = insertNodeRelative(input, freshCell(), {
|
|
position: "after",
|
|
anchorNodeId: "p1", // paragraph inside first cell of first row
|
|
});
|
|
assert.equal(inserted, true);
|
|
// The cell is spliced into the enclosing row (r1) after c1.
|
|
assert.deepEqual(
|
|
out.content[0].content[0].content.map((c) => c.attrs.id),
|
|
["c1", "cNEW", "c2"],
|
|
);
|
|
// The other row is untouched.
|
|
assert.deepEqual(
|
|
out.content[0].content[1].content.map((c) => c.attrs.id),
|
|
["c3", "c4"],
|
|
);
|
|
});
|
|
|
|
test("insertNodeRelative inserting a tableRow with an anchor NOT inside a table throws", () => {
|
|
const input = doc(para("p-1", textNode("plain")));
|
|
assert.throws(
|
|
() =>
|
|
insertNodeRelative(input, freshRow(), {
|
|
position: "after",
|
|
anchorNodeId: "p-1",
|
|
}),
|
|
/not inside a table/,
|
|
);
|
|
});
|
|
|
|
test("insertNodeRelative append + tableRow throws", () => {
|
|
const input = make2x2Table();
|
|
assert.throws(
|
|
() => insertNodeRelative(input, freshRow(), { position: "append" }),
|
|
/cannot append a tableRow at the top level/,
|
|
);
|
|
});
|
|
|
|
test("insertNodeRelative structural insert with unresolved anchor returns inserted:false (no throw)", () => {
|
|
const input = make2x2Table();
|
|
const { doc: out, inserted } = insertNodeRelative(input, freshRow(), {
|
|
position: "after",
|
|
anchorNodeId: "does-not-exist",
|
|
});
|
|
assert.equal(inserted, false);
|
|
assert.deepEqual(out, input);
|
|
});
|
|
|
|
test("insertNodeRelative tableRow by anchorText resolving to the table block appends within the table", () => {
|
|
const input = make2x2Table();
|
|
// anchorText "A1" lives in the first cell; the matched top-level block is the
|
|
// table itself, so the row appends at the end of the table.
|
|
const { doc: out, inserted } = insertNodeRelative(input, freshRow(), {
|
|
position: "after",
|
|
anchorText: "A1",
|
|
});
|
|
assert.equal(inserted, true);
|
|
assert.deepEqual(
|
|
out.content[0].content.map((r) => r.attrs.id),
|
|
["r1", "r2", "rNEW"],
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Regression: a normal (non-structural) paragraph insert is unchanged.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("insertNodeRelative regression: normal paragraph before/after a top-level block behaves as before", () => {
|
|
const before = doc(para("p-1", textNode("one")), para("p-2", textNode("two")));
|
|
{
|
|
const { doc: out, inserted } = insertNodeRelative(
|
|
before,
|
|
para("new", textNode("NEW")),
|
|
{ position: "before", anchorNodeId: "p-2" },
|
|
);
|
|
assert.equal(inserted, true);
|
|
assert.deepEqual(
|
|
out.content.map((n) => n.attrs.id),
|
|
["p-1", "new", "p-2"],
|
|
);
|
|
}
|
|
{
|
|
const snap = snapshot(before);
|
|
const { doc: out, inserted } = insertNodeRelative(
|
|
before,
|
|
para("new", textNode("NEW")),
|
|
{ position: "after", anchorNodeId: "p-1" },
|
|
);
|
|
assert.equal(inserted, true);
|
|
assert.deepEqual(
|
|
out.content.map((n) => n.attrs.id),
|
|
["p-1", "new", "p-2"],
|
|
);
|
|
// Input not mutated.
|
|
assert.deepEqual(before, snap);
|
|
}
|
|
});
|