revit-model-mcp 0.3.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,43 @@
1
+ # .NET
2
+ [Bb]in/
3
+ [Oo]bj/
4
+ .vs/
5
+ .idea/
6
+ *.user
7
+ *.suo
8
+ *.nupkg
9
+ *.snupkg
10
+ TestResults/
11
+ artifacts/
12
+ /output/
13
+
14
+ # Build sources are versioned even when a global ignore excludes build folders.
15
+ !/build/
16
+ !/build/**
17
+ /build/**/[Bb]in/
18
+ /build/**/[Oo]bj/
19
+
20
+ # Python
21
+ __pycache__/
22
+ *.py[cod]
23
+ .venv/
24
+ .pytest_cache/
25
+ .ruff_cache/
26
+ .mypy_cache/
27
+ .coverage
28
+ htmlcov/
29
+ *.egg-info/
30
+ dist/
31
+ uv.lock
32
+
33
+ # Local data
34
+ .env
35
+ .env.*
36
+ .DS_Store
37
+ *.rvt
38
+ *.rfa
39
+ *.log
40
+ docs/screenshots/raw/
41
+
42
+ # Documentation site
43
+ site/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dinar Sharafutdinov
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,156 @@
1
+ Metadata-Version: 2.5
2
+ Name: revit-model-mcp
3
+ Version: 0.3.0
4
+ Summary: MCP access to live Autodesk Revit models, read-only by default with opt-in actions
5
+ Project-URL: Homepage, https://github.com/sharafutdinovdi/revit-model-mcp
6
+ Project-URL: Changelog, https://github.com/sharafutdinovdi/revit-model-mcp/blob/main/CHANGELOG.md
7
+ Project-URL: Repository, https://github.com/sharafutdinovdi/revit-model-mcp
8
+ Project-URL: Documentation, https://github.com/sharafutdinovdi/revit-model-mcp#readme
9
+ Project-URL: Issues, https://github.com/sharafutdinovdi/revit-model-mcp/issues
10
+ Author: Dinar Sharafutdinov
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: autodesk,bim,claude,mcp,mcp-server,revit
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Scientific/Engineering
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: mcp>=2.0.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # Revit Model MCP server
28
+
29
+ The Python package exposes Revit tools over MCP stdio, read-only by default.
30
+ Optional actions require `REVIT_MCP_ALLOW_WRITE=1` and a workstation `allow-write` gate.
31
+ It requires Python 3.11 or later and the matching add-in loaded in Revit on Windows.
32
+
33
+ ## Install and run
34
+
35
+ After the first PyPI release, run the published package with uv:
36
+
37
+ ```sh
38
+ uvx revit-model-mcp
39
+ ```
40
+
41
+ Register the local Windows server with Claude Code:
42
+
43
+ ```sh
44
+ claude mcp add revit-model-mcp -e REVIT_MCP_HOST=local -- uvx revit-model-mcp
45
+ ```
46
+
47
+ For a client on macOS or Linux:
48
+
49
+ ```sh
50
+ claude mcp add revit-model-mcp -e REVIT_MCP_HOST=ssh:revit-host -e REVIT_MCP_REDACT_PATHS=1 -- uvx revit-model-mcp
51
+ ```
52
+
53
+ Replace `revit-host` with an alias from the client's SSH configuration.
54
+ The Windows SSH session must use the same account as Revit or an explicitly shared channel directory.
55
+
56
+ Claude Desktop uses this entry in `claude_desktop_config.json` on Windows:
57
+
58
+ ```json
59
+ {
60
+ "mcpServers": {
61
+ "revit-model-mcp": {
62
+ "command": "uvx",
63
+ "args": ["revit-model-mcp"],
64
+ "env": {
65
+ "REVIT_MCP_HOST": "local",
66
+ "REVIT_MCP_REDACT_PATHS": "1"
67
+ }
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+ `uvx` must be available on the client's PATH; an absolute executable path is also supported.
74
+ A remote Desktop client uses `REVIT_MCP_HOST=ssh:revit-host`.
75
+
76
+ For development or before the first PyPI publication, run from the repository root:
77
+
78
+ ```sh
79
+ uv run --directory server revit-model-mcp
80
+ claude mcp add revit-model-mcp -e REVIT_MCP_HOST=local -- uv run --directory /absolute/path/to/revit-model-mcp/server revit-model-mcp
81
+ ```
82
+
83
+ mcp-name: io.github.sharafutdinovdi/revit-model-mcp
84
+
85
+ ## Configuration
86
+
87
+ | Variable | Default | Behavior |
88
+ |---|---|---|
89
+ | `REVIT_MCP_HOST` | `local` | Local PowerShell, `ssh:<alias>` or an `http://` / `https://` add-in endpoint. `--host` overrides it. |
90
+ | `REVIT_MCP_ALLOW_WRITE` | Unset | Only `1` registers the nine action tools, including `revit_batch` at server startup; the workstation gate is also required. |
91
+ | `REVIT_MCP_TOKEN` | Unset | HTTP bearer token from workstation settings. `--token` overrides it. |
92
+ | `REVIT_MCP_SSH_MUX` | Enabled | `0` disables OpenSSH connection multiplexing. Local mode ignores SSH settings. |
93
+ | `REVIT_MCP_SSH_OPTIONS` | Unset | Extra SSH arguments, parsed with shell quoting and appended after built-in options, before the host. Example: `-o ServerAliveInterval=30 -p 2222`. |
94
+ | `REVIT_MCP_ACTIVATE_TASK` | Unset | Optional existing Windows scheduled task. Runs once after 60 seconds if the trigger remains pending. The task must activate the interactive Revit window. No task is created by the server. |
95
+ | `REVIT_MCP_CHANNEL_DIR` | `%LOCALAPPDATA%\RevitModelMcp` on Windows | Absolute Windows channel path. Set the same value in the Python server environment and in Revit's environment before starting Revit. In SSH mode this path belongs to the remote host. |
96
+ | `REVIT_MCP_REDACT_PATHS` | Unset | `1` replaces every response `documentPath` and nested `path` value with its file name. `--redact-paths` enables the same behavior. |
97
+
98
+ SSH mode passes `ControlMaster=auto`, `ControlPath=<dir>/mux-%C` and `ControlPersist=600` on every invocation.
99
+ The socket directory is `$XDG_RUNTIME_DIR` when nonempty, otherwise `/tmp/revit-model-mcp-<uid>/`.
100
+ The directory is created or restricted to mode `0700` on macOS and Linux.
101
+ Keep its absolute path short for Unix socket limits; `%C` hashes the connection identity.
102
+ The master connection remains available for 600 seconds after its last client disconnects.
103
+ Extra options follow OpenSSH's first-value-wins behavior.
104
+ To supply a custom multiplexing path or lifetime, set `REVIT_MCP_SSH_MUX=0` and provide all three `Control*` options through `REVIT_MCP_SSH_OPTIONS`.
105
+ Clients whose OpenSSH lacks multiplexing support, such as native Windows OpenSSH, use `REVIT_MCP_SSH_MUX=0`.
106
+
107
+ `uv run --directory server revit-model-mcp --help` prints the environment host mode, Windows channel directory and path redaction flag without contacting Revit.
108
+
109
+ The server reads activation configuration at process startup.
110
+ A configured task may restore and focus the Revit window.
111
+ Without a task the server only polls for pickup.
112
+
113
+ ## Responses and privacy
114
+
115
+ Model paths occur in responder metadata and instance listings.
116
+ Redaction covers `documentPath` and all nested `path` fields in successful MCP results, including RVT/CAD/image link paths from `revit_links_status`.
117
+ It preserves exported image `localPath` values for clients that open the downloaded file.
118
+ It does not redact names, parameter values, add-in error text or files stored in the channel.
119
+ Revit model data and errors can retain their original language.
120
+ Python tool descriptions and server-generated messages are English.
121
+
122
+ ## Request behavior
123
+
124
+ For HTTP setup and remote access commands, see [transport](../docs/transport.md#http-configuration).
125
+ HTTP submits once and polls by job ID within `timeout_seconds`; pickup timeout applies only to file transports.
126
+ HTTP exports download PNG directly without remote PowerShell.
127
+ Each HTTP endpoint represents one Revit process.
128
+
129
+ The default file pickup timeout is 300 seconds.
130
+ The response timeout is 120 seconds after pickup.
131
+ Most tools accept `pickup_timeout_seconds` and `timeout_seconds`.
132
+ `revit_export_view` uses the defaults.
133
+ Supply `document` when multiple Revit instances run on the host.
134
+ Use a distinctive document title or file name.
135
+ Matching is case-insensitive and accepts substrings.
136
+ A pending job remains in the channel after pickup timeout and may execute later.
137
+ Use one MCP server process per channel directory.
138
+
139
+ See [transport](../docs/transport.md) for file handling and SSH behavior.
140
+
141
+ ## Tests
142
+
143
+ From the repository root:
144
+
145
+ ```sh
146
+ cd server
147
+ uv run --with pytest pytest -q
148
+ ```
149
+
150
+ The tests use mocked host operations and exercise MCP stdio without Revit.
151
+
152
+ See the [tool arguments](../README.md#tools), [action arguments](../README.md#actions-opt-in) and [response contract](../docs/feed-format.md#command-responses).
153
+
154
+ ## License
155
+
156
+ [MIT](LICENSE), copyright (c) 2026 Dinar Sharafutdinov.
@@ -0,0 +1,130 @@
1
+ # Revit Model MCP server
2
+
3
+ The Python package exposes Revit tools over MCP stdio, read-only by default.
4
+ Optional actions require `REVIT_MCP_ALLOW_WRITE=1` and a workstation `allow-write` gate.
5
+ It requires Python 3.11 or later and the matching add-in loaded in Revit on Windows.
6
+
7
+ ## Install and run
8
+
9
+ After the first PyPI release, run the published package with uv:
10
+
11
+ ```sh
12
+ uvx revit-model-mcp
13
+ ```
14
+
15
+ Register the local Windows server with Claude Code:
16
+
17
+ ```sh
18
+ claude mcp add revit-model-mcp -e REVIT_MCP_HOST=local -- uvx revit-model-mcp
19
+ ```
20
+
21
+ For a client on macOS or Linux:
22
+
23
+ ```sh
24
+ claude mcp add revit-model-mcp -e REVIT_MCP_HOST=ssh:revit-host -e REVIT_MCP_REDACT_PATHS=1 -- uvx revit-model-mcp
25
+ ```
26
+
27
+ Replace `revit-host` with an alias from the client's SSH configuration.
28
+ The Windows SSH session must use the same account as Revit or an explicitly shared channel directory.
29
+
30
+ Claude Desktop uses this entry in `claude_desktop_config.json` on Windows:
31
+
32
+ ```json
33
+ {
34
+ "mcpServers": {
35
+ "revit-model-mcp": {
36
+ "command": "uvx",
37
+ "args": ["revit-model-mcp"],
38
+ "env": {
39
+ "REVIT_MCP_HOST": "local",
40
+ "REVIT_MCP_REDACT_PATHS": "1"
41
+ }
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ `uvx` must be available on the client's PATH; an absolute executable path is also supported.
48
+ A remote Desktop client uses `REVIT_MCP_HOST=ssh:revit-host`.
49
+
50
+ For development or before the first PyPI publication, run from the repository root:
51
+
52
+ ```sh
53
+ uv run --directory server revit-model-mcp
54
+ claude mcp add revit-model-mcp -e REVIT_MCP_HOST=local -- uv run --directory /absolute/path/to/revit-model-mcp/server revit-model-mcp
55
+ ```
56
+
57
+ mcp-name: io.github.sharafutdinovdi/revit-model-mcp
58
+
59
+ ## Configuration
60
+
61
+ | Variable | Default | Behavior |
62
+ |---|---|---|
63
+ | `REVIT_MCP_HOST` | `local` | Local PowerShell, `ssh:<alias>` or an `http://` / `https://` add-in endpoint. `--host` overrides it. |
64
+ | `REVIT_MCP_ALLOW_WRITE` | Unset | Only `1` registers the nine action tools, including `revit_batch` at server startup; the workstation gate is also required. |
65
+ | `REVIT_MCP_TOKEN` | Unset | HTTP bearer token from workstation settings. `--token` overrides it. |
66
+ | `REVIT_MCP_SSH_MUX` | Enabled | `0` disables OpenSSH connection multiplexing. Local mode ignores SSH settings. |
67
+ | `REVIT_MCP_SSH_OPTIONS` | Unset | Extra SSH arguments, parsed with shell quoting and appended after built-in options, before the host. Example: `-o ServerAliveInterval=30 -p 2222`. |
68
+ | `REVIT_MCP_ACTIVATE_TASK` | Unset | Optional existing Windows scheduled task. Runs once after 60 seconds if the trigger remains pending. The task must activate the interactive Revit window. No task is created by the server. |
69
+ | `REVIT_MCP_CHANNEL_DIR` | `%LOCALAPPDATA%\RevitModelMcp` on Windows | Absolute Windows channel path. Set the same value in the Python server environment and in Revit's environment before starting Revit. In SSH mode this path belongs to the remote host. |
70
+ | `REVIT_MCP_REDACT_PATHS` | Unset | `1` replaces every response `documentPath` and nested `path` value with its file name. `--redact-paths` enables the same behavior. |
71
+
72
+ SSH mode passes `ControlMaster=auto`, `ControlPath=<dir>/mux-%C` and `ControlPersist=600` on every invocation.
73
+ The socket directory is `$XDG_RUNTIME_DIR` when nonempty, otherwise `/tmp/revit-model-mcp-<uid>/`.
74
+ The directory is created or restricted to mode `0700` on macOS and Linux.
75
+ Keep its absolute path short for Unix socket limits; `%C` hashes the connection identity.
76
+ The master connection remains available for 600 seconds after its last client disconnects.
77
+ Extra options follow OpenSSH's first-value-wins behavior.
78
+ To supply a custom multiplexing path or lifetime, set `REVIT_MCP_SSH_MUX=0` and provide all three `Control*` options through `REVIT_MCP_SSH_OPTIONS`.
79
+ Clients whose OpenSSH lacks multiplexing support, such as native Windows OpenSSH, use `REVIT_MCP_SSH_MUX=0`.
80
+
81
+ `uv run --directory server revit-model-mcp --help` prints the environment host mode, Windows channel directory and path redaction flag without contacting Revit.
82
+
83
+ The server reads activation configuration at process startup.
84
+ A configured task may restore and focus the Revit window.
85
+ Without a task the server only polls for pickup.
86
+
87
+ ## Responses and privacy
88
+
89
+ Model paths occur in responder metadata and instance listings.
90
+ Redaction covers `documentPath` and all nested `path` fields in successful MCP results, including RVT/CAD/image link paths from `revit_links_status`.
91
+ It preserves exported image `localPath` values for clients that open the downloaded file.
92
+ It does not redact names, parameter values, add-in error text or files stored in the channel.
93
+ Revit model data and errors can retain their original language.
94
+ Python tool descriptions and server-generated messages are English.
95
+
96
+ ## Request behavior
97
+
98
+ For HTTP setup and remote access commands, see [transport](../docs/transport.md#http-configuration).
99
+ HTTP submits once and polls by job ID within `timeout_seconds`; pickup timeout applies only to file transports.
100
+ HTTP exports download PNG directly without remote PowerShell.
101
+ Each HTTP endpoint represents one Revit process.
102
+
103
+ The default file pickup timeout is 300 seconds.
104
+ The response timeout is 120 seconds after pickup.
105
+ Most tools accept `pickup_timeout_seconds` and `timeout_seconds`.
106
+ `revit_export_view` uses the defaults.
107
+ Supply `document` when multiple Revit instances run on the host.
108
+ Use a distinctive document title or file name.
109
+ Matching is case-insensitive and accepts substrings.
110
+ A pending job remains in the channel after pickup timeout and may execute later.
111
+ Use one MCP server process per channel directory.
112
+
113
+ See [transport](../docs/transport.md) for file handling and SSH behavior.
114
+
115
+ ## Tests
116
+
117
+ From the repository root:
118
+
119
+ ```sh
120
+ cd server
121
+ uv run --with pytest pytest -q
122
+ ```
123
+
124
+ The tests use mocked host operations and exercise MCP stdio without Revit.
125
+
126
+ See the [tool arguments](../README.md#tools), [action arguments](../README.md#actions-opt-in) and [response contract](../docs/feed-format.md#command-responses).
127
+
128
+ ## License
129
+
130
+ [MIT](LICENSE), copyright (c) 2026 Dinar Sharafutdinov.
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "revit-model-mcp"
7
+ version = "0.3.0"
8
+ description = "MCP access to live Autodesk Revit models, read-only by default with opt-in actions"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ keywords = ["revit", "bim", "mcp", "mcp-server", "autodesk", "claude"]
14
+ authors = [{name = "Dinar Sharafutdinov"}]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Operating System :: OS Independent",
24
+ "Topic :: Scientific/Engineering",
25
+ ]
26
+ dependencies = ["mcp>=2.0.0"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/sharafutdinovdi/revit-model-mcp"
30
+ Changelog = "https://github.com/sharafutdinovdi/revit-model-mcp/blob/main/CHANGELOG.md"
31
+ Repository = "https://github.com/sharafutdinovdi/revit-model-mcp"
32
+ Documentation = "https://github.com/sharafutdinovdi/revit-model-mcp#readme"
33
+ Issues = "https://github.com/sharafutdinovdi/revit-model-mcp/issues"
34
+
35
+ [project.scripts]
36
+ revit-model-mcp = "revit_model_mcp.server:main"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["revit_model_mcp"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
43
+
44
+ [tool.ruff]
45
+ required-version = "==0.16.7"
46
+ target-version = "py311"
47
+ line-length = 100
48
+
49
+ [tool.ruff.lint]
50
+ select = ["E4", "E7", "E9", "F", "I"]
@@ -0,0 +1,11 @@
1
+ """MCP tools for reading a live Autodesk Revit model."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _version
5
+
6
+
7
+ def package_version() -> str:
8
+ try:
9
+ return _version("revit-model-mcp")
10
+ except PackageNotFoundError:
11
+ return "0.0.0+unknown"
@@ -0,0 +1,247 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Annotated, Any, Literal
5
+
6
+ from mcp.server.mcpserver.exceptions import ToolError
7
+ from mcp.types import ToolAnnotations
8
+ from pydantic import BaseModel, ConfigDict, Field, create_model, model_validator
9
+
10
+ from revit_model_mcp.revit_channel import (
11
+ DEFAULT_PICKUP_TIMEOUT_SECONDS,
12
+ DEFAULT_TIMEOUT_SECONDS,
13
+ ReadJob,
14
+ RevitChannelError,
15
+ )
16
+
17
+ ElementId = Annotated[int, Field(strict=True, gt=0, le=9223372036854775807)]
18
+ ElementIds = list[ElementId]
19
+ NonEmptyIds = Annotated[ElementIds, Field(min_length=1)]
20
+ Number = Annotated[float, Field(allow_inf_nan=False)]
21
+ PositiveLength = Annotated[float, Field(gt=0, allow_inf_nan=False)]
22
+ Name = Annotated[str, Field(min_length=1, pattern=r"\S")]
23
+ Point = Annotated[list[Number], Field(min_length=2, max_length=2)]
24
+
25
+
26
+ _BATCH_FIELDS = {
27
+ "select": {"element_ids": (ElementIds, ...)},
28
+ "isolate": {"element_ids": (ElementIds, ...), "reset": (bool, False)},
29
+ "move": {
30
+ "element_ids": (NonEmptyIds, ...),
31
+ "dx_mm": (Number, ...),
32
+ "dy_mm": (Number, ...),
33
+ "dz_mm": (Number, 0),
34
+ },
35
+ "place_family": {
36
+ "family": (Name, ...),
37
+ "type_name": (Name | None, ...),
38
+ "x_mm": (Number, ...),
39
+ "y_mm": (Number, ...),
40
+ "level": (Name, ...),
41
+ "rotation_deg": (Number, 0),
42
+ },
43
+ "create_wall": {
44
+ "start_mm": (Point, ...),
45
+ "end_mm": (Point, ...),
46
+ "level": (Name, ...),
47
+ "wall_type": (Name | None, ...),
48
+ "height_mm": (PositiveLength, 3000),
49
+ },
50
+ "set_parameter": {
51
+ "element_id": (ElementId, ...),
52
+ "parameter": (Name, ...),
53
+ "value": (str, ...),
54
+ },
55
+ "delete": {"element_ids": (NonEmptyIds, ...)},
56
+ }
57
+ for _action in ("move", "place_family", "create_wall", "set_parameter", "delete"):
58
+ _BATCH_FIELDS[_action]["dry_run"] = (bool, False)
59
+ _BATCH_MODELS = {
60
+ action: create_model(action, __config__=ConfigDict(extra="forbid"), **fields)
61
+ for action, fields in _BATCH_FIELDS.items()
62
+ }
63
+
64
+
65
+ class BatchStep(BaseModel):
66
+ model_config = ConfigDict(extra="forbid")
67
+ action: Literal[
68
+ "move", "place_family", "create_wall", "set_parameter", "delete", "select", "isolate"
69
+ ]
70
+ args: dict
71
+
72
+ @model_validator(mode="after")
73
+ def validate_args(self):
74
+ self.args = _BATCH_MODELS[self.action].model_validate(self.args).model_dump()
75
+ if self.action == "isolate" and not self.args["reset"] and not self.args["element_ids"]:
76
+ raise ValueError("element_ids must not be empty unless reset is true.")
77
+ if self.action == "create_wall" and self.args["start_mm"] == self.args["end_mm"]:
78
+ raise ValueError("Wall endpoints must differ.")
79
+ return self
80
+
81
+ def payload(self) -> dict:
82
+ def camel(key):
83
+ first, *rest = key.split("_")
84
+ return first + "".join(part.title() for part in rest)
85
+
86
+ return {
87
+ "command": self.action.replace("_", "-"),
88
+ **{camel(key): value for key, value in self.args.items()},
89
+ }
90
+
91
+
92
+ def millimeters_to_feet(value: float) -> float:
93
+ """Convert a finite millimetre length to Revit internal feet for client calculations."""
94
+ import math
95
+
96
+ if not math.isfinite(value):
97
+ raise ValueError("Length must be finite.")
98
+ return value / 304.8
99
+
100
+
101
+ def register_actions(mcp, execute, host_provider) -> None:
102
+ if os.environ.get("REVIT_MCP_ALLOW_WRITE") != "1":
103
+ return
104
+
105
+ async def send(command: str, **payload) -> dict[str, Any]:
106
+ try:
107
+ instances = await host_provider().list_revit_instances()
108
+ except RevitChannelError as error:
109
+ raise ToolError(str(error)) from error
110
+ if len(instances) != 1:
111
+ raise ToolError("Actions require exactly one running Revit instance.")
112
+ job = ReadJob(
113
+ command,
114
+ {
115
+ "command": command,
116
+ **payload,
117
+ "targetProcessId": instances[0]["processId"],
118
+ },
119
+ )
120
+ return await execute(job, DEFAULT_TIMEOUT_SECONDS, DEFAULT_PICKUP_TIMEOUT_SECONDS, None)
121
+
122
+ def action(function):
123
+ return mcp.tool(
124
+ annotations=ToolAnnotations(
125
+ readOnlyHint=False,
126
+ destructiveHint=function.__name__
127
+ not in {"revit_select", "revit_show", "revit_isolate"},
128
+ idempotentHint=function.__name__
129
+ in {"revit_select", "revit_show", "revit_isolate", "revit_set_parameter"},
130
+ )
131
+ )(function)
132
+
133
+ @action
134
+ async def revit_select(element_ids: ElementIds) -> dict[str, Any]:
135
+ """Select element IDs for inspection in Revit; an empty list clears selection; IDs are unitless."""
136
+ return await send("select", elementIds=element_ids)
137
+
138
+ @action
139
+ async def revit_show(element_ids: NonEmptyIds, select: bool = True) -> dict[str, Any]:
140
+ """Show elements, optionally selecting them; open a level plan or 3D view when needed. Returns activeView, viewOpened and dialogsSuppressed; IDs are unitless."""
141
+ return await send("show", elementIds=element_ids, select=select)
142
+
143
+ @action
144
+ async def revit_isolate(element_ids: ElementIds, reset: bool = False) -> dict[str, Any]:
145
+ """Temporarily isolate IDs for visual review in the active view, or reset with an empty list; IDs are unitless."""
146
+ if not reset and not element_ids:
147
+ raise ToolError("element_ids must not be empty unless reset is true.")
148
+ return await send("isolate", elementIds=element_ids, reset=reset)
149
+
150
+ @action
151
+ async def revit_move(
152
+ element_ids: NonEmptyIds,
153
+ dx_mm: Number,
154
+ dy_mm: Number,
155
+ dz_mm: Number = 0,
156
+ dry_run: bool = False,
157
+ ) -> dict[str, Any]:
158
+ """Move elements when adjusting their position; dx_mm, dy_mm and dz_mm are offsets in millimetres on model axes.
159
+ dry_run executes and rolls back, returning the same verification block without changing the model.
160
+ """
161
+ return await send(
162
+ "move", elementIds=element_ids, dxMm=dx_mm, dyMm=dy_mm, dzMm=dz_mm, dryRun=dry_run
163
+ )
164
+
165
+ @action
166
+ async def revit_place_family(
167
+ family: Name,
168
+ type_name: Name | None,
169
+ x_mm: Number,
170
+ y_mm: Number,
171
+ level: Name,
172
+ rotation_deg: Number = 0,
173
+ dry_run: bool = False,
174
+ ) -> dict[str, Any]:
175
+ """Place a loaded unhosted family on a named level for layout.
176
+
177
+ family accepts a family name or Family: Type, case-insensitively.
178
+ null type_name uses the embedded type or the first type. Conflicting types
179
+ are rejected. Missing families return similar names with categories.
180
+ Model XY is in millimetres and Z rotation in degrees.
181
+ Use roomCenterMm when placing something inside a room.
182
+
183
+ dry_run executes and rolls back, returning the same verification block without changing the model.
184
+ """
185
+ return await send(
186
+ "place-family",
187
+ family=family,
188
+ typeName=type_name,
189
+ xMm=x_mm,
190
+ yMm=y_mm,
191
+ level=level,
192
+ rotationDeg=rotation_deg,
193
+ dryRun=dry_run,
194
+ )
195
+
196
+ @action
197
+ async def revit_create_wall(
198
+ start_mm: Point,
199
+ end_mm: Point,
200
+ level: Name,
201
+ wall_type: Name | None,
202
+ height_mm: PositiveLength = 3000,
203
+ dry_run: bool = False,
204
+ ) -> dict[str, Any]:
205
+ """Create a straight wall for layout on a named level; model XY endpoints and height are millimetres; null wall_type chooses the first basic type.
206
+ dry_run executes and rolls back, returning the same verification block without changing the model.
207
+ """
208
+ if start_mm == end_mm:
209
+ raise ToolError("Wall endpoints must differ.")
210
+ return await send(
211
+ "create-wall",
212
+ startMm=start_mm,
213
+ endMm=end_mm,
214
+ level=level,
215
+ wallType=wall_type,
216
+ heightMm=height_mm,
217
+ dryRun=dry_run,
218
+ )
219
+
220
+ @action
221
+ async def revit_set_parameter(
222
+ element_id: ElementId, parameter: Name, value: str, dry_run: bool = False
223
+ ) -> dict[str, Any]:
224
+ """Set a named instance parameter, falling back to its shared type; use for edits, with length in mm, area in m2 and other doubles in internal units.
225
+ dry_run executes and rolls back, returning the same verification block without changing the model.
226
+ """
227
+ return await send(
228
+ "set-parameter", elementId=element_id, parameter=parameter, value=value, dryRun=dry_run
229
+ )
230
+
231
+ @action
232
+ async def revit_delete(element_ids: NonEmptyIds, dry_run: bool = False) -> dict[str, Any]:
233
+ """Delete elements and their Revit dependencies when removal is intended; IDs are unitless and the returned count includes dependents.
234
+ dry_run executes and rolls back, returning the same verification block without changing the model.
235
+ """
236
+ return await send("delete", elementIds=element_ids, dryRun=dry_run)
237
+
238
+ @action
239
+ async def revit_batch(
240
+ steps: Annotated[list[BatchStep], Field(min_length=1, max_length=50)],
241
+ dry_run: bool = False,
242
+ ) -> dict[str, Any]:
243
+ """Execute up to 50 actions with one undo step; roll back the batch on its first failure.
244
+
245
+ dry_run executes and rolls back, returning the same verification block without changing the model.
246
+ """
247
+ return await send("batch", steps=[step.payload() for step in steps], dryRun=dry_run)
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+
8
+ def save_artifact(result: dict[str, object], save_to: str | None) -> str:
9
+ name = result.get("artifactName")
10
+ encoded = result.get("artifact")
11
+ if not isinstance(name, str) or Path(name).name != name or not isinstance(encoded, str):
12
+ raise ValueError("Remote response does not contain a safe image artifact.")
13
+ target = (
14
+ Path(save_to).expanduser().resolve()
15
+ if save_to
16
+ else Path(tempfile.mkdtemp(prefix="revit-view-")) / name
17
+ )
18
+ if target.exists():
19
+ raise ValueError(f"Local file already exists: {target}")
20
+ target.parent.mkdir(parents=True, exist_ok=True)
21
+ target.write_bytes(base64.b64decode(encoded, validate=True))
22
+ return str(target)