cad-mcp 0.10.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.
- cad_mcp-0.10.0/LICENSE +21 -0
- cad_mcp-0.10.0/PKG-INFO +38 -0
- cad_mcp-0.10.0/README.md +18 -0
- cad_mcp-0.10.0/pyproject.toml +42 -0
- cad_mcp-0.10.0/setup.cfg +4 -0
- cad_mcp-0.10.0/src/cad_mcp/__init__.py +53 -0
- cad_mcp-0.10.0/src/cad_mcp/__main__.py +7 -0
- cad_mcp-0.10.0/src/cad_mcp/browser.py +156 -0
- cad_mcp-0.10.0/src/cad_mcp/engine.py +682 -0
- cad_mcp-0.10.0/src/cad_mcp/host.py +182 -0
- cad_mcp-0.10.0/src/cad_mcp/protocol.py +195 -0
- cad_mcp-0.10.0/src/cad_mcp/shim.py +292 -0
- cad_mcp-0.10.0/src/cad_mcp.egg-info/PKG-INFO +38 -0
- cad_mcp-0.10.0/src/cad_mcp.egg-info/SOURCES.txt +15 -0
- cad_mcp-0.10.0/src/cad_mcp.egg-info/dependency_links.txt +1 -0
- cad_mcp-0.10.0/src/cad_mcp.egg-info/entry_points.txt +2 -0
- cad_mcp-0.10.0/src/cad_mcp.egg-info/top_level.txt +1 -0
cad_mcp-0.10.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thompson Labs LLC
|
|
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.
|
cad_mcp-0.10.0/PKG-INFO
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cad-mcp
|
|
3
|
+
Version: 0.10.0
|
|
4
|
+
Summary: The stdio transport an agent app starts for text-to-cad's CAD app: MCP carried to the device's CAD server, with the standard library alone.
|
|
5
|
+
Author: earthtojake
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/earthtojake/text-to-cad
|
|
8
|
+
Project-URL: Repository, https://github.com/earthtojake/text-to-cad
|
|
9
|
+
Project-URL: Issues, https://github.com/earthtojake/text-to-cad/issues
|
|
10
|
+
Keywords: cad,mcp,agent-skills
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
|
|
16
|
+
Requires-Python: >=3.14
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# cad-mcp
|
|
22
|
+
|
|
23
|
+
The stdio transport an agent app starts for text-to-cad's CAD app (`cad-mcp`): it carries the app's MCP
|
|
24
|
+
requests to the CAD server on this computer, over HTTP, and the server's answers back. The standard
|
|
25
|
+
library alone, imported as `cad_mcp`.
|
|
26
|
+
|
|
27
|
+
It holds no tool, view or document: the server does. What it does on its own:
|
|
28
|
+
|
|
29
|
+
- **Reaches the server** the way every client does: through the server's door in its folder, which names
|
|
30
|
+
the server's HTTP endpoint and token. When none answers, it starts the current installation's server
|
|
31
|
+
(`installations/current` in the user's state folder), whatever release it is itself.
|
|
32
|
+
- **Registers one first** on a computer with no installation current: the server its own environment
|
|
33
|
+
carries, else the server of its own release, installed by `uvx`.
|
|
34
|
+
- **Holds the server** while the app runs, under a name its sessions are opened by, which end when the
|
|
35
|
+
app quits.
|
|
36
|
+
- **Opens the person's browser** where a text-only app's `cad_show` asks, in the app's own environment,
|
|
37
|
+
never in the server's, and tells the server whether it opened.
|
|
38
|
+
- **Says which app started it**, read from its parent processes, which only it can see.
|
cad_mcp-0.10.0/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# cad-mcp
|
|
2
|
+
|
|
3
|
+
The stdio transport an agent app starts for text-to-cad's CAD app (`cad-mcp`): it carries the app's MCP
|
|
4
|
+
requests to the CAD server on this computer, over HTTP, and the server's answers back. The standard
|
|
5
|
+
library alone, imported as `cad_mcp`.
|
|
6
|
+
|
|
7
|
+
It holds no tool, view or document: the server does. What it does on its own:
|
|
8
|
+
|
|
9
|
+
- **Reaches the server** the way every client does: through the server's door in its folder, which names
|
|
10
|
+
the server's HTTP endpoint and token. When none answers, it starts the current installation's server
|
|
11
|
+
(`installations/current` in the user's state folder), whatever release it is itself.
|
|
12
|
+
- **Registers one first** on a computer with no installation current: the server its own environment
|
|
13
|
+
carries, else the server of its own release, installed by `uvx`.
|
|
14
|
+
- **Holds the server** while the app runs, under a name its sessions are opened by, which end when the
|
|
15
|
+
app quits.
|
|
16
|
+
- **Opens the person's browser** where a text-only app's `cad_show` asks, in the app's own environment,
|
|
17
|
+
never in the server's, and tells the server whether it opened.
|
|
18
|
+
- **Says which app started it**, read from its parent processes, which only it can see.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cad-mcp"
|
|
7
|
+
version = "0.10.0"
|
|
8
|
+
description = "The stdio transport an agent app starts for text-to-cad's CAD app: MCP carried to the device's CAD server, with the standard library alone."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.14"
|
|
13
|
+
authors = [{ name = "earthtojake" }]
|
|
14
|
+
keywords = ["cad", "mcp", "agent-skills"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Topic :: Multimedia :: Graphics :: 3D Modeling",
|
|
21
|
+
]
|
|
22
|
+
# The standard library alone: an agent app installs this to start the CAD app, and the server it
|
|
23
|
+
# reaches is installed on its own (the install command), or by this transport on a device with none.
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/earthtojake/text-to-cad"
|
|
28
|
+
Repository = "https://github.com/earthtojake/text-to-cad"
|
|
29
|
+
Issues = "https://github.com/earthtojake/text-to-cad/issues"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
cad-mcp = "cad_mcp.shim:main"
|
|
33
|
+
|
|
34
|
+
[tool.pyrefly]
|
|
35
|
+
preset = "basic"
|
|
36
|
+
project-includes = ["src"]
|
|
37
|
+
search-path = ["src"]
|
|
38
|
+
use-ignore-files = false
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["src"]
|
|
42
|
+
include = ["cad_mcp", "cad_mcp.*"]
|
cad_mcp-0.10.0/setup.cfg
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""CAD beside an agent's chat: the stdio transport an agent app starts, forwarding MCP to the device's CAD
|
|
2
|
+
server over HTTP (``shim``).
|
|
3
|
+
|
|
4
|
+
The standard library alone. It reads where the server is the way the server's own clients do -- the
|
|
5
|
+
engine folder's door, the current installation in the state folder (``engine``) -- starts the current
|
|
6
|
+
installation's server when none answers, and installs one, the release it belongs to, when the device has
|
|
7
|
+
none. Everything CAD is, the server is: this process carries the host's requests to it and its answers
|
|
8
|
+
back, opens the person's browser where a text host's ``cad_show`` asks, and tells the server which host
|
|
9
|
+
started it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from functools import cache as _cache
|
|
15
|
+
from pathlib import Path as _Path
|
|
16
|
+
|
|
17
|
+
# The distribution this package ships in, whose release it is (``__version__``).
|
|
18
|
+
DISTRIBUTION = "cad-mcp"
|
|
19
|
+
|
|
20
|
+
__all__ = ["DISTRIBUTION", "__version__"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def __getattr__(name: str):
|
|
24
|
+
if name == "__version__":
|
|
25
|
+
return _version()
|
|
26
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@_cache
|
|
30
|
+
def _version() -> str:
|
|
31
|
+
"""The version declared beside this very package: the source tree's ``pyproject.toml`` two levels up
|
|
32
|
+
(``src/cad_mcp``), else the newest ``.dist-info`` of its distribution installed beside it; ``0+unknown``
|
|
33
|
+
with neither. Never the first metadata on the import path, which can be another install's."""
|
|
34
|
+
import tomllib
|
|
35
|
+
|
|
36
|
+
package = _Path(__file__).resolve().parent
|
|
37
|
+
try:
|
|
38
|
+
with (package.parent.parent / "pyproject.toml").open("rb") as handle:
|
|
39
|
+
project = tomllib.load(handle).get("project") or {}
|
|
40
|
+
if project.get("name") == DISTRIBUTION and project.get("version"):
|
|
41
|
+
return str(project["version"])
|
|
42
|
+
except (OSError, ValueError):
|
|
43
|
+
pass
|
|
44
|
+
stem = DISTRIBUTION.replace("-", "_")
|
|
45
|
+
found = []
|
|
46
|
+
for metadata in package.parent.glob(f"{stem}-*.dist-info"):
|
|
47
|
+
try:
|
|
48
|
+
found.append((metadata.stat().st_mtime_ns, metadata.name))
|
|
49
|
+
except OSError:
|
|
50
|
+
continue
|
|
51
|
+
if found:
|
|
52
|
+
return max(found)[1][len(stem) + 1:-len(".dist-info")]
|
|
53
|
+
return "0+unknown"
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""The one act of its own a text host's ``cad_show`` asks of this process: opening the person's browser.
|
|
2
|
+
|
|
3
|
+
The server runs in the environment of whatever started it (another agent's shell, another ``BROWSER``, no
|
|
4
|
+
display), so it never opens a browser itself: this process, which the host started in the person's own
|
|
5
|
+
environment, does. It says at initialize that it opens one for its host, and whether it can
|
|
6
|
+
(``transport_browser``); the server's answer then carries the page to open in its result's ``_meta``
|
|
7
|
+
(``OPEN``), which this process opens and strips before the host sees the result (``deliver``), and before
|
|
8
|
+
that it tells the server on its session whether the page opened (``OPENED``): the server counts a page as
|
|
9
|
+
open in the person's browser only once told it opened, so after a failed open the next ``cad_show`` opens
|
|
10
|
+
it again.
|
|
11
|
+
|
|
12
|
+
The page's URL carries a link, a credential for a day, and a browser (or the launcher that starts it) is
|
|
13
|
+
given its URL as an argument, which other local users may read in the process list. So the browser is
|
|
14
|
+
given a file instead: a page of one redirect to the URL, written owner-only (0600) in the engine folder,
|
|
15
|
+
which is this user's alone (``engine.folder_refusal``), and removed after ``KEEP_SECONDS``.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import html
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import secrets
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import webbrowser
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Callable
|
|
29
|
+
|
|
30
|
+
from cad_mcp import engine
|
|
31
|
+
|
|
32
|
+
# A text host's cad_show result: ``{"url", "said", "failed"}``, the page this process opens, the sentence
|
|
33
|
+
# of the result that says it opened, and the one it says in its place where the open fails (``{reason}``
|
|
34
|
+
# the failure). Only a transport that declared ``transport_browser`` is sent it.
|
|
35
|
+
OPEN = "dev.texttocad/open"
|
|
36
|
+
# This process's word on that page, the notification it sends on its session once its open returned and
|
|
37
|
+
# before its host has the answer: ``{"url", "opened"}``.
|
|
38
|
+
OPENED = "notifications/dev.texttocad/opened"
|
|
39
|
+
KEEP_SECONDS = 10.0
|
|
40
|
+
PREFIX = "open-"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CannotOpen(Exception):
|
|
44
|
+
"""No browser could be opened here, said in words (no display, no opener, a refused folder)."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def display_refusal(environ: Any = None) -> str | None:
|
|
48
|
+
"""Why no browser can be shown to the person from this process, or None: a remote shell with no
|
|
49
|
+
forwarded display, or a POSIX desktop-less session (no X11 or Wayland display)."""
|
|
50
|
+
environ = os.environ if environ is None else environ
|
|
51
|
+
if os.name == "nt":
|
|
52
|
+
return None
|
|
53
|
+
shown = environ.get("DISPLAY") or environ.get("WAYLAND_DISPLAY")
|
|
54
|
+
if (environ.get("SSH_CONNECTION") or environ.get("SSH_TTY")) and not shown:
|
|
55
|
+
return "this is a remote session with no display to open a browser on"
|
|
56
|
+
if sys.platform != "darwin" and not shown:
|
|
57
|
+
return "there is no display to open a browser on"
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def transport_browser() -> dict[str, Any]:
|
|
62
|
+
"""What this process says of its browser at initialize: that it opens one for its host, and
|
|
63
|
+
``refusal``, why none can be opened from its environment, or None."""
|
|
64
|
+
return {"refusal": display_refusal()}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def redirect_page(url: str) -> bytes:
|
|
68
|
+
"""A page that only navigates to ``url``, sending no referrer."""
|
|
69
|
+
return ('<!doctype html><html><head><meta charset="utf-8"><meta name="referrer" content="no-referrer">'
|
|
70
|
+
f'<meta http-equiv="refresh" content="0;url={html.escape(url, quote=True)}"><title>CAD</title></head>'
|
|
71
|
+
f'<body><script>location.replace({json.dumps(url)})</script></body></html>\n').encode("utf-8")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _sweep(folder: Path) -> None:
|
|
75
|
+
stale = time.time() - KEEP_SECONDS
|
|
76
|
+
for found in folder.glob(f"{PREFIX}*.html"):
|
|
77
|
+
try:
|
|
78
|
+
if found.lstat().st_mtime < stale:
|
|
79
|
+
found.unlink()
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _remove_later(path: Path) -> None:
|
|
85
|
+
def remove() -> None:
|
|
86
|
+
time.sleep(KEEP_SECONDS)
|
|
87
|
+
try:
|
|
88
|
+
path.unlink()
|
|
89
|
+
except OSError:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
threading.Thread(target=remove, name="cad-mcp-browser-open", daemon=True).start()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def open_page(url: str, *, opener: Callable[[str], bool] = webbrowser.open) -> Path:
|
|
96
|
+
"""Open ``url`` in the browser through an owner-only redirect file; the file's path, or ``CannotOpen``
|
|
97
|
+
when no browser can be opened here."""
|
|
98
|
+
refusal = display_refusal()
|
|
99
|
+
if refusal is not None:
|
|
100
|
+
raise CannotOpen(refusal)
|
|
101
|
+
folder = engine.engine_dir()
|
|
102
|
+
try:
|
|
103
|
+
folder.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
104
|
+
except OSError as error:
|
|
105
|
+
raise CannotOpen(f"the engine's folder {folder} cannot be made: {error.strerror or error}") from error
|
|
106
|
+
refusal = engine.folder_refusal(folder)
|
|
107
|
+
if refusal is not None:
|
|
108
|
+
raise CannotOpen(refusal)
|
|
109
|
+
_sweep(folder)
|
|
110
|
+
path = folder / f"{PREFIX}{secrets.token_hex(8)}.html"
|
|
111
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
112
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
113
|
+
handle.write(redirect_page(url))
|
|
114
|
+
try:
|
|
115
|
+
opened = opener(path.as_uri())
|
|
116
|
+
except Exception as error: # noqa: BLE001 - any opener's failure is "no browser here"
|
|
117
|
+
opened, reason = False, str(error)
|
|
118
|
+
else:
|
|
119
|
+
reason = "no browser could be started here (no display, or no browser to open)"
|
|
120
|
+
if not opened:
|
|
121
|
+
path.unlink(missing_ok=True)
|
|
122
|
+
raise CannotOpen(reason)
|
|
123
|
+
_remove_later(path)
|
|
124
|
+
return path
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def deliver(result: Any, opener: Callable[[str], bool] | None = None,
|
|
128
|
+
tell: Callable[[str, bool], None] = lambda url, opened: None) -> Any:
|
|
129
|
+
"""``result`` as the host is given it: a page the server asked this process to open (``OPEN``) opened
|
|
130
|
+
here (``open_page``) by ``opener`` (``webbrowser.open``, which reads this process's ``BROWSER``), and the
|
|
131
|
+
request removed; where no browser opens, the sentence that said it did replaced by the one that says why
|
|
132
|
+
not. Whether it opened is told, ``tell(url, opened)`` (the server, ``OPENED``), before the result is
|
|
133
|
+
returned."""
|
|
134
|
+
meta = result.get("_meta") if isinstance(result, dict) else None
|
|
135
|
+
if not isinstance(meta, dict) or OPEN not in meta:
|
|
136
|
+
return result
|
|
137
|
+
asked = meta.pop(OPEN)
|
|
138
|
+
if not meta:
|
|
139
|
+
del result["_meta"]
|
|
140
|
+
if not isinstance(asked, dict):
|
|
141
|
+
return result
|
|
142
|
+
url, said, failed = asked.get("url"), asked.get("said"), asked.get("failed")
|
|
143
|
+
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
|
|
144
|
+
return result # only a page the server serves is opened, never a file or a program
|
|
145
|
+
try:
|
|
146
|
+
open_page(url, opener=opener or webbrowser.open)
|
|
147
|
+
except CannotOpen as failure:
|
|
148
|
+
tell(url, False)
|
|
149
|
+
if isinstance(said, str) and isinstance(failed, str):
|
|
150
|
+
instead = failed.replace("{reason}", str(failure))
|
|
151
|
+
for item in result.get("content") or []:
|
|
152
|
+
if isinstance(item, dict) and isinstance(item.get("text"), str):
|
|
153
|
+
item["text"] = item["text"].replace(said, instead, 1)
|
|
154
|
+
else:
|
|
155
|
+
tell(url, True)
|
|
156
|
+
return result
|