dsh-origin-bridge 0.4.3 → 0.4.5

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/server.py CHANGED
@@ -1,612 +1,612 @@
1
- """origin-bridge MCP server.
2
-
3
- Deliberately a synchronous, newline-delimited JSON-RPC loop over stdio instead
4
- of the official `mcp` SDK's asyncio transport: on Windows the SDK builds an
5
- asyncio ProactorEventLoop, whose self-pipe falls back to a loopback
6
- socketpair() (127.0.0.1 listen+connect+accept). Host firewalls that block
7
- loopback accepts make that hang forever, the MCP handshake never completes, and
8
- the client stalls for its full 60s request timeout before giving up.
9
-
10
- Usage: python server.py # stdio, for dsh / any MCP client
11
- python server.py --tools # print the tool table and exit
12
- """
13
-
14
- import json
15
- import os
16
- import sys
17
-
18
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19
-
20
- import originlab # noqa: E402
21
-
22
- SERVER_NAME = "origin-bridge"
23
- SERVER_VERSION = "0.4.3"
24
- PROTOCOL = "2024-11-05"
25
-
26
-
27
- def _write(a):
28
- if a.get("rows"):
29
- return originlab.write_block(a.get("headers") or [], a["rows"], a.get("book_name", ""))
30
- return originlab.write_columns(a.get("columns") or {}, a.get("book_name", ""))
31
-
32
- TOOLS = [
33
- {
34
- "name": "origin_status",
35
- "description": "检查能否连上本机 Origin/OriginPro,返回版本、Python、已打开的页面。任何绘图操作前先用它确认连接。",
36
- "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
37
- "run": lambda a: originlab.status(),
38
- },
39
- {
40
- "name": "origin_import",
41
- "description": "把本地数据文件导入 Origin 新建的工作表。支持 .dat/.csv/.txt/.tsv/.xls/.xlsx/.wks,以及电化学工作站的二进制 .bin(CV/LSV/EIS/i-t,头部带 80F21B00 魔数那种)。返回 worksheet 句柄和列清单,后续步骤原样传回该句柄。.bin 会带 role_confidence:ambiguous 表示两列同量级、分不清电势/电流,这时先 origin_view 看图再定轴标题。",
42
- "inputSchema": {
43
- "type": "object",
44
- "properties": {
45
- "path": {"type": "string", "description": "数据文件绝对路径"},
46
- "book_name": {"type": "string", "description": "可选:设给工作表的长名(Long Name),不是短名"},
47
- },
48
- "required": ["path"],
49
- },
50
- "run": lambda a: originlab.import_file(a["path"], a.get("book_name", "")),
51
- },
52
- {
53
- "name": "origin_write",
54
- "description": "用内联数据建工作表。窄表用 columns={列名:[数值...]};宽表(>5 列)用 headers+rows 二维块,一次写完,比逐列快很多。",
55
- "inputSchema": {
56
- "type": "object",
57
- "properties": {
58
- "columns": {"type": "object", "description": '{"Time": [0,1,2], "Signal": [3,4,5]}'},
59
- "headers": {"type": "array", "description": "配合 rows 使用:列名列表"},
60
- "rows": {"type": "array", "description": "配合 headers 使用:二维数据块"},
61
- "book_name": {"type": "string"},
62
- },
63
- },
64
- "run": lambda a: _write(a),
65
- },
66
- {
67
- "name": "origin_formula",
68
- "description": "给某列设计算公式(Fx),让它随兄弟列自动重算,用于派生列。列号从 1 开始或给列名。会读回该列算出的行数和首末值。",
69
- "inputSchema": {
70
- "type": "object",
71
- "properties": {
72
- "worksheet": {"type": "string"},
73
- "col": {"type": ["integer", "string"], "description": "1 起始列号或列名"},
74
- "formula": {"type": "string", "description": "如 Col(1)*2.5+0.1;LabTalk 写法"},
75
- "label": {"type": "string"},
76
- "units": {"type": "string"},
77
- },
78
- "required": ["worksheet", "col", "formula"],
79
- },
80
- "run": lambda a: originlab.column_formula(a["worksheet"], a["col"], a["formula"],
81
- a.get("label", ""), a.get("units", "")),
82
- },
83
- {
84
- "name": "origin_plot",
85
- "description": "在工作表上画图。x/y 用 1 起始列号或列名;y 可以给列表,一次把多条曲线画进同一层。plot_type: line|scatter|line_symbol|column。给 template 用 Origin 自带图模建页(如 doubley/heat_map/line),给 graph+layer 则往已有图页的指定层里加曲线。返回里有 warnings 时代表图可能不可读(线性轴跨数量级等),要把选项交给用户决定。",
86
- "inputSchema": {
87
- "type": "object",
88
- "properties": {
89
- "worksheet": {"type": "string", "description": "origin_import/origin_write 返回的 worksheet 句柄"},
90
- "x": {"type": ["integer", "string"], "default": 1},
91
- "y": {"type": ["integer", "string", "array"], "default": 2,
92
- "description": "列号/列名,或列表 [2,3,4] 一次画多条"},
93
- "plot_type": {"type": "string", "enum": ["line", "scatter", "line_symbol", "column"], "default": "line"},
94
- "graph_name": {"type": "string"},
95
- "title": {"type": "string", "description": "图层标题"},
96
- "graph": {"type": "string", "description": "已有图页句柄;配合 layer 往该层加曲线"},
97
- "layer": {"type": "integer", "default": 0, "description": "目标层索引(双 Y 轴的第二层=1)"},
98
- "template": {"type": "string", "description": "Origin 图模名,如 line/scatter/doubley/heat_map/cmap/mesh"},
99
- },
100
- "required": ["worksheet"],
101
- },
102
- "run": lambda a: originlab.plot(a["worksheet"], a.get("x", 1), a.get("y", 2),
103
- a.get("plot_type", "line"), a.get("graph_name", ""),
104
- a.get("title", ""), graph=a.get("graph", ""),
105
- layer=a.get("layer", 0), template=a.get("template", "")),
106
- },
107
- {
108
- "name": "origin_read_file",
109
- "description": "只解析数据文件、不碰 Origin:返回编码、分隔符、表头行数、每列长名/单位/有效行数,以及仪器参数 metadata。拿到陌生 .dat/.csv/.txt 先用它看清结构,再决定 origin_import 用哪几列。支持 #、//、!、; 注释、BOM、GBK、空格对齐、Fortran 的 1.5d+00 指数、NA/- 缺失值、Origin 三行表头(Long Name/Units/Comments),也支持电化学工作站(CH Instruments 等)导出的 txt:前面几十行参数说明会被跳进 metadata,紧贴数据的 Potential/V, Current/A 这类表头按斜杠拆出列名和单位。.bin 走另一条解析路线:返回技术标签(CV/LSV/IMP/i-t)、点数、两个 float32 数组的范围和 role_confidence。",
110
- "inputSchema": {
111
- "type": "object",
112
- "properties": {
113
- "path": {"type": "string", "description": "数据文件绝对路径(含 .bin)"},
114
- "comment": {"type": "string", "description": "可选:强制指定注释前缀,默认自动识别"},
115
- },
116
- "required": ["path"],
117
- },
118
- "run": lambda a: originlab.read_file(a["path"], a.get("comment")),
119
- },
120
- {
121
- "name": "origin_style",
122
- "description": "改图并逐项读回验证:轴标题、对数轴、坐标范围与步长、逐曲线颜色/线宽/符号形状/符号大小/填充/透明度。读不回来的项会进 failed 列表,不会假装成功。",
123
- "inputSchema": {
124
- "type": "object",
125
- "properties": {
126
- "graph": {"type": "string", "description": "origin_plot 返回的 graph 句柄"},
127
- "layer": {"type": "integer", "default": 0, "description": "层索引,0 起"},
128
- "series": {"type": "integer", "default": 0, "description": "该层里的第几条曲线,0 起"},
129
- "x_title": {"type": "string"},
130
- "y_title": {"type": "string"},
131
- "x_scale": {"type": "string", "enum": ["linear", "log", "log10", "ln", "log2",
132
- "probit", "probability", "reciprocal",
133
- "offset_reciprocal", "logit"]},
134
- "y_scale": {"type": "string", "enum": ["linear", "log", "log10", "ln", "log2",
135
- "probit", "probability", "reciprocal",
136
- "offset_reciprocal", "logit"]},
137
- "xlim": {"type": "array", "description": "[from,to] 或 [from,to,step]"},
138
- "ylim": {"type": "array", "description": "[from,to] 或 [from,to,step]"},
139
- "xtick": {"type": "number", "description": "X 刻度步长"},
140
- "ytick": {"type": "number", "description": "Y 刻度步长"},
141
- "color": {"type": "string", "description": "如 #0072B2 或 Origin 颜色名"},
142
- "line_width": {"type": "number"},
143
- "symbol_kind": {"type": "integer", "description": "Origin 符号形状编号,如 3=圆 1=方"},
144
- "symbol_size": {"type": "number"},
145
- "symbol_interior": {"type": "integer", "enum": [0, 1, 2, 3],
146
- "description": "0 无符号 1 实心 2 空心 3 点心"},
147
- "transparency": {"type": "number", "description": "0-100 百分比"},
148
- "fill_area": {"type": "boolean", "description": "线下填充(面积图效果)"},
149
- },
150
- "required": ["graph"],
151
- },
152
- "run": lambda a: originlab.style(
153
- a["graph"], layer=a.get("layer", 0), series=a.get("series", 0),
154
- x_title=a.get("x_title", ""), y_title=a.get("y_title", ""),
155
- x_scale=a.get("x_scale", ""), y_scale=a.get("y_scale", ""),
156
- xlim=a.get("xlim"), ylim=a.get("ylim"),
157
- xtick=a.get("xtick", 0.0), ytick=a.get("ytick", 0.0),
158
- color=a.get("color", ""), symbol_kind=a.get("symbol_kind", 0),
159
- symbol_size=a.get("symbol_size", 0.0), symbol_interior=a.get("symbol_interior", 0),
160
- line_width=a.get("line_width", 0.0), transparency=a.get("transparency", 0.0),
161
- fill_area=a.get("fill_area", False)),
162
- },
163
- {
164
- "name": "origin_fit",
165
- "description": "拟合。kind=linear 线性(Origin 标准版可用);非线性直接用预设名 kind=gauss|lorentz|voigt|expdec1|expdec2|sine|power|logistic|boltzmann|doseresp|cubic(本机逐个实测可用;poly3=cubic、expdecay=expdec1 是别名),或 kind=nlfitsing + func 给 Origin 的函数名(区分大小写:Gauss 对、gauss1 不存在)。func 只能是内置函数名,自定义表达式不支持。只给 func 不给 kind 时按非线性执行并在 note 里说明。可固定参数 fixed、给初值 starts、加边界 bounds,并可生成报告表。缺失值已消毒为 null。",
166
- "inputSchema": {
167
- "type": "object",
168
- "properties": {
169
- "worksheet": {"type": "string"},
170
- "x": {"type": ["integer", "string"], "default": 1},
171
- "y": {"type": ["integer", "string"], "default": 2},
172
- "kind": {"type": "string", "enum": ["linear", "nlfitsing", "gauss", "lorentz",
173
- "voigt", "expdec1", "expdec2", "sine", "power",
174
- "logistic", "boltzmann", "doseresp", "cubic",
175
- "poly3", "expdecay"],
176
- "default": "linear"},
177
- "func": {"type": "string", "description": "Origin 内置函数名(区分大小写),如 Gauss / ExpDec1;给了 func 就按非线性拟合,不要写自定义表达式"},
178
- "fixed": {"type": "object", "description": "固定参数 {参数名: 值},如 {\"y0\": 0}"},
179
- "starts": {"type": "object", "description": "初值 {参数名: 值};linear 用 Slope/Intercept"},
180
- "bounds": {"type": "object", "description": "边界 {参数名: [下界, 上界]},null 表示不设"},
181
- "make_report": {"type": "boolean", "default": False, "description": "生成 Origin 拟合报告表"},
182
- "full": {"type": "boolean", "default": False,
183
- "description": "true 返回 Origin 的完整报告字典(很大);默认只回参数值与关键统计"},
184
- },
185
- "required": ["worksheet"],
186
- },
187
- "run": lambda a: originlab.fit(a["worksheet"], a.get("x", 1), a.get("y", 2),
188
- a.get("kind", "linear"), a.get("func", ""),
189
- fixed=a.get("fixed"), starts=a.get("starts"),
190
- bounds=a.get("bounds"), make_report=a.get("make_report", False),
191
- full=a.get("full", False)),
192
- },
193
- {
194
- "name": "origin_export",
195
- "description": "把图页导出为图片文件,并校验文件确实存在、够大、文件头正确(Origin 有静默失败的历史)。fmt: png/tif/jpg/svg/pdf/emf(tiff→tif、jpeg→jpg 会自动改写扩展名)。",
196
- "inputSchema": {
197
- "type": "object",
198
- "properties": {
199
- "graph": {"type": "string"},
200
- "path": {"type": "string", "description": "输出文件绝对路径;省略则用图页名放到当前目录"},
201
- "fmt": {"type": "string", "enum": ["png", "tif", "svg", "pdf", "emf"], "default": "png"},
202
- "width": {"type": "integer", "default": 1600},
203
- },
204
- },
205
- "run": lambda a: originlab.export(a.get("graph", ""), a.get("path", ""),
206
- a.get("fmt", "png"), a.get("width", 1600)),
207
- },
208
- {
209
- "name": "origin_save_project",
210
- "description": "保存为可在 Origin 里继续编辑的 .opju 工程文件(不是图片快照)。省略 path 会自动命名。",
211
- "inputSchema": {
212
- "type": "object",
213
- "properties": {"path": {"type": "string"}},
214
- },
215
- "run": lambda a: originlab.save_project(a.get("path", "")),
216
- },
217
- {
218
- "name": "origin_open",
219
- "description": "打开已有的 .opju/.opj 工程,返回页面清单。",
220
- "inputSchema": {
221
- "type": "object",
222
- "properties": {"path": {"type": "string"}},
223
- "required": ["path"],
224
- },
225
- "run": lambda a: originlab.open_project(a["path"]),
226
- },
227
- {
228
- "name": "origin_inspect",
229
- "description": "读回当前项目真实状态:有哪些页面、图页有几层、每层轴标题和曲线数、已分配的句柄。改图之前先调用它,别猜索引。",
230
- "inputSchema": {
231
- "type": "object",
232
- "properties": {"graph": {"type": "string"}},
233
- },
234
- "run": lambda a: originlab.inspect(a.get("graph", "")),
235
- },
236
- {
237
- "name": "origin_figure",
238
- "description": "主路径:一次调用完成 导入/写数 → 画图 → 轴标题 → 导出图片 → 保存可编辑 .opju。用户只想“把这份数据画成图并给我文件”时用它,不要拆成多步。返回里若有 warnings,说明图虽然画成了但可能不可读(例如线性横轴跨了几个数量级,点全挤在最左边):把 next_actions 里的方案讲给用户让他选,不要自己擅自改轴。",
239
- "inputSchema": {
240
- "type": "object",
241
- "properties": {
242
- "source": {"type": "string", "description": "数据文件路径(与 columns 二选一)"},
243
- "columns": {"type": "object", "description": "内联数据 {列名: [值...]},优先用 source"},
244
- "x": {"type": ["integer", "string"], "default": 1},
245
- "y": {"type": ["integer", "string"], "default": 2},
246
- "plot_type": {"type": "string", "enum": ["line", "scatter", "line_symbol", "column"], "default": "line"},
247
- "title": {"type": "string"},
248
- "x_title": {"type": "string"},
249
- "y_title": {"type": "string"},
250
- "fmt": {"type": "string", "enum": ["png", "tif", "svg", "pdf", "emf"], "default": "png"},
251
- "width": {"type": "integer", "default": 1600},
252
- "output_dir": {"type": "string", "description": "交付目录:图片 + opju 都写到这。不给则只建图、不落盘"},
253
- "export_path": {"type": "string"},
254
- "project_path": {"type": "string"},
255
- "do_project": {"type": "boolean", "default": False,
256
- "description": "true 时即使没给目录也存一份 .opju 到 Origin 的 User Files"},
257
- "template": {"type": "string", "description": "Origin 图模名,如 line/scatter/doubley;省略走默认 plotxy"},
258
- "legend": {"type": ["boolean", "array"], "default": False,
259
- "description": "true=按列长名自动生成图例;或给名称列表写死"},
260
- },
261
- },
262
- "run": lambda a: originlab.figure(
263
- source=a.get("source", ""), columns=a.get("columns"), x=a.get("x", 1), y=a.get("y", 2),
264
- plot_type=a.get("plot_type", "line"), title=a.get("title", ""),
265
- x_title=a.get("x_title", ""), y_title=a.get("y_title", ""), fmt=a.get("fmt", "png"),
266
- width=a.get("width", 1600), output_dir=a.get("output_dir", ""),
267
- export_path=a.get("export_path", ""), project_path=a.get("project_path", ""),
268
- do_project=a.get("do_project"), template=a.get("template", ""),
269
- legend=a.get("legend", False)),
270
- },
271
- {
272
- "name": "origin_exit",
273
- "description": "关闭 Origin。默认不保存;给 save_to 会先存一份 .opju 再退。",
274
- "inputSchema": {
275
- "type": "object",
276
- "properties": {"save_to": {"type": "string"}},
277
- },
278
- "run": lambda a: originlab.exit_origin(a.get("save_to", "")),
279
- },
280
- {
281
- "name": "origin_instances",
282
- "description": "清点机器上的 Origin 实例:分「后台无窗口」(客户端退出后残留,会占配额)和「前台有窗口」(可能是用户自己开着的项目)。Origin 实例数超上限、或报 connection_error 时先调这个看情况。",
283
- "inputSchema": {"type": "object", "properties": {}},
284
- "run": lambda a: originlab.instances(),
285
- },
286
- {
287
- "name": "origin_reclaim",
288
- "description": "回收残留的 Origin 实例:只关**没有窗口**的后台实例;有窗口的一律不动,只在返回里告诉你哪几个需要用户自己关。close_background=false 时只看不动手。",
289
- "inputSchema": {
290
- "type": "object",
291
- "properties": {"close_background": {"type": "boolean", "default": True}},
292
- },
293
- "run": lambda a: originlab.reclaim(bool(a.get("close_background", True))),
294
- },
295
- {
296
- "name": "origin_annotate",
297
- "description": "在图上加文字标注和参考线(数据坐标系)。比如标峰位、画阈值线。标注没有值可读回,返回 readback_only,要确证用 origin_view 看图。",
298
- "inputSchema": {
299
- "type": "object",
300
- "properties": {
301
- "graph": {"type": "string"},
302
- "layer": {"type": "integer", "default": 0},
303
- "labels": {"type": "array", "description": '[{"text":"peak","x":12.5,"y":80}]'},
304
- "lines": {"type": "array", "description": '[{"x1":0,"y1":50,"x2":23,"y2":50}]'},
305
- },
306
- "required": ["graph"],
307
- },
308
- "run": lambda a: originlab.annotate(a["graph"], a.get("labels") or [],
309
- a.get("lines") or [], a.get("layer", 0)),
310
- },
311
- {
312
- "name": "origin_layer",
313
- "description": "给已有图页加一层:双 Y 轴用 layer_type=right,上下多面板用 top/bottom。返回 Origin 自己给这层起的名字(RightY 等)作为证据,以及新层索引。往新层画:origin_plot 时带上 graph 和 layer=该索引。",
314
- "inputSchema": {
315
- "type": "object",
316
- "properties": {
317
- "graph": {"type": "string"},
318
- "layer_type": {"type": ["string", "integer"], "default": "right",
319
- "description": "bottom(=bottom_left)|top|right|left|top_right|bottom_right|inset,或对应整数 0-6"},
320
- },
321
- "required": ["graph"],
322
- },
323
- "run": lambda a: originlab.add_layer(a["graph"], a.get("layer_type", "right")),
324
- },
325
- {
326
- "name": "origin_legend",
327
- "description": "图例。不给 entries 时用 legend -r 按各条曲线对应列的长名自动生成;给了就逐行写死。position 用图内位置名(inside_top_right/top_right/bottom_left…)。位置按该层数据坐标写入并读回,文字和可见性都验证。",
328
- "inputSchema": {
329
- "type": "object",
330
- "properties": {
331
- "graph": {"type": "string", "description": "省略则用当前活动图页"},
332
- "layer": {"type": "integer", "default": 0},
333
- "entries": {"type": "array", "description": '["sin(x)","cos(x)"];省略=用列长名自动生成'},
334
- "position": {"type": "string", "enum": ["top_left", "top", "top_right", "left", "right",
335
- "bottom_left", "bottom", "bottom_right",
336
- "inside_top_left", "inside_top_right",
337
- "inside_bottom_left", "inside_bottom_right", "inside"]},
338
- "show": {"type": "boolean", "default": True},
339
- },
340
- },
341
- "run": lambda a: originlab.legend_entries(a.get("graph", ""), a.get("layer", 0),
342
- a.get("entries"), a.get("position", ""),
343
- a.get("show", True)),
344
- },
345
- {
346
- "name": "origin_matrix",
347
- "description": "网格数据 → Origin 矩阵 → 模板化矩阵图:heat_map 热力图、cmap 三维彩色映射面、mesh 网格面、contline/contgray 等高线、3d/bar3d。rows 是二维数值矩阵。可选 xy_range=[x1,x2,y1,y2] 设坐标范围。给 export_path/project_path 会顺手落盘。",
348
- "inputSchema": {
349
- "type": "object",
350
- "properties": {
351
- "rows": {"type": "array", "description": "二维矩阵 [[...],[...]],行=Y 方向、列=X 方向"},
352
- "template": {"type": "string", "enum": ["heat_map", "cmap", "mesh", "contline",
353
- "contgray", "3d", "glwireface", "glwirefrm", "bar3d"],
354
- "default": "heat_map"},
355
- "book_name": {"type": "string"},
356
- "graph_name": {"type": "string"},
357
- "xy_range": {"type": "array", "description": "[x1,x2,y1,y2] 矩阵坐标范围"},
358
- "title": {"type": "string"},
359
- "export_path": {"type": "string"},
360
- "project_path": {"type": "string"},
361
- },
362
- "required": ["rows"],
363
- },
364
- "run": lambda a: originlab.matrix_plot(a["rows"], a.get("template", "heat_map"),
365
- a.get("book_name", ""), a.get("graph_name", ""),
366
- a.get("xy_range"), a.get("title", ""),
367
- a.get("export_path", ""), a.get("project_path", "")),
368
- },
369
- {
370
- "name": "origin_chart_presets",
371
- "description": "列出 40 个科研图表预设(XPS/XRD/UV-Vis/荧光/循环伏安/Arrhenius…):每个预设带好哪些列、默认轴标题、建议拟合函数。不确定用户要的是哪类图时先查这里,别自己编轴标题。category 可过滤。",
372
- "inputSchema": {
373
- "type": "object",
374
- "properties": {"category": {"type": "string"}},
375
- },
376
- "run": lambda a: originlab.chart_presets(a.get("category", "")),
377
- },
378
- {
379
- "name": "origin_chart_preset",
380
- "description": "看单个预设的完整约定:required_columns / optional_columns / default_axis_titles / default_plot_kind / suggested_fit / extra(如 XPS 的 X 轴要反向)。",
381
- "inputSchema": {
382
- "type": "object",
383
- "properties": {"name": {"type": "string"}},
384
- "required": ["name"],
385
- },
386
- "run": lambda a: originlab.chart_preset(a["name"]),
387
- },
388
- {
389
- "name": "origin_chart_check",
390
- "description": "拿数据列名对预设做 dry-run(不碰 Origin):缺哪列、建议用什么拟合、默认轴标题是什么。渲染前先调它,能省一次失败。",
391
- "inputSchema": {
392
- "type": "object",
393
- "properties": {
394
- "name": {"type": "string"},
395
- "data": {"type": "string", "description": "数据文件路径,会先 origin_read_file 解析列名"},
396
- "columns": {"type": "array", "description": "或者直接给列名列表"},
397
- },
398
- "required": ["name"],
399
- },
400
- "run": lambda a: originlab.chart_check(a["name"], a.get("data"), a.get("columns")),
401
- },
402
- {
403
- "name": "origin_chart_render",
404
- "description": "按科研预设一步出图:读预设的轴标题/图类型 → 导入数据 → 画图 → 轴标题 → 图例 → 导出图片 + 保存 .opju。用户说“把这个做成 XPS 图”这类需求优先用它。轴要反向(如 XPS 结合能)时再调 origin_style 设 xlim=[高,低]。",
405
- "inputSchema": {
406
- "type": "object",
407
- "properties": {
408
- "name": {"type": "string"},
409
- "source": {"type": "string", "description": "数据文件路径"},
410
- "columns": {"type": "object", "description": "或内联数据 {列名:[值]}"}
411
- ,
412
- "x": {"type": ["integer", "string"], "default": 1},
413
- "y": {"type": ["integer", "string", "array"], "default": 2},
414
- "output_dir": {"type": "string", "description": "交付目录;不给则只建图不落盘"},
415
- "x_title": {"type": "string", "description": "覆盖预设的 X 轴标题"},
416
- "y_title": {"type": "string"},
417
- "title": {"type": "string"},
418
- "plot_type": {"type": "string", "enum": ["line", "scatter", "line_symbol", "column"]},
419
- "with_legend": {"type": "boolean", "default": True},
420
- "fmt": {"type": "string", "enum": ["png", "tif", "svg", "pdf", "emf"], "default": "png"},
421
- "width": {"type": "integer", "default": 1600},
422
- },
423
- "required": ["name"],
424
- },
425
- "run": lambda a: originlab.chart_render(
426
- a["name"], a.get("source", ""), a.get("columns"), a.get("x", 1), a.get("y", 2),
427
- a.get("output_dir", ""), a.get("x_title"), a.get("y_title"), a.get("title"),
428
- a.get("plot_type"), a.get("with_legend", True), a.get("fmt", "png"),
429
- a.get("width", 1600)),
430
- },
431
- {
432
- "name": "origin_close_pages",
433
- "description": "关掉项目里攒下来的页面。长会话里页面攒太多会让 Origin 的 COM 桥报“无效指针”,这时按类型或名字清理。默认只看图页;dry_run=true 只报告不关。",
434
- "inputSchema": {
435
- "type": "object",
436
- "properties": {
437
- "kind": {"type": "string", "enum": ["graph", "worksheet", "matrix", "notes"], "default": "graph"},
438
- "names": {"type": "array", "description": '只关这些页面 ["Graph1","Book2"];省略=该类型全部'},
439
- "dry_run": {"type": "boolean", "default": False},
440
- },
441
- },
442
- "run": lambda a: originlab.close_pages(a.get("kind", "graph"), a.get("names"),
443
- a.get("dry_run", False)),
444
- },
445
- {
446
- "name": "origin_view",
447
- "description": "把图页渲染成小图回传(MCP image content),让模型自己看见结果。写后读不回的东西(样式、标注)用它确认,别声称你没看过的效果。",
448
- "inputSchema": {
449
- "type": "object",
450
- "properties": {
451
- "graph": {"type": "string", "description": "省略则用当前活动图页"},
452
- "width": {"type": "integer", "default": 900},
453
- },
454
- },
455
- "run": lambda a: originlab.view(a.get("graph", ""), a.get("width", 900)),
456
- },
457
- {
458
- "name": "origin_labtalk",
459
- "description": "逃生舱:执行原始 LabTalk,并读回你点名的变量(numeric 读数值表达式,readback 读字符串变量)。实测限制:数值/表达式类语句可用(如 layer.x.type=2、win -a Graph1),但字符串赋值类语句(layer.x.title$=... 、set c1 -l ...)在这台机器上 lt_exec 会返回 False,轴标题等请用 origin_style。returned=false 就是真失败,别重试同一条。doc -s / saveas / exitloop 等破坏性命令被禁。",
460
- "inputSchema": {
461
- "type": "object",
462
- "properties": {
463
- "script": {"type": "string", "description": "LabTalk 语句,如 'win -a Book1; plotxy iy:=(1,2) plot:=200 ng:=0;'"},
464
- "readback": {"type": "array", "description": "要读回的字符串变量名,如 ['layer.x.title$']"},
465
- "numeric": {"type": "array", "description": "要读回的数值表达式,如 ['npy', 'layer.x.to']"},
466
- },
467
- "required": ["script"],
468
- },
469
- "run": lambda a: originlab.labtalk(a["script"], a.get("readback") or [],
470
- a.get("numeric") or []),
471
- },
472
- ]
473
-
474
- _BY_NAME = {t["name"]: t for t in TOOLS}
475
-
476
-
477
- def _log(msg):
478
- sys.stderr.write("[%s] %s\n" % (SERVER_NAME, msg))
479
- sys.stderr.flush()
480
-
481
-
482
- def _tool_result(call_id, payload, is_error=False):
483
- content = []
484
- body = payload
485
- if isinstance(payload, dict) and payload.get("image_base64"):
486
- body = {k: v for k, v in payload.items() if k != "image_base64"}
487
- body["image_returned"] = True
488
- content.append({"type": "image", "data": payload["image_base64"],
489
- "mimeType": payload.get("mime", "image/png")})
490
- content.append({"type": "text", "text": json.dumps(body, ensure_ascii=False, default=str)})
491
- return {"jsonrpc": "2.0", "id": call_id, "result": {"content": content, "isError": is_error}}
492
-
493
-
494
- def _handle(msg):
495
- if not isinstance(msg, dict):
496
- return {"jsonrpc": "2.0", "id": None,
497
- "error": {"code": -32600, "message": "invalid request: expected an object"}}
498
- method = msg.get("method")
499
- call_id = msg.get("id")
500
- params = msg.get("params")
501
- if not isinstance(params, dict):
502
- params = {}
503
-
504
- if method == "initialize":
505
- return {"jsonrpc": "2.0", "id": call_id, "result": {
506
- "protocolVersion": PROTOCOL,
507
- "capabilities": {"tools": {}},
508
- "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION}}}
509
-
510
- if method in ("notifications/initialized", "notifications/cancelled"):
511
- return None
512
-
513
- if method == "ping":
514
- return {"jsonrpc": "2.0", "id": call_id, "result": {}}
515
-
516
- if method == "tools/list":
517
- return {"jsonrpc": "2.0", "id": call_id, "result": {
518
- "tools": [{"name": t["name"], "description": t["description"], "inputSchema": t["inputSchema"]}
519
- for t in TOOLS]}}
520
-
521
- if method == "tools/call":
522
- name = params.get("name")
523
- args = params.get("arguments") or {}
524
- tool = _BY_NAME.get(name)
525
- if tool is None:
526
- return _tool_result(call_id, {"ok": False, "code": "unknown_tool",
527
- "message": "没有这个工具:%s" % name}, True)
528
- if not isinstance(args, dict):
529
- return _tool_result(call_id, {"ok": False, "code": "bad_arguments",
530
- "message": "arguments 必须是对象"}, True)
531
- try:
532
- out = tool["run"](args)
533
- if isinstance(out, dict):
534
- out = {"ok": True, **out}
535
- return _tool_result(call_id, out, False)
536
- except originlab.OriginError as exc:
537
- return _tool_result(call_id, {"ok": False, "code": exc.code, "message": exc.message,
538
- "next_actions": exc.next_actions, **exc.extra}, True)
539
- except KeyError as exc:
540
- # A model can omit a required argument even though the schema names it;
541
- # that is a bad_arguments answer, not a crash with a stack trace.
542
- want = (tool.get("inputSchema") or {}).get("required") or []
543
- return _tool_result(call_id, {"ok": False, "code": "bad_arguments",
544
- "message": "%s 缺少参数 %s(该工具要求 %s)"
545
- % (name, exc, want or "见 schema"),
546
- "next_actions": ["补上缺的参数再调用"]}, True)
547
- except Exception as exc: # noqa: BLE001 - report, never kill the server
548
- _log("tool %s crashed: %r" % (name, exc))
549
- bare = isinstance(exc, (SystemError, TypeError, AttributeError))
550
- if bare:
551
- originlab.mark_suspect()
552
- return _tool_result(
553
- call_id,
554
- {"ok": False,
555
- "code": "origin_unavailable" if bare else "internal_error",
556
- "message": "%s: %s" % (type(exc).__name__, exc),
557
- "next_actions": (["Origin 的 COM 通道进入了坏状态(多为页面被销毁后残留错误)",
558
- "重新 origin_import / origin_write 拿新句柄,旧句柄已作废",
559
- "还是不行就 origin_exit 让插件重启 Origin,或 origin_reclaim 关后台实例"]
560
- if bare else ["origin_status 看连接是否还活着"])}, True)
561
-
562
- if method == "shutdown":
563
- return {"jsonrpc": "2.0", "id": call_id, "result": {}}
564
-
565
- if call_id is not None:
566
- return {"jsonrpc": "2.0", "id": call_id,
567
- "error": {"code": -32601, "message": "method not found: %s" % method}}
568
- return None
569
-
570
-
571
- def main():
572
- if "--tools" in sys.argv:
573
- for t in TOOLS:
574
- print("%-20s %s" % (t["name"], t["description"].split("。")[0]))
575
- return 0
576
-
577
- stdin = sys.stdin.buffer
578
- stdout = sys.stdout.buffer
579
- _log("ready; python=%s" % sys.executable)
580
-
581
- while True:
582
- raw = stdin.readline()
583
- if not raw:
584
- _log("stdin closed; exiting")
585
- return 0
586
- raw = raw.strip()
587
- if not raw:
588
- continue
589
- try:
590
- msg = json.loads(raw.decode("utf-8"))
591
- except Exception as exc: # noqa: BLE001
592
- _log("bad json: %s" % exc)
593
- reply = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}}
594
- else:
595
- # A single message must never take the server down: dsh only spawns
596
- # this process once per session, so dying here means every origin
597
- # tool is dead until the user restarts the harness.
598
- try:
599
- reply = _handle(msg)
600
- except Exception as exc: # noqa: BLE001
601
- _log("handler crashed on %r: %r" % (
602
- msg.get("method") if isinstance(msg, dict) else type(msg).__name__, exc))
603
- reply = {"jsonrpc": "2.0", "id": msg.get("id") if isinstance(msg, dict) else None,
604
- "error": {"code": -32603, "message": "internal error: %s" % exc}}
605
- if reply is None:
606
- continue
607
- stdout.write((json.dumps(reply, ensure_ascii=False, default=str) + "\n").encode("utf-8"))
608
- stdout.flush()
609
-
610
-
611
- if __name__ == "__main__":
612
- sys.exit(main() or 0)
1
+ """origin-bridge MCP server.
2
+
3
+ Deliberately a synchronous, newline-delimited JSON-RPC loop over stdio instead
4
+ of the official `mcp` SDK's asyncio transport: on Windows the SDK builds an
5
+ asyncio ProactorEventLoop, whose self-pipe falls back to a loopback
6
+ socketpair() (127.0.0.1 listen+connect+accept). Host firewalls that block
7
+ loopback accepts make that hang forever, the MCP handshake never completes, and
8
+ the client stalls for its full 60s request timeout before giving up.
9
+
10
+ Usage: python server.py # stdio, for dsh / any MCP client
11
+ python server.py --tools # print the tool table and exit
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import sys
17
+
18
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19
+
20
+ import originlab # noqa: E402
21
+
22
+ SERVER_NAME = "origin-bridge"
23
+ SERVER_VERSION = "0.4.5"
24
+ PROTOCOL = "2024-11-05"
25
+
26
+
27
+ def _write(a):
28
+ if a.get("rows"):
29
+ return originlab.write_block(a.get("headers") or [], a["rows"], a.get("book_name", ""))
30
+ return originlab.write_columns(a.get("columns") or {}, a.get("book_name", ""))
31
+
32
+ TOOLS = [
33
+ {
34
+ "name": "origin_status",
35
+ "description": "检查能否连上本机 Origin/OriginPro,返回版本、Python、已打开的页面。任何绘图操作前先用它确认连接。",
36
+ "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
37
+ "run": lambda a: originlab.status(),
38
+ },
39
+ {
40
+ "name": "origin_import",
41
+ "description": "把本地数据文件导入 Origin 新建的工作表。支持 .dat/.csv/.txt/.tsv/.xls/.xlsx/.wks,以及电化学工作站的二进制 .bin(CV/LSV/EIS/i-t,头部带 80F21B00 魔数那种)。返回 worksheet 句柄和列清单,后续步骤原样传回该句柄。.bin 会带 role_confidence:ambiguous 表示两列同量级、分不清电势/电流,这时先 origin_view 看图再定轴标题。",
42
+ "inputSchema": {
43
+ "type": "object",
44
+ "properties": {
45
+ "path": {"type": "string", "description": "数据文件绝对路径"},
46
+ "book_name": {"type": "string", "description": "可选:设给工作表的长名(Long Name),不是短名"},
47
+ },
48
+ "required": ["path"],
49
+ },
50
+ "run": lambda a: originlab.import_file(a["path"], a.get("book_name", "")),
51
+ },
52
+ {
53
+ "name": "origin_write",
54
+ "description": "用内联数据建工作表。窄表用 columns={列名:[数值...]};宽表(>5 列)用 headers+rows 二维块,一次写完,比逐列快很多。",
55
+ "inputSchema": {
56
+ "type": "object",
57
+ "properties": {
58
+ "columns": {"type": "object", "description": '{"Time": [0,1,2], "Signal": [3,4,5]}'},
59
+ "headers": {"type": "array", "description": "配合 rows 使用:列名列表"},
60
+ "rows": {"type": "array", "description": "配合 headers 使用:二维数据块"},
61
+ "book_name": {"type": "string"},
62
+ },
63
+ },
64
+ "run": lambda a: _write(a),
65
+ },
66
+ {
67
+ "name": "origin_formula",
68
+ "description": "给某列设计算公式(Fx),让它随兄弟列自动重算,用于派生列。列号从 1 开始或给列名。会读回该列算出的行数和首末值。",
69
+ "inputSchema": {
70
+ "type": "object",
71
+ "properties": {
72
+ "worksheet": {"type": "string"},
73
+ "col": {"type": ["integer", "string"], "description": "1 起始列号或列名"},
74
+ "formula": {"type": "string", "description": "如 Col(1)*2.5+0.1;LabTalk 写法"},
75
+ "label": {"type": "string"},
76
+ "units": {"type": "string"},
77
+ },
78
+ "required": ["worksheet", "col", "formula"],
79
+ },
80
+ "run": lambda a: originlab.column_formula(a["worksheet"], a["col"], a["formula"],
81
+ a.get("label", ""), a.get("units", "")),
82
+ },
83
+ {
84
+ "name": "origin_plot",
85
+ "description": "在工作表上画图。x/y 用 1 起始列号或列名;y 可以给列表,一次把多条曲线画进同一层。plot_type: line|scatter|line_symbol|column。给 template 用 Origin 自带图模建页(如 doubley/heat_map/line),给 graph+layer 则往已有图页的指定层里加曲线。返回里有 warnings 时代表图可能不可读(线性轴跨数量级等),要把选项交给用户决定。",
86
+ "inputSchema": {
87
+ "type": "object",
88
+ "properties": {
89
+ "worksheet": {"type": "string", "description": "origin_import/origin_write 返回的 worksheet 句柄"},
90
+ "x": {"type": ["integer", "string"], "default": 1},
91
+ "y": {"type": ["integer", "string", "array"], "default": 2,
92
+ "description": "列号/列名,或列表 [2,3,4] 一次画多条"},
93
+ "plot_type": {"type": "string", "enum": ["line", "scatter", "line_symbol", "column"], "default": "line"},
94
+ "graph_name": {"type": "string"},
95
+ "title": {"type": "string", "description": "图层标题"},
96
+ "graph": {"type": "string", "description": "已有图页句柄;配合 layer 往该层加曲线"},
97
+ "layer": {"type": "integer", "default": 0, "description": "目标层索引(双 Y 轴的第二层=1)"},
98
+ "template": {"type": "string", "description": "Origin 图模名,如 line/scatter/doubley/heat_map/cmap/mesh"},
99
+ },
100
+ "required": ["worksheet"],
101
+ },
102
+ "run": lambda a: originlab.plot(a["worksheet"], a.get("x", 1), a.get("y", 2),
103
+ a.get("plot_type", "line"), a.get("graph_name", ""),
104
+ a.get("title", ""), graph=a.get("graph", ""),
105
+ layer=a.get("layer", 0), template=a.get("template", "")),
106
+ },
107
+ {
108
+ "name": "origin_read_file",
109
+ "description": "只解析数据文件、不碰 Origin:返回编码、分隔符、表头行数、每列长名/单位/有效行数,以及仪器参数 metadata。拿到陌生 .dat/.csv/.txt 先用它看清结构,再决定 origin_import 用哪几列。支持 #、//、!、; 注释、BOM、GBK、空格对齐、Fortran 的 1.5d+00 指数、NA/- 缺失值、Origin 三行表头(Long Name/Units/Comments),也支持电化学工作站(CH Instruments 等)导出的 txt:前面几十行参数说明会被跳进 metadata,紧贴数据的 Potential/V, Current/A 这类表头按斜杠拆出列名和单位。.bin 走另一条解析路线:返回技术标签(CV/LSV/IMP/i-t)、点数、两个 float32 数组的范围和 role_confidence。",
110
+ "inputSchema": {
111
+ "type": "object",
112
+ "properties": {
113
+ "path": {"type": "string", "description": "数据文件绝对路径(含 .bin)"},
114
+ "comment": {"type": "string", "description": "可选:强制指定注释前缀,默认自动识别"},
115
+ },
116
+ "required": ["path"],
117
+ },
118
+ "run": lambda a: originlab.read_file(a["path"], a.get("comment")),
119
+ },
120
+ {
121
+ "name": "origin_style",
122
+ "description": "改图并逐项读回验证:轴标题、对数轴、坐标范围与步长、逐曲线颜色/线宽/符号形状/符号大小/填充/透明度。读不回来的项会进 failed 列表,不会假装成功。",
123
+ "inputSchema": {
124
+ "type": "object",
125
+ "properties": {
126
+ "graph": {"type": "string", "description": "origin_plot 返回的 graph 句柄"},
127
+ "layer": {"type": "integer", "default": 0, "description": "层索引,0 起"},
128
+ "series": {"type": "integer", "default": 0, "description": "该层里的第几条曲线,0 起"},
129
+ "x_title": {"type": "string"},
130
+ "y_title": {"type": "string"},
131
+ "x_scale": {"type": "string", "enum": ["linear", "log", "log10", "ln", "log2",
132
+ "probit", "probability", "reciprocal",
133
+ "offset_reciprocal", "logit"]},
134
+ "y_scale": {"type": "string", "enum": ["linear", "log", "log10", "ln", "log2",
135
+ "probit", "probability", "reciprocal",
136
+ "offset_reciprocal", "logit"]},
137
+ "xlim": {"type": "array", "description": "[from,to] 或 [from,to,step]"},
138
+ "ylim": {"type": "array", "description": "[from,to] 或 [from,to,step]"},
139
+ "xtick": {"type": "number", "description": "X 刻度步长"},
140
+ "ytick": {"type": "number", "description": "Y 刻度步长"},
141
+ "color": {"type": "string", "description": "如 #0072B2 或 Origin 颜色名"},
142
+ "line_width": {"type": "number"},
143
+ "symbol_kind": {"type": "integer", "description": "Origin 符号形状编号,如 3=圆 1=方"},
144
+ "symbol_size": {"type": "number"},
145
+ "symbol_interior": {"type": "integer", "enum": [0, 1, 2, 3],
146
+ "description": "0 无符号 1 实心 2 空心 3 点心"},
147
+ "transparency": {"type": "number", "description": "0-100 百分比"},
148
+ "fill_area": {"type": "boolean", "description": "线下填充(面积图效果)"},
149
+ },
150
+ "required": ["graph"],
151
+ },
152
+ "run": lambda a: originlab.style(
153
+ a["graph"], layer=a.get("layer", 0), series=a.get("series", 0),
154
+ x_title=a.get("x_title", ""), y_title=a.get("y_title", ""),
155
+ x_scale=a.get("x_scale", ""), y_scale=a.get("y_scale", ""),
156
+ xlim=a.get("xlim"), ylim=a.get("ylim"),
157
+ xtick=a.get("xtick", 0.0), ytick=a.get("ytick", 0.0),
158
+ color=a.get("color", ""), symbol_kind=a.get("symbol_kind", 0),
159
+ symbol_size=a.get("symbol_size", 0.0), symbol_interior=a.get("symbol_interior", 0),
160
+ line_width=a.get("line_width", 0.0), transparency=a.get("transparency", 0.0),
161
+ fill_area=a.get("fill_area", False)),
162
+ },
163
+ {
164
+ "name": "origin_fit",
165
+ "description": "拟合。kind=linear 线性(Origin 标准版可用);非线性直接用预设名 kind=gauss|lorentz|voigt|expdec1|expdec2|sine|power|logistic|boltzmann|doseresp|cubic(本机逐个实测可用;poly3=cubic、expdecay=expdec1 是别名),或 kind=nlfitsing + func 给 Origin 的函数名(区分大小写:Gauss 对、gauss1 不存在)。func 只能是内置函数名,自定义表达式不支持。只给 func 不给 kind 时按非线性执行并在 note 里说明。可固定参数 fixed、给初值 starts、加边界 bounds,并可生成报告表。缺失值已消毒为 null。",
166
+ "inputSchema": {
167
+ "type": "object",
168
+ "properties": {
169
+ "worksheet": {"type": "string"},
170
+ "x": {"type": ["integer", "string"], "default": 1},
171
+ "y": {"type": ["integer", "string"], "default": 2},
172
+ "kind": {"type": "string", "enum": ["linear", "nlfitsing", "gauss", "lorentz",
173
+ "voigt", "expdec1", "expdec2", "sine", "power",
174
+ "logistic", "boltzmann", "doseresp", "cubic",
175
+ "poly3", "expdecay"],
176
+ "default": "linear"},
177
+ "func": {"type": "string", "description": "Origin 内置函数名(区分大小写),如 Gauss / ExpDec1;给了 func 就按非线性拟合,不要写自定义表达式"},
178
+ "fixed": {"type": "object", "description": "固定参数 {参数名: 值},如 {\"y0\": 0}"},
179
+ "starts": {"type": "object", "description": "初值 {参数名: 值};linear 用 Slope/Intercept"},
180
+ "bounds": {"type": "object", "description": "边界 {参数名: [下界, 上界]},null 表示不设"},
181
+ "make_report": {"type": "boolean", "default": False, "description": "生成 Origin 拟合报告表"},
182
+ "full": {"type": "boolean", "default": False,
183
+ "description": "true 返回 Origin 的完整报告字典(很大);默认只回参数值与关键统计"},
184
+ },
185
+ "required": ["worksheet"],
186
+ },
187
+ "run": lambda a: originlab.fit(a["worksheet"], a.get("x", 1), a.get("y", 2),
188
+ a.get("kind", "linear"), a.get("func", ""),
189
+ fixed=a.get("fixed"), starts=a.get("starts"),
190
+ bounds=a.get("bounds"), make_report=a.get("make_report", False),
191
+ full=a.get("full", False)),
192
+ },
193
+ {
194
+ "name": "origin_export",
195
+ "description": "把图页导出为图片文件,并校验文件确实存在、够大、文件头正确(Origin 有静默失败的历史)。fmt: png/tif/jpg/svg/pdf/emf(tiff→tif、jpeg→jpg 会自动改写扩展名)。",
196
+ "inputSchema": {
197
+ "type": "object",
198
+ "properties": {
199
+ "graph": {"type": "string"},
200
+ "path": {"type": "string", "description": "输出文件绝对路径;省略则用图页名放到当前目录"},
201
+ "fmt": {"type": "string", "enum": ["png", "tif", "svg", "pdf", "emf"], "default": "png"},
202
+ "width": {"type": "integer", "default": 1600},
203
+ },
204
+ },
205
+ "run": lambda a: originlab.export(a.get("graph", ""), a.get("path", ""),
206
+ a.get("fmt", "png"), a.get("width", 1600)),
207
+ },
208
+ {
209
+ "name": "origin_save_project",
210
+ "description": "保存为可在 Origin 里继续编辑的 .opju 工程文件(不是图片快照)。省略 path 会自动命名。",
211
+ "inputSchema": {
212
+ "type": "object",
213
+ "properties": {"path": {"type": "string"}},
214
+ },
215
+ "run": lambda a: originlab.save_project(a.get("path", "")),
216
+ },
217
+ {
218
+ "name": "origin_open",
219
+ "description": "打开已有的 .opju/.opj 工程,返回页面清单。",
220
+ "inputSchema": {
221
+ "type": "object",
222
+ "properties": {"path": {"type": "string"}},
223
+ "required": ["path"],
224
+ },
225
+ "run": lambda a: originlab.open_project(a["path"]),
226
+ },
227
+ {
228
+ "name": "origin_inspect",
229
+ "description": "读回当前项目真实状态:有哪些页面、图页有几层、每层轴标题和曲线数、已分配的句柄。改图之前先调用它,别猜索引。",
230
+ "inputSchema": {
231
+ "type": "object",
232
+ "properties": {"graph": {"type": "string"}},
233
+ },
234
+ "run": lambda a: originlab.inspect(a.get("graph", "")),
235
+ },
236
+ {
237
+ "name": "origin_figure",
238
+ "description": "主路径:一次调用完成 导入/写数 → 画图 → 轴标题 → 导出图片 → 保存可编辑 .opju。用户只想“把这份数据画成图并给我文件”时用它,不要拆成多步。返回里若有 warnings,说明图虽然画成了但可能不可读(例如线性横轴跨了几个数量级,点全挤在最左边):把 next_actions 里的方案讲给用户让他选,不要自己擅自改轴。",
239
+ "inputSchema": {
240
+ "type": "object",
241
+ "properties": {
242
+ "source": {"type": "string", "description": "数据文件路径(与 columns 二选一)"},
243
+ "columns": {"type": "object", "description": "内联数据 {列名: [值...]},优先用 source"},
244
+ "x": {"type": ["integer", "string"], "default": 1},
245
+ "y": {"type": ["integer", "string"], "default": 2},
246
+ "plot_type": {"type": "string", "enum": ["line", "scatter", "line_symbol", "column"], "default": "line"},
247
+ "title": {"type": "string"},
248
+ "x_title": {"type": "string"},
249
+ "y_title": {"type": "string"},
250
+ "fmt": {"type": "string", "enum": ["png", "tif", "svg", "pdf", "emf"], "default": "png"},
251
+ "width": {"type": "integer", "default": 1600},
252
+ "output_dir": {"type": "string", "description": "交付目录:图片 + opju 都写到这。不给则只建图、不落盘"},
253
+ "export_path": {"type": "string"},
254
+ "project_path": {"type": "string"},
255
+ "do_project": {"type": "boolean", "default": False,
256
+ "description": "true 时即使没给目录也存一份 .opju 到 Origin 的 User Files"},
257
+ "template": {"type": "string", "description": "Origin 图模名,如 line/scatter/doubley;省略走默认 plotxy"},
258
+ "legend": {"type": ["boolean", "array"], "default": False,
259
+ "description": "true=按列长名自动生成图例;或给名称列表写死"},
260
+ },
261
+ },
262
+ "run": lambda a: originlab.figure(
263
+ source=a.get("source", ""), columns=a.get("columns"), x=a.get("x", 1), y=a.get("y", 2),
264
+ plot_type=a.get("plot_type", "line"), title=a.get("title", ""),
265
+ x_title=a.get("x_title", ""), y_title=a.get("y_title", ""), fmt=a.get("fmt", "png"),
266
+ width=a.get("width", 1600), output_dir=a.get("output_dir", ""),
267
+ export_path=a.get("export_path", ""), project_path=a.get("project_path", ""),
268
+ do_project=a.get("do_project"), template=a.get("template", ""),
269
+ legend=a.get("legend", False)),
270
+ },
271
+ {
272
+ "name": "origin_exit",
273
+ "description": "关闭 Origin。默认不保存;给 save_to 会先存一份 .opju 再退。",
274
+ "inputSchema": {
275
+ "type": "object",
276
+ "properties": {"save_to": {"type": "string"}},
277
+ },
278
+ "run": lambda a: originlab.exit_origin(a.get("save_to", "")),
279
+ },
280
+ {
281
+ "name": "origin_instances",
282
+ "description": "清点机器上的 Origin 实例:分「后台无窗口」(客户端退出后残留,会占配额)和「前台有窗口」(可能是用户自己开着的项目)。Origin 实例数超上限、或报 connection_error 时先调这个看情况。",
283
+ "inputSchema": {"type": "object", "properties": {}},
284
+ "run": lambda a: originlab.instances(),
285
+ },
286
+ {
287
+ "name": "origin_reclaim",
288
+ "description": "回收残留的 Origin 实例:只关**没有窗口**的后台实例;有窗口的一律不动,只在返回里告诉你哪几个需要用户自己关。close_background=false 时只看不动手。",
289
+ "inputSchema": {
290
+ "type": "object",
291
+ "properties": {"close_background": {"type": "boolean", "default": True}},
292
+ },
293
+ "run": lambda a: originlab.reclaim(bool(a.get("close_background", True))),
294
+ },
295
+ {
296
+ "name": "origin_annotate",
297
+ "description": "在图上加文字标注和参考线(数据坐标系)。比如标峰位、画阈值线。标注没有值可读回,返回 readback_only,要确证用 origin_view 看图。",
298
+ "inputSchema": {
299
+ "type": "object",
300
+ "properties": {
301
+ "graph": {"type": "string"},
302
+ "layer": {"type": "integer", "default": 0},
303
+ "labels": {"type": "array", "description": '[{"text":"peak","x":12.5,"y":80}]'},
304
+ "lines": {"type": "array", "description": '[{"x1":0,"y1":50,"x2":23,"y2":50}]'},
305
+ },
306
+ "required": ["graph"],
307
+ },
308
+ "run": lambda a: originlab.annotate(a["graph"], a.get("labels") or [],
309
+ a.get("lines") or [], a.get("layer", 0)),
310
+ },
311
+ {
312
+ "name": "origin_layer",
313
+ "description": "给已有图页加一层:双 Y 轴用 layer_type=right,上下多面板用 top/bottom。返回 Origin 自己给这层起的名字(RightY 等)作为证据,以及新层索引。往新层画:origin_plot 时带上 graph 和 layer=该索引。",
314
+ "inputSchema": {
315
+ "type": "object",
316
+ "properties": {
317
+ "graph": {"type": "string"},
318
+ "layer_type": {"type": ["string", "integer"], "default": "right",
319
+ "description": "bottom(=bottom_left)|top|right|left|top_right|bottom_right|inset,或对应整数 0-6"},
320
+ },
321
+ "required": ["graph"],
322
+ },
323
+ "run": lambda a: originlab.add_layer(a["graph"], a.get("layer_type", "right")),
324
+ },
325
+ {
326
+ "name": "origin_legend",
327
+ "description": "图例。不给 entries 时用 legend -r 按各条曲线对应列的长名自动生成;给了就逐行写死。position 用图内位置名(inside_top_right/top_right/bottom_left…)。位置按该层数据坐标写入并读回,文字和可见性都验证。",
328
+ "inputSchema": {
329
+ "type": "object",
330
+ "properties": {
331
+ "graph": {"type": "string", "description": "省略则用当前活动图页"},
332
+ "layer": {"type": "integer", "default": 0},
333
+ "entries": {"type": "array", "description": '["sin(x)","cos(x)"];省略=用列长名自动生成'},
334
+ "position": {"type": "string", "enum": ["top_left", "top", "top_right", "left", "right",
335
+ "bottom_left", "bottom", "bottom_right",
336
+ "inside_top_left", "inside_top_right",
337
+ "inside_bottom_left", "inside_bottom_right", "inside"]},
338
+ "show": {"type": "boolean", "default": True},
339
+ },
340
+ },
341
+ "run": lambda a: originlab.legend_entries(a.get("graph", ""), a.get("layer", 0),
342
+ a.get("entries"), a.get("position", ""),
343
+ a.get("show", True)),
344
+ },
345
+ {
346
+ "name": "origin_matrix",
347
+ "description": "网格数据 → Origin 矩阵 → 模板化矩阵图:heat_map 热力图、cmap 三维彩色映射面、mesh 网格面、contline/contgray 等高线、3d/bar3d。rows 是二维数值矩阵。可选 xy_range=[x1,x2,y1,y2] 设坐标范围。给 export_path/project_path 会顺手落盘。",
348
+ "inputSchema": {
349
+ "type": "object",
350
+ "properties": {
351
+ "rows": {"type": "array", "description": "二维矩阵 [[...],[...]],行=Y 方向、列=X 方向"},
352
+ "template": {"type": "string", "enum": ["heat_map", "cmap", "mesh", "contline",
353
+ "contgray", "3d", "glwireface", "glwirefrm", "bar3d"],
354
+ "default": "heat_map"},
355
+ "book_name": {"type": "string"},
356
+ "graph_name": {"type": "string"},
357
+ "xy_range": {"type": "array", "description": "[x1,x2,y1,y2] 矩阵坐标范围"},
358
+ "title": {"type": "string"},
359
+ "export_path": {"type": "string"},
360
+ "project_path": {"type": "string"},
361
+ },
362
+ "required": ["rows"],
363
+ },
364
+ "run": lambda a: originlab.matrix_plot(a["rows"], a.get("template", "heat_map"),
365
+ a.get("book_name", ""), a.get("graph_name", ""),
366
+ a.get("xy_range"), a.get("title", ""),
367
+ a.get("export_path", ""), a.get("project_path", "")),
368
+ },
369
+ {
370
+ "name": "origin_chart_presets",
371
+ "description": "列出 40 个科研图表预设(XPS/XRD/UV-Vis/荧光/循环伏安/Arrhenius…):每个预设带好哪些列、默认轴标题、建议拟合函数。不确定用户要的是哪类图时先查这里,别自己编轴标题。category 可过滤。",
372
+ "inputSchema": {
373
+ "type": "object",
374
+ "properties": {"category": {"type": "string"}},
375
+ },
376
+ "run": lambda a: originlab.chart_presets(a.get("category", "")),
377
+ },
378
+ {
379
+ "name": "origin_chart_preset",
380
+ "description": "看单个预设的完整约定:required_columns / optional_columns / default_axis_titles / default_plot_kind / suggested_fit / extra(如 XPS 的 X 轴要反向)。",
381
+ "inputSchema": {
382
+ "type": "object",
383
+ "properties": {"name": {"type": "string"}},
384
+ "required": ["name"],
385
+ },
386
+ "run": lambda a: originlab.chart_preset(a["name"]),
387
+ },
388
+ {
389
+ "name": "origin_chart_check",
390
+ "description": "拿数据列名对预设做 dry-run(不碰 Origin):缺哪列、建议用什么拟合、默认轴标题是什么。渲染前先调它,能省一次失败。",
391
+ "inputSchema": {
392
+ "type": "object",
393
+ "properties": {
394
+ "name": {"type": "string"},
395
+ "data": {"type": "string", "description": "数据文件路径,会先 origin_read_file 解析列名"},
396
+ "columns": {"type": "array", "description": "或者直接给列名列表"},
397
+ },
398
+ "required": ["name"],
399
+ },
400
+ "run": lambda a: originlab.chart_check(a["name"], a.get("data"), a.get("columns")),
401
+ },
402
+ {
403
+ "name": "origin_chart_render",
404
+ "description": "按科研预设一步出图:读预设的轴标题/图类型 → 导入数据 → 画图 → 轴标题 → 图例 → 导出图片 + 保存 .opju。用户说“把这个做成 XPS 图”这类需求优先用它。轴要反向(如 XPS 结合能)时再调 origin_style 设 xlim=[高,低]。",
405
+ "inputSchema": {
406
+ "type": "object",
407
+ "properties": {
408
+ "name": {"type": "string"},
409
+ "source": {"type": "string", "description": "数据文件路径"},
410
+ "columns": {"type": "object", "description": "或内联数据 {列名:[值]}"}
411
+ ,
412
+ "x": {"type": ["integer", "string"], "default": 1},
413
+ "y": {"type": ["integer", "string", "array"], "default": 2},
414
+ "output_dir": {"type": "string", "description": "交付目录;不给则只建图不落盘"},
415
+ "x_title": {"type": "string", "description": "覆盖预设的 X 轴标题"},
416
+ "y_title": {"type": "string"},
417
+ "title": {"type": "string"},
418
+ "plot_type": {"type": "string", "enum": ["line", "scatter", "line_symbol", "column"]},
419
+ "with_legend": {"type": "boolean", "default": True},
420
+ "fmt": {"type": "string", "enum": ["png", "tif", "svg", "pdf", "emf"], "default": "png"},
421
+ "width": {"type": "integer", "default": 1600},
422
+ },
423
+ "required": ["name"],
424
+ },
425
+ "run": lambda a: originlab.chart_render(
426
+ a["name"], a.get("source", ""), a.get("columns"), a.get("x", 1), a.get("y", 2),
427
+ a.get("output_dir", ""), a.get("x_title"), a.get("y_title"), a.get("title"),
428
+ a.get("plot_type"), a.get("with_legend", True), a.get("fmt", "png"),
429
+ a.get("width", 1600)),
430
+ },
431
+ {
432
+ "name": "origin_close_pages",
433
+ "description": "关掉项目里攒下来的页面。长会话里页面攒太多会让 Origin 的 COM 桥报“无效指针”,这时按类型或名字清理。默认只看图页;dry_run=true 只报告不关。",
434
+ "inputSchema": {
435
+ "type": "object",
436
+ "properties": {
437
+ "kind": {"type": "string", "enum": ["graph", "worksheet", "matrix", "notes"], "default": "graph"},
438
+ "names": {"type": "array", "description": '只关这些页面 ["Graph1","Book2"];省略=该类型全部'},
439
+ "dry_run": {"type": "boolean", "default": False},
440
+ },
441
+ },
442
+ "run": lambda a: originlab.close_pages(a.get("kind", "graph"), a.get("names"),
443
+ a.get("dry_run", False)),
444
+ },
445
+ {
446
+ "name": "origin_view",
447
+ "description": "把图页渲染成小图回传(MCP image content),让模型自己看见结果。写后读不回的东西(样式、标注)用它确认,别声称你没看过的效果。",
448
+ "inputSchema": {
449
+ "type": "object",
450
+ "properties": {
451
+ "graph": {"type": "string", "description": "省略则用当前活动图页"},
452
+ "width": {"type": "integer", "default": 900},
453
+ },
454
+ },
455
+ "run": lambda a: originlab.view(a.get("graph", ""), a.get("width", 900)),
456
+ },
457
+ {
458
+ "name": "origin_labtalk",
459
+ "description": "逃生舱:执行原始 LabTalk,并读回你点名的变量(numeric 读数值表达式,readback 读字符串变量)。实测限制:数值/表达式类语句可用(如 layer.x.type=2、win -a Graph1),但字符串赋值类语句(layer.x.title$=... 、set c1 -l ...)在这台机器上 lt_exec 会返回 False,轴标题等请用 origin_style。returned=false 就是真失败,别重试同一条。doc -s / saveas / exitloop 等破坏性命令被禁。",
460
+ "inputSchema": {
461
+ "type": "object",
462
+ "properties": {
463
+ "script": {"type": "string", "description": "LabTalk 语句,如 'win -a Book1; plotxy iy:=(1,2) plot:=200 ng:=0;'"},
464
+ "readback": {"type": "array", "description": "要读回的字符串变量名,如 ['layer.x.title$']"},
465
+ "numeric": {"type": "array", "description": "要读回的数值表达式,如 ['npy', 'layer.x.to']"},
466
+ },
467
+ "required": ["script"],
468
+ },
469
+ "run": lambda a: originlab.labtalk(a["script"], a.get("readback") or [],
470
+ a.get("numeric") or []),
471
+ },
472
+ ]
473
+
474
+ _BY_NAME = {t["name"]: t for t in TOOLS}
475
+
476
+
477
+ def _log(msg):
478
+ sys.stderr.write("[%s] %s\n" % (SERVER_NAME, msg))
479
+ sys.stderr.flush()
480
+
481
+
482
+ def _tool_result(call_id, payload, is_error=False):
483
+ content = []
484
+ body = payload
485
+ if isinstance(payload, dict) and payload.get("image_base64"):
486
+ body = {k: v for k, v in payload.items() if k != "image_base64"}
487
+ body["image_returned"] = True
488
+ content.append({"type": "image", "data": payload["image_base64"],
489
+ "mimeType": payload.get("mime", "image/png")})
490
+ content.append({"type": "text", "text": json.dumps(body, ensure_ascii=False, default=str)})
491
+ return {"jsonrpc": "2.0", "id": call_id, "result": {"content": content, "isError": is_error}}
492
+
493
+
494
+ def _handle(msg):
495
+ if not isinstance(msg, dict):
496
+ return {"jsonrpc": "2.0", "id": None,
497
+ "error": {"code": -32600, "message": "invalid request: expected an object"}}
498
+ method = msg.get("method")
499
+ call_id = msg.get("id")
500
+ params = msg.get("params")
501
+ if not isinstance(params, dict):
502
+ params = {}
503
+
504
+ if method == "initialize":
505
+ return {"jsonrpc": "2.0", "id": call_id, "result": {
506
+ "protocolVersion": PROTOCOL,
507
+ "capabilities": {"tools": {}},
508
+ "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION}}}
509
+
510
+ if method in ("notifications/initialized", "notifications/cancelled"):
511
+ return None
512
+
513
+ if method == "ping":
514
+ return {"jsonrpc": "2.0", "id": call_id, "result": {}}
515
+
516
+ if method == "tools/list":
517
+ return {"jsonrpc": "2.0", "id": call_id, "result": {
518
+ "tools": [{"name": t["name"], "description": t["description"], "inputSchema": t["inputSchema"]}
519
+ for t in TOOLS]}}
520
+
521
+ if method == "tools/call":
522
+ name = params.get("name")
523
+ args = params.get("arguments") or {}
524
+ tool = _BY_NAME.get(name)
525
+ if tool is None:
526
+ return _tool_result(call_id, {"ok": False, "code": "unknown_tool",
527
+ "message": "没有这个工具:%s" % name}, True)
528
+ if not isinstance(args, dict):
529
+ return _tool_result(call_id, {"ok": False, "code": "bad_arguments",
530
+ "message": "arguments 必须是对象"}, True)
531
+ try:
532
+ out = tool["run"](args)
533
+ if isinstance(out, dict):
534
+ out = {"ok": True, **out}
535
+ return _tool_result(call_id, out, False)
536
+ except originlab.OriginError as exc:
537
+ return _tool_result(call_id, {"ok": False, "code": exc.code, "message": exc.message,
538
+ "next_actions": exc.next_actions, **exc.extra}, True)
539
+ except KeyError as exc:
540
+ # A model can omit a required argument even though the schema names it;
541
+ # that is a bad_arguments answer, not a crash with a stack trace.
542
+ want = (tool.get("inputSchema") or {}).get("required") or []
543
+ return _tool_result(call_id, {"ok": False, "code": "bad_arguments",
544
+ "message": "%s 缺少参数 %s(该工具要求 %s)"
545
+ % (name, exc, want or "见 schema"),
546
+ "next_actions": ["补上缺的参数再调用"]}, True)
547
+ except Exception as exc: # noqa: BLE001 - report, never kill the server
548
+ _log("tool %s crashed: %r" % (name, exc))
549
+ bare = isinstance(exc, (SystemError, TypeError, AttributeError))
550
+ if bare:
551
+ originlab.mark_suspect()
552
+ return _tool_result(
553
+ call_id,
554
+ {"ok": False,
555
+ "code": "origin_unavailable" if bare else "internal_error",
556
+ "message": "%s: %s" % (type(exc).__name__, exc),
557
+ "next_actions": (["Origin 的 COM 通道进入了坏状态(多为页面被销毁后残留错误)",
558
+ "重新 origin_import / origin_write 拿新句柄,旧句柄已作废",
559
+ "还是不行就 origin_exit 让插件重启 Origin,或 origin_reclaim 关后台实例"]
560
+ if bare else ["origin_status 看连接是否还活着"])}, True)
561
+
562
+ if method == "shutdown":
563
+ return {"jsonrpc": "2.0", "id": call_id, "result": {}}
564
+
565
+ if call_id is not None:
566
+ return {"jsonrpc": "2.0", "id": call_id,
567
+ "error": {"code": -32601, "message": "method not found: %s" % method}}
568
+ return None
569
+
570
+
571
+ def main():
572
+ if "--tools" in sys.argv:
573
+ for t in TOOLS:
574
+ print("%-20s %s" % (t["name"], t["description"].split("。")[0]))
575
+ return 0
576
+
577
+ stdin = sys.stdin.buffer
578
+ stdout = sys.stdout.buffer
579
+ _log("ready; python=%s" % sys.executable)
580
+
581
+ while True:
582
+ raw = stdin.readline()
583
+ if not raw:
584
+ _log("stdin closed; exiting")
585
+ return 0
586
+ raw = raw.strip()
587
+ if not raw:
588
+ continue
589
+ try:
590
+ msg = json.loads(raw.decode("utf-8"))
591
+ except Exception as exc: # noqa: BLE001
592
+ _log("bad json: %s" % exc)
593
+ reply = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}}
594
+ else:
595
+ # A single message must never take the server down: dsh only spawns
596
+ # this process once per session, so dying here means every origin
597
+ # tool is dead until the user restarts the harness.
598
+ try:
599
+ reply = _handle(msg)
600
+ except Exception as exc: # noqa: BLE001
601
+ _log("handler crashed on %r: %r" % (
602
+ msg.get("method") if isinstance(msg, dict) else type(msg).__name__, exc))
603
+ reply = {"jsonrpc": "2.0", "id": msg.get("id") if isinstance(msg, dict) else None,
604
+ "error": {"code": -32603, "message": "internal error: %s" % exc}}
605
+ if reply is None:
606
+ continue
607
+ stdout.write((json.dumps(reply, ensure_ascii=False, default=str) + "\n").encode("utf-8"))
608
+ stdout.flush()
609
+
610
+
611
+ if __name__ == "__main__":
612
+ sys.exit(main() or 0)