-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patheditaplot_mcp_server.py
More file actions
273 lines (245 loc) · 10.4 KB
/
Copy patheditaplot_mcp_server.py
File metadata and controls
273 lines (245 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
editaplot-dsh MCP server — exposes hang-jin/editaplot as MCP tools to DSH.
This is a derivative work of hang-jin/editaplot (Apache License 2.0) adapted
to the Model Context Protocol (MCP). It does not modify the upstream EditaPlot
runtime; it shells out to the upstream `editaplot.cmd` (Windows) or
`editaplot` CLI and translates the arguments/results into MCP tool shapes.
Tool surface (the model sees these as mcp__editaplot__*):
compatibility() — probe local Origin/OriginPro, return status
list_templates() — enumerate the 30+ verified templates
describe_template(name) — return the template's contract (input/output)
render_chart(input) — invoke editaplot, return OPJU/PNG/PDF/TIF paths
export(input) — re-export an existing OPJU to PNG/PDF/TIF
validate_template(name, data) — dry-run a template against sample data
The server only calls a locally installed EditaPlot (via the upstream CLI) and
never installs or modifies Origin/OriginPro itself. When Origin is missing,
every tool returns a structured error so the model can fall back gracefully.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
# MCP is the same dependency dsh-origin-plugin relies on.
try:
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import ImageContent, TextContent, Tool
except ImportError as exc: # pragma: no cover - the launcher surfaces this
sys.stderr.write(
"[editaplot-dsh] Python dependency `mcp` not found. "
"Install with: pip install mcp>=1.0.0\n"
)
raise
SERVER_NAME = "editaplot"
VERSION_TARGET = os.environ.get("EDITAPLOT_VERSION_TARGET", "2025b")
VERIFIED_BASELINE = os.environ.get("EDITAPLOT_VERIFIED_BASELINE", "2024b")
# On Windows the upstream ships editaplot.cmd; on POSIX the install matrix
# does not yet support Origin automation (per upstream SKILL.md line 22).
IS_WINDOWS = sys.platform == "win32"
CLI_BASENAME = "editaplot.cmd" if IS_WINDOWS else "editaplot"
def _cli_path() -> str:
"""Locate the upstream editaplot CLI inside the same install tree.
The DSH plugin manager installs editaplot-dsh under
<DSH_HOME>/profiles/<profile>/node_modules/editaplot-dsh/. The upstream
editaplot package is a sibling (peerDep), so its CLI sits at
node_modules/editaplot/editaplot.cmd. PATH may also carry it.
"""
here = Path(__file__).resolve().parent
sibling = here.parent / "editaplot" / CLI_BASENAME
if sibling.exists():
return str(sibling)
from shutil import which
found = which(CLI_BASENAME.rstrip(".cmd"))
return found or str(sibling)
def _run_cli(args: list[str], timeout: int = 60) -> dict[str, Any]:
"""Invoke the upstream CLI and capture a structured result."""
cmd = [_cli_path(), *args, "--output-format", "json"]
try:
completed = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except FileNotFoundError:
return {
"ok": False,
"code": "EDITAPLOT_NOT_FOUND",
"message": f"Could not locate upstream EditaPlot CLI at {_cli_path()}",
}
except subprocess.TimeoutExpired:
return {
"ok": False,
"code": "EDITAPLOT_TIMEOUT",
"message": f"EditaPlot CLI exceeded {timeout}s timeout",
}
if completed.returncode != 0:
return {
"ok": False,
"code": "EDITAPLOT_NONZERO_EXIT",
"message": completed.stderr.strip() or completed.stdout.strip(),
"exit_code": completed.returncode,
}
try:
return {"ok": True, "data": json.loads(completed.stdout)}
except json.JSONDecodeError:
return {"ok": True, "data": {"raw": completed.stdout}}
server = Server(SERVER_NAME)
@server.list_tools()
async def list_tools() -> list[Tool]:
"""The tools the DSH model will see as mcp__editaplot__*."""
return [
Tool(
name="compatibility",
description=(
"Probe the local Origin/OriginPro installation and return a "
"structured compatibility report (verified | "
"compatible_unverified | blocked). The fully verified "
f"baseline is Origin/OriginPro {VERIFIED_BASELINE}. This "
f"plugin targets the {VERSION_TARGET} range; 2021+ is in scope."
),
inputSchema={"type": "object", "properties": {}, "additionalProperties": False},
),
Tool(
name="list_templates",
description=(
"List the 30+ EditaPlot scientific chart templates (bar, "
"bland_altman, bubble, calibration_curve, circular_network, "
"confusion_matrix, cv, decision_curve, density_ridgeline3d, "
"diagnostic_curve, dsc, eis, forest, ftir, grouped_box, "
"heatmap, histogram, horizontal_bar, line_error, lsv, nmr, "
"paired_trajectory, percent_stacked_bar, radar, sankey, "
"scatter_matrix, stacked_bar, subplot_grid, trajectory3d, "
"violin, xps, xrd, ...). Each entry includes the verified "
"Origin version it was tested on."
),
inputSchema={"type": "object", "properties": {}, "additionalProperties": False},
),
Tool(
name="describe_template",
description=(
"Return the data contract and origin_acceptance notes for a "
"single template. The model should call this BEFORE "
"render_chart so it knows the required columns and the "
"experimental claims the template supports."
),
inputSchema={
"type": "object",
"properties": {
"name": {"type": "string", "description": "Template name."}
},
"required": ["name"],
"additionalProperties": False,
},
),
Tool(
name="render_chart",
description=(
"Render a chart through EditaPlot and return the produced "
"OPJU/PNG/PDF/TIF file paths plus an object-readback summary. "
"The model must have called describe_template first and the "
"user must have approved the scientific purpose."
),
inputSchema={
"type": "object",
"properties": {
"template": {"type": "string"},
"data": {"type": "string", "description": "Path to CSV/XLSX input."},
"evidence_role": {
"type": "string",
"enum": ["main", "support", "verify"],
},
"output_dir": {"type": "string"},
},
"required": ["template", "data"],
"additionalProperties": False,
},
),
Tool(
name="validate_template",
description=(
"Dry-run a template against sample data without producing any "
"OPJU. Returns the same per-column contract checks the live "
"route performs, so the model can confirm the data shape "
"before committing to a render."
),
inputSchema={
"type": "object",
"properties": {
"name": {"type": "string"},
"data": {"type": "string"},
},
"required": ["name", "data"],
"additionalProperties": False,
},
),
Tool(
name="export",
description=(
"Re-export an existing editable OPJU to PNG/PDF/TIF. Useful "
"when the user edits the OPJU manually and wants updated "
"raster exports without re-running the whole pipeline."
),
inputSchema={
"type": "object",
"properties": {
"opju": {"type": "string"},
"formats": {
"type": "array",
"items": {"type": "string", "enum": ["png", "pdf", "tif"]},
},
},
"required": ["opju"],
"additionalProperties": False,
},
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
"""Dispatch an MCP tool call to the upstream EditaPlot CLI."""
if name == "compatibility":
result = _run_cli(["compat", "check"])
elif name == "list_templates":
result = _run_cli(["templates", "list"])
elif name == "describe_template":
tpl = arguments.get("name", "")
result = _run_cli(["templates", "describe", tpl])
elif name == "render_chart":
args = ["render"]
if arguments.get("template"):
args += ["--template", arguments["template"]]
if arguments.get("data"):
args += ["--data", arguments["data"]]
if arguments.get("output_dir"):
args += ["--output-dir", arguments["output_dir"]]
if arguments.get("evidence_role"):
args += ["--evidence-role", arguments["evidence_role"]]
result = _run_cli(args, timeout=180)
elif name == "validate_template":
result = _run_cli(
["templates", "validate", arguments["name"], arguments["data"]],
timeout=60,
)
elif name == "export":
formats = ",".join(arguments.get("formats") or ["png", "pdf", "tif"])
result = _run_cli(["export", arguments["opju"], "--formats", formats])
else:
return [TextContent(type="text", text=json.dumps({
"ok": False,
"code": "UNKNOWN_TOOL",
"message": f"Tool {name} is not registered on editaplot-dsh",
}))]
return [TextContent(type="text", text=json.dumps(result, indent=2))]
async def main() -> None:
"""Run the MCP server over stdio (the transport declared in cordis.patch.yml)."""
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())
if __name__ == "__main__":
import asyncio
asyncio.run(main())