cura-mcp 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.
Files changed (60) hide show
  1. cura_mcp-0.1.0/.gitignore +47 -0
  2. cura_mcp-0.1.0/PKG-INFO +58 -0
  3. cura_mcp-0.1.0/README.md +40 -0
  4. cura_mcp-0.1.0/pyproject.toml +48 -0
  5. cura_mcp-0.1.0/src/cura_mcp/__init__.py +3 -0
  6. cura_mcp-0.1.0/src/cura_mcp/client.py +59 -0
  7. cura_mcp-0.1.0/src/cura_mcp/config.py +42 -0
  8. cura_mcp-0.1.0/src/cura_mcp/errors.py +117 -0
  9. cura_mcp-0.1.0/src/cura_mcp/models.py +357 -0
  10. cura_mcp-0.1.0/src/cura_mcp/server.py +142 -0
  11. cura_mcp-0.1.0/src/cura_mcp/tools/__init__.py +1 -0
  12. cura_mcp-0.1.0/src/cura_mcp/tools/arrange_all.py +19 -0
  13. cura_mcp-0.1.0/src/cura_mcp/tools/center_model.py +18 -0
  14. cura_mcp-0.1.0/src/cura_mcp/tools/clear_plate.py +20 -0
  15. cura_mcp-0.1.0/src/cura_mcp/tools/duplicate_model.py +21 -0
  16. cura_mcp-0.1.0/src/cura_mcp/tools/estimates.py +20 -0
  17. cura_mcp-0.1.0/src/cura_mcp/tools/export_gcode.py +19 -0
  18. cura_mcp-0.1.0/src/cura_mcp/tools/export_model.py +24 -0
  19. cura_mcp-0.1.0/src/cura_mcp/tools/get_all_user_settings.py +19 -0
  20. cura_mcp-0.1.0/src/cura_mcp/tools/get_machine_info.py +18 -0
  21. cura_mcp-0.1.0/src/cura_mcp/tools/get_model_settings.py +18 -0
  22. cura_mcp-0.1.0/src/cura_mcp/tools/get_setting.py +19 -0
  23. cura_mcp-0.1.0/src/cura_mcp/tools/get_snapshot.py +23 -0
  24. cura_mcp-0.1.0/src/cura_mcp/tools/group_models.py +26 -0
  25. cura_mcp-0.1.0/src/cura_mcp/tools/list_machines.py +16 -0
  26. cura_mcp-0.1.0/src/cura_mcp/tools/list_materials.py +18 -0
  27. cura_mcp-0.1.0/src/cura_mcp/tools/list_models.py +20 -0
  28. cura_mcp-0.1.0/src/cura_mcp/tools/list_variants.py +18 -0
  29. cura_mcp-0.1.0/src/cura_mcp/tools/load_model.py +21 -0
  30. cura_mcp-0.1.0/src/cura_mcp/tools/merge_models.py +25 -0
  31. cura_mcp-0.1.0/src/cura_mcp/tools/mirror_model.py +18 -0
  32. cura_mcp-0.1.0/src/cura_mcp/tools/move_model.py +31 -0
  33. cura_mcp-0.1.0/src/cura_mcp/tools/open_project.py +38 -0
  34. cura_mcp-0.1.0/src/cura_mcp/tools/orientation.py +22 -0
  35. cura_mcp-0.1.0/src/cura_mcp/tools/remove_model.py +20 -0
  36. cura_mcp-0.1.0/src/cura_mcp/tools/reset_all_settings.py +20 -0
  37. cura_mcp-0.1.0/src/cura_mcp/tools/reset_model_setting.py +19 -0
  38. cura_mcp-0.1.0/src/cura_mcp/tools/reset_setting.py +18 -0
  39. cura_mcp-0.1.0/src/cura_mcp/tools/rotate.py +20 -0
  40. cura_mcp-0.1.0/src/cura_mcp/tools/save_project.py +21 -0
  41. cura_mcp-0.1.0/src/cura_mcp/tools/scale_model.py +31 -0
  42. cura_mcp-0.1.0/src/cura_mcp/tools/scale_to_fit.py +18 -0
  43. cura_mcp-0.1.0/src/cura_mcp/tools/select_model.py +19 -0
  44. cura_mcp-0.1.0/src/cura_mcp/tools/set_adhesion.py +16 -0
  45. cura_mcp-0.1.0/src/cura_mcp/tools/set_infill_density.py +16 -0
  46. cura_mcp-0.1.0/src/cura_mcp/tools/set_layer_height.py +16 -0
  47. cura_mcp-0.1.0/src/cura_mcp/tools/set_mesh_type.py +23 -0
  48. cura_mcp-0.1.0/src/cura_mcp/tools/set_model_setting.py +25 -0
  49. cura_mcp-0.1.0/src/cura_mcp/tools/set_quality.py +18 -0
  50. cura_mcp-0.1.0/src/cura_mcp/tools/set_setting.py +23 -0
  51. cura_mcp-0.1.0/src/cura_mcp/tools/set_supports.py +21 -0
  52. cura_mcp-0.1.0/src/cura_mcp/tools/slice.py +25 -0
  53. cura_mcp-0.1.0/src/cura_mcp/tools/status.py +23 -0
  54. cura_mcp-0.1.0/src/cura_mcp/tools/switch_machine.py +18 -0
  55. cura_mcp-0.1.0/src/cura_mcp/tools/switch_material.py +18 -0
  56. cura_mcp-0.1.0/src/cura_mcp/tools/switch_variant.py +20 -0
  57. cura_mcp-0.1.0/src/cura_mcp/tools/ungroup_model.py +19 -0
  58. cura_mcp-0.1.0/tests/__init__.py +0 -0
  59. cura_mcp-0.1.0/tests/test_bridge.py +197 -0
  60. cura_mcp-0.1.0/uv.lock +1283 -0
@@ -0,0 +1,47 @@
1
+ # --- Project-specific ---
2
+ # Internal build specs: local reference for the implementation agent, never shipped.
3
+ specs/
4
+ docs/
5
+ # Local agent/dev context (implementation guide for the coding agent), never shipped.
6
+ CLAUDE.md
7
+ # Local dogfooding instrumentation (Layer-1 logger + local bridge entrypoint): kept
8
+ # on disk for local use, never published. The public cura_mcp package has no
9
+ # reference to it (see cura_dogfood/bridge.py).
10
+ cura_dogfood/
11
+ # Local auth token written by the Cura plugin at runtime (see SECURITY).
12
+ *.cura-mcp-token
13
+ .cura-mcp/
14
+
15
+ # --- Python ---
16
+ __pycache__/
17
+ *.py[cod]
18
+ *$py.class
19
+ *.egg-info/
20
+ .eggs/
21
+ build/
22
+ dist/
23
+ .venv/
24
+ venv/
25
+ env/
26
+ .pytest_cache/
27
+ .mypy_cache/
28
+ .ruff_cache/
29
+ .coverage
30
+ htmlcov/
31
+ .tox/
32
+
33
+ # --- Editors / OS ---
34
+ .idea/
35
+ .vscode/
36
+ *.swp
37
+ .DS_Store
38
+ Thumbs.db
39
+
40
+ # --- Logs / local artifacts ---
41
+ logs/
42
+ *.log
43
+ *.gcode
44
+ *.stl
45
+ *.3mf
46
+ !examples/**/*.stl
47
+ !examples/**/*.3mf
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: cura-mcp
3
+ Version: 0.1.0
4
+ Summary: Local MCP server that drives the UltiMaker Cura slicer. No hub, no telemetry.
5
+ Author-email: padymies <padymies@gmail.com>
6
+ License: MIT
7
+ Keywords: 3d-printing,cura,mcp,model-context-protocol,slicer
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: mcp>=1.2.0
11
+ Requires-Dist: pydantic>=2.6
12
+ Provides-Extra: dev
13
+ Requires-Dist: mypy>=1.10; extra == 'dev'
14
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
15
+ Requires-Dist: pytest>=8.0; extra == 'dev'
16
+ Requires-Dist: ruff>=0.6; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # cura-mcp (bridge)
20
+
21
+ The MCP server. Speaks MCP to your AI client and forwards each tool call to the
22
+ Cura plugin over loopback HTTP. Knows nothing about Cura internals — it is pure
23
+ transport, schemas, and error mapping.
24
+
25
+ ## Run (users)
26
+
27
+ Users don't install this directly — their MCP client launches it via
28
+ [`uv`](https://docs.astral.sh/uv/): `uvx cura-mcp` (see the top-level
29
+ [`README`](../README.md#install)). `uv` brings its own Python, so no separate
30
+ Python install is required.
31
+
32
+ ## Install (dev)
33
+
34
+ ```bash
35
+ cd mcp-server
36
+ pip install -e ".[dev]"
37
+ python -m cura_mcp.server
38
+ ```
39
+
40
+ Requires Cura running with the `cura-plugin` installed (the bridge fails fast
41
+ with a clear error otherwise). Configuration via env vars — see `config.py`:
42
+
43
+ - `CURA_MCP_HOST` (default `127.0.0.1`)
44
+ - `CURA_MCP_PORT` (default `8765`)
45
+ - `CURA_MCP_TOKEN_FILE` (default: platform user dir `~/.cura-mcp/token`)
46
+ - `CURA_MCP_TIMEOUT` (default `30` seconds; slice waits use a longer timeout)
47
+
48
+ ## Layout
49
+
50
+ ```
51
+ src/cura_mcp/
52
+ server.py MCP entrypoint; registers tools
53
+ client.py HTTP client to the plugin (token-authenticated)
54
+ models.py pydantic schemas (tool I/O + plugin contract)
55
+ config.py settings
56
+ errors.py typed error hierarchy
57
+ tools/ one module per tool
58
+ ```
@@ -0,0 +1,40 @@
1
+ # cura-mcp (bridge)
2
+
3
+ The MCP server. Speaks MCP to your AI client and forwards each tool call to the
4
+ Cura plugin over loopback HTTP. Knows nothing about Cura internals — it is pure
5
+ transport, schemas, and error mapping.
6
+
7
+ ## Run (users)
8
+
9
+ Users don't install this directly — their MCP client launches it via
10
+ [`uv`](https://docs.astral.sh/uv/): `uvx cura-mcp` (see the top-level
11
+ [`README`](../README.md#install)). `uv` brings its own Python, so no separate
12
+ Python install is required.
13
+
14
+ ## Install (dev)
15
+
16
+ ```bash
17
+ cd mcp-server
18
+ pip install -e ".[dev]"
19
+ python -m cura_mcp.server
20
+ ```
21
+
22
+ Requires Cura running with the `cura-plugin` installed (the bridge fails fast
23
+ with a clear error otherwise). Configuration via env vars — see `config.py`:
24
+
25
+ - `CURA_MCP_HOST` (default `127.0.0.1`)
26
+ - `CURA_MCP_PORT` (default `8765`)
27
+ - `CURA_MCP_TOKEN_FILE` (default: platform user dir `~/.cura-mcp/token`)
28
+ - `CURA_MCP_TIMEOUT` (default `30` seconds; slice waits use a longer timeout)
29
+
30
+ ## Layout
31
+
32
+ ```
33
+ src/cura_mcp/
34
+ server.py MCP entrypoint; registers tools
35
+ client.py HTTP client to the plugin (token-authenticated)
36
+ models.py pydantic schemas (tool I/O + plugin contract)
37
+ config.py settings
38
+ errors.py typed error hierarchy
39
+ tools/ one module per tool
40
+ ```
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cura-mcp"
7
+ version = "0.1.0"
8
+ description = "Local MCP server that drives the UltiMaker Cura slicer. No hub, no telemetry."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "padymies", email = "padymies@gmail.com" }]
13
+ keywords = ["mcp", "cura", "3d-printing", "slicer", "model-context-protocol"]
14
+ dependencies = [
15
+ "mcp>=1.2.0",
16
+ "httpx>=0.27",
17
+ "pydantic>=2.6",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ dev = [
22
+ "ruff>=0.6",
23
+ "mypy>=1.10",
24
+ "pytest>=8.0",
25
+ "pytest-asyncio>=0.23",
26
+ ]
27
+
28
+ [project.scripts]
29
+ cura-mcp = "cura_mcp.server:main"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/cura_mcp"]
33
+
34
+ [tool.ruff]
35
+ line-length = 100
36
+ src = ["src", "tests"]
37
+
38
+ [tool.ruff.lint]
39
+ select = ["E", "F", "I", "B", "UP", "ASYNC"]
40
+
41
+ [tool.mypy]
42
+ python_version = "3.10"
43
+ strict = true
44
+ files = ["src"]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+ asyncio_mode = "auto"
@@ -0,0 +1,3 @@
1
+ """cura-mcp: a local MCP server that drives the UltiMaker Cura slicer."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,59 @@
1
+ """HTTP client to the Cura plugin's local server.
2
+
3
+ Reads the per-session token written by the plugin, injects it on every request,
4
+ and maps the plugin's structured error envelope back to typed exceptions. This is
5
+ the only place the bridge talks to the plugin.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from .config import Settings
14
+ from .errors import CuraNotRunning, from_plugin_code
15
+ from .models import PluginResponse
16
+
17
+
18
+ class PluginClient:
19
+ def __init__(self, settings: Settings) -> None:
20
+ self._settings = settings
21
+ self._client = httpx.AsyncClient(base_url=settings.base_url, timeout=settings.timeout)
22
+
23
+ def _read_token(self) -> str:
24
+ try:
25
+ return self._settings.token_file.read_text(encoding="utf-8").strip()
26
+ except OSError as exc:
27
+ raise CuraNotRunning(
28
+ "No Cura plugin token found. Is Cura running with the cura-mcp plugin?"
29
+ ) from exc
30
+
31
+ async def call(
32
+ self,
33
+ method: str,
34
+ payload: dict[str, Any] | None = None,
35
+ *,
36
+ timeout: float | None = None,
37
+ ) -> dict[str, Any]:
38
+ """Call a plugin method; return ``data`` on success, raise a typed error otherwise."""
39
+ token = self._read_token()
40
+ try:
41
+ resp = await self._client.post(
42
+ "/rpc",
43
+ json={"method": method, "params": payload or {}},
44
+ headers={"X-Cura-Mcp-Token": token, "Host": self._settings.host},
45
+ timeout=timeout or self._settings.timeout,
46
+ )
47
+ except httpx.ConnectError as exc:
48
+ raise CuraNotRunning(
49
+ "Could not reach the Cura plugin server. Is Cura open?"
50
+ ) from exc
51
+
52
+ body = PluginResponse.model_validate(resp.json())
53
+ if body.ok:
54
+ return body.data or {}
55
+ assert body.error is not None
56
+ raise from_plugin_code(body.error.code, body.error.message)
57
+
58
+ async def aclose(self) -> None:
59
+ await self._client.aclose()
@@ -0,0 +1,42 @@
1
+ """Bridge configuration. All values overridable via environment variables.
2
+
3
+ The token-file path is the shared contract with the plugin: the plugin writes
4
+ the per-session token there, the bridge reads it. Keep both sides in sync.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+
13
+ def _default_token_file() -> Path:
14
+ # Mirrors the plugin's token location (see cura-plugin/server/auth.py).
15
+ return Path.home() / ".cura-mcp" / "token"
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Settings:
20
+ host: str = os.environ.get("CURA_MCP_HOST", "127.0.0.1")
21
+ port: int = int(os.environ.get("CURA_MCP_PORT", "8765"))
22
+ token_file: Path = Path(os.environ.get("CURA_MCP_TOKEN_FILE", "")) or _default_token_file()
23
+ # General request timeout (seconds). Slicing uses slice_timeout instead.
24
+ timeout: float = float(os.environ.get("CURA_MCP_TIMEOUT", "30"))
25
+ slice_timeout: float = float(os.environ.get("CURA_MCP_SLICE_TIMEOUT", "300"))
26
+
27
+ @property
28
+ def base_url(self) -> str:
29
+ return f"http://{self.host}:{self.port}"
30
+
31
+
32
+ def load_settings() -> Settings:
33
+ # Resolve token_file explicitly (dataclass default expr runs at import time).
34
+ env_token = os.environ.get("CURA_MCP_TOKEN_FILE")
35
+ token_file = Path(env_token) if env_token else _default_token_file()
36
+ return Settings(
37
+ host=os.environ.get("CURA_MCP_HOST", "127.0.0.1"),
38
+ port=int(os.environ.get("CURA_MCP_PORT", "8765")),
39
+ token_file=token_file,
40
+ timeout=float(os.environ.get("CURA_MCP_TIMEOUT", "30")),
41
+ slice_timeout=float(os.environ.get("CURA_MCP_SLICE_TIMEOUT", "300")),
42
+ )
@@ -0,0 +1,117 @@
1
+ """Typed error hierarchy for the bridge.
2
+
3
+ Tool functions catch these and return structured errors to the LLM rather than
4
+ leaking stack traces. The plugin returns an error ``code`` in its JSON response;
5
+ ``from_plugin_code`` maps it back to the right class.
6
+ """
7
+ from __future__ import annotations
8
+
9
+
10
+ class CuraMcpError(Exception):
11
+ """Base class for all bridge-surfaced errors."""
12
+
13
+ code = "cura_mcp_error"
14
+
15
+
16
+ class CuraNotRunning(CuraMcpError):
17
+ """The plugin's local server could not be reached (Cura not open?)."""
18
+
19
+ code = "cura_not_running"
20
+
21
+
22
+ class AuthError(CuraMcpError):
23
+ """Token missing/invalid, or request rejected by the plugin's auth layer."""
24
+
25
+ code = "auth_error"
26
+
27
+
28
+ class InvalidPath(CuraMcpError):
29
+ """load_model path failed the plugin's sandbox (allow-list / traversal / ext)."""
30
+
31
+ code = "invalid_path"
32
+
33
+
34
+ class NodeNotFound(CuraMcpError):
35
+ """No model on the plate matches the given node_id."""
36
+
37
+ code = "node_not_found"
38
+
39
+
40
+ class ExportFailed(CuraMcpError):
41
+ """Writing the mesh to disk failed (no writer for the format, or I/O error)."""
42
+
43
+ code = "export_failed"
44
+
45
+
46
+ class UnknownSetting(CuraMcpError):
47
+ """The setting key does not exist in the active machine definition."""
48
+
49
+ code = "unknown_setting"
50
+
51
+
52
+ class InvalidSettingValue(CuraMcpError):
53
+ """The value is the wrong type or outside the setting's allowed range/options."""
54
+
55
+ code = "invalid_setting_value"
56
+
57
+
58
+ class PerExtruderUnsupported(CuraMcpError):
59
+ """The setting is per-extruder; v1 of the settings API handles global only."""
60
+
61
+ code = "per_extruder_unsupported"
62
+
63
+
64
+ class UnknownProfile(CuraMcpError):
65
+ """No machine/material/quality profile matches the given name."""
66
+
67
+ code = "unknown_profile"
68
+
69
+
70
+ class LoadFailed(CuraMcpError):
71
+ """The model load did not complete (e.g. timed out or the reader failed)."""
72
+
73
+ code = "load_failed"
74
+
75
+
76
+ class SliceFailed(CuraMcpError):
77
+ """The slice ended in an error state or the model was unsliceable."""
78
+
79
+ code = "slice_failed"
80
+
81
+
82
+ class SliceTimeout(CuraMcpError):
83
+ """The slice did not settle within the timeout."""
84
+
85
+ code = "slice_timeout"
86
+
87
+
88
+ class NoActiveProfile(CuraMcpError):
89
+ """No machine/material profile is active; estimates would be meaningless."""
90
+
91
+ code = "no_active_profile"
92
+
93
+
94
+ _BY_CODE = {
95
+ cls.code: cls
96
+ for cls in (
97
+ CuraMcpError,
98
+ CuraNotRunning,
99
+ AuthError,
100
+ InvalidPath,
101
+ NodeNotFound,
102
+ ExportFailed,
103
+ UnknownSetting,
104
+ InvalidSettingValue,
105
+ PerExtruderUnsupported,
106
+ UnknownProfile,
107
+ LoadFailed,
108
+ SliceFailed,
109
+ SliceTimeout,
110
+ NoActiveProfile,
111
+ )
112
+ }
113
+
114
+
115
+ def from_plugin_code(code: str, message: str) -> CuraMcpError:
116
+ """Build the right error subtype from a plugin error code."""
117
+ return _BY_CODE.get(code, CuraMcpError)(message)