Skip to content

Commit 59c1f49

Browse files
masonwyatt23claude
andcommitted
feat: MCP export tool — theme override, export:current resource, full integration tests
- Enhance export tool: add optional theme param (paper/sepia/midnight) for HTML exports; validated in tool_export, forwarded as 'theme' in IPC body - Add export:current MCP resource (text/html, read-only): polls /export/preview on the IPC server and returns live HTML with a metadata comment header so agents can preview rendered output without writing any files - 29 new Rust integration tests in tests/integration.rs covering: - Successful export for pdf/docx/html formats - output_path forwarded (or null) in IPC body - theme override for all three values; invalid theme → -32602 - File-write errors (permission denied, disk full, app not running) → -32000 - export:current in resources/list (mime type, description) - resources/read export:current: success, metadata comment, IPC errors - dispatch routing for both resources/list and resources/read - Add src/mcp/bridge.test.ts (32 tests) verifying: - mcp://export → uiStore.openExport() when doc open; toast when no doc - All three export formats (pdf/html/docx) open the dialog - mcp://set-content → store update + immediate mcp_sync_state push - mcp://edit → find/replace on live content + mcp_edit_result (ok/fail) - mcp://review → pending review registered in reviewStore - mcp://present → zenMode=true + viewMode=read - mcp://open → read_markdown_file invoked + mode applied - Mount: mcp_sync_state + mcp_sync_vault called on startup Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 098fc37 commit 59c1f49

3 files changed

Lines changed: 1048 additions & 8 deletions

File tree

src-tauri/bins/mdopener-mcp/src/lib.rs

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ pub fn tool_registry() -> Vec<ToolDef> {
279279
},
280280
ToolDef {
281281
name: "export",
282-
description: "Trigger an export of the currently open document.",
282+
description: "Trigger an export of the currently open document. For HTML exports a theme override (paper/sepia/midnight) can be supplied; otherwise the app's current theme is used.",
283283
input_schema: json!({
284284
"type": "object",
285285
"properties": {
@@ -291,6 +291,11 @@ pub fn tool_registry() -> Vec<ToolDef> {
291291
"output_path": {
292292
"type": "string",
293293
"description": "Optional absolute path for the output file."
294+
},
295+
"theme": {
296+
"type": "string",
297+
"enum": ["paper", "sepia", "midnight"],
298+
"description": "Optional theme override for HTML exports (paper/sepia/midnight). When omitted the app's current theme is used. Ignored for PDF and DOCX."
294299
}
295300
},
296301
"required": ["format"]
@@ -565,11 +570,27 @@ pub fn tool_export(id: Value, args: &Value, ipc: &dyn IpcClient) -> Response {
565570
Some(f) => f.to_string(),
566571
None => return Response::err(id, -32602, "`format` is required"),
567572
};
573+
574+
// Validate theme when provided — only valid for html format.
575+
let theme = args["theme"].as_str().map(str::to_string);
576+
if let Some(ref t) = theme {
577+
match t.as_str() {
578+
"paper" | "sepia" | "midnight" => {}
579+
other => {
580+
return Response::err(
581+
id,
582+
-32602,
583+
format!("`theme` must be one of paper/sepia/midnight, got '{other}'"),
584+
)
585+
}
586+
}
587+
}
588+
568589
let output_path = args["output_path"].as_str().map(str::to_string);
569590

570591
match ipc.post(
571592
"/export",
572-
json!({ "format": format, "outputPath": output_path }),
593+
json!({ "format": format, "outputPath": output_path, "theme": theme }),
573594
) {
574595
Ok(v) => tool_result(id, v),
575596
Err(e) => Response::err(id, -32000, app_not_running_msg(&e)),
@@ -878,12 +899,20 @@ pub fn tool_list_ai_actions(id: Value, args: &Value) -> Response {
878899
// ── Resources ─────────────────────────────────────────────────────────────────
879900

880901
pub fn handle_resources_list(id: Value, ipc: &dyn IpcClient) -> Response {
881-
let mut resources = vec![json!({
882-
"uri": "mdopener://current",
883-
"name": "Current document",
884-
"description": "The document currently open in Ashlr MD.",
885-
"mimeType": "text/markdown"
886-
})];
902+
let mut resources = vec![
903+
json!({
904+
"uri": "mdopener://current",
905+
"name": "Current document",
906+
"description": "The document currently open in Ashlr MD.",
907+
"mimeType": "text/markdown"
908+
}),
909+
json!({
910+
"uri": "export:current",
911+
"name": "Live HTML export",
912+
"description": "Read-only live HTML export of the current document. Auto-updates when the document is edited. Agents can poll this to preview rendered output without writing any files.",
913+
"mimeType": "text/html"
914+
}),
915+
];
887916

888917
if let Ok(v) = ipc.get("/vault") {
889918
if let Some(files) = v["files"].as_array() {
@@ -914,6 +943,34 @@ pub fn handle_resource_read(id: Value, uri: &str, ipc: &dyn IpcClient) -> Respon
914943
Err(e) => Response::err(id, -32000, app_not_running_msg(&e)),
915944
};
916945
}
946+
947+
// export:current — live HTML export of the current document.
948+
// The app renders the document to HTML and returns it via /export/preview.
949+
// This endpoint is read-only and returns the latest rendered state each time
950+
// it is polled, so agents always see up-to-date output without file I/O.
951+
if uri == "export:current" {
952+
return match ipc.get("/export/preview") {
953+
Ok(v) => {
954+
let html = v["html"].as_str().unwrap_or("").to_string();
955+
let title = v["title"].as_str().unwrap_or("").to_string();
956+
let theme = v["theme"].as_str().unwrap_or("paper").to_string();
957+
// Include metadata as a comment so agents can inspect it without
958+
// parsing the full HTML document.
959+
let annotated = format!(
960+
"<!-- export:current title={title:?} theme={theme:?} -->\n{html}"
961+
);
962+
Response::ok(id, json!({
963+
"contents": [{
964+
"uri": uri,
965+
"mimeType": "text/html",
966+
"text": annotated
967+
}]
968+
}))
969+
}
970+
Err(e) => Response::err(id, -32000, app_not_running_msg(&e)),
971+
};
972+
}
973+
917974
if let Some(p) = uri.strip_prefix("file://") {
918975
if !is_advertised_path(p, ipc) {
919976
return Response::err(id, -32002, format!("Resource not in vault: {uri}"));

0 commit comments

Comments
 (0)