ventilatepro-cli 0.4.0__tar.gz → 0.5.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 (34) hide show
  1. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/PKG-INFO +8 -1
  2. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/README.md +7 -0
  3. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/tests/test_cli.py +160 -1
  4. ventilatepro_cli-0.5.0/tests/test_mcp_server.py +287 -0
  5. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/__init__.py +1 -1
  6. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/api_client.py +23 -0
  7. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/cli.py +2 -0
  8. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/mcp_server.py +114 -3
  9. ventilatepro_cli-0.5.0/ventilatepro/revit_imports.py +273 -0
  10. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro_cli.egg-info/PKG-INFO +8 -1
  11. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro_cli.egg-info/SOURCES.txt +1 -0
  12. ventilatepro_cli-0.4.0/tests/test_mcp_server.py +0 -126
  13. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/pyproject.toml +0 -0
  14. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/setup.cfg +0 -0
  15. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/tests/test_config.py +0 -0
  16. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/__main__.py +0 -0
  17. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/auth.py +0 -0
  18. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/calc.py +0 -0
  19. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/categorization.py +0 -0
  20. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/config.py +0 -0
  21. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/design_day.py +0 -0
  22. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/entities.py +0 -0
  23. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/mcp_cli.py +0 -0
  24. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/meetings.py +0 -0
  25. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/notes.py +0 -0
  26. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/output.py +0 -0
  27. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/payloads.py +0 -0
  28. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/projects.py +0 -0
  29. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/queue.py +0 -0
  30. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/systems.py +0 -0
  31. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
  32. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro_cli.egg-info/entry_points.txt +0 -0
  33. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro_cli.egg-info/requires.txt +0 -0
  34. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ventilatepro-cli
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: VentilatePro CLI
5
5
  Project-URL: Homepage, https://ventilatepro.com/exam/cli/
6
6
  Project-URL: Documentation, https://ventilatepro.com/exam/cli/
@@ -47,6 +47,8 @@ ventilatepro projects show 1
47
47
  ventilatepro rooms list --project 1
48
48
  ventilatepro room-equipment summary --project 1
49
49
  ventilatepro room-equipment create --data '{"room": 501, "name": "Sterilizer", "heat_gain_value": 1.8, "heat_gain_unit": "KW"}'
50
+ ventilatepro revit-imports review --project 1 --json
51
+ ventilatepro revit-imports confirm 41 --project 1 --review-token <token-from-review> --yes
50
52
  ventilatepro categorization review --project 1 --output room-categorization.json
51
53
  ventilatepro categorization apply --project 1 --input room-categorization.json --yes
52
54
  ventilatepro ahus list --project 1
@@ -77,6 +79,11 @@ review resource or use the dedicated MCP tools. Review manifests default every
77
79
  room to rejected so ambiguous spaces remain visible until a category is
78
80
  explicitly accepted with a reason.
79
81
 
82
+ For staged Revit imports, agents use `revit-imports review` or
83
+ `vp_review_revit_import` first. The returned review token identifies the exact
84
+ payload and diff that was inspected. Confirmation requires that unchanged token
85
+ plus explicit `--yes` or MCP `confirm=true`; stale reviews are rejected.
86
+
80
87
  ## MCP setup
81
88
 
82
89
  1. In the VentilatePro web app, open Account Settings and create a CLI token with
@@ -32,6 +32,8 @@ ventilatepro projects show 1
32
32
  ventilatepro rooms list --project 1
33
33
  ventilatepro room-equipment summary --project 1
34
34
  ventilatepro room-equipment create --data '{"room": 501, "name": "Sterilizer", "heat_gain_value": 1.8, "heat_gain_unit": "KW"}'
35
+ ventilatepro revit-imports review --project 1 --json
36
+ ventilatepro revit-imports confirm 41 --project 1 --review-token <token-from-review> --yes
35
37
  ventilatepro categorization review --project 1 --output room-categorization.json
36
38
  ventilatepro categorization apply --project 1 --input room-categorization.json --yes
37
39
  ventilatepro ahus list --project 1
@@ -62,6 +64,11 @@ review resource or use the dedicated MCP tools. Review manifests default every
62
64
  room to rejected so ambiguous spaces remain visible until a category is
63
65
  explicitly accepted with a reason.
64
66
 
67
+ For staged Revit imports, agents use `revit-imports review` or
68
+ `vp_review_revit_import` first. The returned review token identifies the exact
69
+ payload and diff that was inspected. Confirmation requires that unchanged token
70
+ plus explicit `--yes` or MCP `confirm=true`; stale reviews are rejected.
71
+
65
72
  ## MCP setup
66
73
 
67
74
  1. In the VentilatePro web app, open Account Settings and create a CLI token with
@@ -7,10 +7,11 @@ from unittest import mock
7
7
  from typer.testing import CliRunner
8
8
 
9
9
  from ventilatepro import auth, config
10
- from ventilatepro.api_client import ApiError, NetworkError
10
+ from ventilatepro.api_client import ApiClient, ApiError, NetworkError
11
11
  from ventilatepro.cli import app
12
12
  from ventilatepro.categorization import _classification_group_key
13
13
  from ventilatepro.queue import QueueStore
14
+ from ventilatepro.revit_imports import build_revit_import_review
14
15
 
15
16
 
16
17
  class CliTests(unittest.TestCase):
@@ -1181,3 +1182,161 @@ class CliTests(unittest.TestCase):
1181
1182
 
1182
1183
  self.assertEqual(result.exit_code, 0)
1183
1184
  fake_client.design_day_run.assert_called_once_with(1, {"zones": [{"zone_id": 1}]})
1185
+
1186
+ def test_revit_import_review_returns_diff_and_stable_review_token(self):
1187
+ fake_client = mock.Mock()
1188
+ session = {
1189
+ "id": 41,
1190
+ "project": 7,
1191
+ "status": "pending",
1192
+ "updated_at": "2026-07-29T10:00:00Z",
1193
+ "payload": {
1194
+ "rooms": [{"number": "101", "name": "Exam", "revit_space_unique_id": "uid-101"}],
1195
+ "zones": [{"name": "Clinic"}],
1196
+ },
1197
+ "options": {"mark_missing_rooms": True},
1198
+ "diff_summary": {
1199
+ "summary": {
1200
+ "new_rooms": 1,
1201
+ "updated_rooms": 0,
1202
+ "missing_rooms": 2,
1203
+ "new_zones": 1,
1204
+ "missing_zones": 0,
1205
+ "ambiguous_matches": 0,
1206
+ },
1207
+ "new_rooms": [{"key": "revit:uid-101"}],
1208
+ "updated_rooms": [],
1209
+ "missing_rooms": [{"id": 9, "number": "099"}],
1210
+ "new_zones": [{"name": "Clinic"}],
1211
+ "missing_zones": [],
1212
+ "conflicts": {},
1213
+ "errors": [],
1214
+ },
1215
+ }
1216
+ fake_client.revit_import_pending.return_value = session
1217
+
1218
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
1219
+ result = self.runner.invoke(
1220
+ app,
1221
+ ["revit-imports", "review", "--project", "7", "--json"],
1222
+ )
1223
+
1224
+ self.assertEqual(result.exit_code, 0)
1225
+ payload = json.loads(result.stdout)
1226
+ self.assertEqual(payload["summary"]["new_rooms"], 1)
1227
+ self.assertEqual(payload["changes"]["missing_rooms"][0]["id"], 9)
1228
+ self.assertEqual(payload["payload_counts"], {"rooms": 1, "zones": 1})
1229
+ self.assertTrue(payload["review_token"].startswith("revit-import:41:"))
1230
+ self.assertNotIn("payload", payload)
1231
+ fake_client.revit_import_pending.assert_called_once_with(7)
1232
+
1233
+ def test_revit_import_confirm_requires_yes_before_any_api_call(self):
1234
+ fake_client = mock.Mock()
1235
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
1236
+ result = self.runner.invoke(
1237
+ app,
1238
+ [
1239
+ "revit-imports",
1240
+ "confirm",
1241
+ "41",
1242
+ "--project",
1243
+ "7",
1244
+ "--review-token",
1245
+ "revit-import:41:test",
1246
+ ],
1247
+ )
1248
+
1249
+ self.assertNotEqual(result.exit_code, 0)
1250
+ self.assertIn("requires --yes", result.output)
1251
+ fake_client.revit_import_show.assert_not_called()
1252
+ fake_client.revit_import_confirm.assert_not_called()
1253
+
1254
+ def test_revit_import_confirm_rechecks_project_status_and_review_token(self):
1255
+ fake_client = mock.Mock()
1256
+ session = {
1257
+ "id": 41,
1258
+ "project": 7,
1259
+ "status": "pending",
1260
+ "updated_at": "2026-07-29T10:00:00Z",
1261
+ "payload": {
1262
+ "rooms": [{"number": "101", "name": "Exam", "revit_space_unique_id": "uid-101"}],
1263
+ "zones": [],
1264
+ },
1265
+ "options": {},
1266
+ "diff_summary": {"summary": {"new_rooms": 1}},
1267
+ }
1268
+ review_token = build_revit_import_review(session)["review_token"]
1269
+ fake_client.revit_import_show.return_value = session
1270
+ fake_client.revit_import_confirm.return_value = {
1271
+ "session": {"id": 41, "project": 7, "status": "committed"},
1272
+ "result": {"created": 1, "updated": 0, "marked_missing": 0},
1273
+ }
1274
+
1275
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
1276
+ result = self.runner.invoke(
1277
+ app,
1278
+ [
1279
+ "revit-imports",
1280
+ "confirm",
1281
+ "41",
1282
+ "--project",
1283
+ "7",
1284
+ "--review-token",
1285
+ review_token,
1286
+ "--yes",
1287
+ "--json",
1288
+ ],
1289
+ )
1290
+
1291
+ self.assertEqual(result.exit_code, 0)
1292
+ self.assertEqual(json.loads(result.stdout)["session"]["status"], "committed")
1293
+ fake_client.revit_import_show.assert_called_once_with(41)
1294
+ fake_client.revit_import_confirm.assert_called_once_with(41)
1295
+
1296
+ def test_revit_import_confirm_rejects_stale_review_token(self):
1297
+ fake_client = mock.Mock()
1298
+ fake_client.revit_import_show.return_value = {
1299
+ "id": 41,
1300
+ "project": 7,
1301
+ "status": "pending",
1302
+ "updated_at": "2026-07-29T10:05:00Z",
1303
+ "payload": {
1304
+ "rooms": [{"number": "102", "name": "Changed", "revit_space_unique_id": "uid-102"}],
1305
+ "zones": [],
1306
+ },
1307
+ "options": {},
1308
+ "diff_summary": {"summary": {"new_rooms": 1}},
1309
+ }
1310
+
1311
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
1312
+ result = self.runner.invoke(
1313
+ app,
1314
+ [
1315
+ "revit-imports",
1316
+ "confirm",
1317
+ "41",
1318
+ "--project",
1319
+ "7",
1320
+ "--review-token",
1321
+ "revit-import:41:stale",
1322
+ "--yes",
1323
+ ],
1324
+ )
1325
+
1326
+ self.assertNotEqual(result.exit_code, 0)
1327
+ self.assertIn("changed after it was reviewed", result.output)
1328
+ fake_client.revit_import_confirm.assert_not_called()
1329
+
1330
+ def test_revit_import_api_client_uses_scoped_proxy_and_long_commit_timeout(self):
1331
+ client = ApiClient("https://ventilatepro.example", "token")
1332
+ with mock.patch.object(client, "request", return_value={"status": "ok"}) as request:
1333
+ result = client.revit_import_confirm(41)
1334
+
1335
+ self.assertEqual(result, {"status": "ok"})
1336
+ request.assert_called_once_with(
1337
+ "POST",
1338
+ "/api/cli/proxy/revit-imports/41/commit/",
1339
+ json_payload={},
1340
+ params=None,
1341
+ timeout=300,
1342
+ )
@@ -0,0 +1,287 @@
1
+ import asyncio
2
+ import unittest
3
+ from unittest import mock
4
+
5
+ from ventilatepro.mcp_server import _requires_confirmation, build_server
6
+
7
+
8
+ class FakeClient:
9
+ def __init__(self):
10
+ self.calls = []
11
+
12
+ def schemas(self):
13
+ return {"schemas": {"api_request": {"description": "ok"}}}
14
+
15
+ def schema(self, schema_key):
16
+ return {"key": schema_key}
17
+
18
+ def auth_me(self):
19
+ return {"username": "agent", "scopes": ["projects:read"]}
20
+
21
+ def projects_list(self, *, limit=20, offset=0, query=None):
22
+ return {"count": 0, "limit": limit, "offset": offset, "query": query, "results": []}
23
+
24
+ def cli_proxy(self, method, api_path, *, json_payload=None, params=None):
25
+ self.calls.append((method, api_path, json_payload, params))
26
+ return {"method": method, "path": api_path, "payload": json_payload, "params": params}
27
+
28
+ def categorization_review(self, project_id):
29
+ self.calls.append(("categorization_review", project_id))
30
+ return {
31
+ "project": {"id": project_id},
32
+ "category_catalog": {"hcai": [], "occupancy": []},
33
+ "rooms": [],
34
+ "categorizations": [],
35
+ }
36
+
37
+ def record_meeting(self, payload):
38
+ self.calls.append(("record_meeting", payload))
39
+ return {
40
+ "meeting": {"id": 31, "title": payload["title"]},
41
+ "deep_link": "https://ventilatepro.com/?view=meeting-minutes&project=7&minute=31",
42
+ }
43
+
44
+ def revit_import_pending(self, project_id):
45
+ self.calls.append(("revit_import_pending", project_id))
46
+ return self.revit_session
47
+
48
+ def revit_import_show(self, import_id):
49
+ self.calls.append(("revit_import_show", import_id))
50
+ return self.revit_session
51
+
52
+ def revit_import_confirm(self, import_id):
53
+ self.calls.append(("revit_import_confirm", import_id))
54
+ return {
55
+ "session": {**self.revit_session, "status": "committed"},
56
+ "result": {"created": 1},
57
+ }
58
+
59
+ def revit_import_discard(self, import_id):
60
+ self.calls.append(("revit_import_discard", import_id))
61
+ return {**self.revit_session, "status": "discarded"}
62
+
63
+
64
+ class McpServerTests(unittest.TestCase):
65
+ def test_tool_catalog_contains_core_tools(self):
66
+ async def run():
67
+ server = build_server()
68
+ tools = await server.list_tools()
69
+ names = {tool.name for tool in tools}
70
+ self.assertIn("vp_whoami", names)
71
+ self.assertIn("vp_api_request", names)
72
+ self.assertIn("vp_list_entities", names)
73
+ self.assertIn("vp_run_workflow", names)
74
+ self.assertIn("vp_get_categorization_review", names)
75
+ self.assertIn("vp_apply_categorization", names)
76
+ self.assertIn("vp_get_meeting_context", names)
77
+ self.assertIn("vp_record_meeting", names)
78
+ self.assertIn("vp_list_meetings", names)
79
+ self.assertIn("vp_get_meeting", names)
80
+ self.assertIn("vp_revise_decision", names)
81
+ self.assertIn("vp_review_revit_import", names)
82
+ self.assertIn("vp_confirm_revit_import", names)
83
+ self.assertIn("vp_discard_revit_import", names)
84
+
85
+ asyncio.run(run())
86
+
87
+ def test_record_meeting_tool_saves_without_confirmation(self):
88
+ async def run():
89
+ server = build_server()
90
+ fake_client = FakeClient()
91
+ with mock.patch(
92
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
93
+ return_value=(fake_client, "http://localhost:8000"),
94
+ ), mock.patch("ventilatepro.mcp_server.QueueStore"):
95
+ _content, structured = await server.call_tool(
96
+ "vp_record_meeting",
97
+ {
98
+ "project_id": 7,
99
+ "idempotency_key": "mcp-meeting-31",
100
+ "payload": {
101
+ "title": "MCP Coordination",
102
+ "started_at": "2026-07-24T09:00:00-07:00",
103
+ },
104
+ },
105
+ )
106
+
107
+ result = structured["result"]
108
+ self.assertEqual(result["status"], "created")
109
+ self.assertEqual(result["meeting"]["id"], 31)
110
+ sent_payload = fake_client.calls[0][1]
111
+ self.assertEqual(sent_payload["project_id"], 7)
112
+ self.assertEqual(sent_payload["idempotency_key"], "mcp-meeting-31")
113
+
114
+ asyncio.run(run())
115
+
116
+ def test_api_request_tool_uses_cli_proxy(self):
117
+ async def run():
118
+ server = build_server()
119
+ fake_client = FakeClient()
120
+ with mock.patch(
121
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
122
+ return_value=(fake_client, "http://localhost:8000"),
123
+ ):
124
+ _content, structured = await server.call_tool(
125
+ "vp_api_request",
126
+ {
127
+ "method": "GET",
128
+ "path": "projects/1/",
129
+ "params": {"include": "summary"},
130
+ },
131
+ )
132
+
133
+ self.assertEqual(structured["result"]["path"], "projects/1/")
134
+ self.assertEqual(fake_client.calls[0], ("GET", "projects/1/", None, {"include": "summary"}))
135
+
136
+ asyncio.run(run())
137
+
138
+ def test_guarded_operations_require_confirmation(self):
139
+ self.assertTrue(_requires_confirmation("DELETE", "rooms/1/", None))
140
+ self.assertTrue(_requires_confirmation("POST", "projects/1/recalculate-stale/", {}))
141
+ self.assertTrue(_requires_confirmation("POST", "projects/1/design-day/run/", {}))
142
+ self.assertTrue(_requires_confirmation("POST", "projects/1/apply-categorization/", {}))
143
+ self.assertTrue(_requires_confirmation("PATCH", "projects/1/", {"force": True}))
144
+ self.assertFalse(_requires_confirmation("GET", "projects/1/", None))
145
+
146
+ def test_generic_api_tool_cannot_bypass_revit_review_token(self):
147
+ async def run():
148
+ server = build_server()
149
+ fake_client = FakeClient()
150
+ with mock.patch(
151
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
152
+ return_value=(fake_client, "http://localhost:8000"),
153
+ ):
154
+ _content, structured = await server.call_tool(
155
+ "vp_api_request",
156
+ {
157
+ "method": "POST",
158
+ "path": "revit-imports/41/commit/",
159
+ "payload": {},
160
+ "confirm": True,
161
+ },
162
+ )
163
+
164
+ result = structured["result"]
165
+ self.assertEqual(result["error"]["code"], "specialized_tool_required")
166
+ self.assertIn("vp_confirm_revit_import", result["error"]["message"])
167
+ self.assertEqual(fake_client.calls, [])
168
+
169
+ asyncio.run(run())
170
+
171
+ def test_generic_api_tool_blocks_prefixed_revit_commit_path(self):
172
+ async def run():
173
+ server = build_server()
174
+ fake_client = FakeClient()
175
+ with mock.patch(
176
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
177
+ return_value=(fake_client, "http://localhost:8000"),
178
+ ):
179
+ _content, structured = await server.call_tool(
180
+ "vp_api_request",
181
+ {
182
+ "method": "POST",
183
+ "path": "hvac/api/revit-imports/41/commit/",
184
+ "payload": {},
185
+ "confirm": True,
186
+ },
187
+ )
188
+
189
+ self.assertEqual(structured["result"]["error"]["code"], "specialized_tool_required")
190
+ self.assertEqual(fake_client.calls, [])
191
+
192
+ asyncio.run(run())
193
+
194
+ def test_revit_import_review_and_confirm_require_matching_review_token(self):
195
+ async def run():
196
+ server = build_server()
197
+ fake_client = FakeClient()
198
+ fake_client.revit_session = {
199
+ "id": 41,
200
+ "project": 7,
201
+ "status": "pending",
202
+ "updated_at": "2026-07-29T10:00:00Z",
203
+ "payload": {
204
+ "rooms": [{"number": "101", "name": "Exam", "revit_space_unique_id": "uid-101"}],
205
+ "zones": [],
206
+ },
207
+ "options": {},
208
+ "diff_summary": {
209
+ "summary": {"new_rooms": 1, "updated_rooms": 0, "missing_rooms": 0},
210
+ "new_rooms": [{"key": "revit:uid-101"}],
211
+ },
212
+ }
213
+ with mock.patch(
214
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
215
+ return_value=(fake_client, "http://localhost:8000"),
216
+ ):
217
+ _content, structured = await server.call_tool(
218
+ "vp_review_revit_import",
219
+ {"project_id": 7},
220
+ )
221
+ review = structured["result"]
222
+ self.assertEqual(review["summary"]["new_rooms"], 1)
223
+ self.assertTrue(review["review_token"].startswith("revit-import:41:"))
224
+
225
+ _content, blocked = await server.call_tool(
226
+ "vp_confirm_revit_import",
227
+ {
228
+ "project_id": 7,
229
+ "import_id": 41,
230
+ "review_token": review["review_token"],
231
+ "confirm": False,
232
+ },
233
+ )
234
+ self.assertEqual(blocked["result"]["error"]["code"], "confirmation_required")
235
+
236
+ _content, confirmed = await server.call_tool(
237
+ "vp_confirm_revit_import",
238
+ {
239
+ "project_id": 7,
240
+ "import_id": 41,
241
+ "review_token": review["review_token"],
242
+ "confirm": True,
243
+ },
244
+ )
245
+
246
+ self.assertEqual(confirmed["result"]["session"]["status"], "committed")
247
+ self.assertIn(("revit_import_confirm", 41), fake_client.calls)
248
+
249
+ asyncio.run(run())
250
+
251
+ def test_revit_import_confirm_rejects_stale_token(self):
252
+ async def run():
253
+ server = build_server()
254
+ fake_client = FakeClient()
255
+ fake_client.revit_session = {
256
+ "id": 41,
257
+ "project": 7,
258
+ "status": "pending",
259
+ "updated_at": "2026-07-29T10:05:00Z",
260
+ "payload": {"rooms": [{"number": "102", "name": "Changed"}], "zones": []},
261
+ "options": {},
262
+ "diff_summary": {"summary": {"new_rooms": 1}},
263
+ }
264
+ with mock.patch(
265
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
266
+ return_value=(fake_client, "http://localhost:8000"),
267
+ ):
268
+ _content, structured = await server.call_tool(
269
+ "vp_confirm_revit_import",
270
+ {
271
+ "project_id": 7,
272
+ "import_id": 41,
273
+ "review_token": "revit-import:41:stale",
274
+ "confirm": True,
275
+ },
276
+ )
277
+
278
+ result = structured["result"]
279
+ self.assertEqual(result["error"]["code"], "ValueError")
280
+ self.assertIn("changed after it was reviewed", result["error"]["message"])
281
+ self.assertNotIn(("revit_import_confirm", 41), fake_client.calls)
282
+
283
+ asyncio.run(run())
284
+
285
+
286
+ if __name__ == "__main__":
287
+ unittest.main()
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.4.0"
5
+ __version__ = "0.5.0"
@@ -149,12 +149,14 @@ class ApiClient:
149
149
  *,
150
150
  json_payload: Optional[Dict[str, Any]] = None,
151
151
  params: Optional[Dict[str, Any]] = None,
152
+ timeout: Optional[int] = None,
152
153
  ):
153
154
  return self.request(
154
155
  method.upper(),
155
156
  self._proxy_path(api_path),
156
157
  json_payload=json_payload,
157
158
  params=params,
159
+ timeout=timeout,
158
160
  )
159
161
 
160
162
  def cli_proxy_download(
@@ -245,6 +247,27 @@ class ApiClient:
245
247
  timeout=300,
246
248
  )
247
249
 
250
+ def revit_import_pending(self, project_id: int):
251
+ return self.cli_proxy("GET", f"projects/{project_id}/revit-imports/pending/")
252
+
253
+ def revit_import_show(self, import_id: int):
254
+ return self.cli_proxy("GET", f"revit-imports/{import_id}/")
255
+
256
+ def revit_import_confirm(self, import_id: int):
257
+ return self.cli_proxy(
258
+ "POST",
259
+ f"revit-imports/{import_id}/commit/",
260
+ json_payload={},
261
+ timeout=300,
262
+ )
263
+
264
+ def revit_import_discard(self, import_id: int):
265
+ return self.cli_proxy(
266
+ "POST",
267
+ f"revit-imports/{import_id}/discard/",
268
+ json_payload={},
269
+ )
270
+
248
271
  def _list_project_entities(
249
272
  self,
250
273
  entity_name: str,
@@ -22,6 +22,7 @@ from .output import emit, emit_error
22
22
  from .payloads import require_confirmation
23
23
  from .projects import app as projects_app
24
24
  from .queue import QueueStore
25
+ from .revit_imports import app as revit_imports_app
25
26
  from .systems import app as systems_app
26
27
 
27
28
  app = typer.Typer(help="VentilatePro CLI")
@@ -40,6 +41,7 @@ app.add_typer(plants_app, name="plants")
40
41
  app.add_typer(systems_app, name="systems")
41
42
  app.add_typer(calc_app, name="calc")
42
43
  app.add_typer(categorization_app, name="categorization")
44
+ app.add_typer(revit_imports_app, name="revit-imports")
43
45
  app.add_typer(design_day_app, name="design-day")
44
46
  app.add_typer(notes_app, name="notes")
45
47
  app.add_typer(meetings_app, name="meetings")
@@ -11,6 +11,11 @@ from . import meetings
11
11
  from .api_client import ApiError, NetworkError
12
12
  from .categorization import _validate_apply_manifest
13
13
  from .queue import QueueStore
14
+ from .revit_imports import (
15
+ build_revit_import_review,
16
+ get_revit_import_session,
17
+ require_matching_review_token,
18
+ )
14
19
 
15
20
  try:
16
21
  from mcp.server.fastmcp import FastMCP
@@ -91,8 +96,6 @@ WORKFLOW_PATHS = {
91
96
  "gbxml_commit": "projects/{project_id}/commit-gbxml/",
92
97
  "revit_import_pending": "projects/{project_id}/revit-imports/pending/",
93
98
  "revit_import_detail": "revit-imports/{import_id}/",
94
- "revit_import_commit": "revit-imports/{import_id}/commit/",
95
- "revit_import_discard": "revit-imports/{import_id}/discard/",
96
99
  "qc_list": "projects/{project_id}/qc/",
97
100
  "qc_detail": "projects/{project_id}/qc/{report_id}/",
98
101
  "revit_qc_runs": "projects/{project_id}/revit-qc/runs/",
@@ -115,6 +118,11 @@ CAPABILITIES = {
115
118
  "meeting_show",
116
119
  "decision_revise",
117
120
  ],
121
+ "revit_import_tools": [
122
+ "vp_review_revit_import",
123
+ "vp_confirm_revit_import",
124
+ "vp_discard_revit_import",
125
+ ],
118
126
  "resources": [
119
127
  "ventilatepro://auth/me",
120
128
  "ventilatepro://projects",
@@ -122,6 +130,7 @@ CAPABILITIES = {
122
130
  "ventilatepro://projects/{project_id}/hierarchy",
123
131
  "ventilatepro://projects/{project_id}/system-graph",
124
132
  "ventilatepro://projects/{project_id}/system-topology",
133
+ "ventilatepro://projects/{project_id}/revit-imports/pending",
125
134
  "ventilatepro://projects/{project_id}/categorization/review",
126
135
  "ventilatepro://schemas/{schema_key}",
127
136
  ],
@@ -204,6 +213,23 @@ def _call_api(
204
213
  try:
205
214
  normalized_method = _normalize_method(method)
206
215
  normalized_path = _normalize_path(path)
216
+ path_parts = normalized_path.strip("/").lower().split("/")
217
+ if (
218
+ normalized_method != "GET"
219
+ and len(path_parts) >= 3
220
+ and "revit-imports" in path_parts
221
+ and path_parts[-1] in {"commit", "discard"}
222
+ ):
223
+ tool_name = (
224
+ "vp_confirm_revit_import"
225
+ if path_parts[-1] == "commit"
226
+ else "vp_discard_revit_import"
227
+ )
228
+ return _error_payload(
229
+ "specialized_tool_required",
230
+ f"Use {tool_name} for this Revit import operation.",
231
+ detail={"method": normalized_method, "path": normalized_path},
232
+ )
207
233
  if _requires_confirmation(normalized_method, normalized_path, payload) and not confirm:
208
234
  return _error_payload(
209
235
  "confirmation_required",
@@ -307,7 +333,8 @@ def build_server():
307
333
  "VentilatePro",
308
334
  instructions=(
309
335
  "Operate VentilatePro through scoped backend APIs. Use confirm=true only after the user has "
310
- "approved destructive, broad, import, export, apply, or run operations."
336
+ "approved destructive, broad, import, export, apply, or run operations. For Revit imports, call "
337
+ "vp_review_revit_import first and confirm only with its unchanged review_token."
311
338
  ),
312
339
  )
313
340
 
@@ -508,6 +535,77 @@ def build_server():
508
535
  detail = getattr(exc, "payload", None)
509
536
  return _error_payload(exc.__class__.__name__, str(exc), detail=detail)
510
537
 
538
+ @server.tool()
539
+ def vp_review_revit_import(
540
+ project_id: int,
541
+ import_id: Optional[int] = None,
542
+ ) -> Dict[str, Any]:
543
+ """Review the pending or selected Revit import diff without changing project data."""
544
+ try:
545
+ client = _client()
546
+ session = get_revit_import_session(
547
+ client,
548
+ project_id=project_id,
549
+ import_id=import_id,
550
+ )
551
+ return build_revit_import_review(session)
552
+ except (ValueError, ApiError, NetworkError) as exc:
553
+ detail = getattr(exc, "payload", None)
554
+ return _error_payload(exc.__class__.__name__, str(exc), detail=detail)
555
+
556
+ @server.tool()
557
+ def vp_confirm_revit_import(
558
+ project_id: int,
559
+ import_id: int,
560
+ review_token: str,
561
+ confirm: bool = False,
562
+ ) -> Dict[str, Any]:
563
+ """Commit the exact reviewed Revit import revision. Requires its review token and confirm=true."""
564
+ if not confirm:
565
+ return _error_payload(
566
+ "confirmation_required",
567
+ "Confirming a Revit import requires confirm=true after the import review is presented to the user.",
568
+ )
569
+ try:
570
+ client = _client()
571
+ session = get_revit_import_session(
572
+ client,
573
+ project_id=project_id,
574
+ import_id=import_id,
575
+ require_pending=True,
576
+ )
577
+ review = build_revit_import_review(session)
578
+ require_matching_review_token(review, review_token)
579
+ return client.revit_import_confirm(import_id)
580
+ except (ValueError, ApiError, NetworkError) as exc:
581
+ detail = getattr(exc, "payload", None)
582
+ return _error_payload(exc.__class__.__name__, str(exc), detail=detail)
583
+
584
+ @server.tool()
585
+ def vp_discard_revit_import(
586
+ project_id: int,
587
+ import_id: int,
588
+ confirm: bool = False,
589
+ ) -> Dict[str, Any]:
590
+ """Discard a pending Revit import without applying it. Requires confirm=true."""
591
+ if not confirm:
592
+ return _error_payload(
593
+ "confirmation_required",
594
+ "Discarding a Revit import requires confirm=true.",
595
+ )
596
+ try:
597
+ client = _client()
598
+ get_revit_import_session(
599
+ client,
600
+ project_id=project_id,
601
+ import_id=import_id,
602
+ require_pending=True,
603
+ )
604
+ return client.revit_import_discard(import_id)
605
+ except (ValueError, ApiError, NetworkError) as exc:
606
+ detail = getattr(exc, "payload", None)
607
+ return _error_payload(exc.__class__.__name__, str(exc), detail=detail)
608
+
511
609
  @server.tool()
512
610
  def vp_list_entities(
513
611
  entity: str,
@@ -615,6 +713,10 @@ def build_server():
615
713
  def resource_categorization_review(project_id: int) -> str:
616
714
  return _json_resource(vp_get_categorization_review(project_id))
617
715
 
716
+ @server.resource("ventilatepro://projects/{project_id}/revit-imports/pending")
717
+ def resource_pending_revit_import(project_id: int) -> str:
718
+ return _json_resource(vp_review_revit_import(project_id))
719
+
618
720
  @server.resource("ventilatepro://projects/{project_id}")
619
721
  def resource_project(project_id: str) -> str:
620
722
  return _json_resource(vp_get_project(int(project_id)))
@@ -666,6 +768,15 @@ def build_server():
666
768
  "notes, and schedules. Prioritize findings by risk and identify concrete model or schedule edits."
667
769
  )
668
770
 
771
+ @server.prompt()
772
+ def revit_import_review(project_id: int) -> str:
773
+ return (
774
+ f"Review the pending Revit import for VentilatePro project {project_id}. Use vp_review_revit_import, "
775
+ "summarize room and zone changes plus every conflict, and do not commit it until the user approves the "
776
+ "review. After approval, pass the unchanged review_token to vp_confirm_revit_import with confirm=true. "
777
+ "If the token is stale, review the import again before requesting approval."
778
+ )
779
+
669
780
  @server.prompt()
670
781
  def controls_package_draft(project_id: int) -> str:
671
782
  return (
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import secrets
6
+ from typing import Any, Dict, Optional
7
+
8
+ import typer
9
+
10
+ from . import auth
11
+ from .api_client import ApiError, NetworkError
12
+ from .output import emit, emit_error
13
+ from .payloads import require_confirmation
14
+
15
+ app = typer.Typer(help="Review and confirm staged Revit imports")
16
+
17
+
18
+ def _session_project_id(session: Dict[str, Any]) -> Optional[int]:
19
+ project = session.get("project")
20
+ if isinstance(project, dict):
21
+ project = project.get("id")
22
+ try:
23
+ return int(project)
24
+ except (TypeError, ValueError):
25
+ return None
26
+
27
+
28
+ def validate_revit_import_session(
29
+ session: Dict[str, Any],
30
+ *,
31
+ project_id: int,
32
+ require_pending: bool = False,
33
+ ) -> Dict[str, Any]:
34
+ if not isinstance(session, dict):
35
+ raise ValueError("The Revit import response was not a JSON object.")
36
+ actual_project_id = _session_project_id(session)
37
+ if actual_project_id != project_id:
38
+ raise ValueError(
39
+ f"Revit import {session.get('id')} belongs to project {actual_project_id}, "
40
+ f"not project {project_id}."
41
+ )
42
+ if require_pending and session.get("status") != "pending":
43
+ raise ValueError(
44
+ f"Revit import {session.get('id')} is {session.get('status') or 'not pending'}; "
45
+ "only pending imports can be confirmed."
46
+ )
47
+ return session
48
+
49
+
50
+ def get_revit_import_session(
51
+ client,
52
+ *,
53
+ project_id: int,
54
+ import_id: Optional[int] = None,
55
+ require_pending: bool = False,
56
+ ) -> Dict[str, Any]:
57
+ if import_id is None:
58
+ session = client.revit_import_pending(project_id)
59
+ else:
60
+ session = client.revit_import_show(import_id)
61
+ return validate_revit_import_session(
62
+ session,
63
+ project_id=project_id,
64
+ require_pending=require_pending,
65
+ )
66
+
67
+
68
+ def _review_token(session: Dict[str, Any]) -> str:
69
+ snapshot = {
70
+ "id": session.get("id"),
71
+ "project": _session_project_id(session),
72
+ "status": session.get("status"),
73
+ "updated_at": session.get("updated_at"),
74
+ "payload": session.get("payload") or {},
75
+ "options": session.get("options") or {},
76
+ "diff_summary": session.get("diff_summary") or {},
77
+ }
78
+ canonical = json.dumps(
79
+ snapshot,
80
+ sort_keys=True,
81
+ separators=(",", ":"),
82
+ ensure_ascii=False,
83
+ default=str,
84
+ ).encode("utf-8")
85
+ return f"revit-import:{session.get('id')}:{hashlib.sha256(canonical).hexdigest()}"
86
+
87
+
88
+ def build_revit_import_review(session: Dict[str, Any]) -> Dict[str, Any]:
89
+ diff = session.get("diff_summary") or {}
90
+ payload = session.get("payload") or {}
91
+ rooms = payload.get("rooms") or []
92
+ zones = payload.get("zones") or []
93
+ return {
94
+ "id": session.get("id"),
95
+ "project": _session_project_id(session),
96
+ "status": session.get("status"),
97
+ "created_by": session.get("created_by"),
98
+ "created_by_name": session.get("created_by_name"),
99
+ "created_at": session.get("created_at"),
100
+ "updated_at": session.get("updated_at"),
101
+ "metadata": session.get("metadata") or {},
102
+ "options": session.get("options") or {},
103
+ "payload_counts": {
104
+ "rooms": len(rooms),
105
+ "zones": len(zones),
106
+ },
107
+ "summary": diff.get("summary") or {},
108
+ "changes": {
109
+ "new_rooms": diff.get("new_rooms") or [],
110
+ "updated_rooms": diff.get("updated_rooms") or [],
111
+ "missing_rooms": diff.get("missing_rooms") or diff.get("deleted_rooms") or [],
112
+ "new_zones": diff.get("new_zones") or [],
113
+ "missing_zones": diff.get("missing_zones") or diff.get("deleted_zones") or [],
114
+ },
115
+ "conflicts": diff.get("conflicts") or {},
116
+ "errors": diff.get("errors") or [],
117
+ "review_token": _review_token(session),
118
+ }
119
+
120
+
121
+ def require_matching_review_token(review: Dict[str, Any], supplied_token: str) -> None:
122
+ expected_token = str(review.get("review_token") or "")
123
+ if not supplied_token:
124
+ raise ValueError("Confirming a Revit import requires the review_token from a fresh review.")
125
+ if not secrets.compare_digest(expected_token, supplied_token):
126
+ raise ValueError(
127
+ "The Revit import changed after it was reviewed. Review it again and use the new review_token."
128
+ )
129
+
130
+
131
+ def review_lines(review: Dict[str, Any]) -> list[str]:
132
+ summary = review.get("summary") or {}
133
+ counts = review.get("payload_counts") or {}
134
+ issue_keys = (
135
+ "duplicate_room_keys",
136
+ "duplicate_stable_ids",
137
+ "duplicate_room_labels",
138
+ "missing_identifiers",
139
+ "existing_duplicates",
140
+ "ambiguous_matches",
141
+ )
142
+ issue_count = sum(int(summary.get(key) or 0) for key in issue_keys)
143
+ return [
144
+ f"Revit import: {review.get('id')} ({review.get('status')})",
145
+ f"Project: {review.get('project')}",
146
+ f"Payload: {counts.get('rooms', 0)} rooms, {counts.get('zones', 0)} zones",
147
+ (
148
+ "Room changes: "
149
+ f"{summary.get('new_rooms', 0)} new, "
150
+ f"{summary.get('updated_rooms', 0)} updated, "
151
+ f"{summary.get('missing_rooms', summary.get('deleted_rooms', 0))} missing"
152
+ ),
153
+ (
154
+ "Zone changes: "
155
+ f"{summary.get('new_zones', 0)} new, "
156
+ f"{summary.get('missing_zones', summary.get('deleted_zones', 0))} missing"
157
+ ),
158
+ f"Review issues: {issue_count}",
159
+ f"Review token: {review.get('review_token')}",
160
+ ]
161
+
162
+
163
+ @app.command("review")
164
+ def revit_import_review(
165
+ project: int = typer.Option(..., "--project", help="Target project ID"),
166
+ import_id: int = typer.Option(None, "--import", help="Specific import ID; defaults to the pending import"),
167
+ json_output: bool = typer.Option(False, "--json", help="Return the machine-readable review artifact"),
168
+ ):
169
+ """Review a pending or historical Revit import without changing project data."""
170
+ try:
171
+ client, _ = auth.get_authenticated_client()
172
+ session = get_revit_import_session(
173
+ client,
174
+ project_id=project,
175
+ import_id=import_id,
176
+ )
177
+ review = build_revit_import_review(session)
178
+ except (ValueError, ApiError, NetworkError) as exc:
179
+ emit_error(str(exc), json_output=json_output)
180
+ raise typer.Exit(1)
181
+ emit(review, json_output=json_output, lines=review_lines(review))
182
+
183
+
184
+ @app.command("show")
185
+ def revit_import_show(
186
+ import_id: int = typer.Argument(..., help="Revit import ID"),
187
+ project: int = typer.Option(..., "--project", help="Expected project ID"),
188
+ json_output: bool = typer.Option(False, "--json", help="Return the full import session including its payload"),
189
+ ):
190
+ """Show the full staged import payload and computed diff."""
191
+ try:
192
+ client, _ = auth.get_authenticated_client()
193
+ session = get_revit_import_session(
194
+ client,
195
+ project_id=project,
196
+ import_id=import_id,
197
+ )
198
+ review = build_revit_import_review(session)
199
+ except (ValueError, ApiError, NetworkError) as exc:
200
+ emit_error(str(exc), json_output=json_output)
201
+ raise typer.Exit(1)
202
+ emit(session, json_output=json_output, lines=review_lines(review))
203
+
204
+
205
+ @app.command("confirm")
206
+ def revit_import_confirm(
207
+ import_id: int = typer.Argument(..., help="Pending Revit import ID"),
208
+ project: int = typer.Option(..., "--project", help="Expected project ID"),
209
+ review_token: str = typer.Option(..., "--review-token", help="Token returned by a fresh review"),
210
+ yes: bool = typer.Option(False, "--yes", help="Confirm applying the staged Revit import"),
211
+ json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
212
+ ):
213
+ """Commit the exact import revision that was reviewed."""
214
+ try:
215
+ require_confirmation(yes=yes, action="Confirming a Revit import")
216
+ client, _ = auth.get_authenticated_client()
217
+ session = get_revit_import_session(
218
+ client,
219
+ project_id=project,
220
+ import_id=import_id,
221
+ require_pending=True,
222
+ )
223
+ review = build_revit_import_review(session)
224
+ require_matching_review_token(review, review_token)
225
+ result = client.revit_import_confirm(import_id)
226
+ except (ValueError, ApiError, NetworkError) as exc:
227
+ emit_error(str(exc), json_output=json_output)
228
+ raise typer.Exit(1)
229
+
230
+ committed_session = result.get("session") or {}
231
+ import_result = result.get("result") or {}
232
+ emit(
233
+ result,
234
+ json_output=json_output,
235
+ lines=[
236
+ f"Committed Revit import {committed_session.get('id', import_id)} for project {project}.",
237
+ (
238
+ "Rooms: "
239
+ f"{import_result.get('created', 0)} created, "
240
+ f"{import_result.get('updated', 0)} updated, "
241
+ f"{import_result.get('marked_missing', 0)} marked missing"
242
+ ),
243
+ ],
244
+ )
245
+
246
+
247
+ @app.command("discard")
248
+ def revit_import_discard(
249
+ import_id: int = typer.Argument(..., help="Pending Revit import ID"),
250
+ project: int = typer.Option(..., "--project", help="Expected project ID"),
251
+ yes: bool = typer.Option(False, "--yes", help="Confirm discarding the staged import"),
252
+ json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
253
+ ):
254
+ """Discard a pending import without applying it."""
255
+ try:
256
+ require_confirmation(yes=yes, action="Discarding a Revit import")
257
+ client, _ = auth.get_authenticated_client()
258
+ get_revit_import_session(
259
+ client,
260
+ project_id=project,
261
+ import_id=import_id,
262
+ require_pending=True,
263
+ )
264
+ result = client.revit_import_discard(import_id)
265
+ except (ValueError, ApiError, NetworkError) as exc:
266
+ emit_error(str(exc), json_output=json_output)
267
+ raise typer.Exit(1)
268
+
269
+ emit(
270
+ result,
271
+ json_output=json_output,
272
+ lines=[f"Discarded Revit import {result.get('id', import_id)} for project {project}."],
273
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ventilatepro-cli
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: VentilatePro CLI
5
5
  Project-URL: Homepage, https://ventilatepro.com/exam/cli/
6
6
  Project-URL: Documentation, https://ventilatepro.com/exam/cli/
@@ -47,6 +47,8 @@ ventilatepro projects show 1
47
47
  ventilatepro rooms list --project 1
48
48
  ventilatepro room-equipment summary --project 1
49
49
  ventilatepro room-equipment create --data '{"room": 501, "name": "Sterilizer", "heat_gain_value": 1.8, "heat_gain_unit": "KW"}'
50
+ ventilatepro revit-imports review --project 1 --json
51
+ ventilatepro revit-imports confirm 41 --project 1 --review-token <token-from-review> --yes
50
52
  ventilatepro categorization review --project 1 --output room-categorization.json
51
53
  ventilatepro categorization apply --project 1 --input room-categorization.json --yes
52
54
  ventilatepro ahus list --project 1
@@ -77,6 +79,11 @@ review resource or use the dedicated MCP tools. Review manifests default every
77
79
  room to rejected so ambiguous spaces remain visible until a category is
78
80
  explicitly accepted with a reason.
79
81
 
82
+ For staged Revit imports, agents use `revit-imports review` or
83
+ `vp_review_revit_import` first. The returned review token identifies the exact
84
+ payload and diff that was inspected. Confirmation requires that unchanged token
85
+ plus explicit `--yes` or MCP `confirm=true`; stale reviews are rejected.
86
+
80
87
  ## MCP setup
81
88
 
82
89
  1. In the VentilatePro web app, open Account Settings and create a CLI token with
@@ -21,6 +21,7 @@ ventilatepro/output.py
21
21
  ventilatepro/payloads.py
22
22
  ventilatepro/projects.py
23
23
  ventilatepro/queue.py
24
+ ventilatepro/revit_imports.py
24
25
  ventilatepro/systems.py
25
26
  ventilatepro_cli.egg-info/PKG-INFO
26
27
  ventilatepro_cli.egg-info/SOURCES.txt
@@ -1,126 +0,0 @@
1
- import asyncio
2
- import unittest
3
- from unittest import mock
4
-
5
- from ventilatepro.mcp_server import _requires_confirmation, build_server
6
-
7
-
8
- class FakeClient:
9
- def __init__(self):
10
- self.calls = []
11
-
12
- def schemas(self):
13
- return {"schemas": {"api_request": {"description": "ok"}}}
14
-
15
- def schema(self, schema_key):
16
- return {"key": schema_key}
17
-
18
- def auth_me(self):
19
- return {"username": "agent", "scopes": ["projects:read"]}
20
-
21
- def projects_list(self, *, limit=20, offset=0, query=None):
22
- return {"count": 0, "limit": limit, "offset": offset, "query": query, "results": []}
23
-
24
- def cli_proxy(self, method, api_path, *, json_payload=None, params=None):
25
- self.calls.append((method, api_path, json_payload, params))
26
- return {"method": method, "path": api_path, "payload": json_payload, "params": params}
27
-
28
- def categorization_review(self, project_id):
29
- self.calls.append(("categorization_review", project_id))
30
- return {
31
- "project": {"id": project_id},
32
- "category_catalog": {"hcai": [], "occupancy": []},
33
- "rooms": [],
34
- "categorizations": [],
35
- }
36
-
37
- def record_meeting(self, payload):
38
- self.calls.append(("record_meeting", payload))
39
- return {
40
- "meeting": {"id": 31, "title": payload["title"]},
41
- "deep_link": "https://ventilatepro.com/?view=meeting-minutes&project=7&minute=31",
42
- }
43
-
44
-
45
- class McpServerTests(unittest.TestCase):
46
- def test_tool_catalog_contains_core_tools(self):
47
- async def run():
48
- server = build_server()
49
- tools = await server.list_tools()
50
- names = {tool.name for tool in tools}
51
- self.assertIn("vp_whoami", names)
52
- self.assertIn("vp_api_request", names)
53
- self.assertIn("vp_list_entities", names)
54
- self.assertIn("vp_run_workflow", names)
55
- self.assertIn("vp_get_categorization_review", names)
56
- self.assertIn("vp_apply_categorization", names)
57
- self.assertIn("vp_get_meeting_context", names)
58
- self.assertIn("vp_record_meeting", names)
59
- self.assertIn("vp_list_meetings", names)
60
- self.assertIn("vp_get_meeting", names)
61
- self.assertIn("vp_revise_decision", names)
62
-
63
- asyncio.run(run())
64
-
65
- def test_record_meeting_tool_saves_without_confirmation(self):
66
- async def run():
67
- server = build_server()
68
- fake_client = FakeClient()
69
- with mock.patch(
70
- "ventilatepro.mcp_server.auth.get_authenticated_client",
71
- return_value=(fake_client, "http://localhost:8000"),
72
- ), mock.patch("ventilatepro.mcp_server.QueueStore"):
73
- _content, structured = await server.call_tool(
74
- "vp_record_meeting",
75
- {
76
- "project_id": 7,
77
- "idempotency_key": "mcp-meeting-31",
78
- "payload": {
79
- "title": "MCP Coordination",
80
- "started_at": "2026-07-24T09:00:00-07:00",
81
- },
82
- },
83
- )
84
-
85
- result = structured["result"]
86
- self.assertEqual(result["status"], "created")
87
- self.assertEqual(result["meeting"]["id"], 31)
88
- sent_payload = fake_client.calls[0][1]
89
- self.assertEqual(sent_payload["project_id"], 7)
90
- self.assertEqual(sent_payload["idempotency_key"], "mcp-meeting-31")
91
-
92
- asyncio.run(run())
93
-
94
- def test_api_request_tool_uses_cli_proxy(self):
95
- async def run():
96
- server = build_server()
97
- fake_client = FakeClient()
98
- with mock.patch(
99
- "ventilatepro.mcp_server.auth.get_authenticated_client",
100
- return_value=(fake_client, "http://localhost:8000"),
101
- ):
102
- _content, structured = await server.call_tool(
103
- "vp_api_request",
104
- {
105
- "method": "GET",
106
- "path": "projects/1/",
107
- "params": {"include": "summary"},
108
- },
109
- )
110
-
111
- self.assertEqual(structured["result"]["path"], "projects/1/")
112
- self.assertEqual(fake_client.calls[0], ("GET", "projects/1/", None, {"include": "summary"}))
113
-
114
- asyncio.run(run())
115
-
116
- def test_guarded_operations_require_confirmation(self):
117
- self.assertTrue(_requires_confirmation("DELETE", "rooms/1/", None))
118
- self.assertTrue(_requires_confirmation("POST", "projects/1/recalculate-stale/", {}))
119
- self.assertTrue(_requires_confirmation("POST", "projects/1/design-day/run/", {}))
120
- self.assertTrue(_requires_confirmation("POST", "projects/1/apply-categorization/", {}))
121
- self.assertTrue(_requires_confirmation("PATCH", "projects/1/", {"force": True}))
122
- self.assertFalse(_requires_confirmation("GET", "projects/1/", None))
123
-
124
-
125
- if __name__ == "__main__":
126
- unittest.main()