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.
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/PKG-INFO +8 -1
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/README.md +7 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/tests/test_cli.py +160 -1
- ventilatepro_cli-0.5.0/tests/test_mcp_server.py +287 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/__init__.py +1 -1
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/api_client.py +23 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/cli.py +2 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/mcp_server.py +114 -3
- ventilatepro_cli-0.5.0/ventilatepro/revit_imports.py +273 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro_cli.egg-info/PKG-INFO +8 -1
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro_cli.egg-info/SOURCES.txt +1 -0
- ventilatepro_cli-0.4.0/tests/test_mcp_server.py +0 -126
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/pyproject.toml +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/setup.cfg +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/tests/test_config.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/__main__.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/auth.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/calc.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/categorization.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/config.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/design_day.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/entities.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/mcp_cli.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/meetings.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/notes.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/output.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/payloads.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/projects.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/queue.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro/systems.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro_cli.egg-info/entry_points.txt +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro_cli.egg-info/requires.txt +0 -0
- {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.
|
|
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()
|
|
@@ -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.
|
|
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
|
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.0}/ventilatepro_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|