lamcp 0.1.0__py3-none-any.whl

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.
lamcp/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
lamcp/server.py ADDED
@@ -0,0 +1,131 @@
1
+ """LAMCP: Lambda MCP server bridging LLMs to Grasshopper.
2
+
3
+ Architecture::
4
+
5
+ Claude ──MCP stdio──▶ lamcp (this process, Python 3.10+)
6
+
7
+ │ HTTP POST /exec
8
+
9
+ LAMCP Bridge GH component (Rhino 8 CPython 3.9)
10
+ ├─ http.server on 127.0.0.1:8765
11
+ ├─ exec() with shared globals
12
+ └─ returns stdout/stderr/repr(_)
13
+
14
+ The bridge URL is read from the ``LAMCP_BRIDGE_URL`` env var
15
+ (default ``http://127.0.0.1:8765``).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+
22
+ import httpx
23
+ from fastmcp import FastMCP
24
+
25
+ BRIDGE_URL = os.environ.get("LAMCP_BRIDGE_URL", "http://127.0.0.1:8765")
26
+ DEFAULT_TIMEOUT = 30.0
27
+
28
+ mcp = FastMCP("lamcp")
29
+
30
+
31
+ @mcp.tool()
32
+ def run_python_script(code: str, timeout: float = DEFAULT_TIMEOUT) -> dict:
33
+ """Execute Python code inside the running Rhino 8 instance.
34
+
35
+ The code runs in Rhino 8's CPython 3.9 runtime with full access to
36
+ RhinoCommon and Grasshopper APIs (active document, GH component
37
+ lookup, slider mutation, etc.). A shared globals dict persists across
38
+ calls so state accumulates (e.g., ``import`` once, reuse the binding
39
+ on later calls).
40
+
41
+ To return a value, assign it to ``_`` — the bridge returns ``repr(_)``.
42
+
43
+ Parameters
44
+ ----------
45
+ code : str
46
+ Python source to execute inside Rhino.
47
+ timeout : float, optional
48
+ Max seconds to wait for the execution to complete.
49
+
50
+ Returns
51
+ -------
52
+ dict
53
+ ``{"stdout": str, "stderr": str, "result": str|None, "error": str|None}``.
54
+ ``result`` is ``repr(_)`` if ``_`` was assigned, else ``"None"``.
55
+ ``error`` is a formatted traceback if the script raised, else ``None``.
56
+ """
57
+ try:
58
+ with httpx.Client(base_url=BRIDGE_URL, timeout=timeout + 5) as client:
59
+ response = client.post("/exec", json={"code": code, "timeout": timeout})
60
+ response.raise_for_status()
61
+ return response.json()
62
+ except httpx.RequestError as exc:
63
+ return {
64
+ "stdout": "",
65
+ "stderr": "",
66
+ "result": None,
67
+ "error": "Failed to reach LAMCP bridge at {}: {!r}".format(BRIDGE_URL, exc),
68
+ }
69
+
70
+
71
+ @mcp.tool()
72
+ def unload_python_modules(prefix: str) -> dict:
73
+ """Unload every imported Python module whose name starts with ``prefix``.
74
+
75
+ Useful during dev iteration: after editing a Python module on disk
76
+ that's imported by Rhino-side code, Rhino's CPython runtime keeps the
77
+ old version cached in ``sys.modules``. This tool drops all cached
78
+ entries under ``prefix`` so the next import re-reads from disk.
79
+
80
+ Note: this only updates ``sys.modules``. If your Grasshopper script's
81
+ top-level ``from foo import bar`` already bound a name, that binding
82
+ is unaffected — you also need to re-save / re-run the script for the
83
+ import to be re-evaluated.
84
+
85
+ Parameters
86
+ ----------
87
+ prefix : str
88
+ Dotted module prefix (e.g. ``"compas_fab"``, ``"my_pkg.sub"``).
89
+ All modules with names equal to or starting with ``prefix + "."`` are
90
+ unloaded.
91
+
92
+ Returns
93
+ -------
94
+ dict
95
+ ``{"unloaded": [list of module names], "count": int}``.
96
+ """
97
+ code = (
98
+ "import sys\n"
99
+ "prefix = {!r}\n"
100
+ "to_drop = [n for n in list(sys.modules) if n == prefix or n.startswith(prefix + '.')]\n"
101
+ "for n in to_drop: del sys.modules[n]\n"
102
+ "_ = {{'unloaded': sorted(to_drop), 'count': len(to_drop)}}\n"
103
+ ).format(prefix)
104
+ return run_python_script(code)
105
+
106
+
107
+ @mcp.tool()
108
+ def bridge_health() -> dict:
109
+ """Check whether the LAMCP Bridge HTTP server is reachable.
110
+
111
+ Returns
112
+ -------
113
+ dict
114
+ ``{"reachable": bool, "url": str, "detail": str}``.
115
+ """
116
+ try:
117
+ with httpx.Client(base_url=BRIDGE_URL, timeout=5.0) as client:
118
+ response = client.get("/health")
119
+ response.raise_for_status()
120
+ return {"reachable": True, "url": BRIDGE_URL, "detail": response.text}
121
+ except httpx.RequestError as exc:
122
+ return {"reachable": False, "url": BRIDGE_URL, "detail": repr(exc)}
123
+
124
+
125
+ def main():
126
+ """Run the MCP server on stdio."""
127
+ mcp.run()
128
+
129
+
130
+ if __name__ == "__main__":
131
+ main()
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.4
2
+ Name: lamcp
3
+ Version: 0.1.0
4
+ Summary: Lambda MCP: teach your LLM to do Grasshopper tricks.
5
+ Author: Gramazio Kohler Research, ETH Zürich
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Gramazio Kohler Research, ETH Zürich
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ License-File: LICENSE
28
+ Keywords: bridge,claude,grasshopper,llm,mcp,rhino
29
+ Requires-Python: >=3.10
30
+ Requires-Dist: fastmcp>=0.4
31
+ Requires-Dist: httpx>=0.27
32
+ Provides-Extra: dev
33
+ Requires-Dist: ruff>=0.7; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # LAMCP
37
+
38
+ **LA**mbda **MCP**: teach your LLM to do Grasshopper tricks.
39
+
40
+ Lets Claude Code (or any MCP client) introspect and mutate a
41
+ live Grasshopper session in real time: inspect the canvas,
42
+ wire components, read/write slider values, run `RhinoCommon`
43
+ calls, hot-reload modules -all from inside an AI agent loop, without rebuilding userobjects or restarting Rhino.
44
+
45
+ ## Architecture
46
+
47
+ ```text
48
+ LLM ──MCP stdio──▶ lamcp (Python 3.10+)
49
+
50
+ │ HTTP POST /exec {"code": "...", "timeout": 30}
51
+
52
+ LAMCP Bridge GH component (Rhino 8 CPython 3.9)
53
+ ├─ http.server on 127.0.0.1:8765
54
+ ├─ exec() with shared globals
55
+ └─ returns {stdout, stderr, result, error}
56
+ ```
57
+
58
+ Why split: Rhino 8's CPython runtime is pinned to 3.9. `fastmcp` and the
59
+ underlying `mcp` SDK require 3.10+. So the MCP-speaking half runs in a
60
+ system Python and forwards over loopback HTTP to a stdlib-only HTTP server
61
+ living inside Rhino as a regular Grasshopper component.
62
+
63
+ ## Setup
64
+
65
+ ### 1. Install the MCP server
66
+
67
+ ```bash
68
+ git clone https://github.com/gramaziokohler/lamcp.git
69
+ cd lamcp
70
+ uv pip install -e .
71
+ # or: pip install -e .
72
+ ```
73
+
74
+ ### 2. Register with Claude Code (or whatever LLM you use)
75
+
76
+ Add to `~/.claude.json`:
77
+
78
+ ```json
79
+ {
80
+ "mcpServers": {
81
+ "lamcp": {
82
+ "command": "lamcp"
83
+ }
84
+ }
85
+ }
86
+ ```
87
+
88
+ If `lamcp` isn't on `PATH`, use the absolute path to the venv's script:
89
+
90
+ ```json
91
+ {
92
+ "mcpServers": {
93
+ "lamcp": {
94
+ "command": "/absolute/path/to/.venv/bin/lamcp"
95
+ }
96
+ }
97
+ }
98
+ ```
99
+
100
+ Restart Claude Code so it discovers the new MCP server.
101
+
102
+ ### 3. Install the bridge in Grasshopper
103
+
104
+ **Option A — drop the pre-built userobject (recommended).**
105
+
106
+ 1. Download `Lamcp_Bridge.ghuser` from the [latest release](https://github.com/gramaziokohler/lamcp/releases/latest).
107
+ 2. In Grasshopper: *File → Special Folders → Components Folder*. Move the
108
+ `.ghuser` file there.
109
+ 3. Restart Grasshopper. `LAMCP Bridge` appears under the `LAMCP` tab.
110
+ 4. Drop it on the canvas, wire a `Boolean Toggle` (set to `True`) into
111
+ `enable`. The `status` output reads `listening on http://127.0.0.1:8765`.
112
+
113
+ **Option B — paste the source manually (for hacking).**
114
+
115
+ 1. Drop a Python 3 Script component on the canvas. Paste the contents of
116
+ [`grasshopper/Lamcp_Bridge/code.py`](grasshopper/Lamcp_Bridge/code.py) in.
117
+ 2. Add two inputs: `enable` (bool) and `port` (int). Add one output: `status`.
118
+ 3. Wire a `Boolean Toggle` (set to `True`) into `enable`.
119
+ 4. The `status` output should read `listening on http://127.0.0.1:8765`.
120
+
121
+ Either way, your MCP client now has a `run_python_script` tool that
122
+ exec()s code inside your live Rhino session.
123
+
124
+ ## Tools exposed
125
+
126
+ | Tool | Purpose |
127
+ | ----------------------- | ------------------------------------------------------------------------ |
128
+ | `run_python_script` | exec() arbitrary Python inside Rhino, capture stdout / stderr / repr(_) |
129
+ | `unload_python_modules` | drop `sys.modules[prefix.*]` so the next import re-reads from disk |
130
+ | `bridge_health` | ping the bridge to verify it's reachable |
131
+
132
+ ### Return contract for `run_python_script`
133
+
134
+ ```json
135
+ {
136
+ "stdout": "...", // captured stdout
137
+ "stderr": "...", // captured stderr
138
+ "result": "repr of _", // assign to `_` to return a value
139
+ "error": null // formatted traceback if exception raised
140
+ }
141
+ ```
142
+
143
+ Globals persist between calls, so you can `import` once and reuse:
144
+
145
+ ```python
146
+ # call 1
147
+ import scriptcontext as sc; doc = sc.doc.ActiveDoc
148
+ # call 2
149
+ print(doc.Name) # `doc` is still bound
150
+ ```
151
+
152
+ ## Environment variables
153
+
154
+ | Variable | Default | Purpose |
155
+ | ------------------ | ------------------------ | -------------------------------- |
156
+ | `LAMCP_BRIDGE_URL` | `http://127.0.0.1:8765` | URL of the bridge's HTTP server |
157
+
158
+ ## Caveats
159
+
160
+ - **UI thread**: code runs on the HTTP server thread, not the Rhino UI
161
+ thread. Most read-only `RhinoCommon` / `Grasshopper` access works
162
+ cross-thread, but heavy mutations (bulk `RemoveObject`, etc.) can crash
163
+ Rhino. Eto-based UI marshalling is a planned addition.
164
+ - **`isinstance` doesn't always work**: in Rhino 8 CPython, `isinstance`
165
+ against concrete .NET types often returns False due to interface interop.
166
+ Use `obj.GetType().Name == "..."` instead.
167
+ - **`RemoveSource(IGH_Param)` is a silent no-op**: use the
168
+ `RemoveSource(Guid)` overload.
169
+ - **`float(System.Decimal)` raises**: wrap with `System.Convert.ToDouble(x)`
170
+ or `float(str(x))`.
171
+
172
+ ## Security
173
+
174
+ The bridge listens on `127.0.0.1` only and accepts no auth: it runs
175
+ arbitrary Python in your Rhino with no sandboxing. **Never expose it
176
+ beyond localhost**, and stop it (`enable=False`) when you're done.
177
+
178
+ ## Development
179
+
180
+ Install with the `dev` extra to pull in `ruff`:
181
+
182
+ ```bash
183
+ pip install -e ".[dev]"
184
+ ```
185
+
186
+ Lint + format checks (same commands CI runs):
187
+
188
+ ```bash
189
+ ruff check . # lint
190
+ ruff format --check . # formatting (non-destructive)
191
+ ```
192
+
193
+ Auto-fix:
194
+
195
+ ```bash
196
+ ruff check . --fix # fix lint issues
197
+ ruff format . # reformat
198
+ ```
199
+
200
+ For one-off runs without installing into your env, `uvx ruff ...` works
201
+ identically.
202
+
203
+ Releases are tag-driven — see [RELEASING.md](RELEASING.md).
204
+
205
+ ## License
206
+
207
+ MIT
@@ -0,0 +1,7 @@
1
+ lamcp/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ lamcp/server.py,sha256=-KsaJF8J4SV8Q58tud6ysjeAogEAoH5Axy9ccIU6YiQ,4332
3
+ lamcp-0.1.0.dist-info/METADATA,sha256=pxR2LTbwwMr2GiYT3aPHUT72OAi3F1YkKwoYQXBxRoE,7158
4
+ lamcp-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
5
+ lamcp-0.1.0.dist-info/entry_points.txt,sha256=nrDqJK5nBBQVPyTitJWOfzLRp_1Rwh0CP72Y7DuEof0,44
6
+ lamcp-0.1.0.dist-info/licenses/LICENSE,sha256=2MAz4e-_lRv_8QvIbqBa-99gRML-Gf3r-T_PNul1HlI,1094
7
+ lamcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lamcp = lamcp.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gramazio Kohler Research, ETH Zürich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.