@lalalic/markcut 3.2.6 → 3.2.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lalalic/markcut",
3
- "version": "3.2.6",
3
+ "version": "3.2.8",
4
4
  "description": "Markdown-to-video engine. Describe scenes in markdown, get a rendered video.",
5
5
  "bin": {
6
6
  "markcut": "bin/markcut"
@@ -22,6 +22,7 @@
22
22
  "render:demo": "node src/render/cli.mjs render src/demo-components.json",
23
23
  "templates": "node src/render/cli.mjs templates",
24
24
  "preview": "node src/render/cli.mjs preview",
25
+ "mcp": "node src/mcp/server.mjs",
25
26
  "verify": "node src/render/cli.mjs verify",
26
27
  "resolve": "node src/render/cli.mjs resolve",
27
28
  "render:pixelle": "remotion render examples/pixelle/index.tsx Root out/pixelle-asset-default.mp4 --props=./examples/pixelle/asset-default.json",
@@ -37,6 +38,7 @@
37
38
  },
38
39
  "dependencies": {
39
40
  "@babel/standalone": "^8.0.3",
41
+ "@modelcontextprotocol/sdk": "^1.32.1",
40
42
  "@remotion/cli": "4.0.469",
41
43
  "@remotion/media-parser": "4.0.469",
42
44
  "@remotion/media-utils": "4.0.469",
@@ -78,10 +78,20 @@ npx @lalalic/markcut spots --waypoints "lat,lng;lat,lng" # discover POIs along a
78
78
 
79
79
  ---
80
80
 
81
+ ## MCP Markdown Review
82
+
83
+ Use the model-visible MCP tool `markcut.preview({ path })` when a human needs to review a Markdown artifact. This is a generic Markdown review surface and is separate from the video-specific `npx @lalalic/markcut preview <file>` CLI.
84
+
85
+ - `path` must be an absolute path to a Markdown file.
86
+ - The user sees the rendered Markdown and can either **Approve** or submit feedback with **Request changes**.
87
+ - Markcut does not edit the file. The calling agent owns all source changes.
88
+ - On `approved`, continue the workflow.
89
+ - On `changes_requested`, apply the feedback to the source Markdown, then call `markcut.preview({ path })` again for another review round.
90
+
81
91
  ## review
82
92
  use the review contract defined in [./review.md](./review.md) to guide the review process.
83
93
  review as early as possible in the video production process to catch issues before they propagate.
84
- * review md file
94
+ * review md file with `markcut.preview({ path })` when the MCP tool is available
85
95
  * review compiled.json
86
96
  * review the rendered video
87
97
 
@@ -0,0 +1,15 @@
1
+ export function buildPreviewBridgeScript(submitToolName) {
2
+ return `
3
+ const feedback=document.getElementById('feedback'),status=document.getElementById('status'),article=document.querySelector('article'),pathLabel=document.querySelector('main>p code');
4
+ let nextRequestId=0;
5
+ const pendingRequests=new Map();
6
+ function send(message){window.parent.postMessage(message,'*');}
7
+ function rpcRequest(method,params){const id=++nextRequestId;send({jsonrpc:'2.0',id,method,params});return new Promise((resolve,reject)=>pendingRequests.set(id,{resolve,reject}));}
8
+ function applyToolResult(params){const result=params?.result||params||{},data=result.structuredContent||result.structured_content||{};if(typeof data.path==='string')pathLabel.textContent=data.path;if(typeof data.markdown==='string'){article.innerHTML=data.markdown.split(/\\r?\\n/).map(line=>{const escaped=line.replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));if(line.startsWith('### '))return '<h3>'+escaped.slice(4)+'</h3>';if(line.startsWith('## '))return '<h2>'+escaped.slice(3)+'</h2>';if(line.startsWith('# '))return '<h1>'+escaped.slice(2)+'</h1>';if(line.startsWith('- '))return '<li>'+escaped.slice(2)+'</li>';return line.trim()?'<p>'+escaped+'</p>':''}).join('\\n');}}
9
+ window.addEventListener('message',event=>{if(event.source!==window.parent)return;const message=event.data;if(!message||typeof message!=='object'||message.jsonrpc!=='2.0')return;if(Object.prototype.hasOwnProperty.call(message,'id')&&pendingRequests.has(message.id)){const pending=pendingRequests.get(message.id);pendingRequests.delete(message.id);if(message.error)pending.reject(new Error(message.error.message||'MCP request failed'));else pending.resolve(message.result);return;}if(message.method==='ui/notifications/tool-result')applyToolResult(message.params);});
10
+ async function initializeBridge(){try{await rpcRequest('ui/initialize',{protocolVersion:'2026-01-26',capabilities:{},clientInfo:{name:'markcut-preview',version:'1'}});send({jsonrpc:'2.0',method:'ui/notifications/initialized'});status.textContent='Ready for review.';}catch(error){status.textContent=error.message||'MCP initialization failed.';}}
11
+ async function submit(decision){const value=feedback.value.trim();if(decision==='changes_requested'&&!value){status.textContent='Feedback is required.';feedback.focus();return;}const result={decision};if(value&&decision==='changes_requested')result.feedback=value;status.textContent='Submitting...';try{await rpcRequest('tools/call',{name:'${submitToolName}',arguments:result});send({jsonrpc:'2.0',method:'ui/message',params:{role:'user',content:[{type:'text',text:JSON.stringify(result)}]}});status.textContent=decision==='approved'?'Approved.':'Changes requested.';}catch(error){status.textContent=error.message||'Unable to submit review.';}}
12
+ initializeBridge();
13
+ document.getElementById('approve').onclick=()=>submit('approved');document.getElementById('changes').onclick=()=>submit('changes_requested');
14
+ `;
15
+ }
@@ -0,0 +1,77 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { isAbsolute, extname } from "node:path";
3
+ import { buildPreviewBridgeScript } from "./bridge.mjs";
4
+
5
+ export const TOOL_NAME = "markcut.preview";
6
+ export const SUBMIT_TOOL_NAME = "markcut.preview.submit";
7
+ export const RESOURCE_URI = "ui://markcut/markdown-preview.html";
8
+
9
+ export function validateMarkdownPath(value) {
10
+ if (typeof value !== "string" || !isAbsolute(value)) {
11
+ throw new Error("path must be an absolute filesystem path");
12
+ }
13
+ if (extname(value).toLowerCase() !== ".md") {
14
+ throw new Error("path must point to a Markdown file (.md)");
15
+ }
16
+ return value;
17
+ }
18
+
19
+ export async function readMarkdownPreview(path) {
20
+ const validatedPath = validateMarkdownPath(path);
21
+ try {
22
+ return { path: validatedPath, markdown: await readFile(validatedPath, "utf8") };
23
+ } catch (error) {
24
+ throw new Error(`unable to read Markdown file: ${error instanceof Error ? error.message : String(error)}`);
25
+ }
26
+ }
27
+
28
+ export function reviewResult(decision, feedback = "") {
29
+ if (decision === "approved") return { decision };
30
+ if (decision === "changes_requested") {
31
+ const normalized = typeof feedback === "string" ? feedback.trim() : "";
32
+ if (!normalized) throw new Error("feedback is required when requesting changes");
33
+ return { decision, feedback: normalized };
34
+ }
35
+ throw new Error(`unsupported review decision: ${String(decision)}`);
36
+ }
37
+
38
+ export function reviewMessage(result) {
39
+ const normalized = reviewResult(result?.decision, result?.feedback);
40
+ return {
41
+ jsonrpc: "2.0",
42
+ method: "ui/message",
43
+ params: {
44
+ role: "user",
45
+ content: [{ type: "text", text: JSON.stringify(normalized) }],
46
+ },
47
+ };
48
+ }
49
+
50
+ function escapeHtml(value) {
51
+ return value.replace(/[&<>"']/g, (character) => ({
52
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
53
+ })[character]);
54
+ }
55
+
56
+ function renderMarkdown(markdown) {
57
+ return markdown.split(/\r?\n/).map((line) => {
58
+ const escaped = escapeHtml(line);
59
+ if (line.startsWith("### ")) return `<h3>${escaped.slice(4)}</h3>`;
60
+ if (line.startsWith("## ")) return `<h2>${escaped.slice(3)}</h2>`;
61
+ if (line.startsWith("# ")) return `<h1>${escaped.slice(2)}</h1>`;
62
+ if (line.startsWith("- ")) return `<li>${escaped.slice(2)}</li>`;
63
+ return line.trim() ? `<p>${escaped}</p>` : "";
64
+ }).join("\n");
65
+ }
66
+
67
+ export function buildPreviewHtml({ path = "", markdown = "" }) {
68
+ const safePath = escapeHtml(path);
69
+ const rendered = renderMarkdown(markdown);
70
+ return `<!doctype html>
71
+ <html><head><meta charset="utf-8"><title>MarkCut Markdown review</title>
72
+ <style>body{font:15px system-ui,sans-serif;margin:0;padding:24px;color:#17202a;background:#fff}main{max-width:860px;margin:auto}article{border:1px solid #d8dee4;border-radius:8px;padding:20px;overflow:auto}h1,h2,h3{margin-top:0}p{line-height:1.5}textarea{box-sizing:border-box;width:100%;min-height:100px;margin:16px 0 10px;padding:10px;border:1px solid #8c959f;border-radius:6px;font:inherit}button{border:0;border-radius:6px;padding:9px 14px;margin-right:8px;font:inherit;cursor:pointer}#approve{background:#1f883d;color:#fff}#changes{background:#cf222e;color:#fff}#status{min-height:1.4em;color:#cf222e}</style></head>
73
+ <body><main><h1>Review Markdown</h1><p><code>${safePath}</code></p><article>${rendered}</article>
74
+ <label for="feedback">Feedback (required for requested changes)</label><textarea id="feedback" placeholder="What should be changed?"></textarea><div id="status" role="status"></div>
75
+ <button id="approve">Approve</button><button id="changes">Request changes</button></main>
76
+ <script>${buildPreviewBridgeScript(SUBMIT_TOOL_NAME)}</script></body></html>`;
77
+ }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import {
5
+ CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema,
6
+ ReadResourceRequestSchema,
7
+ } from "@modelcontextprotocol/sdk/types.js";
8
+ import {
9
+ TOOL_NAME, SUBMIT_TOOL_NAME, RESOURCE_URI, buildPreviewHtml, readMarkdownPreview, reviewResult,
10
+ } from "./preview.mjs";
11
+
12
+ const server = new Server(
13
+ { name: "markcut", version: "3.2.0" },
14
+ { capabilities: { tools: {}, resources: {} } },
15
+ );
16
+
17
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
18
+ tools: [{
19
+ name: TOOL_NAME,
20
+ description: "Ask a human to review a Markdown file and return an approval or requested changes.",
21
+ inputSchema: {
22
+ type: "object", properties: { path: { type: "string", description: "Absolute path to a .md file" } },
23
+ required: ["path"], additionalProperties: false,
24
+ },
25
+ _meta: { "ui/resourceUri": RESOURCE_URI, ui: { resourceUri: RESOURCE_URI } },
26
+ }, {
27
+ name: SUBMIT_TOOL_NAME,
28
+ description: "Submit the review decision from the Markdown preview UI.",
29
+ inputSchema: {
30
+ type: "object",
31
+ properties: {
32
+ decision: { type: "string", enum: ["approved", "changes_requested"] },
33
+ feedback: { type: "string" },
34
+ },
35
+ required: ["decision"], additionalProperties: false,
36
+ },
37
+ _meta: { ui: { visibility: ["app"] } },
38
+ }],
39
+ }));
40
+
41
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
42
+ resources: [{ uri: RESOURCE_URI, name: "MarkCut Markdown review", mimeType: "text/html;profile=mcp-app" }],
43
+ }));
44
+
45
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => ({
46
+ contents: [{ uri: RESOURCE_URI, mimeType: "text/html;profile=mcp-app", text: buildPreviewHtml({ path: "", markdown: "" }) }],
47
+ }));
48
+
49
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
50
+ try {
51
+ const input = request.params.arguments ?? {};
52
+ if (request.params.name === SUBMIT_TOOL_NAME) {
53
+ const decision = reviewResult(input.decision, input.feedback);
54
+ return { content: [{ type: "text", text: JSON.stringify(decision) }], structuredContent: decision };
55
+ }
56
+ if (request.params.name !== TOOL_NAME) throw new Error(`unknown tool: ${request.params.name}`);
57
+ const { path, markdown } = await readMarkdownPreview(input.path);
58
+ return {
59
+ content: [{ type: "text", text: `Markdown ready for review: ${path}` }],
60
+ structuredContent: { path, markdown, resourceUri: RESOURCE_URI },
61
+ _meta: {
62
+ "ui/resourceUri": RESOURCE_URI,
63
+ ui: { resourceUri: RESOURCE_URI, props: { path, markdown } },
64
+ },
65
+ };
66
+ } catch (error) {
67
+ return { isError: true, content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }] };
68
+ }
69
+ });
70
+
71
+ await server.connect(new StdioServerTransport());
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { mkdtempSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import vm from "node:vm";
6
+ import { buildPreviewHtml, readMarkdownPreview, reviewMessage, reviewResult, validateMarkdownPath } from "../src/mcp/preview.mjs";
7
+ import { buildPreviewBridgeScript } from "../src/mcp/bridge.mjs";
8
+
9
+ describe("MarkCut MCP Markdown preview", () => {
10
+ it("requires an absolute .md path", () => {
11
+ expect(() => validateMarkdownPath("notes.md")).toThrow(/absolute/);
12
+ expect(() => validateMarkdownPath("/tmp/notes.txt")).toThrow(/Markdown/);
13
+ });
14
+
15
+ it("reads Markdown and builds a reviewable UI resource", async () => {
16
+ const directory = mkdtempSync(join(tmpdir(), "markcut-mcp-"));
17
+ const path = join(directory, "review.md");
18
+ writeFileSync(path, "# Hello\n\nThis is a review.");
19
+ const preview = await readMarkdownPreview(path);
20
+ const html = buildPreviewHtml(preview);
21
+ expect(preview.markdown).toContain("# Hello");
22
+ expect(html).toContain("This is a review.");
23
+ expect(html).toContain("Approve");
24
+ expect(html).toContain("Request changes");
25
+ expect(html).toContain("ui/initialize");
26
+ expect(html).toContain("ui/notifications/tool-result");
27
+ expect(html).toContain("tools/call");
28
+ expect(html).toContain("ui/message");
29
+ expect(html).toContain("review.md");
30
+ });
31
+
32
+ it("returns approved", () => {
33
+ expect(reviewResult("approved")).toEqual({ decision: "approved" });
34
+ });
35
+
36
+ it("requires non-empty feedback for requested changes", () => {
37
+ expect(() => reviewResult("changes_requested", " ")).toThrow(/feedback is required/);
38
+ expect(reviewResult("changes_requested", " Fix the title ")).toEqual({
39
+ decision: "changes_requested", feedback: "Fix the title",
40
+ });
41
+ });
42
+
43
+ it("formats decisions as an MCP Apps ui/message notification", () => {
44
+ expect(reviewMessage({ decision: "approved" })).toEqual({
45
+ jsonrpc: "2.0",
46
+ method: "ui/message",
47
+ params: { role: "user", content: [{ type: "text", text: '{"decision":"approved"}' }] },
48
+ });
49
+ expect(reviewMessage({ decision: "changes_requested", feedback: "Fix the title" }).params.content[0].text)
50
+ .toBe('{"decision":"changes_requested","feedback":"Fix the title"}');
51
+ });
52
+
53
+ it("performs the MCP Apps handshake and awaits a successful submit", async () => {
54
+ const harness = createBridgeHarness();
55
+ harness.run();
56
+ expect(harness.messages[0]).toMatchObject({ id: 1, method: "ui/initialize" });
57
+ harness.respond({ id: 1, result: { protocolVersion: "2026-01-26" } });
58
+ await tick();
59
+ expect(harness.messages[1]).toMatchObject({ method: "ui/notifications/initialized" });
60
+
61
+ harness.elements.approve.onclick();
62
+ expect(harness.messages[2]).toMatchObject({ id: 2, method: "tools/call" });
63
+ expect(harness.messages).not.toContainEqual(expect.objectContaining({ method: "ui/message" }));
64
+ harness.respond({ id: 2, result: { structuredContent: { decision: "approved" } } });
65
+ await tick();
66
+ expect(harness.messages[3]).toMatchObject({ method: "ui/message" });
67
+ expect(new Set(harness.messages.filter((message) => "id" in message).map((message) => message.id)).size).toBe(2);
68
+ });
69
+
70
+ it("surfaces submit errors without sending a decision message and rejects invalid bridge messages", async () => {
71
+ const harness = createBridgeHarness();
72
+ harness.run();
73
+ harness.dispatch({ source: {}, data: { jsonrpc: "2.0", id: 1, result: {} } });
74
+ expect(harness.elements.status.textContent).toBe("");
75
+ harness.respond({ id: 1, result: {} });
76
+ await tick();
77
+ harness.elements.changes.onclick();
78
+ expect(harness.elements.status.textContent).toBe("Feedback is required.");
79
+ harness.elements.feedback.value = "Fix the title";
80
+ harness.elements.changes.onclick();
81
+ harness.respond({ id: 2, error: { message: "submit failed" } });
82
+ await tick();
83
+ expect(harness.elements.status.textContent).toBe("submit failed");
84
+ expect(harness.messages.some((message) => message.method === "ui/message")).toBe(false);
85
+ });
86
+ });
87
+
88
+ function tick() {
89
+ return new Promise((resolve) => setTimeout(resolve, 0));
90
+ }
91
+
92
+ function createBridgeHarness() {
93
+ const messages: any[] = [];
94
+ const listeners: ((event: any) => void)[] = [];
95
+ const element = (value = "") => ({ value, textContent: "", innerHTML: "", onclick: undefined as (() => void) | undefined, focus() {} });
96
+ const elements = {
97
+ feedback: element(),
98
+ status: element(),
99
+ article: element(),
100
+ pathLabel: element(),
101
+ approve: element(),
102
+ changes: element(),
103
+ };
104
+ const parent = { postMessage(message: any) { messages.push(message); } };
105
+ const document = {
106
+ getElementById(id: string) { return id === "feedback" ? elements.feedback : id === "status" ? elements.status : id === "approve" ? elements.approve : elements.changes; },
107
+ querySelector(selector: string) { return selector === "article" ? elements.article : elements.pathLabel; },
108
+ };
109
+ const window = { parent, addEventListener(_type: string, listener: (event: any) => void) { listeners.push(listener); } };
110
+ const context = vm.createContext({ window, document });
111
+ return {
112
+ messages,
113
+ elements,
114
+ run() { vm.runInContext(buildPreviewBridgeScript("markcut.preview.submit"), context); },
115
+ dispatch(event: any) { listeners.forEach((listener) => listener(event)); },
116
+ respond(message: any) { this.dispatch({ source: parent, data: { jsonrpc: "2.0", ...message } }); },
117
+ };
118
+ }