ventilatepro-cli 0.1.1__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.1.1 → ventilatepro_cli-0.3.0}/PKG-INFO +55 -2
- ventilatepro_cli-0.3.0/README.md +99 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/pyproject.toml +3 -1
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/tests/test_cli.py +270 -0
- ventilatepro_cli-0.3.0/tests/test_mcp_server.py +73 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro/__init__.py +1 -1
- ventilatepro_cli-0.3.0/ventilatepro/api_client.py +422 -0
- ventilatepro_cli-0.3.0/ventilatepro/calc.py +142 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro/cli.py +80 -35
- ventilatepro_cli-0.3.0/ventilatepro/design_day.py +95 -0
- 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/output.py +31 -0
- ventilatepro_cli-0.3.0/ventilatepro/payloads.py +43 -0
- ventilatepro_cli-0.3.0/ventilatepro/projects.py +120 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro/queue.py +8 -1
- ventilatepro_cli-0.3.0/ventilatepro/systems.py +76 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/PKG-INFO +55 -2
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/SOURCES.txt +10 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/entry_points.txt +1 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/requires.txt +1 -0
- ventilatepro_cli-0.1.1/README.md +0 -47
- ventilatepro_cli-0.1.1/ventilatepro/api_client.py +0 -122
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/setup.cfg +0 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/tests/test_config.py +0 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro/__main__.py +0 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro/auth.py +0 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro/config.py +0 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro/notes.py +0 -0
- {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
- {ventilatepro_cli-0.1.1 → 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
|
|
|
@@ -42,10 +43,62 @@ pip install -e .
|
|
|
42
43
|
```bash
|
|
43
44
|
ventilatepro auth login --url http://localhost:8000
|
|
44
45
|
ventilatepro projects list
|
|
46
|
+
ventilatepro projects show 1
|
|
47
|
+
ventilatepro rooms list --project 1
|
|
48
|
+
ventilatepro ahus list --project 1
|
|
49
|
+
ventilatepro systems hierarchy --project 1
|
|
50
|
+
ventilatepro calc status --project 1
|
|
51
|
+
ventilatepro calc ahu-properties --ahu 1
|
|
52
|
+
ventilatepro design-day context --project 1
|
|
45
53
|
ventilatepro notes create --project 1 --body "Captured from terminal"
|
|
46
54
|
ventilatepro notes sync
|
|
55
|
+
ventilatepro mcp doctor
|
|
56
|
+
ventilatepro-mcp
|
|
47
57
|
```
|
|
48
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
|
+
|
|
49
102
|
If the hidden token prompt is awkward in your terminal, use either:
|
|
50
103
|
|
|
51
104
|
```bash
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# VentilatePro CLI
|
|
2
|
+
|
|
3
|
+
VentilatePro CLI is a local command-line client for authenticating to VentilatePro and creating or syncing notes through the scoped CLI API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Published releases install from PyPI:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pipx install ventilatepro-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Upgrade an existing install:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pipx upgrade ventilatepro-cli
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Local development install:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
cd ventilatepro-cli
|
|
23
|
+
pip install -e .
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Commands
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
ventilatepro auth login --url http://localhost:8000
|
|
30
|
+
ventilatepro projects list
|
|
31
|
+
ventilatepro projects show 1
|
|
32
|
+
ventilatepro rooms list --project 1
|
|
33
|
+
ventilatepro ahus list --project 1
|
|
34
|
+
ventilatepro systems hierarchy --project 1
|
|
35
|
+
ventilatepro calc status --project 1
|
|
36
|
+
ventilatepro calc ahu-properties --ahu 1
|
|
37
|
+
ventilatepro design-day context --project 1
|
|
38
|
+
ventilatepro notes create --project 1 --body "Captured from terminal"
|
|
39
|
+
ventilatepro notes sync
|
|
40
|
+
ventilatepro mcp doctor
|
|
41
|
+
ventilatepro-mcp
|
|
42
|
+
```
|
|
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
|
+
|
|
87
|
+
If the hidden token prompt is awkward in your terminal, use either:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
ventilatepro auth login --url https://ventilatepro.com --token-prompt-visible
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
or pipe the token through stdin:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
printf '%s' "$VPCLI_TOKEN" | ventilatepro auth login --url https://ventilatepro.com --token-stdin
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
See the hosted install guide at `https://ventilatepro.com/exam/cli/`, plus [docs/commands.md](./docs/commands.md), [docs/auth.md](./docs/auth.md), [docs/api.md](./docs/api.md), and [docs/releasing.md](./docs/releasing.md) in this repo.
|
|
@@ -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
|
|
@@ -286,3 +321,238 @@ class CliTests(unittest.TestCase):
|
|
|
286
321
|
tags=["field", "urgent"],
|
|
287
322
|
query="terminal",
|
|
288
323
|
)
|
|
324
|
+
|
|
325
|
+
def test_projects_list_json_outputs_paginated_payload(self):
|
|
326
|
+
fake_client = mock.Mock()
|
|
327
|
+
fake_client.projects_list.return_value = {
|
|
328
|
+
"count": 1,
|
|
329
|
+
"limit": 20,
|
|
330
|
+
"offset": 0,
|
|
331
|
+
"results": [
|
|
332
|
+
{
|
|
333
|
+
"id": 1,
|
|
334
|
+
"name": "CLI Project",
|
|
335
|
+
}
|
|
336
|
+
],
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
340
|
+
result = self.runner.invoke(app, ["projects", "list", "--json"])
|
|
341
|
+
|
|
342
|
+
self.assertEqual(result.exit_code, 0)
|
|
343
|
+
payload = json.loads(result.stdout)
|
|
344
|
+
self.assertEqual(payload["results"][0]["name"], "CLI Project")
|
|
345
|
+
fake_client.projects_list.assert_called_once_with(limit=20, offset=0, query=None)
|
|
346
|
+
|
|
347
|
+
def test_projects_show_calls_api_client(self):
|
|
348
|
+
fake_client = mock.Mock()
|
|
349
|
+
fake_client.project_show.return_value = {
|
|
350
|
+
"id": 7,
|
|
351
|
+
"name": "Hospital South Wing",
|
|
352
|
+
"counts": {"rooms": 3, "zones": 2, "ahus": 1},
|
|
353
|
+
"location": "Los Angeles",
|
|
354
|
+
"time_zone": "America/Los_Angeles",
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
358
|
+
result = self.runner.invoke(app, ["projects", "show", "7"])
|
|
359
|
+
|
|
360
|
+
self.assertEqual(result.exit_code, 0)
|
|
361
|
+
self.assertIn("Hospital South Wing", result.stdout)
|
|
362
|
+
fake_client.project_show.assert_called_once_with(7)
|
|
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
|
+
|
|
423
|
+
def test_rooms_list_passes_filters_to_api_client(self):
|
|
424
|
+
fake_client = mock.Mock()
|
|
425
|
+
fake_client.list_rooms.return_value = {
|
|
426
|
+
"project_id": 1,
|
|
427
|
+
"count": 0,
|
|
428
|
+
"limit": 5,
|
|
429
|
+
"offset": 10,
|
|
430
|
+
"results": [],
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
434
|
+
result = self.runner.invoke(
|
|
435
|
+
app,
|
|
436
|
+
[
|
|
437
|
+
"rooms",
|
|
438
|
+
"list",
|
|
439
|
+
"--project",
|
|
440
|
+
"1",
|
|
441
|
+
"--limit",
|
|
442
|
+
"5",
|
|
443
|
+
"--offset",
|
|
444
|
+
"10",
|
|
445
|
+
"--query",
|
|
446
|
+
"operating",
|
|
447
|
+
"--json",
|
|
448
|
+
],
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
self.assertEqual(result.exit_code, 0)
|
|
452
|
+
fake_client.list_rooms.assert_called_once_with(1, limit=5, offset=10, query="operating")
|
|
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
|
+
|
|
513
|
+
def test_calc_recalculate_stale_passes_mode_scope_and_force(self):
|
|
514
|
+
fake_client = mock.Mock()
|
|
515
|
+
fake_client.calc_recalculate_stale.return_value = {
|
|
516
|
+
"message": "Queued stale recalculation.",
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
520
|
+
result = self.runner.invoke(
|
|
521
|
+
app,
|
|
522
|
+
[
|
|
523
|
+
"calc",
|
|
524
|
+
"recalculate-stale",
|
|
525
|
+
"--project",
|
|
526
|
+
"1",
|
|
527
|
+
"--scope",
|
|
528
|
+
"zones",
|
|
529
|
+
"--mode",
|
|
530
|
+
"sync",
|
|
531
|
+
"--force",
|
|
532
|
+
"--json",
|
|
533
|
+
],
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
self.assertEqual(result.exit_code, 0)
|
|
537
|
+
fake_client.calc_recalculate_stale.assert_called_once_with(1, scope="zones", mode="sync", force=True)
|
|
538
|
+
|
|
539
|
+
def test_design_day_run_reads_json_payload_from_file(self):
|
|
540
|
+
fake_client = mock.Mock()
|
|
541
|
+
fake_client.design_day_run.return_value = {
|
|
542
|
+
"id": 42,
|
|
543
|
+
"state": "QUEUED",
|
|
544
|
+
}
|
|
545
|
+
|
|
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)
|
|
550
|
+
|
|
551
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
552
|
+
result = self.runner.invoke(
|
|
553
|
+
app,
|
|
554
|
+
["design-day", "run", "--project", "1", "--input", payload_path, "--json"],
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
self.assertEqual(result.exit_code, 0)
|
|
558
|
+
fake_client.design_day_run.assert_called_once_with(1, {"zones": [{"zone_id": 1}]})
|
|
@@ -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()
|