ventilatepro-cli 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/PKG-INFO +48 -2
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/README.md +45 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/pyproject.toml +3 -1
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/tests/test_cli.py +158 -4
- ventilatepro_cli-0.3.0/tests/test_mcp_server.py +73 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/__init__.py +1 -1
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/api_client.py +201 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/cli.py +68 -1
- ventilatepro_cli-0.3.0/ventilatepro/entities.py +538 -0
- ventilatepro_cli-0.3.0/ventilatepro/mcp_cli.py +192 -0
- ventilatepro_cli-0.3.0/ventilatepro/mcp_server.py +558 -0
- ventilatepro_cli-0.3.0/ventilatepro/payloads.py +43 -0
- ventilatepro_cli-0.3.0/ventilatepro/projects.py +120 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/queue.py +8 -1
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/PKG-INFO +48 -2
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/SOURCES.txt +4 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/entry_points.txt +1 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/requires.txt +1 -0
- ventilatepro_cli-0.2.0/ventilatepro/entities.py +0 -210
- ventilatepro_cli-0.2.0/ventilatepro/projects.py +0 -55
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/setup.cfg +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/tests/test_config.py +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/__main__.py +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/auth.py +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/calc.py +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/config.py +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/design_day.py +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/notes.py +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/output.py +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro/systems.py +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/top_level.txt +0 -0
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ventilatepro-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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/
|
|
7
7
|
Project-URL: Support, https://ventilatepro.com/exam/support/
|
|
8
|
-
Requires-Python: >=3.
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
Requires-Dist: typer>=0.16.0
|
|
11
11
|
Requires-Dist: requests>=2.32.0
|
|
12
12
|
Requires-Dist: keyring>=25.6.0
|
|
13
13
|
Requires-Dist: platformdirs>=4.3.0
|
|
14
|
+
Requires-Dist: mcp>=1.23.0
|
|
14
15
|
|
|
15
16
|
# VentilatePro CLI
|
|
16
17
|
|
|
@@ -51,8 +52,53 @@ ventilatepro calc ahu-properties --ahu 1
|
|
|
51
52
|
ventilatepro design-day context --project 1
|
|
52
53
|
ventilatepro notes create --project 1 --body "Captured from terminal"
|
|
53
54
|
ventilatepro notes sync
|
|
55
|
+
ventilatepro mcp doctor
|
|
56
|
+
ventilatepro-mcp
|
|
54
57
|
```
|
|
55
58
|
|
|
59
|
+
`ventilatepro-mcp` starts the local stdio MCP server for Codex, Claude Desktop,
|
|
60
|
+
and other MCP clients. It uses the same saved base URL and CLI token created by
|
|
61
|
+
`ventilatepro auth login`.
|
|
62
|
+
|
|
63
|
+
## MCP setup
|
|
64
|
+
|
|
65
|
+
1. In the VentilatePro web app, open Account Settings and create a CLI token with
|
|
66
|
+
the `Agent Editor` preset.
|
|
67
|
+
2. Log in locally:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
ventilatepro auth login --url https://app.ventilatepro.com --token vpcli_...
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
3. Confirm the local setup:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
ventilatepro mcp doctor
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
4. Add the stdio server to Codex:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
codex mcp add ventilatepro -- ventilatepro-mcp
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
For JSON-based MCP clients, use:
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"mcpServers": {
|
|
90
|
+
"ventilatepro": {
|
|
91
|
+
"command": "ventilatepro-mcp",
|
|
92
|
+
"args": []
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
If the client cannot find `ventilatepro-mcp`, run `where ventilatepro-mcp` on
|
|
99
|
+
Windows or `which ventilatepro-mcp` on macOS/Linux and use the full path as the
|
|
100
|
+
command.
|
|
101
|
+
|
|
56
102
|
If the hidden token prompt is awkward in your terminal, use either:
|
|
57
103
|
|
|
58
104
|
```bash
|
|
@@ -37,8 +37,53 @@ ventilatepro calc ahu-properties --ahu 1
|
|
|
37
37
|
ventilatepro design-day context --project 1
|
|
38
38
|
ventilatepro notes create --project 1 --body "Captured from terminal"
|
|
39
39
|
ventilatepro notes sync
|
|
40
|
+
ventilatepro mcp doctor
|
|
41
|
+
ventilatepro-mcp
|
|
40
42
|
```
|
|
41
43
|
|
|
44
|
+
`ventilatepro-mcp` starts the local stdio MCP server for Codex, Claude Desktop,
|
|
45
|
+
and other MCP clients. It uses the same saved base URL and CLI token created by
|
|
46
|
+
`ventilatepro auth login`.
|
|
47
|
+
|
|
48
|
+
## MCP setup
|
|
49
|
+
|
|
50
|
+
1. In the VentilatePro web app, open Account Settings and create a CLI token with
|
|
51
|
+
the `Agent Editor` preset.
|
|
52
|
+
2. Log in locally:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
ventilatepro auth login --url https://app.ventilatepro.com --token vpcli_...
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
3. Confirm the local setup:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
ventilatepro mcp doctor
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
4. Add the stdio server to Codex:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
codex mcp add ventilatepro -- ventilatepro-mcp
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
For JSON-based MCP clients, use:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"ventilatepro": {
|
|
76
|
+
"command": "ventilatepro-mcp",
|
|
77
|
+
"args": []
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
If the client cannot find `ventilatepro-mcp`, run `where ventilatepro-mcp` on
|
|
84
|
+
Windows or `which ventilatepro-mcp` on macOS/Linux and use the full path as the
|
|
85
|
+
command.
|
|
86
|
+
|
|
42
87
|
If the hidden token prompt is awkward in your terminal, use either:
|
|
43
88
|
|
|
44
89
|
```bash
|
|
@@ -6,13 +6,14 @@ build-backend = "setuptools.build_meta"
|
|
|
6
6
|
name = "ventilatepro-cli"
|
|
7
7
|
dynamic = ["version"]
|
|
8
8
|
description = "VentilatePro CLI"
|
|
9
|
-
requires-python = ">=3.
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
dependencies = [
|
|
12
12
|
"typer>=0.16.0",
|
|
13
13
|
"requests>=2.32.0",
|
|
14
14
|
"keyring>=25.6.0",
|
|
15
15
|
"platformdirs>=4.3.0",
|
|
16
|
+
"mcp>=1.23.0",
|
|
16
17
|
]
|
|
17
18
|
|
|
18
19
|
[project.urls]
|
|
@@ -22,6 +23,7 @@ Support = "https://ventilatepro.com/exam/support/"
|
|
|
22
23
|
|
|
23
24
|
[project.scripts]
|
|
24
25
|
ventilatepro = "ventilatepro.cli:main"
|
|
26
|
+
ventilatepro-mcp = "ventilatepro.mcp_server:main"
|
|
25
27
|
|
|
26
28
|
[tool.setuptools.packages.find]
|
|
27
29
|
where = ["."]
|
|
@@ -101,6 +101,41 @@ class CliTests(unittest.TestCase):
|
|
|
101
101
|
os.environ.pop(config.CONFIG_ENV_VAR, None)
|
|
102
102
|
os.environ.pop(config.DATA_ENV_VAR, None)
|
|
103
103
|
|
|
104
|
+
def test_mcp_doctor_json_reports_ok_status(self):
|
|
105
|
+
doctor_payload = {
|
|
106
|
+
"status": "ok",
|
|
107
|
+
"checks": [
|
|
108
|
+
{"name": "saved_login", "ok": True, "required": True, "message": "Saved login found."},
|
|
109
|
+
{"name": "mcp_catalog", "ok": True, "required": True, "message": "MCP catalog loaded."},
|
|
110
|
+
],
|
|
111
|
+
"summary": {"mcp_catalog": {"tools": 17}},
|
|
112
|
+
"next_steps": [],
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
with mock.patch("ventilatepro.mcp_cli.run_doctor", return_value=doctor_payload):
|
|
116
|
+
result = self.runner.invoke(app, ["mcp", "doctor", "--json"])
|
|
117
|
+
|
|
118
|
+
self.assertEqual(result.exit_code, 0)
|
|
119
|
+
payload = json.loads(result.stdout)
|
|
120
|
+
self.assertEqual(payload["status"], "ok")
|
|
121
|
+
self.assertEqual(payload["summary"]["mcp_catalog"]["tools"], 17)
|
|
122
|
+
|
|
123
|
+
def test_mcp_doctor_exits_nonzero_on_required_failure(self):
|
|
124
|
+
doctor_payload = {
|
|
125
|
+
"status": "failed",
|
|
126
|
+
"checks": [
|
|
127
|
+
{"name": "saved_login", "ok": False, "required": True, "message": "Run login first."},
|
|
128
|
+
],
|
|
129
|
+
"summary": {},
|
|
130
|
+
"next_steps": ["Run login first."],
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
with mock.patch("ventilatepro.mcp_cli.run_doctor", return_value=doctor_payload):
|
|
134
|
+
result = self.runner.invoke(app, ["mcp", "doctor"])
|
|
135
|
+
|
|
136
|
+
self.assertEqual(result.exit_code, 1)
|
|
137
|
+
self.assertIn("FAIL saved_login", result.output)
|
|
138
|
+
|
|
104
139
|
def test_notes_create_queues_on_network_error(self):
|
|
105
140
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
106
141
|
os.environ[config.CONFIG_ENV_VAR] = tmpdir
|
|
@@ -326,6 +361,65 @@ class CliTests(unittest.TestCase):
|
|
|
326
361
|
self.assertIn("Hospital South Wing", result.stdout)
|
|
327
362
|
fake_client.project_show.assert_called_once_with(7)
|
|
328
363
|
|
|
364
|
+
def test_projects_create_reads_json_payload_from_inline_data(self):
|
|
365
|
+
fake_client = mock.Mock()
|
|
366
|
+
fake_client.project_create.return_value = {
|
|
367
|
+
"operation": "created",
|
|
368
|
+
"project": {"id": 9, "name": "CLI Write Project"},
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
372
|
+
result = self.runner.invoke(
|
|
373
|
+
app,
|
|
374
|
+
["projects", "create", "--data", '{"name":"CLI Write Project","time_zone":"America/Los_Angeles"}', "--json"],
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
self.assertEqual(result.exit_code, 0)
|
|
378
|
+
fake_client.project_create.assert_called_once_with(
|
|
379
|
+
{"name": "CLI Write Project", "time_zone": "America/Los_Angeles"}
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
def test_notes_update_passes_patch_payload_to_api_client(self):
|
|
383
|
+
fake_client = mock.Mock()
|
|
384
|
+
fake_client.update_note.return_value = {
|
|
385
|
+
"operation": "updated",
|
|
386
|
+
"note": {"note_ref": "general-info:1"},
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
390
|
+
result = self.runner.invoke(
|
|
391
|
+
app,
|
|
392
|
+
[
|
|
393
|
+
"notes",
|
|
394
|
+
"update",
|
|
395
|
+
"general-info:1",
|
|
396
|
+
"--project",
|
|
397
|
+
"1",
|
|
398
|
+
"--title",
|
|
399
|
+
"Updated note",
|
|
400
|
+
"--tags",
|
|
401
|
+
"field,urgent",
|
|
402
|
+
"--json",
|
|
403
|
+
],
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
self.assertEqual(result.exit_code, 0)
|
|
407
|
+
fake_client.update_note.assert_called_once_with(
|
|
408
|
+
"general-info:1",
|
|
409
|
+
project=1,
|
|
410
|
+
payload={"title": "Updated note", "tags": ["field", "urgent"]},
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
def test_rooms_delete_requires_yes_confirmation(self):
|
|
414
|
+
fake_client = mock.Mock()
|
|
415
|
+
|
|
416
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
417
|
+
result = self.runner.invoke(app, ["rooms", "delete", "12"])
|
|
418
|
+
|
|
419
|
+
self.assertNotEqual(result.exit_code, 0)
|
|
420
|
+
self.assertIn("requires --yes", result.output)
|
|
421
|
+
fake_client.delete_room.assert_not_called()
|
|
422
|
+
|
|
329
423
|
def test_rooms_list_passes_filters_to_api_client(self):
|
|
330
424
|
fake_client = mock.Mock()
|
|
331
425
|
fake_client.list_rooms.return_value = {
|
|
@@ -357,6 +451,65 @@ class CliTests(unittest.TestCase):
|
|
|
357
451
|
self.assertEqual(result.exit_code, 0)
|
|
358
452
|
fake_client.list_rooms.assert_called_once_with(1, limit=5, offset=10, query="operating")
|
|
359
453
|
|
|
454
|
+
def test_terminal_units_create_includes_zone_in_payload(self):
|
|
455
|
+
fake_client = mock.Mock()
|
|
456
|
+
fake_client.create_terminal_unit.return_value = {
|
|
457
|
+
"operation": "created",
|
|
458
|
+
"terminal_unit": {"id": 3, "tag": "TU-CLI"},
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
462
|
+
result = self.runner.invoke(
|
|
463
|
+
app,
|
|
464
|
+
[
|
|
465
|
+
"terminal-units",
|
|
466
|
+
"create",
|
|
467
|
+
"--zone",
|
|
468
|
+
"7",
|
|
469
|
+
"--data",
|
|
470
|
+
'{"tag":"TU-CLI","unit_type":"FCU"}',
|
|
471
|
+
"--json",
|
|
472
|
+
],
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
self.assertEqual(result.exit_code, 0)
|
|
476
|
+
fake_client.create_terminal_unit.assert_called_once_with(
|
|
477
|
+
{"zone": 7, "tag": "TU-CLI", "unit_type": "FCU"}
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
def test_zones_bulk_assign_ahu_accepts_assignment_list(self):
|
|
481
|
+
fake_client = mock.Mock()
|
|
482
|
+
fake_client.assign_zones_to_ahu.return_value = {
|
|
483
|
+
"operation": "assigned",
|
|
484
|
+
"updated": 2,
|
|
485
|
+
"errors": 0,
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
489
|
+
result = self.runner.invoke(
|
|
490
|
+
app,
|
|
491
|
+
[
|
|
492
|
+
"zones",
|
|
493
|
+
"bulk-assign-ahu",
|
|
494
|
+
"--project",
|
|
495
|
+
"1",
|
|
496
|
+
"--data",
|
|
497
|
+
'[{"zone_id":1,"ahu_id":2},{"zone_id":3,"ahu_id":2}]',
|
|
498
|
+
"--json",
|
|
499
|
+
],
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
self.assertEqual(result.exit_code, 0)
|
|
503
|
+
fake_client.assign_zones_to_ahu.assert_called_once_with(
|
|
504
|
+
{
|
|
505
|
+
"project": 1,
|
|
506
|
+
"assignments": [
|
|
507
|
+
{"zone_id": 1, "ahu_id": 2},
|
|
508
|
+
{"zone_id": 3, "ahu_id": 2},
|
|
509
|
+
],
|
|
510
|
+
}
|
|
511
|
+
)
|
|
512
|
+
|
|
360
513
|
def test_calc_recalculate_stale_passes_mode_scope_and_force(self):
|
|
361
514
|
fake_client = mock.Mock()
|
|
362
515
|
fake_client.calc_recalculate_stale.return_value = {
|
|
@@ -390,14 +543,15 @@ class CliTests(unittest.TestCase):
|
|
|
390
543
|
"state": "QUEUED",
|
|
391
544
|
}
|
|
392
545
|
|
|
393
|
-
with tempfile.
|
|
394
|
-
|
|
395
|
-
|
|
546
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
547
|
+
payload_path = os.path.join(tmpdir, "design-day-request.json")
|
|
548
|
+
with open(payload_path, "w", encoding="utf-8") as handle:
|
|
549
|
+
json.dump({"zones": [{"zone_id": 1}]}, handle)
|
|
396
550
|
|
|
397
551
|
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
398
552
|
result = self.runner.invoke(
|
|
399
553
|
app,
|
|
400
|
-
["design-day", "run", "--project", "1", "--input",
|
|
554
|
+
["design-day", "run", "--project", "1", "--input", payload_path, "--json"],
|
|
401
555
|
)
|
|
402
556
|
|
|
403
557
|
self.assertEqual(result.exit_code, 0)
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
|
|
29
|
+
class McpServerTests(unittest.TestCase):
|
|
30
|
+
def test_tool_catalog_contains_core_tools(self):
|
|
31
|
+
async def run():
|
|
32
|
+
server = build_server()
|
|
33
|
+
tools = await server.list_tools()
|
|
34
|
+
names = {tool.name for tool in tools}
|
|
35
|
+
self.assertIn("vp_whoami", names)
|
|
36
|
+
self.assertIn("vp_api_request", names)
|
|
37
|
+
self.assertIn("vp_list_entities", names)
|
|
38
|
+
self.assertIn("vp_run_workflow", names)
|
|
39
|
+
|
|
40
|
+
asyncio.run(run())
|
|
41
|
+
|
|
42
|
+
def test_api_request_tool_uses_cli_proxy(self):
|
|
43
|
+
async def run():
|
|
44
|
+
server = build_server()
|
|
45
|
+
fake_client = FakeClient()
|
|
46
|
+
with mock.patch(
|
|
47
|
+
"ventilatepro.mcp_server.auth.get_authenticated_client",
|
|
48
|
+
return_value=(fake_client, "http://localhost:8000"),
|
|
49
|
+
):
|
|
50
|
+
_content, structured = await server.call_tool(
|
|
51
|
+
"vp_api_request",
|
|
52
|
+
{
|
|
53
|
+
"method": "GET",
|
|
54
|
+
"path": "projects/1/",
|
|
55
|
+
"params": {"include": "summary"},
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
self.assertEqual(structured["result"]["path"], "projects/1/")
|
|
60
|
+
self.assertEqual(fake_client.calls[0], ("GET", "projects/1/", None, {"include": "summary"}))
|
|
61
|
+
|
|
62
|
+
asyncio.run(run())
|
|
63
|
+
|
|
64
|
+
def test_guarded_operations_require_confirmation(self):
|
|
65
|
+
self.assertTrue(_requires_confirmation("DELETE", "rooms/1/", None))
|
|
66
|
+
self.assertTrue(_requires_confirmation("POST", "projects/1/recalculate-stale/", {}))
|
|
67
|
+
self.assertTrue(_requires_confirmation("POST", "projects/1/design-day/run/", {}))
|
|
68
|
+
self.assertTrue(_requires_confirmation("PATCH", "projects/1/", {"force": True}))
|
|
69
|
+
self.assertFalse(_requires_confirmation("GET", "projects/1/", None))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
unittest.main()
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from pathlib import Path
|
|
3
4
|
from typing import Any, Dict, Optional
|
|
5
|
+
from urllib.parse import quote
|
|
4
6
|
|
|
5
7
|
import requests
|
|
6
8
|
|
|
@@ -78,9 +80,130 @@ class ApiClient:
|
|
|
78
80
|
)
|
|
79
81
|
return payload if payload is not None else {}
|
|
80
82
|
|
|
83
|
+
def raw_request(
|
|
84
|
+
self,
|
|
85
|
+
method: str,
|
|
86
|
+
path: str,
|
|
87
|
+
*,
|
|
88
|
+
json_payload: Optional[Dict[str, Any]] = None,
|
|
89
|
+
headers: Optional[Dict[str, str]] = None,
|
|
90
|
+
params: Optional[Dict[str, Any]] = None,
|
|
91
|
+
files: Optional[Dict[str, Any]] = None,
|
|
92
|
+
):
|
|
93
|
+
final_headers = {
|
|
94
|
+
"Accept": "*/*",
|
|
95
|
+
}
|
|
96
|
+
if headers:
|
|
97
|
+
final_headers.update(headers)
|
|
98
|
+
if self.token:
|
|
99
|
+
final_headers["Authorization"] = f"Bearer {self.token}"
|
|
100
|
+
try:
|
|
101
|
+
response = requests.request(
|
|
102
|
+
method=method,
|
|
103
|
+
url=f"{self.base_url}{path}",
|
|
104
|
+
json=json_payload if files is None else None,
|
|
105
|
+
data=None if files is None else (json_payload or {}),
|
|
106
|
+
files=files,
|
|
107
|
+
headers=final_headers,
|
|
108
|
+
params=params,
|
|
109
|
+
timeout=self.timeout,
|
|
110
|
+
)
|
|
111
|
+
except requests.RequestException as exc:
|
|
112
|
+
raise NetworkError(str(exc)) from exc
|
|
113
|
+
|
|
114
|
+
if not response.ok:
|
|
115
|
+
payload: Any = None
|
|
116
|
+
if response.content:
|
|
117
|
+
try:
|
|
118
|
+
payload = response.json()
|
|
119
|
+
except ValueError:
|
|
120
|
+
payload = response.text
|
|
121
|
+
raise ApiError(
|
|
122
|
+
status_code=response.status_code,
|
|
123
|
+
message=extract_error_message(payload, f"Request failed with status {response.status_code}."),
|
|
124
|
+
payload=payload,
|
|
125
|
+
)
|
|
126
|
+
return response
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _proxy_path(api_path: str) -> str:
|
|
130
|
+
normalized = (api_path or "").strip().lstrip("/")
|
|
131
|
+
if not normalized:
|
|
132
|
+
raise ValueError("api_path is required.")
|
|
133
|
+
return f"/api/cli/proxy/{quote(normalized, safe='/:-_')}"
|
|
134
|
+
|
|
81
135
|
def auth_me(self):
|
|
82
136
|
return self.request("GET", "/api/cli/auth/me/")
|
|
83
137
|
|
|
138
|
+
def schemas(self):
|
|
139
|
+
return self.request("GET", "/api/cli/schemas/")
|
|
140
|
+
|
|
141
|
+
def schema(self, schema_key: str):
|
|
142
|
+
return self.request("GET", f"/api/cli/schemas/{quote(schema_key, safe='')}/")
|
|
143
|
+
|
|
144
|
+
def cli_proxy(
|
|
145
|
+
self,
|
|
146
|
+
method: str,
|
|
147
|
+
api_path: str,
|
|
148
|
+
*,
|
|
149
|
+
json_payload: Optional[Dict[str, Any]] = None,
|
|
150
|
+
params: Optional[Dict[str, Any]] = None,
|
|
151
|
+
):
|
|
152
|
+
return self.request(
|
|
153
|
+
method.upper(),
|
|
154
|
+
self._proxy_path(api_path),
|
|
155
|
+
json_payload=json_payload,
|
|
156
|
+
params=params,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def cli_proxy_download(
|
|
160
|
+
self,
|
|
161
|
+
method: str,
|
|
162
|
+
api_path: str,
|
|
163
|
+
output_path: str,
|
|
164
|
+
*,
|
|
165
|
+
json_payload: Optional[Dict[str, Any]] = None,
|
|
166
|
+
params: Optional[Dict[str, Any]] = None,
|
|
167
|
+
):
|
|
168
|
+
response = self.raw_request(
|
|
169
|
+
method.upper(),
|
|
170
|
+
self._proxy_path(api_path),
|
|
171
|
+
json_payload=json_payload,
|
|
172
|
+
params=params,
|
|
173
|
+
)
|
|
174
|
+
target = Path(output_path).expanduser()
|
|
175
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
target.write_bytes(response.content)
|
|
177
|
+
return {
|
|
178
|
+
"path": str(target),
|
|
179
|
+
"size_bytes": len(response.content),
|
|
180
|
+
"content_type": response.headers.get("content-type", ""),
|
|
181
|
+
"content_disposition": response.headers.get("content-disposition", ""),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
def cli_proxy_upload(
|
|
185
|
+
self,
|
|
186
|
+
api_path: str,
|
|
187
|
+
file_path: str,
|
|
188
|
+
*,
|
|
189
|
+
fields: Optional[Dict[str, Any]] = None,
|
|
190
|
+
):
|
|
191
|
+
target = Path(file_path).expanduser()
|
|
192
|
+
if not target.exists():
|
|
193
|
+
raise ValueError(f"File does not exist: {target}")
|
|
194
|
+
with target.open("rb") as handle:
|
|
195
|
+
response = self.raw_request(
|
|
196
|
+
"POST",
|
|
197
|
+
self._proxy_path(api_path),
|
|
198
|
+
json_payload={key: str(value) for key, value in (fields or {}).items()},
|
|
199
|
+
files={"file": (target.name, handle)},
|
|
200
|
+
headers={"Accept": "application/json"},
|
|
201
|
+
)
|
|
202
|
+
try:
|
|
203
|
+
return response.json() if response.content else {}
|
|
204
|
+
except ValueError:
|
|
205
|
+
return {"text": response.text}
|
|
206
|
+
|
|
84
207
|
def projects_list(self, *, limit: int = 20, offset: int = 0, query: Optional[str] = None):
|
|
85
208
|
params: Dict[str, Any] = {"limit": limit, "offset": offset}
|
|
86
209
|
if query:
|
|
@@ -90,6 +213,15 @@ class ApiClient:
|
|
|
90
213
|
def project_show(self, project_id: int):
|
|
91
214
|
return self.request("GET", f"/api/cli/projects/{project_id}/")
|
|
92
215
|
|
|
216
|
+
def project_create(self, payload: Dict[str, Any]):
|
|
217
|
+
return self.request("POST", "/api/cli/projects/", json_payload=payload)
|
|
218
|
+
|
|
219
|
+
def project_update(self, project_id: int, payload: Dict[str, Any]):
|
|
220
|
+
return self.request("PATCH", f"/api/cli/projects/{project_id}/", json_payload=payload)
|
|
221
|
+
|
|
222
|
+
def project_delete(self, project_id: int):
|
|
223
|
+
return self.request("DELETE", f"/api/cli/projects/{project_id}/")
|
|
224
|
+
|
|
93
225
|
def _list_project_entities(
|
|
94
226
|
self,
|
|
95
227
|
entity_name: str,
|
|
@@ -107,30 +239,93 @@ class ApiClient:
|
|
|
107
239
|
def _show_entity(self, entity_name: str, entity_id: int):
|
|
108
240
|
return self.request("GET", f"/api/cli/{entity_name}/{entity_id}/")
|
|
109
241
|
|
|
242
|
+
def _create_entity(self, entity_name: str, payload: Dict[str, Any]):
|
|
243
|
+
return self.request("POST", f"/api/cli/{entity_name}/", json_payload=payload)
|
|
244
|
+
|
|
245
|
+
def _update_entity(self, entity_name: str, entity_id: int, payload: Dict[str, Any]):
|
|
246
|
+
return self.request("PATCH", f"/api/cli/{entity_name}/{entity_id}/", json_payload=payload)
|
|
247
|
+
|
|
248
|
+
def _delete_entity(self, entity_name: str, entity_id: int):
|
|
249
|
+
return self.request("DELETE", f"/api/cli/{entity_name}/{entity_id}/")
|
|
250
|
+
|
|
110
251
|
def list_rooms(self, project: int, *, limit: int = 20, offset: int = 0, query: Optional[str] = None):
|
|
111
252
|
return self._list_project_entities("rooms", project, limit=limit, offset=offset, query=query)
|
|
112
253
|
|
|
113
254
|
def show_room(self, room_id: int):
|
|
114
255
|
return self._show_entity("rooms", room_id)
|
|
115
256
|
|
|
257
|
+
def create_room(self, payload: Dict[str, Any]):
|
|
258
|
+
return self._create_entity("rooms", payload)
|
|
259
|
+
|
|
260
|
+
def update_room(self, room_id: int, payload: Dict[str, Any]):
|
|
261
|
+
return self._update_entity("rooms", room_id, payload)
|
|
262
|
+
|
|
263
|
+
def delete_room(self, room_id: int):
|
|
264
|
+
return self._delete_entity("rooms", room_id)
|
|
265
|
+
|
|
116
266
|
def list_zones(self, project: int, *, limit: int = 20, offset: int = 0, query: Optional[str] = None):
|
|
117
267
|
return self._list_project_entities("zones", project, limit=limit, offset=offset, query=query)
|
|
118
268
|
|
|
119
269
|
def show_zone(self, zone_id: int):
|
|
120
270
|
return self._show_entity("zones", zone_id)
|
|
121
271
|
|
|
272
|
+
def create_zone(self, payload: Dict[str, Any]):
|
|
273
|
+
return self._create_entity("zones", payload)
|
|
274
|
+
|
|
275
|
+
def update_zone(self, zone_id: int, payload: Dict[str, Any]):
|
|
276
|
+
return self._update_entity("zones", zone_id, payload)
|
|
277
|
+
|
|
278
|
+
def delete_zone(self, zone_id: int):
|
|
279
|
+
return self._delete_entity("zones", zone_id)
|
|
280
|
+
|
|
281
|
+
def assign_zones_to_ahu(self, payload: Dict[str, Any]):
|
|
282
|
+
return self.request("POST", "/api/cli/zones/assign-ahu/", json_payload=payload)
|
|
283
|
+
|
|
122
284
|
def list_ahus(self, project: int, *, limit: int = 20, offset: int = 0, query: Optional[str] = None):
|
|
123
285
|
return self._list_project_entities("ahus", project, limit=limit, offset=offset, query=query)
|
|
124
286
|
|
|
125
287
|
def show_ahu(self, ahu_id: int):
|
|
126
288
|
return self._show_entity("ahus", ahu_id)
|
|
127
289
|
|
|
290
|
+
def create_ahu(self, payload: Dict[str, Any]):
|
|
291
|
+
return self._create_entity("ahus", payload)
|
|
292
|
+
|
|
293
|
+
def update_ahu(self, ahu_id: int, payload: Dict[str, Any]):
|
|
294
|
+
return self._update_entity("ahus", ahu_id, payload)
|
|
295
|
+
|
|
296
|
+
def delete_ahu(self, ahu_id: int):
|
|
297
|
+
return self._delete_entity("ahus", ahu_id)
|
|
298
|
+
|
|
299
|
+
def list_terminal_units(self, project: int, *, limit: int = 20, offset: int = 0, query: Optional[str] = None):
|
|
300
|
+
return self._list_project_entities("terminal-units", project, limit=limit, offset=offset, query=query)
|
|
301
|
+
|
|
302
|
+
def show_terminal_unit(self, terminal_unit_id: int):
|
|
303
|
+
return self._show_entity("terminal-units", terminal_unit_id)
|
|
304
|
+
|
|
305
|
+
def create_terminal_unit(self, payload: Dict[str, Any]):
|
|
306
|
+
return self._create_entity("terminal-units", payload)
|
|
307
|
+
|
|
308
|
+
def update_terminal_unit(self, terminal_unit_id: int, payload: Dict[str, Any]):
|
|
309
|
+
return self._update_entity("terminal-units", terminal_unit_id, payload)
|
|
310
|
+
|
|
311
|
+
def delete_terminal_unit(self, terminal_unit_id: int):
|
|
312
|
+
return self._delete_entity("terminal-units", terminal_unit_id)
|
|
313
|
+
|
|
128
314
|
def list_plants(self, project: int, *, limit: int = 20, offset: int = 0, query: Optional[str] = None):
|
|
129
315
|
return self._list_project_entities("plants", project, limit=limit, offset=offset, query=query)
|
|
130
316
|
|
|
131
317
|
def show_plant(self, plant_ref: str):
|
|
132
318
|
return self.request("GET", f"/api/cli/plants/{plant_ref}/")
|
|
133
319
|
|
|
320
|
+
def create_plant(self, payload: Dict[str, Any]):
|
|
321
|
+
return self.request("POST", "/api/cli/plants/", json_payload=payload)
|
|
322
|
+
|
|
323
|
+
def update_plant(self, plant_ref: str, payload: Dict[str, Any]):
|
|
324
|
+
return self.request("PATCH", f"/api/cli/plants/{plant_ref}/", json_payload=payload)
|
|
325
|
+
|
|
326
|
+
def delete_plant(self, plant_ref: str):
|
|
327
|
+
return self.request("DELETE", f"/api/cli/plants/{plant_ref}/")
|
|
328
|
+
|
|
134
329
|
def system_hierarchy(self, project_id: int, *, include_envelope: bool = True):
|
|
135
330
|
return self.request(
|
|
136
331
|
"GET",
|
|
@@ -219,3 +414,9 @@ class ApiClient:
|
|
|
219
414
|
|
|
220
415
|
def show_note(self, note_ref: str, *, project: int):
|
|
221
416
|
return self.request("GET", f"/api/cli/notes/{note_ref}/", params={"project": project})
|
|
417
|
+
|
|
418
|
+
def update_note(self, note_ref: str, *, project: int, payload: Dict[str, Any]):
|
|
419
|
+
return self.request("PATCH", f"/api/cli/notes/{note_ref}/", json_payload=payload, params={"project": project})
|
|
420
|
+
|
|
421
|
+
def delete_note(self, note_ref: str, *, project: int):
|
|
422
|
+
return self.request("DELETE", f"/api/cli/notes/{note_ref}/", params={"project": project})
|