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).
174 lines
5.4 KiB
JavaScript
174 lines
5.4 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { applyTextEdits } from "../../build/lib/json-edit.js";
|
|
|
|
// Helpers to build small ProseMirror docs.
|
|
const textNode = (text, extra = {}) => ({ type: "text", text, ...extra });
|
|
const paragraph = (...children) => ({ type: "paragraph", content: children });
|
|
const doc = (...children) => ({ type: "doc", content: children });
|
|
|
|
test("single-match replace preserves ids/marks and reports replacements===1", () => {
|
|
const input = doc({
|
|
type: "paragraph",
|
|
attrs: { id: "para-1" },
|
|
content: [
|
|
textNode("Hello world", { marks: [{ type: "bold" }] }),
|
|
],
|
|
});
|
|
|
|
const { doc: out, results } = applyTextEdits(input, [
|
|
{ find: "world", replace: "there" },
|
|
]);
|
|
|
|
assert.deepEqual(results, [{ find: "world", replacements: 1 }]);
|
|
|
|
const para = out.content[0];
|
|
// Paragraph id attribute is preserved.
|
|
assert.equal(para.attrs.id, "para-1");
|
|
const tnode = para.content[0];
|
|
// Text node marks are preserved.
|
|
assert.deepEqual(tnode.marks, [{ type: "bold" }]);
|
|
assert.equal(tnode.text, "Hello there");
|
|
});
|
|
|
|
test("zero match throws not found", () => {
|
|
const input = doc(paragraph(textNode("Hello world")));
|
|
|
|
assert.throws(
|
|
() => applyTextEdits(input, [{ find: "absent", replace: "x" }]),
|
|
/not found/,
|
|
);
|
|
});
|
|
|
|
test("text split across two text nodes (one bold) throws spans-multiple-runs", () => {
|
|
// "Hello world" is split: "Hello " (plain) + "world" (bold). No single text
|
|
// node contains "Hello world", but the collected document text does.
|
|
const input = doc(
|
|
paragraph(
|
|
textNode("Hello "),
|
|
textNode("world", { marks: [{ type: "bold" }] }),
|
|
),
|
|
);
|
|
|
|
assert.throws(
|
|
() => applyTextEdits(input, [{ find: "Hello world", replace: "x" }]),
|
|
/spans/,
|
|
);
|
|
});
|
|
|
|
test("multi-match without replaceAll throws matches", () => {
|
|
// "ab" appears twice inside a single text node.
|
|
const input = doc(paragraph(textNode("ab cd ab")));
|
|
|
|
assert.throws(
|
|
() => applyTextEdits(input, [{ find: "ab", replace: "x" }]),
|
|
/matches/,
|
|
);
|
|
});
|
|
|
|
test("replaceAll replaces all occurrences", () => {
|
|
const input = doc(
|
|
paragraph(textNode("foo and foo")),
|
|
paragraph(textNode("more foo")),
|
|
);
|
|
|
|
const { doc: out, results } = applyTextEdits(input, [
|
|
{ find: "foo", replace: "bar", replaceAll: true },
|
|
]);
|
|
|
|
// 2 in the first paragraph, 1 in the second = 3 total.
|
|
assert.deepEqual(results, [{ find: "foo", replacements: 3 }]);
|
|
assert.equal(out.content[0].content[0].text, "bar and bar");
|
|
assert.equal(out.content[1].content[0].text, "more bar");
|
|
});
|
|
|
|
test("replacement containing $&, $1, $$ is inserted LITERALLY (regression)", () => {
|
|
const input = doc(paragraph(textNode("token here")));
|
|
|
|
const literal = "price $& cost $1 dollars $$ end";
|
|
const { doc: out } = applyTextEdits(input, [
|
|
{ find: "token", replace: literal },
|
|
]);
|
|
|
|
// The replacement must appear verbatim, NOT regex-expanded.
|
|
assert.equal(out.content[0].content[0].text, `${literal} here`);
|
|
// Be explicit that the find text was not re-injected via $&.
|
|
assert.ok(out.content[0].content[0].text.includes("$&"));
|
|
assert.ok(!out.content[0].content[0].text.includes("token"));
|
|
});
|
|
|
|
test("$ patterns are inserted literally under replaceAll too", () => {
|
|
const input = doc(paragraph(textNode("x and x")));
|
|
|
|
const { doc: out } = applyTextEdits(input, [
|
|
{ find: "x", replace: "$&$1$$", replaceAll: true },
|
|
]);
|
|
|
|
assert.equal(out.content[0].content[0].text, "$&$1$$ and $&$1$$");
|
|
});
|
|
|
|
test("empty replacement prunes the emptied text node", () => {
|
|
// A paragraph whose only text node becomes empty: the node must be pruned.
|
|
const input = doc(
|
|
paragraph(
|
|
textNode("DELETE", { marks: [{ type: "italic" }] }),
|
|
textNode(" kept"),
|
|
),
|
|
);
|
|
|
|
const { doc: out, results } = applyTextEdits(input, [
|
|
{ find: "DELETE", replace: "" },
|
|
]);
|
|
|
|
assert.deepEqual(results, [{ find: "DELETE", replacements: 1 }]);
|
|
const para = out.content[0];
|
|
// The emptied first text node is gone; only the " kept" node remains.
|
|
assert.equal(para.content.length, 1);
|
|
assert.equal(para.content[0].text, " kept");
|
|
});
|
|
|
|
test("multi-edit array applied in order", () => {
|
|
const input = doc(paragraph(textNode("alpha beta")));
|
|
|
|
const { doc: out, results } = applyTextEdits(input, [
|
|
{ find: "alpha", replace: "ALPHA" },
|
|
{ find: "beta", replace: "BETA" },
|
|
]);
|
|
|
|
assert.deepEqual(results, [
|
|
{ find: "alpha", replacements: 1 },
|
|
{ find: "beta", replacements: 1 },
|
|
]);
|
|
assert.equal(out.content[0].content[0].text, "ALPHA BETA");
|
|
});
|
|
|
|
test("second edit can target text produced by the first (ordered application)", () => {
|
|
const input = doc(paragraph(textNode("one")));
|
|
|
|
const { doc: out, results } = applyTextEdits(input, [
|
|
{ find: "one", replace: "two" },
|
|
{ find: "two", replace: "three" },
|
|
]);
|
|
|
|
assert.deepEqual(results, [
|
|
{ find: "one", replacements: 1 },
|
|
{ find: "two", replacements: 1 },
|
|
]);
|
|
assert.equal(out.content[0].content[0].text, "three");
|
|
});
|
|
|
|
test("input doc is not mutated", () => {
|
|
const input = doc(paragraph(textNode("immutable source")));
|
|
const snapshot = JSON.parse(JSON.stringify(input));
|
|
|
|
const { doc: out } = applyTextEdits(input, [
|
|
{ find: "immutable", replace: "changed" },
|
|
]);
|
|
|
|
// Original is untouched; the returned doc is a distinct object.
|
|
assert.deepEqual(input, snapshot);
|
|
assert.notEqual(out, input);
|
|
assert.equal(out.content[0].content[0].text, "changed source");
|
|
});
|