labmcp-julabo 0.1.0__tar.gz

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.
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .DS_Store
10
+ *.jsonl
11
+ !**/fixtures/*.jsonl
12
+ .idea/
13
+ .vscode/
14
+
15
+ CLAUDE.md
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.5
2
+ Name: labmcp-julabo
3
+ Version: 0.1.0
4
+ Summary: MCP server for JULABO heating and refrigerated circulators (CORIO, MAGIO, DYNEO): read temperatures, set setpoint, start/stop, alarms.
5
+ Project-URL: Homepage, https://github.com/K-Dense-AI/lab-instrument-mcps/tree/main/servers/chemistry/julabo-circulator
6
+ Author: K-Dense and LabMCP contributors
7
+ License-Expression: Apache-2.0
8
+ Keywords: chiller,circulator,julabo,lab-instrument,mcp,temperature-control,thermostat
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: labmcp<0.2,>=0.1
11
+ Description-Content-Type: text/markdown
12
+
13
+ # JULABO Circulator โ€” MCP Server
14
+
15
+ <!-- mcp-name: io.github.K-Dense-AI/labmcp-julabo -->
16
+
17
+ Let an AI agent run **JULABO heating and refrigerated circulators** through JULABO's documented interface commands: read the bath and external temperatures and the heating/cooling power, set the setpoint, start and stop temperature control, decode status and alarm messages, and wait until the bath is stable at temperature.
18
+
19
+ | | |
20
+ |---|---|
21
+ | **Package** | `labmcp-julabo` |
22
+ | **Instruments** | CORIO CD, CORIO CP, MAGIO MS (and MX), DYNEO DD circulators. PRESTO and older JULABO models use a similar command set but are unverified |
23
+ | **Interfaces** | USB (virtual COM port), RS-232 (CORIO CP, MAGIO, DYNEO) |
24
+ | **Protocol** | JULABO interface commands, appendix "Interface commands" of the original operating manuals: [CORIO CD](https://pim-resources.coleparmer.com/instruction-manual/corio-cd-circulator-manual.pdf) (1.950.0800.us.V10), [CORIO CP](https://pim-resources.coleparmer.com/instruction-manual/corio-cp-operating-manual-1-950-0900-us.pdf) (1.950.0900.us.V04), [MAGIO MS](https://pim-resources.coleparmer.com/instruction-manual/magio-ms-operating-manual-1-950-1700-us-v05.pdf) (1.950.1700.us.V05), [DYNEO DD](https://pim-resources.coleparmer.com/instruction-manual/dyneo-dd-operating-manual-1-950-1300-us-v03.pdf) (1.950.1300.us.V03). Official downloads: [julabo.com](https://www.julabo.com/en/service/downloads/operating-manuals) |
25
+ | **Status** | ๐Ÿงช **simulated**: tested against a wire-level simulator, not yet verified on hardware. [Report a hardware test](https://github.com/K-Dense-AI/lab-instrument-mcps/issues/new?template=hardware-verification.yml) |
26
+
27
+ ## Try it without hardware
28
+
29
+ ```bash
30
+ uvx labmcp-julabo --simulate --check
31
+ ```
32
+
33
+ ## Connect your circulator
34
+
35
+ 1. **Enable remote control on the circulator.** Without it the circulator answers queries but ignores every setting command.
36
+ - CORIO CD/CP: `MENU` โ†’ `IntE` โ†’ `rEM` โ†’ `USb` (or `232` for RS-232). `rOFF` appears in standby.
37
+ - MAGIO / DYNEO: Main menu โ†’ Connect unit โ†’ Remote control โ†’ USB or RS232. An "R" appears in the status bar.
38
+ 2. **Cable:** USB-B (the circulator appears as a virtual COM port; on Windows install JULABO's USB driver) or a **null-modem** RS-232 cable. RS-232 factory settings: **4800 baud, 7 data bits, even parity, 1 stop bit, hardware handshake (RTS/CTS)**. Interface parameters can only be changed while remote control is off. The server uses these settings but does not enforce the handshake by default (it keeps RTS asserted, which lets the circulator transmit, and USB virtual COM ports often never assert CTS); add `?rtscts=true` to the address to enforce it.
39
+ 3. **Find the port:** `uvx labmcp ports`
40
+ 4. **Test the connection:**
41
+ ```bash
42
+ uvx labmcp-julabo --address /dev/ttyACM0 --check # Linux (USB)
43
+ uvx labmcp-julabo --address /dev/tty.usbmodem1101 --check # macOS
44
+ uvx labmcp-julabo --address COM3 --check # Windows
45
+ uvx labmcp-julabo --address "serial://COM3?baudrate=9600" --check # RS-232, non-default baud rate
46
+ ```
47
+ `--check` prints the firmware string (`version`) and the current status, e.g. `02 REMOTE STOP`.
48
+
49
+ ## Add to your MCP client
50
+
51
+ **Claude Code**
52
+ ```bash
53
+ claude mcp add julabo -- uvx labmcp-julabo --address /dev/ttyACM0
54
+ ```
55
+
56
+ **Claude Desktop / Cursor / Windsurf** (`claude_desktop_config.json`, `.cursor/mcp.json`, โ€ฆ)
57
+ ```json
58
+ {
59
+ "mcpServers": {
60
+ "julabo": {
61
+ "command": "uvx",
62
+ "args": ["labmcp-julabo", "--address", "/dev/ttyACM0", "--limit", "max_temperature_c=120"]
63
+ }
64
+ }
65
+ }
66
+ ```
67
+
68
+ Add `--read-only` to allow monitoring only (`stop_circulation` stays available). For other clients: `uvx labmcp config julabo-circulator --address /dev/ttyACM0 --client vscode`.
69
+
70
+ ## Tools
71
+
72
+ <!-- TOOLS:START -->
73
+ | Tool | Kind | Description |
74
+ |---|---|---|
75
+ | `get_command_log` | ๐Ÿ‘ read | Return the most recent raw commands sent to / replies received from the instrument (newest last). Useful for debugging and for recording what was done. |
76
+ | `get_connection_info` | ๐Ÿ‘ read | Report which instrument is connected (identity, address, simulated or real), whether the server is read-only, and the active safety limits. Call this first. |
77
+ | `get_setpoint` | ๐Ÿ‘ read | Read the current temperature setpoint and whether temperature control is running. |
78
+ | `get_status` | ๐Ÿ‘ read | Report the circulator's status message (decoded: operating state, rejected command or alarm), whether temperature control is running, the setpoint, the device's own excess-temperature protection setting and warning limits, and the firmware version. |
79
+ | `read_temperatures` | ๐Ÿ‘ read | Read the bath temperature, the heating/cooling power in % and the safety-sensor temperature, and optionally the external Pt100 sensor. |
80
+ | `reconnect` | ๐Ÿ›‘ safety | Close and re-open the connection to the instrument (e.g. after it was power cycled or a cable was re-plugged). |
81
+ | `set_setpoint` | โš ๏ธ hazard | Set the circulator's temperature setpoint (out_sp_00). If temperature control is running, the circulator starts heating or cooling to it immediately. Refused outside `min_temperature_c`..`max_temperature_c`; the circulator itself also rejects values outside its range or warning limits (reported as an error). Needs remote control enabled. |
82
+ | `start_circulation` | โš ๏ธ hazard | Start temperature control (out_mode_05 1): the pump runs and the bath heats or cools to the setpoint, which is checked against the safety limits first (it may have been changed on the front panel). Make sure the bath is filled and any external hoses are connected and secured. Needs remote control enabled. |
83
+ | `stop_circulation` | ๐Ÿ›‘ safety | Stop temperature control and the pump (out_mode_05 0), then confirm with in_mode_05. Also ends a running `wait_for_temperature`. The bath stays hot/cold after stopping. |
84
+ | `wait_for_temperature` | ๐Ÿ‘ read | Poll the bath (or external) temperature until it has been within `tolerance_c` of the target for `stable_for_s` seconds, or `timeout_s` passes. Does not change anything; start circulation first. Stops early (reached = false) if the circulator raises an alarm or a stop command is sent. Returns a short temperature trace. |
85
+ <!-- TOOLS:END -->
86
+
87
+ ## Safety limits
88
+
89
+ | Limit | Default | Meaning |
90
+ |---|---|---|
91
+ | `max_temperature_c` | 90 ยฐC | Highest setpoint an agent may set (also checked before `start_circulation`) |
92
+ | `min_temperature_c` | 5 ยฐC | Lowest setpoint an agent may set (also checked before `start_circulation`) |
93
+ | `max_wait_s` | 7200 s | Longest `wait_for_temperature` |
94
+
95
+ The defaults suit a **water** bath. Set them to your bath fluid's working range, e.g. `--limit min_temperature_c=-30 --limit max_temperature_c=150` for a silicone oil / glycol-rated setup. The circulator additionally enforces its own working range, warning limits and excess-temperature protection; rejections come back as errors such as `-11 VALUE TOO LARGE`.
96
+
97
+ ## Example prompts
98
+
99
+ - "Set the circulator to 37 ยฐC, start it and tell me when the bath has been stable within ยฑ0.1 ยฐC for five minutes."
100
+ - "What's the bath temperature and how hard is the circulator working right now?"
101
+ - "Cool the jacketed reactor to 10 ยฐC using the external Pt100 as the reference and let me know when it gets there."
102
+ - "Is there any alarm on the circulator? Explain what it means."
103
+ - "Stop the circulator."
104
+
105
+ ## Notes
106
+
107
+ - **Remote control is a front-panel setting.** In manual mode (`00 MANUAL STOP` / `01 MANUAL START`) the server refuses to send settings and tells you how to enable remote control.
108
+ - **Every setting is verified.** OUT commands have no reply, so after each one the server queries `status` (to catch `-08 INVALID COMMAND`, `-09 COMMAND NOT ALLOWED IN CURRENT OPERATING MODE`, `-10 VALUE TOO SMALL`, `-11 VALUE TOO LARGE`, `-13 VALUE EXCEEDS TEMPERATURE LIMITS`) and reads the setpoint or `in_mode_05` back. `stop_circulation` retries once and reports an error if the circulator still says it is running.
109
+ - **Model differences.** CORIO CD has only the basic set (actual value, power, safety sensor, setpoint, start/stop). Warning limits (`in_sp_03/04`) need CORIO CP, MAGIO or DYNEO; the external Pt100 (`in_pv_02`) needs MAGIO or DYNEO. The server detects missing commands (no reply) and reports those values as `null`.
110
+ - **Command pacing (unverified).** The manuals give no minimum time between commands. The server waits `command_delay_s` (default 0.25 s) after each setting command before checking `status`; change it with `--option command_delay_s=0.5` if your circulator misses commands.
111
+ - **Watchdog.** MAGIO/DYNEO circulators have a watchdog configured in their own menu (mode, timeout, fallback setpoint, reset on "setpoint only" or "all valid commands"). The server does not configure it. With restart mode "all valid commands" you can keep it fed while the server runs with `--option keepalive_s=10` (queries `status` every 10 s). JULABO's alarm list notes that it expects the setpoint at least every 30 s in "setpoint" restart mode, which this keep-alive does not do.
112
+ - Status and alarm texts are decoded from the code (`-01`, `-14`, `-63`, ...) using the alarm tables in the manuals; the exact wording the circulator sends is passed through unchanged.
113
+ - Not implemented: pump stage/capacity (`out_sp_07/27`), warning-limit writes (`out_sp_03/04`), controller parameters (`out_par_*`), external/internal control switch (`out_mode_04`), EPROG and ATC calibration commands.
114
+
115
+ ## Hardware verification
116
+
117
+ | Model | Firmware | Interface | Verified by | Date |
118
+ |---|---|---|---|---|
119
+ | *none yet: [be the first](https://github.com/K-Dense-AI/lab-instrument-mcps/issues/new?template=hardware-verification.yml)* | | | | |
@@ -0,0 +1,107 @@
1
+ # JULABO Circulator โ€” MCP Server
2
+
3
+ <!-- mcp-name: io.github.K-Dense-AI/labmcp-julabo -->
4
+
5
+ Let an AI agent run **JULABO heating and refrigerated circulators** through JULABO's documented interface commands: read the bath and external temperatures and the heating/cooling power, set the setpoint, start and stop temperature control, decode status and alarm messages, and wait until the bath is stable at temperature.
6
+
7
+ | | |
8
+ |---|---|
9
+ | **Package** | `labmcp-julabo` |
10
+ | **Instruments** | CORIO CD, CORIO CP, MAGIO MS (and MX), DYNEO DD circulators. PRESTO and older JULABO models use a similar command set but are unverified |
11
+ | **Interfaces** | USB (virtual COM port), RS-232 (CORIO CP, MAGIO, DYNEO) |
12
+ | **Protocol** | JULABO interface commands, appendix "Interface commands" of the original operating manuals: [CORIO CD](https://pim-resources.coleparmer.com/instruction-manual/corio-cd-circulator-manual.pdf) (1.950.0800.us.V10), [CORIO CP](https://pim-resources.coleparmer.com/instruction-manual/corio-cp-operating-manual-1-950-0900-us.pdf) (1.950.0900.us.V04), [MAGIO MS](https://pim-resources.coleparmer.com/instruction-manual/magio-ms-operating-manual-1-950-1700-us-v05.pdf) (1.950.1700.us.V05), [DYNEO DD](https://pim-resources.coleparmer.com/instruction-manual/dyneo-dd-operating-manual-1-950-1300-us-v03.pdf) (1.950.1300.us.V03). Official downloads: [julabo.com](https://www.julabo.com/en/service/downloads/operating-manuals) |
13
+ | **Status** | ๐Ÿงช **simulated**: tested against a wire-level simulator, not yet verified on hardware. [Report a hardware test](https://github.com/K-Dense-AI/lab-instrument-mcps/issues/new?template=hardware-verification.yml) |
14
+
15
+ ## Try it without hardware
16
+
17
+ ```bash
18
+ uvx labmcp-julabo --simulate --check
19
+ ```
20
+
21
+ ## Connect your circulator
22
+
23
+ 1. **Enable remote control on the circulator.** Without it the circulator answers queries but ignores every setting command.
24
+ - CORIO CD/CP: `MENU` โ†’ `IntE` โ†’ `rEM` โ†’ `USb` (or `232` for RS-232). `rOFF` appears in standby.
25
+ - MAGIO / DYNEO: Main menu โ†’ Connect unit โ†’ Remote control โ†’ USB or RS232. An "R" appears in the status bar.
26
+ 2. **Cable:** USB-B (the circulator appears as a virtual COM port; on Windows install JULABO's USB driver) or a **null-modem** RS-232 cable. RS-232 factory settings: **4800 baud, 7 data bits, even parity, 1 stop bit, hardware handshake (RTS/CTS)**. Interface parameters can only be changed while remote control is off. The server uses these settings but does not enforce the handshake by default (it keeps RTS asserted, which lets the circulator transmit, and USB virtual COM ports often never assert CTS); add `?rtscts=true` to the address to enforce it.
27
+ 3. **Find the port:** `uvx labmcp ports`
28
+ 4. **Test the connection:**
29
+ ```bash
30
+ uvx labmcp-julabo --address /dev/ttyACM0 --check # Linux (USB)
31
+ uvx labmcp-julabo --address /dev/tty.usbmodem1101 --check # macOS
32
+ uvx labmcp-julabo --address COM3 --check # Windows
33
+ uvx labmcp-julabo --address "serial://COM3?baudrate=9600" --check # RS-232, non-default baud rate
34
+ ```
35
+ `--check` prints the firmware string (`version`) and the current status, e.g. `02 REMOTE STOP`.
36
+
37
+ ## Add to your MCP client
38
+
39
+ **Claude Code**
40
+ ```bash
41
+ claude mcp add julabo -- uvx labmcp-julabo --address /dev/ttyACM0
42
+ ```
43
+
44
+ **Claude Desktop / Cursor / Windsurf** (`claude_desktop_config.json`, `.cursor/mcp.json`, โ€ฆ)
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "julabo": {
49
+ "command": "uvx",
50
+ "args": ["labmcp-julabo", "--address", "/dev/ttyACM0", "--limit", "max_temperature_c=120"]
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ Add `--read-only` to allow monitoring only (`stop_circulation` stays available). For other clients: `uvx labmcp config julabo-circulator --address /dev/ttyACM0 --client vscode`.
57
+
58
+ ## Tools
59
+
60
+ <!-- TOOLS:START -->
61
+ | Tool | Kind | Description |
62
+ |---|---|---|
63
+ | `get_command_log` | ๐Ÿ‘ read | Return the most recent raw commands sent to / replies received from the instrument (newest last). Useful for debugging and for recording what was done. |
64
+ | `get_connection_info` | ๐Ÿ‘ read | Report which instrument is connected (identity, address, simulated or real), whether the server is read-only, and the active safety limits. Call this first. |
65
+ | `get_setpoint` | ๐Ÿ‘ read | Read the current temperature setpoint and whether temperature control is running. |
66
+ | `get_status` | ๐Ÿ‘ read | Report the circulator's status message (decoded: operating state, rejected command or alarm), whether temperature control is running, the setpoint, the device's own excess-temperature protection setting and warning limits, and the firmware version. |
67
+ | `read_temperatures` | ๐Ÿ‘ read | Read the bath temperature, the heating/cooling power in % and the safety-sensor temperature, and optionally the external Pt100 sensor. |
68
+ | `reconnect` | ๐Ÿ›‘ safety | Close and re-open the connection to the instrument (e.g. after it was power cycled or a cable was re-plugged). |
69
+ | `set_setpoint` | โš ๏ธ hazard | Set the circulator's temperature setpoint (out_sp_00). If temperature control is running, the circulator starts heating or cooling to it immediately. Refused outside `min_temperature_c`..`max_temperature_c`; the circulator itself also rejects values outside its range or warning limits (reported as an error). Needs remote control enabled. |
70
+ | `start_circulation` | โš ๏ธ hazard | Start temperature control (out_mode_05 1): the pump runs and the bath heats or cools to the setpoint, which is checked against the safety limits first (it may have been changed on the front panel). Make sure the bath is filled and any external hoses are connected and secured. Needs remote control enabled. |
71
+ | `stop_circulation` | ๐Ÿ›‘ safety | Stop temperature control and the pump (out_mode_05 0), then confirm with in_mode_05. Also ends a running `wait_for_temperature`. The bath stays hot/cold after stopping. |
72
+ | `wait_for_temperature` | ๐Ÿ‘ read | Poll the bath (or external) temperature until it has been within `tolerance_c` of the target for `stable_for_s` seconds, or `timeout_s` passes. Does not change anything; start circulation first. Stops early (reached = false) if the circulator raises an alarm or a stop command is sent. Returns a short temperature trace. |
73
+ <!-- TOOLS:END -->
74
+
75
+ ## Safety limits
76
+
77
+ | Limit | Default | Meaning |
78
+ |---|---|---|
79
+ | `max_temperature_c` | 90 ยฐC | Highest setpoint an agent may set (also checked before `start_circulation`) |
80
+ | `min_temperature_c` | 5 ยฐC | Lowest setpoint an agent may set (also checked before `start_circulation`) |
81
+ | `max_wait_s` | 7200 s | Longest `wait_for_temperature` |
82
+
83
+ The defaults suit a **water** bath. Set them to your bath fluid's working range, e.g. `--limit min_temperature_c=-30 --limit max_temperature_c=150` for a silicone oil / glycol-rated setup. The circulator additionally enforces its own working range, warning limits and excess-temperature protection; rejections come back as errors such as `-11 VALUE TOO LARGE`.
84
+
85
+ ## Example prompts
86
+
87
+ - "Set the circulator to 37 ยฐC, start it and tell me when the bath has been stable within ยฑ0.1 ยฐC for five minutes."
88
+ - "What's the bath temperature and how hard is the circulator working right now?"
89
+ - "Cool the jacketed reactor to 10 ยฐC using the external Pt100 as the reference and let me know when it gets there."
90
+ - "Is there any alarm on the circulator? Explain what it means."
91
+ - "Stop the circulator."
92
+
93
+ ## Notes
94
+
95
+ - **Remote control is a front-panel setting.** In manual mode (`00 MANUAL STOP` / `01 MANUAL START`) the server refuses to send settings and tells you how to enable remote control.
96
+ - **Every setting is verified.** OUT commands have no reply, so after each one the server queries `status` (to catch `-08 INVALID COMMAND`, `-09 COMMAND NOT ALLOWED IN CURRENT OPERATING MODE`, `-10 VALUE TOO SMALL`, `-11 VALUE TOO LARGE`, `-13 VALUE EXCEEDS TEMPERATURE LIMITS`) and reads the setpoint or `in_mode_05` back. `stop_circulation` retries once and reports an error if the circulator still says it is running.
97
+ - **Model differences.** CORIO CD has only the basic set (actual value, power, safety sensor, setpoint, start/stop). Warning limits (`in_sp_03/04`) need CORIO CP, MAGIO or DYNEO; the external Pt100 (`in_pv_02`) needs MAGIO or DYNEO. The server detects missing commands (no reply) and reports those values as `null`.
98
+ - **Command pacing (unverified).** The manuals give no minimum time between commands. The server waits `command_delay_s` (default 0.25 s) after each setting command before checking `status`; change it with `--option command_delay_s=0.5` if your circulator misses commands.
99
+ - **Watchdog.** MAGIO/DYNEO circulators have a watchdog configured in their own menu (mode, timeout, fallback setpoint, reset on "setpoint only" or "all valid commands"). The server does not configure it. With restart mode "all valid commands" you can keep it fed while the server runs with `--option keepalive_s=10` (queries `status` every 10 s). JULABO's alarm list notes that it expects the setpoint at least every 30 s in "setpoint" restart mode, which this keep-alive does not do.
100
+ - Status and alarm texts are decoded from the code (`-01`, `-14`, `-63`, ...) using the alarm tables in the manuals; the exact wording the circulator sends is passed through unchanged.
101
+ - Not implemented: pump stage/capacity (`out_sp_07/27`), warning-limit writes (`out_sp_03/04`), controller parameters (`out_par_*`), external/internal control switch (`out_mode_04`), EPROG and ATC calibration commands.
102
+
103
+ ## Hardware verification
104
+
105
+ | Model | Firmware | Interface | Verified by | Date |
106
+ |---|---|---|---|---|
107
+ | *none yet: [be the first](https://github.com/K-Dense-AI/lab-instrument-mcps/issues/new?template=hardware-verification.yml)* | | | | |
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "labmcp-julabo"
3
+ version = "0.1.0"
4
+ description = "MCP server for JULABO heating and refrigerated circulators (CORIO, MAGIO, DYNEO): read temperatures, set setpoint, start/stop, alarms."
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.10"
8
+ authors = [{ name = "K-Dense and LabMCP contributors" }]
9
+ keywords = ["mcp", "lab-instrument", "julabo", "circulator", "thermostat", "chiller", "temperature-control"]
10
+ dependencies = ["labmcp>=0.1,<0.2"]
11
+
12
+ [project.scripts]
13
+ labmcp-julabo = "labmcp_julabo.server:main"
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/K-Dense-AI/lab-instrument-mcps/tree/main/servers/chemistry/julabo-circulator"
17
+
18
+ [tool.labmcp]
19
+ name = "JULABO Circulator"
20
+ domain = "chemistry"
21
+ category = "Temperature control"
22
+ vendor = "JULABO"
23
+ models = ["CORIO CD", "CORIO CP", "MAGIO MS", "DYNEO DD"]
24
+ interfaces = ["USB (virtual COM)", "RS-232"]
25
+ protocol = "JULABO interface commands (in_/out_/status/version)"
26
+ summary = "Read bath/external temperature and heating power, set setpoint, start/stop tempering, decode status and alarms, wait for temperature."
27
+ status = "simulated"
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/labmcp_julabo"]
@@ -0,0 +1,46 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.K-Dense-AI/labmcp-julabo",
4
+ "title": "JULABO Circulator",
5
+ "description": "MCP server for JULABO heating and refrigerated circulators (CORIO, MAGIO, DYNEO): read temperatures,",
6
+ "version": "0.1.0",
7
+ "repository": {
8
+ "url": "https://github.com/K-Dense-AI/lab-instrument-mcps",
9
+ "source": "github",
10
+ "subfolder": "servers/chemistry/julabo-circulator"
11
+ },
12
+ "websiteUrl": "https://github.com/K-Dense-AI/lab-instrument-mcps/tree/main/servers/chemistry/julabo-circulator",
13
+ "packages": [
14
+ {
15
+ "registryType": "pypi",
16
+ "registryBaseUrl": "https://pypi.org",
17
+ "identifier": "labmcp-julabo",
18
+ "version": "0.1.0",
19
+ "transport": {
20
+ "type": "stdio"
21
+ },
22
+ "environmentVariables": [
23
+ {
24
+ "name": "LABMCP_ADDRESS",
25
+ "description": "Instrument address, e.g. serial:///dev/ttyUSB0 or tcp://192.168.1.50:5025",
26
+ "isRequired": false
27
+ },
28
+ {
29
+ "name": "LABMCP_SIMULATE",
30
+ "description": "Set to 1 to use the built-in simulator (no hardware)",
31
+ "isRequired": false
32
+ },
33
+ {
34
+ "name": "LABMCP_READ_ONLY",
35
+ "description": "Set to 1 to disable all state-changing tools",
36
+ "isRequired": false
37
+ },
38
+ {
39
+ "name": "LABMCP_LIMITS",
40
+ "description": "Safety limit overrides, e.g. max_temperature_c=80",
41
+ "isRequired": false
42
+ }
43
+ ]
44
+ }
45
+ ]
46
+ }
@@ -0,0 +1 @@
1
+ """LabMCP server for JULABO heating and refrigerated circulators."""
@@ -0,0 +1,320 @@
1
+ """Driver for JULABO heating and refrigerated circulators (JULABO interface commands).
2
+
3
+ Commands, status messages, alarm codes and interface settings were verified in the
4
+ "Interface commands" appendix (chapter 11) of these JULABO original operating manuals:
5
+
6
+ * CORIO CD, 1.950.0800.us.V10 (03/2022),
7
+ https://pim-resources.coleparmer.com/instruction-manual/corio-cd-circulator-manual.pdf
8
+ * CORIO CP, 1.950.0900.us.V04 (04/2022),
9
+ https://pim-resources.coleparmer.com/instruction-manual/corio-cp-operating-manual-1-950-0900-us.pdf
10
+ * MAGIO MS, 1.950.1700.us.V05 (05/2022),
11
+ https://pim-resources.coleparmer.com/instruction-manual/magio-ms-operating-manual-1-950-1700-us-v05.pdf
12
+ * DYNEO DD, 1.950.1300.us.V03 (05/2022),
13
+ https://pim-resources.coleparmer.com/instruction-manual/dyneo-dd-operating-manual-1-950-1300-us-v03.pdf
14
+
15
+ Wire format: a command is sent as ``command CR`` or ``command SPACE parameter CR``
16
+ (e.g. ``out_sp_00 55.5``); IN commands answer with one line ending in CR LF (``55.5``).
17
+ OUT commands send no reply and are only accepted in remote-control mode; whether one was
18
+ rejected is reported by the next ``status`` query (``-08 INVALID COMMAND``, ``-10 VALUE TOO
19
+ SMALL``...), which also reports pending alarms. RS-232 factory settings: 4800 baud, 7 data
20
+ bits, even ("straight") parity, 1 stop bit, hardware (RTS/CTS) handshake. On USB the
21
+ circulator is a virtual COM port and these settings do not matter.
22
+
23
+ Command availability differs by family: CORIO CD only has in_pv_00/01/03/04, in_sp_00,
24
+ in_mode_05, out_sp_00, out_mode_05, version and status. CORIO CP adds the warning limits
25
+ (in_sp_03/04); MAGIO and DYNEO add the external Pt100 sensor (in_pv_02) and more.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import contextlib
31
+ import logging
32
+ import re
33
+ import threading
34
+ import time
35
+ from dataclasses import dataclass
36
+
37
+ from labmcp import InstrumentError, InstrumentProtocolError, InstrumentTimeout, Transport
38
+
39
+ log = logging.getLogger("labmcp.julabo")
40
+
41
+ _STATUS_RE = re.compile(r"^\s*(-?\d+)\s*(.*?)\s*$")
42
+ _NUMBER_RE = re.compile(r"^\s*[-+]?(?:\d+(?:\.\d*)?|\.\d+)\s*$")
43
+
44
+ #: Status messages (manual 11.1.4). Codes >= 0 are operating states.
45
+ STATES = {
46
+ 0: ("MANUAL STOP", "standby, manual operation (remote control is not enabled)"),
47
+ 1: ("MANUAL START", "running, manual operation (remote control is not enabled)"),
48
+ 2: ("REMOTE STOP", "standby, remote control operation"),
49
+ 3: ("REMOTE START", "running, remote control operation"),
50
+ }
51
+
52
+ #: Replies to a rejected command (manual 11.1.4).
53
+ COMMAND_ERRORS = {
54
+ -8: "the circulator did not recognise the last command",
55
+ -9: "the last command is not permitted in the current operating mode (is remote control enabled?)",
56
+ -10: "the last value sent was too small",
57
+ -11: "the last value sent was too large",
58
+ -13: "the value is not within the temperature limits set on the circulator",
59
+ }
60
+
61
+ #: Alarms and warnings (manual 11.2; the list depends on the model).
62
+ ALARMS = {
63
+ -1: "bath fluid level too low; top up the bath fluid and check the hoses",
64
+ -3: "measured temperature is above the high-temperature warning limit",
65
+ -4: "measured temperature is below the low-temperature warning limit",
66
+ -5: "working temperature sensor cable broken or short-circuited",
67
+ -6: "temperature difference between working sensor and high-temperature protection sensor too large "
68
+ "(increase pump capacity)",
69
+ -14: "the set excess-temperature protection value has been exceeded",
70
+ -15: "external temperature sensor line short-circuited or interrupted",
71
+ -33: "high-temperature protection sensor line short-circuited or interrupted",
72
+ -38: "setpoint is set to the external sensor but no signal is available",
73
+ -40: "low-level early warning: bath fluid level is critical",
74
+ -41: "high-level early warning: bath fluid level is critical",
75
+ -60: "internal read/write error; switch off at the mains, wait 4 s, switch on",
76
+ -61: "communication (CAN bus) error between circulator and refrigeration unit",
77
+ -62: "CAN bus error; switch off at the mains, wait 4 s, switch on",
78
+ -63: "the watchdog function has responded",
79
+ -70: "units with incompatible voltage/frequency variants connected, or incorrectly configured",
80
+ -72: "configuration between circulator and refrigeration unit failed",
81
+ -83: "excessive power consumption via the USB-A port",
82
+ -108: "alarm latch of the protective equipment is still active; power-cycle the unit",
83
+ -116: "alarm latch of the protective equipment is still active; power-cycle the unit",
84
+ -143: "over-temperature alarm limit exceeded",
85
+ -144: "under-temperature alarm limit exceeded",
86
+ -421: "ambient temperature outside the specification",
87
+ -427: "pressure sensor detects excessive condensation pressure",
88
+ -431: "maximum permissible compressor current exceeded",
89
+ -503: "external setpoint via EPROG selected but no analog module connected",
90
+ -504: "external actuating variable via EPROG selected but no analog module connected",
91
+ -505: "the analog module transmits an invalid setpoint",
92
+ -1109: "bath fluid viscosity too high or circulation rate too low",
93
+ -1305: "pump speed limit of the heating block not reached (motor defective or fluid too viscous)",
94
+ -1427: "pressure sensor detects excessive condensation pressure",
95
+ -1431: "compressor current below the permissible minimum",
96
+ -1501: "timeout on the serial interface (watchdog: send the setpoint at least every 30 s)",
97
+ -2426: "evaporation temperature below the warning threshold",
98
+ }
99
+
100
+
101
+ @dataclass
102
+ class Status:
103
+ code: int
104
+ text: str
105
+ kind: str # "state" | "command_error" | "alarm"
106
+ meaning: str
107
+
108
+ @property
109
+ def label(self) -> str:
110
+ """The code and text as the circulator shows them, e.g. ``03 REMOTE START`` / ``-08 ...``."""
111
+ return f"{self.code:03d} {self.text}" if self.code < 0 else f"{self.code:02d} {self.text}"
112
+
113
+ @property
114
+ def remote(self) -> bool | None:
115
+ return self.code in (2, 3) if self.kind == "state" else None
116
+
117
+ @property
118
+ def running(self) -> bool | None:
119
+ return self.code in (1, 3) if self.kind == "state" else None
120
+
121
+
122
+ def parse_status(reply: str) -> Status:
123
+ """Parse a ``status`` reply such as ``03 REMOTE START`` or ``-01 ...``."""
124
+ m = _STATUS_RE.match(reply)
125
+ if not m:
126
+ raise InstrumentProtocolError(f"Unexpected reply to 'status': {reply!r}")
127
+ code, text = int(m.group(1)), m.group(2)
128
+ if code in STATES:
129
+ return Status(code, text or STATES[code][0], "state", STATES[code][1])
130
+ if code in COMMAND_ERRORS:
131
+ return Status(code, text, "command_error", COMMAND_ERRORS[code])
132
+ if code < 0:
133
+ return Status(code, text, "alarm", ALARMS.get(code, "alarm/warning not listed in the manual"))
134
+ return Status(code, text, "state", "unknown operating state")
135
+
136
+
137
+ class JulaboCirculator:
138
+ def __init__(self, transport: Transport, command_delay_s: float = 0.25) -> None:
139
+ self.t = transport
140
+ #: Pause after each OUT command before the next command (see README "Notes").
141
+ self.command_delay_s = command_delay_s
142
+ #: Set by :meth:`stop` so a running wait loop ends early.
143
+ self.abort = threading.Event()
144
+ self._keepalive_stop = threading.Event()
145
+ self._keepalive_thread: threading.Thread | None = None
146
+ #: Optional commands this model did not answer (e.g. in_sp_03 on a CORIO CD).
147
+ self.unsupported: set[str] = set()
148
+
149
+ # ------------------------------------------------------------ low level
150
+
151
+ def query(self, cmd: str, timeout: float | None = None) -> str:
152
+ with self.t.lock:
153
+ self.t.flush_input() # the circulator never sends unsolicited data
154
+ reply = self.t.query(cmd, timeout).strip()
155
+ if not reply:
156
+ raise InstrumentProtocolError(f"Circulator sent an empty reply to {cmd!r}.")
157
+ return reply
158
+
159
+ def query_number(self, cmd: str) -> float:
160
+ reply = self.query(cmd)
161
+ if not _NUMBER_RE.match(reply):
162
+ if _STATUS_RE.match(reply) and any(c.isalpha() for c in reply):
163
+ st = parse_status(reply)
164
+ raise InstrumentProtocolError(f"Circulator replied {reply!r} to {cmd!r}: {st.meaning}.")
165
+ raise InstrumentProtocolError(f"Circulator replied {reply!r} to {cmd!r}; expected a number.")
166
+ return float(reply)
167
+
168
+ def optional_number(self, cmd: str) -> float | None:
169
+ """Query a command that not every model has; ``None`` if this circulator lacks it."""
170
+ if cmd in self.unsupported:
171
+ return None
172
+ try:
173
+ return self.query_number(cmd)
174
+ except (InstrumentTimeout, InstrumentProtocolError):
175
+ self.unsupported.add(cmd)
176
+ # Read (and so clear) the "-08 INVALID COMMAND" the device may now report.
177
+ with contextlib.suppress(InstrumentError):
178
+ self.status()
179
+ return None
180
+
181
+ def send(self, cmd: str) -> Status:
182
+ """Send an OUT command, then query ``status`` and raise if the command was rejected."""
183
+ with self.t.lock:
184
+ self.t.write(cmd)
185
+ if self.command_delay_s:
186
+ time.sleep(self.command_delay_s)
187
+ st = self.status()
188
+ if st.kind == "command_error":
189
+ raise InstrumentProtocolError(
190
+ f"Circulator rejected {cmd!r}: '{st.label}' ({st.meaning}). Nothing was changed."
191
+ )
192
+ return st
193
+
194
+ # ------------------------------------------------------------ identity / status
195
+
196
+ def version(self) -> str:
197
+ return self.query("version")
198
+
199
+ def status(self) -> Status:
200
+ return parse_status(self.query("status"))
201
+
202
+ def identify(self) -> dict[str, str]:
203
+ info = {"manufacturer": "JULABO"}
204
+ try:
205
+ info["version"] = self.version()
206
+ except InstrumentError as exc:
207
+ info["version"] = f"unknown ({exc})"
208
+ try:
209
+ st = self.status()
210
+ info["status"] = st.label
211
+ except InstrumentError:
212
+ pass
213
+ return info
214
+
215
+ def require_remote(self) -> Status:
216
+ st = self.status()
217
+ if st.kind == "state" and st.remote is False:
218
+ raise InstrumentProtocolError(
219
+ f"The circulator is in manual mode ('{st.label}'), so it ignores remote "
220
+ "commands. Enable remote control on the circulator first (CORIO: MENU > IntE > rEM > "
221
+ "USb or 232; MAGIO/DYNEO: Main menu > Connect unit > Remote control)."
222
+ )
223
+ return st
224
+
225
+ # ------------------------------------------------------------ readings
226
+
227
+ def bath_temperature_c(self) -> float:
228
+ """``in_pv_00``: actual (bath/internal) temperature."""
229
+ return self.query_number("in_pv_00")
230
+
231
+ def heating_power_pct(self) -> float:
232
+ """``in_pv_01``: current actuating variable in % (negative = cooling)."""
233
+ return self.query_number("in_pv_01")
234
+
235
+ def external_temperature_c(self) -> float:
236
+ """``in_pv_02``: external Pt100 sensor (MAGIO, DYNEO; not CORIO)."""
237
+ return self.query_number("in_pv_02")
238
+
239
+ def safety_sensor_temperature_c(self) -> float:
240
+ """``in_pv_03``: temperature of the high-temperature safety sensor."""
241
+ return self.query_number("in_pv_03")
242
+
243
+ def excess_temperature_protection_c(self) -> float:
244
+ """``in_pv_04``: current setting of the high-temperature safety function."""
245
+ return self.query_number("in_pv_04")
246
+
247
+ def setpoint_c(self) -> float:
248
+ return self.query_number("in_sp_00")
249
+
250
+ def warning_limits_c(self) -> tuple[float | None, float | None]:
251
+ """``in_sp_03`` / ``in_sp_04``: (high, low) temperature warning limits (not on CORIO CD)."""
252
+ return self.optional_number("in_sp_03"), self.optional_number("in_sp_04")
253
+
254
+ def is_running(self) -> bool:
255
+ """``in_mode_05``: 1 = temperature control started, 0 = stopped."""
256
+ value = self.query_number("in_mode_05")
257
+ if value not in (0, 1):
258
+ raise InstrumentProtocolError(f"Unexpected in_mode_05 value {value:g}; expected 0 or 1.")
259
+ return value == 1
260
+
261
+ # ------------------------------------------------------------ control
262
+
263
+ def set_setpoint(self, celsius: float) -> Status:
264
+ self.require_remote()
265
+ st = self.send(f"out_sp_00 {celsius:.2f}")
266
+ reported = self.setpoint_c()
267
+ if abs(reported - celsius) > 0.051:
268
+ raise InstrumentProtocolError(
269
+ f"Setpoint not accepted: circulator reports {reported:g} ยฐC after 'out_sp_00 {celsius:.2f}'."
270
+ )
271
+ return st
272
+
273
+ def start(self) -> Status:
274
+ self.require_remote()
275
+ self.abort.clear()
276
+ st = self.send("out_mode_05 1")
277
+ if not self.is_running():
278
+ raise InstrumentProtocolError(
279
+ f"The circulator did not start (status '{st.label}': {st.meaning})."
280
+ )
281
+ return st
282
+
283
+ def stop(self) -> bool:
284
+ """Stop temperature control. Retries once; returns True if in_mode_05 confirms it."""
285
+ self.abort.set()
286
+ for _ in range(2):
287
+ try:
288
+ with self.t.lock:
289
+ self.t.write("out_mode_05 0")
290
+ if self.command_delay_s:
291
+ time.sleep(self.command_delay_s)
292
+ if not self.is_running():
293
+ return True
294
+ except InstrumentError as exc:
295
+ log.warning("Stop attempt failed: %s", exc)
296
+ return False
297
+
298
+ # ------------------------------------------------------------ keep-alive (device watchdog)
299
+
300
+ def start_keepalive(self, interval_s: float) -> None:
301
+ """Query ``status`` every ``interval_s`` so a watchdog configured on the circulator
302
+ (MAGIO/DYNEO, restart mode "all valid commands") sees continuous traffic."""
303
+ self._keepalive_stop.clear()
304
+ self._keepalive_thread = threading.Thread(
305
+ target=self._keepalive, args=(interval_s,), name="julabo-keepalive", daemon=True
306
+ )
307
+ self._keepalive_thread.start()
308
+
309
+ def _keepalive(self, interval_s: float) -> None:
310
+ while not self._keepalive_stop.wait(interval_s):
311
+ try:
312
+ self.status()
313
+ except InstrumentError as exc:
314
+ log.warning("JULABO keep-alive query failed: %s", exc)
315
+
316
+ def close(self) -> None:
317
+ self._keepalive_stop.set()
318
+ if self._keepalive_thread is not None:
319
+ self._keepalive_thread.join(timeout=5)
320
+ self.t.close()