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 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.
@@ -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.
@@ -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.*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,7 @@
1
+ """``python -m cad_mcp``: the stdio transport (``shim.main``)."""
2
+
3
+ import sys
4
+
5
+ from cad_mcp.shim import main
6
+
7
+ sys.exit(main())
@@ -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