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.
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/PKG-INFO +15 -1
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/README.md +14 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/tests/test_cli.py +238 -1
- ventilatepro_cli-0.5.1/tests/test_mcp_server.py +331 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/__init__.py +1 -1
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/api_client.py +33 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/cli.py +64 -1
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/mcp_server.py +150 -3
- ventilatepro_cli-0.5.1/ventilatepro/revit_imports.py +273 -0
- ventilatepro_cli-0.5.1/ventilatepro/tasks.py +58 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro_cli.egg-info/PKG-INFO +15 -1
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro_cli.egg-info/SOURCES.txt +2 -0
- ventilatepro_cli-0.4.0/tests/test_mcp_server.py +0 -126
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/pyproject.toml +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/setup.cfg +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/tests/test_config.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/__main__.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/auth.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/calc.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/categorization.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/config.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/design_day.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/entities.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/mcp_cli.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/meetings.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/notes.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/output.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/payloads.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/projects.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/queue.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro/systems.py +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro_cli.egg-info/entry_points.txt +0 -0
- {ventilatepro_cli-0.4.0 → ventilatepro_cli-0.5.1}/ventilatepro_cli.egg-info/requires.txt +0 -0
- {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.
|
|
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()
|