ventilatepro-cli 0.4.0__tar.gz → 0.5.1__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 (35) hide show
  1. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/PKG-INFO +15 -1
  2. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/README.md +14 -0
  3. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/tests/test_cli.py +238 -1
  4. ventilatepro_cli-0.5.1/tests/test_mcp_server.py +331 -0
  5. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/__init__.py +1 -1
  6. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/api_client.py +33 -0
  7. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/cli.py +64 -1
  8. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/mcp_server.py +150 -3
  9. ventilatepro_cli-0.5.1/ventilatepro/revit_imports.py +273 -0
  10. ventilatepro_cli-0.5.1/ventilatepro/tasks.py +58 -0
  11. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro_cli.egg-info/PKG-INFO +15 -1
  12. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro_cli.egg-info/SOURCES.txt +2 -0
  13. ventilatepro_cli-0.4.0/tests/test_mcp_server.py +0 -126
  14. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/pyproject.toml +0 -0
  15. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/setup.cfg +0 -0
  16. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/tests/test_config.py +0 -0
  17. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/__main__.py +0 -0
  18. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/auth.py +0 -0
  19. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/calc.py +0 -0
  20. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/categorization.py +0 -0
  21. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/config.py +0 -0
  22. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/design_day.py +0 -0
  23. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/entities.py +0 -0
  24. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/mcp_cli.py +0 -0
  25. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/meetings.py +0 -0
  26. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/notes.py +0 -0
  27. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/output.py +0 -0
  28. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/payloads.py +0 -0
  29. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/projects.py +0 -0
  30. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/queue.py +0 -0
  31. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/systems.py +0 -0
  32. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
  33. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro_cli.egg-info/entry_points.txt +0 -0
  34. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro_cli.egg-info/requires.txt +0 -0
  35. {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/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.1
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
@@ -59,6 +61,8 @@ ventilatepro notes sync
59
61
  ventilatepro meetings context --project 1 --json
60
62
  ventilatepro meetings record --project 1 --input meeting.json --json
61
63
  ventilatepro meetings list --project 1
64
+ ventilatepro tasks context --project 1 --json
65
+ ventilatepro tasks create --project 1 --description "Issue updated duct plan" --assignee-id 12 --priority high
62
66
  ventilatepro mcp doctor
63
67
  ventilatepro-mcp
64
68
  ```
@@ -72,11 +76,21 @@ stable project-member IDs, interprets the PM's prose into a structured JSON
72
76
  payload, and calls `meetings record`. The server atomically creates the meeting,
73
77
  decisions, and assigned project tasks and returns a direct web-app link.
74
78
 
79
+ For standalone work, Codex reads `tasks context` to resolve an assignee to a
80
+ stable project-member user ID, then calls `tasks create`. The same workflow is
81
+ available through the typed `vp_get_task_context` and
82
+ `vp_create_project_task` MCP tools.
83
+
75
84
  For room classification, Codex can read the project-specific categorization
76
85
  review resource or use the dedicated MCP tools. Review manifests default every
77
86
  room to rejected so ambiguous spaces remain visible until a category is
78
87
  explicitly accepted with a reason.
79
88
 
89
+ For staged Revit imports, agents use `revit-imports review` or
90
+ `vp_review_revit_import` first. The returned review token identifies the exact
91
+ payload and diff that was inspected. Confirmation requires that unchanged token
92
+ plus explicit `--yes` or MCP `confirm=true`; stale reviews are rejected.
93
+
80
94
  ## MCP setup
81
95
 
82
96
  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
@@ -44,6 +46,8 @@ ventilatepro notes sync
44
46
  ventilatepro meetings context --project 1 --json
45
47
  ventilatepro meetings record --project 1 --input meeting.json --json
46
48
  ventilatepro meetings list --project 1
49
+ ventilatepro tasks context --project 1 --json
50
+ ventilatepro tasks create --project 1 --description "Issue updated duct plan" --assignee-id 12 --priority high
47
51
  ventilatepro mcp doctor
48
52
  ventilatepro-mcp
49
53
  ```
@@ -57,11 +61,21 @@ stable project-member IDs, interprets the PM's prose into a structured JSON
57
61
  payload, and calls `meetings record`. The server atomically creates the meeting,
58
62
  decisions, and assigned project tasks and returns a direct web-app link.
59
63
 
64
+ For standalone work, Codex reads `tasks context` to resolve an assignee to a
65
+ stable project-member user ID, then calls `tasks create`. The same workflow is
66
+ available through the typed `vp_get_task_context` and
67
+ `vp_create_project_task` MCP tools.
68
+
60
69
  For room classification, Codex can read the project-specific categorization
61
70
  review resource or use the dedicated MCP tools. Review manifests default every
62
71
  room to rejected so ambiguous spaces remain visible until a category is
63
72
  explicitly accepted with a reason.
64
73
 
74
+ For staged Revit imports, agents use `revit-imports review` or
75
+ `vp_review_revit_import` first. The returned review token identifies the exact
76
+ payload and diff that was inspected. Confirmation requires that unchanged token
77
+ plus explicit `--yes` or MCP `confirm=true`; stale reviews are rejected.
78
+
65
79
  ## MCP setup
66
80
 
67
81
  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):
@@ -361,6 +362,84 @@ class CliTests(unittest.TestCase):
361
362
  self.assertEqual(payload["members"][0]["user_id"], 12)
362
363
  fake_client.meeting_context.assert_called_once_with(1)
363
364
 
365
+ def test_tasks_context_outputs_assignable_directory(self):
366
+ fake_client = mock.Mock()
367
+ fake_client.task_context.return_value = {
368
+ "project": {"id": 1, "name": "Hospital"},
369
+ "members": [
370
+ {
371
+ "user_id": 12,
372
+ "name": "Alex Engineer",
373
+ "email": "alex@example.com",
374
+ "role": "engineer",
375
+ },
376
+ ],
377
+ }
378
+ with mock.patch.object(
379
+ auth,
380
+ "get_authenticated_client",
381
+ return_value=(fake_client, "http://localhost:8000"),
382
+ ):
383
+ result = self.runner.invoke(
384
+ app,
385
+ ["tasks", "context", "--project", "1", "--json"],
386
+ )
387
+
388
+ self.assertEqual(result.exit_code, 0)
389
+ payload = json.loads(result.stdout)
390
+ self.assertEqual(payload["members"][0]["user_id"], 12)
391
+ fake_client.task_context.assert_called_once_with(1)
392
+
393
+ def test_tasks_create_assigns_project_member(self):
394
+ fake_client = mock.Mock()
395
+ fake_client.base_url = "https://ventilatepro.com"
396
+ fake_client.create_project_task.return_value = {
397
+ "id": 46,
398
+ "project": 1,
399
+ "description": "Issue diffuser update",
400
+ "assigned_user": 12,
401
+ "priority": "high",
402
+ "include_in_minutes": False,
403
+ }
404
+ with mock.patch.object(
405
+ auth,
406
+ "get_authenticated_client",
407
+ return_value=(fake_client, "https://ventilatepro.com"),
408
+ ):
409
+ result = self.runner.invoke(
410
+ app,
411
+ [
412
+ "tasks",
413
+ "create",
414
+ "--project",
415
+ "1",
416
+ "--description",
417
+ "Issue diffuser update",
418
+ "--assignee-id",
419
+ "12",
420
+ "--due-at",
421
+ "2026-08-05T17:00:00-07:00",
422
+ "--priority",
423
+ "high",
424
+ "--notes",
425
+ "Coordinate with architecture.",
426
+ "--estimated-hours",
427
+ "2.5",
428
+ "--json",
429
+ ],
430
+ )
431
+
432
+ self.assertEqual(result.exit_code, 0)
433
+ payload = json.loads(result.stdout)
434
+ self.assertEqual(payload["task"]["id"], 46)
435
+ self.assertIn("view=project-tasks", payload["deep_link"])
436
+ sent_payload = fake_client.create_project_task.call_args.args[0]
437
+ self.assertEqual(sent_payload["project"], 1)
438
+ self.assertEqual(sent_payload["assigned_user"], 12)
439
+ self.assertEqual(sent_payload["priority"], "high")
440
+ self.assertFalse(sent_payload["include_in_minutes"])
441
+ self.assertEqual(sent_payload["source"], "cli")
442
+
364
443
  def test_meetings_record_reads_structured_stdin_and_saves_immediately(self):
365
444
  fake_client = mock.Mock()
366
445
  fake_client.record_meeting.return_value = {
@@ -1181,3 +1260,161 @@ class CliTests(unittest.TestCase):
1181
1260
 
1182
1261
  self.assertEqual(result.exit_code, 0)
1183
1262
  fake_client.design_day_run.assert_called_once_with(1, {"zones": [{"zone_id": 1}]})
1263
+
1264
+ def test_revit_import_review_returns_diff_and_stable_review_token(self):
1265
+ fake_client = mock.Mock()
1266
+ session = {
1267
+ "id": 41,
1268
+ "project": 7,
1269
+ "status": "pending",
1270
+ "updated_at": "2026-07-29T10:00:00Z",
1271
+ "payload": {
1272
+ "rooms": [{"number": "101", "name": "Exam", "revit_space_unique_id": "uid-101"}],
1273
+ "zones": [{"name": "Clinic"}],
1274
+ },
1275
+ "options": {"mark_missing_rooms": True},
1276
+ "diff_summary": {
1277
+ "summary": {
1278
+ "new_rooms": 1,
1279
+ "updated_rooms": 0,
1280
+ "missing_rooms": 2,
1281
+ "new_zones": 1,
1282
+ "missing_zones": 0,
1283
+ "ambiguous_matches": 0,
1284
+ },
1285
+ "new_rooms": [{"key": "revit:uid-101"}],
1286
+ "updated_rooms": [],
1287
+ "missing_rooms": [{"id": 9, "number": "099"}],
1288
+ "new_zones": [{"name": "Clinic"}],
1289
+ "missing_zones": [],
1290
+ "conflicts": {},
1291
+ "errors": [],
1292
+ },
1293
+ }
1294
+ fake_client.revit_import_pending.return_value = session
1295
+
1296
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
1297
+ result = self.runner.invoke(
1298
+ app,
1299
+ ["revit-imports", "review", "--project", "7", "--json"],
1300
+ )
1301
+
1302
+ self.assertEqual(result.exit_code, 0)
1303
+ payload = json.loads(result.stdout)
1304
+ self.assertEqual(payload["summary"]["new_rooms"], 1)
1305
+ self.assertEqual(payload["changes"]["missing_rooms"][0]["id"], 9)
1306
+ self.assertEqual(payload["payload_counts"], {"rooms": 1, "zones": 1})
1307
+ self.assertTrue(payload["review_token"].startswith("revit-import:41:"))
1308
+ self.assertNotIn("payload", payload)
1309
+ fake_client.revit_import_pending.assert_called_once_with(7)
1310
+
1311
+ def test_revit_import_confirm_requires_yes_before_any_api_call(self):
1312
+ fake_client = mock.Mock()
1313
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
1314
+ result = self.runner.invoke(
1315
+ app,
1316
+ [
1317
+ "revit-imports",
1318
+ "confirm",
1319
+ "41",
1320
+ "--project",
1321
+ "7",
1322
+ "--review-token",
1323
+ "revit-import:41:test",
1324
+ ],
1325
+ )
1326
+
1327
+ self.assertNotEqual(result.exit_code, 0)
1328
+ self.assertIn("requires --yes", result.output)
1329
+ fake_client.revit_import_show.assert_not_called()
1330
+ fake_client.revit_import_confirm.assert_not_called()
1331
+
1332
+ def test_revit_import_confirm_rechecks_project_status_and_review_token(self):
1333
+ fake_client = mock.Mock()
1334
+ session = {
1335
+ "id": 41,
1336
+ "project": 7,
1337
+ "status": "pending",
1338
+ "updated_at": "2026-07-29T10:00:00Z",
1339
+ "payload": {
1340
+ "rooms": [{"number": "101", "name": "Exam", "revit_space_unique_id": "uid-101"}],
1341
+ "zones": [],
1342
+ },
1343
+ "options": {},
1344
+ "diff_summary": {"summary": {"new_rooms": 1}},
1345
+ }
1346
+ review_token = build_revit_import_review(session)["review_token"]
1347
+ fake_client.revit_import_show.return_value = session
1348
+ fake_client.revit_import_confirm.return_value = {
1349
+ "session": {"id": 41, "project": 7, "status": "committed"},
1350
+ "result": {"created": 1, "updated": 0, "marked_missing": 0},
1351
+ }
1352
+
1353
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
1354
+ result = self.runner.invoke(
1355
+ app,
1356
+ [
1357
+ "revit-imports",
1358
+ "confirm",
1359
+ "41",
1360
+ "--project",
1361
+ "7",
1362
+ "--review-token",
1363
+ review_token,
1364
+ "--yes",
1365
+ "--json",
1366
+ ],
1367
+ )
1368
+
1369
+ self.assertEqual(result.exit_code, 0)
1370
+ self.assertEqual(json.loads(result.stdout)["session"]["status"], "committed")
1371
+ fake_client.revit_import_show.assert_called_once_with(41)
1372
+ fake_client.revit_import_confirm.assert_called_once_with(41)
1373
+
1374
+ def test_revit_import_confirm_rejects_stale_review_token(self):
1375
+ fake_client = mock.Mock()
1376
+ fake_client.revit_import_show.return_value = {
1377
+ "id": 41,
1378
+ "project": 7,
1379
+ "status": "pending",
1380
+ "updated_at": "2026-07-29T10:05:00Z",
1381
+ "payload": {
1382
+ "rooms": [{"number": "102", "name": "Changed", "revit_space_unique_id": "uid-102"}],
1383
+ "zones": [],
1384
+ },
1385
+ "options": {},
1386
+ "diff_summary": {"summary": {"new_rooms": 1}},
1387
+ }
1388
+
1389
+ with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
1390
+ result = self.runner.invoke(
1391
+ app,
1392
+ [
1393
+ "revit-imports",
1394
+ "confirm",
1395
+ "41",
1396
+ "--project",
1397
+ "7",
1398
+ "--review-token",
1399
+ "revit-import:41:stale",
1400
+ "--yes",
1401
+ ],
1402
+ )
1403
+
1404
+ self.assertNotEqual(result.exit_code, 0)
1405
+ self.assertIn("changed after it was reviewed", result.output)
1406
+ fake_client.revit_import_confirm.assert_not_called()
1407
+
1408
+ def test_revit_import_api_client_uses_scoped_proxy_and_long_commit_timeout(self):
1409
+ client = ApiClient("https://ventilatepro.example", "token")
1410
+ with mock.patch.object(client, "request", return_value={"status": "ok"}) as request:
1411
+ result = client.revit_import_confirm(41)
1412
+
1413
+ self.assertEqual(result, {"status": "ok"})
1414
+ request.assert_called_once_with(
1415
+ "POST",
1416
+ "/api/cli/proxy/revit-imports/41/commit/",
1417
+ json_payload={},
1418
+ params=None,
1419
+ timeout=300,
1420
+ )
@@ -0,0 +1,331 @@
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 task_context(self, project_id):
45
+ self.calls.append(("task_context", project_id))
46
+ return {
47
+ "project": {"id": project_id, "name": "Hospital"},
48
+ "members": [{"user_id": 12, "name": "Alex Engineer"}],
49
+ }
50
+
51
+ def create_project_task(self, payload):
52
+ self.calls.append(("create_project_task", payload))
53
+ return {"id": 46, **payload}
54
+
55
+ def revit_import_pending(self, project_id):
56
+ self.calls.append(("revit_import_pending", project_id))
57
+ return self.revit_session
58
+
59
+ def revit_import_show(self, import_id):
60
+ self.calls.append(("revit_import_show", import_id))
61
+ return self.revit_session
62
+
63
+ def revit_import_confirm(self, import_id):
64
+ self.calls.append(("revit_import_confirm", import_id))
65
+ return {
66
+ "session": {**self.revit_session, "status": "committed"},
67
+ "result": {"created": 1},
68
+ }
69
+
70
+ def revit_import_discard(self, import_id):
71
+ self.calls.append(("revit_import_discard", import_id))
72
+ return {**self.revit_session, "status": "discarded"}
73
+
74
+
75
+ class McpServerTests(unittest.TestCase):
76
+ def test_tool_catalog_contains_core_tools(self):
77
+ async def run():
78
+ server = build_server()
79
+ tools = await server.list_tools()
80
+ names = {tool.name for tool in tools}
81
+ self.assertIn("vp_whoami", names)
82
+ self.assertIn("vp_api_request", names)
83
+ self.assertIn("vp_list_entities", names)
84
+ self.assertIn("vp_run_workflow", names)
85
+ self.assertIn("vp_get_categorization_review", names)
86
+ self.assertIn("vp_apply_categorization", names)
87
+ self.assertIn("vp_get_meeting_context", names)
88
+ self.assertIn("vp_get_task_context", names)
89
+ self.assertIn("vp_create_project_task", names)
90
+ self.assertIn("vp_record_meeting", names)
91
+ self.assertIn("vp_list_meetings", names)
92
+ self.assertIn("vp_get_meeting", names)
93
+ self.assertIn("vp_revise_decision", names)
94
+ self.assertIn("vp_review_revit_import", names)
95
+ self.assertIn("vp_confirm_revit_import", names)
96
+ self.assertIn("vp_discard_revit_import", names)
97
+
98
+ asyncio.run(run())
99
+
100
+ def test_record_meeting_tool_saves_without_confirmation(self):
101
+ async def run():
102
+ server = build_server()
103
+ fake_client = FakeClient()
104
+ with mock.patch(
105
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
106
+ return_value=(fake_client, "http://localhost:8000"),
107
+ ), mock.patch("ventilatepro.mcp_server.QueueStore"):
108
+ _content, structured = await server.call_tool(
109
+ "vp_record_meeting",
110
+ {
111
+ "project_id": 7,
112
+ "idempotency_key": "mcp-meeting-31",
113
+ "payload": {
114
+ "title": "MCP Coordination",
115
+ "started_at": "2026-07-24T09:00:00-07:00",
116
+ },
117
+ },
118
+ )
119
+
120
+ result = structured["result"]
121
+ self.assertEqual(result["status"], "created")
122
+ self.assertEqual(result["meeting"]["id"], 31)
123
+ sent_payload = fake_client.calls[0][1]
124
+ self.assertEqual(sent_payload["project_id"], 7)
125
+ self.assertEqual(sent_payload["idempotency_key"], "mcp-meeting-31")
126
+
127
+ asyncio.run(run())
128
+
129
+ def test_api_request_tool_uses_cli_proxy(self):
130
+ async def run():
131
+ server = build_server()
132
+ fake_client = FakeClient()
133
+ with mock.patch(
134
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
135
+ return_value=(fake_client, "http://localhost:8000"),
136
+ ):
137
+ _content, structured = await server.call_tool(
138
+ "vp_api_request",
139
+ {
140
+ "method": "GET",
141
+ "path": "projects/1/",
142
+ "params": {"include": "summary"},
143
+ },
144
+ )
145
+
146
+ self.assertEqual(structured["result"]["path"], "projects/1/")
147
+ self.assertEqual(fake_client.calls[0], ("GET", "projects/1/", None, {"include": "summary"}))
148
+
149
+ asyncio.run(run())
150
+
151
+ def test_guarded_operations_require_confirmation(self):
152
+ self.assertTrue(_requires_confirmation("DELETE", "rooms/1/", None))
153
+ self.assertTrue(_requires_confirmation("POST", "projects/1/recalculate-stale/", {}))
154
+ self.assertTrue(_requires_confirmation("POST", "projects/1/design-day/run/", {}))
155
+ self.assertTrue(_requires_confirmation("POST", "projects/1/apply-categorization/", {}))
156
+ self.assertTrue(_requires_confirmation("PATCH", "projects/1/", {"force": True}))
157
+ self.assertFalse(_requires_confirmation("GET", "projects/1/", None))
158
+
159
+ def test_generic_api_tool_cannot_bypass_revit_review_token(self):
160
+ async def run():
161
+ server = build_server()
162
+ fake_client = FakeClient()
163
+ with mock.patch(
164
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
165
+ return_value=(fake_client, "http://localhost:8000"),
166
+ ):
167
+ _content, structured = await server.call_tool(
168
+ "vp_api_request",
169
+ {
170
+ "method": "POST",
171
+ "path": "revit-imports/41/commit/",
172
+ "payload": {},
173
+ "confirm": True,
174
+ },
175
+ )
176
+
177
+ result = structured["result"]
178
+ self.assertEqual(result["error"]["code"], "specialized_tool_required")
179
+ self.assertIn("vp_confirm_revit_import", result["error"]["message"])
180
+ self.assertEqual(fake_client.calls, [])
181
+
182
+ asyncio.run(run())
183
+
184
+ def test_create_project_task_tool_assigns_member_without_confirmation(self):
185
+ async def run():
186
+ server = build_server()
187
+ fake_client = FakeClient()
188
+ fake_client.base_url = "https://ventilatepro.com"
189
+ with mock.patch(
190
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
191
+ return_value=(fake_client, "https://ventilatepro.com"),
192
+ ):
193
+ _content, structured = await server.call_tool(
194
+ "vp_create_project_task",
195
+ {
196
+ "project_id": 7,
197
+ "description": "Issue diffuser update",
198
+ "assigned_user_id": 12,
199
+ "priority": "high",
200
+ "due_at": "2026-08-05T17:00:00-07:00",
201
+ },
202
+ )
203
+
204
+ result = structured["result"]
205
+ self.assertEqual(result["status"], "created")
206
+ self.assertEqual(result["task"]["id"], 46)
207
+ self.assertIn("view=project-tasks", result["deep_link"])
208
+ call_name, sent_payload = fake_client.calls[0]
209
+ self.assertEqual(call_name, "create_project_task")
210
+ self.assertEqual(sent_payload["assigned_user"], 12)
211
+ self.assertFalse(sent_payload["include_in_minutes"])
212
+
213
+ asyncio.run(run())
214
+
215
+ def test_generic_api_tool_blocks_prefixed_revit_commit_path(self):
216
+ async def run():
217
+ server = build_server()
218
+ fake_client = FakeClient()
219
+ with mock.patch(
220
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
221
+ return_value=(fake_client, "http://localhost:8000"),
222
+ ):
223
+ _content, structured = await server.call_tool(
224
+ "vp_api_request",
225
+ {
226
+ "method": "POST",
227
+ "path": "hvac/api/revit-imports/41/commit/",
228
+ "payload": {},
229
+ "confirm": True,
230
+ },
231
+ )
232
+
233
+ self.assertEqual(structured["result"]["error"]["code"], "specialized_tool_required")
234
+ self.assertEqual(fake_client.calls, [])
235
+
236
+ asyncio.run(run())
237
+
238
+ def test_revit_import_review_and_confirm_require_matching_review_token(self):
239
+ async def run():
240
+ server = build_server()
241
+ fake_client = FakeClient()
242
+ fake_client.revit_session = {
243
+ "id": 41,
244
+ "project": 7,
245
+ "status": "pending",
246
+ "updated_at": "2026-07-29T10:00:00Z",
247
+ "payload": {
248
+ "rooms": [{"number": "101", "name": "Exam", "revit_space_unique_id": "uid-101"}],
249
+ "zones": [],
250
+ },
251
+ "options": {},
252
+ "diff_summary": {
253
+ "summary": {"new_rooms": 1, "updated_rooms": 0, "missing_rooms": 0},
254
+ "new_rooms": [{"key": "revit:uid-101"}],
255
+ },
256
+ }
257
+ with mock.patch(
258
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
259
+ return_value=(fake_client, "http://localhost:8000"),
260
+ ):
261
+ _content, structured = await server.call_tool(
262
+ "vp_review_revit_import",
263
+ {"project_id": 7},
264
+ )
265
+ review = structured["result"]
266
+ self.assertEqual(review["summary"]["new_rooms"], 1)
267
+ self.assertTrue(review["review_token"].startswith("revit-import:41:"))
268
+
269
+ _content, blocked = await server.call_tool(
270
+ "vp_confirm_revit_import",
271
+ {
272
+ "project_id": 7,
273
+ "import_id": 41,
274
+ "review_token": review["review_token"],
275
+ "confirm": False,
276
+ },
277
+ )
278
+ self.assertEqual(blocked["result"]["error"]["code"], "confirmation_required")
279
+
280
+ _content, confirmed = await server.call_tool(
281
+ "vp_confirm_revit_import",
282
+ {
283
+ "project_id": 7,
284
+ "import_id": 41,
285
+ "review_token": review["review_token"],
286
+ "confirm": True,
287
+ },
288
+ )
289
+
290
+ self.assertEqual(confirmed["result"]["session"]["status"], "committed")
291
+ self.assertIn(("revit_import_confirm", 41), fake_client.calls)
292
+
293
+ asyncio.run(run())
294
+
295
+ def test_revit_import_confirm_rejects_stale_token(self):
296
+ async def run():
297
+ server = build_server()
298
+ fake_client = FakeClient()
299
+ fake_client.revit_session = {
300
+ "id": 41,
301
+ "project": 7,
302
+ "status": "pending",
303
+ "updated_at": "2026-07-29T10:05:00Z",
304
+ "payload": {"rooms": [{"number": "102", "name": "Changed"}], "zones": []},
305
+ "options": {},
306
+ "diff_summary": {"summary": {"new_rooms": 1}},
307
+ }
308
+ with mock.patch(
309
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
310
+ return_value=(fake_client, "http://localhost:8000"),
311
+ ):
312
+ _content, structured = await server.call_tool(
313
+ "vp_confirm_revit_import",
314
+ {
315
+ "project_id": 7,
316
+ "import_id": 41,
317
+ "review_token": "revit-import:41:stale",
318
+ "confirm": True,
319
+ },
320
+ )
321
+
322
+ result = structured["result"]
323
+ self.assertEqual(result["error"]["code"], "ValueError")
324
+ self.assertIn("changed after it was reviewed", result["error"]["message"])
325
+ self.assertNotIn(("revit_import_confirm", 41), fake_client.calls)
326
+
327
+ asyncio.run(run())
328
+
329
+
330
+ if __name__ == "__main__":
331
+ unittest.main()
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.4.0"
5
+ __version__ = "0.5.1"