ventilatepro-cli 0.2.0__tar.gz → 0.3.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.2.0 → ventilatepro_cli-0.3.1}/PKG-INFO +54 -5
- ventilatepro_cli-0.3.1/README.md +102 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/pyproject.toml +3 -1
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/tests/test_cli.py +183 -4
- ventilatepro_cli-0.3.1/tests/test_mcp_server.py +73 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/ventilatepro/__init__.py +1 -1
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/ventilatepro/api_client.py +201 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/ventilatepro/auth.py +2 -4
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/ventilatepro/cli.py +69 -2
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/ventilatepro/config.py +1 -0
- ventilatepro_cli-0.3.1/ventilatepro/entities.py +538 -0
- ventilatepro_cli-0.3.1/ventilatepro/mcp_cli.py +192 -0
- ventilatepro_cli-0.3.1/ventilatepro/mcp_server.py +558 -0
- ventilatepro_cli-0.3.1/ventilatepro/payloads.py +43 -0
- ventilatepro_cli-0.3.1/ventilatepro/projects.py +120 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/ventilatepro/queue.py +8 -1
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/ventilatepro_cli.egg-info/PKG-INFO +54 -5
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/ventilatepro_cli.egg-info/SOURCES.txt +4 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/ventilatepro_cli.egg-info/entry_points.txt +1 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/ventilatepro_cli.egg-info/requires.txt +1 -0
- ventilatepro_cli-0.2.0/README.md +0 -54
- 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.1}/setup.cfg +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/tests/test_config.py +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/ventilatepro/__main__.py +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/ventilatepro/calc.py +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/ventilatepro/design_day.py +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/ventilatepro/notes.py +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/ventilatepro/output.py +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/ventilatepro/systems.py +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
- {ventilatepro_cli-0.2.0 → ventilatepro_cli-0.3.1}/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.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/
|
|
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
|
|
|
@@ -40,7 +41,7 @@ pip install -e .
|
|
|
40
41
|
## Commands
|
|
41
42
|
|
|
42
43
|
```bash
|
|
43
|
-
ventilatepro auth login
|
|
44
|
+
ventilatepro auth login
|
|
44
45
|
ventilatepro projects list
|
|
45
46
|
ventilatepro projects show 1
|
|
46
47
|
ventilatepro rooms list --project 1
|
|
@@ -51,18 +52,66 @@ 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 --token vpcli_...
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The production URL defaults to `https://ventilatepro.com`. Use `--url` only for
|
|
74
|
+
local development, staging, or self-hosted VentilatePro servers.
|
|
75
|
+
|
|
76
|
+
3. Confirm the local setup:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
ventilatepro mcp doctor
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
4. Add the stdio server to Codex:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
codex mcp add ventilatepro -- ventilatepro-mcp
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
For JSON-based MCP clients, use:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"mcpServers": {
|
|
93
|
+
"ventilatepro": {
|
|
94
|
+
"command": "ventilatepro-mcp",
|
|
95
|
+
"args": []
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
If the client cannot find `ventilatepro-mcp`, run `where ventilatepro-mcp` on
|
|
102
|
+
Windows or `which ventilatepro-mcp` on macOS/Linux and use the full path as the
|
|
103
|
+
command.
|
|
104
|
+
|
|
56
105
|
If the hidden token prompt is awkward in your terminal, use either:
|
|
57
106
|
|
|
58
107
|
```bash
|
|
59
|
-
ventilatepro auth login --
|
|
108
|
+
ventilatepro auth login --token-prompt-visible
|
|
60
109
|
```
|
|
61
110
|
|
|
62
111
|
or pipe the token through stdin:
|
|
63
112
|
|
|
64
113
|
```bash
|
|
65
|
-
printf '%s' "$VPCLI_TOKEN" | ventilatepro auth login --
|
|
114
|
+
printf '%s' "$VPCLI_TOKEN" | ventilatepro auth login --token-stdin
|
|
66
115
|
```
|
|
67
116
|
|
|
68
117
|
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.
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
|
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 --token vpcli_...
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The production URL defaults to `https://ventilatepro.com`. Use `--url` only for
|
|
59
|
+
local development, staging, or self-hosted VentilatePro servers.
|
|
60
|
+
|
|
61
|
+
3. Confirm the local setup:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
ventilatepro mcp doctor
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
4. Add the stdio server to Codex:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
codex mcp add ventilatepro -- ventilatepro-mcp
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
For JSON-based MCP clients, use:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"mcpServers": {
|
|
78
|
+
"ventilatepro": {
|
|
79
|
+
"command": "ventilatepro-mcp",
|
|
80
|
+
"args": []
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
If the client cannot find `ventilatepro-mcp`, run `where ventilatepro-mcp` on
|
|
87
|
+
Windows or `which ventilatepro-mcp` on macOS/Linux and use the full path as the
|
|
88
|
+
command.
|
|
89
|
+
|
|
90
|
+
If the hidden token prompt is awkward in your terminal, use either:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
ventilatepro auth login --token-prompt-visible
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
or pipe the token through stdin:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
printf '%s' "$VPCLI_TOKEN" | ventilatepro auth login --token-stdin
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
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 = ["."]
|
|
@@ -44,6 +44,31 @@ class CliTests(unittest.TestCase):
|
|
|
44
44
|
os.environ.pop(config.CONFIG_ENV_VAR, None)
|
|
45
45
|
os.environ.pop(config.DATA_ENV_VAR, None)
|
|
46
46
|
|
|
47
|
+
def test_auth_login_defaults_to_production_url(self):
|
|
48
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
49
|
+
os.environ[config.CONFIG_ENV_VAR] = tmpdir
|
|
50
|
+
os.environ[config.DATA_ENV_VAR] = tmpdir
|
|
51
|
+
try:
|
|
52
|
+
fake_client = mock.Mock()
|
|
53
|
+
fake_client.auth_me.return_value = {
|
|
54
|
+
"username": "demo-user",
|
|
55
|
+
"email": "demo@example.com",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
with mock.patch.object(auth, "ApiClient", return_value=fake_client) as api_client:
|
|
59
|
+
with mock.patch.object(config, "_try_get_keyring", return_value=None):
|
|
60
|
+
result = self.runner.invoke(
|
|
61
|
+
app,
|
|
62
|
+
["auth", "login", "--token", "vpcli_demo_secret", "--json"],
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
self.assertEqual(result.exit_code, 0)
|
|
66
|
+
api_client.assert_called_once_with(config.DEFAULT_BASE_URL, "vpcli_demo_secret")
|
|
67
|
+
self.assertEqual(config.get_base_url(), config.DEFAULT_BASE_URL)
|
|
68
|
+
finally:
|
|
69
|
+
os.environ.pop(config.CONFIG_ENV_VAR, None)
|
|
70
|
+
os.environ.pop(config.DATA_ENV_VAR, None)
|
|
71
|
+
|
|
47
72
|
def test_auth_login_accepts_visible_prompt_input(self):
|
|
48
73
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
49
74
|
os.environ[config.CONFIG_ENV_VAR] = tmpdir
|
|
@@ -101,6 +126,41 @@ class CliTests(unittest.TestCase):
|
|
|
101
126
|
os.environ.pop(config.CONFIG_ENV_VAR, None)
|
|
102
127
|
os.environ.pop(config.DATA_ENV_VAR, None)
|
|
103
128
|
|
|
129
|
+
def test_mcp_doctor_json_reports_ok_status(self):
|
|
130
|
+
doctor_payload = {
|
|
131
|
+
"status": "ok",
|
|
132
|
+
"checks": [
|
|
133
|
+
{"name": "saved_login", "ok": True, "required": True, "message": "Saved login found."},
|
|
134
|
+
{"name": "mcp_catalog", "ok": True, "required": True, "message": "MCP catalog loaded."},
|
|
135
|
+
],
|
|
136
|
+
"summary": {"mcp_catalog": {"tools": 17}},
|
|
137
|
+
"next_steps": [],
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
with mock.patch("ventilatepro.mcp_cli.run_doctor", return_value=doctor_payload):
|
|
141
|
+
result = self.runner.invoke(app, ["mcp", "doctor", "--json"])
|
|
142
|
+
|
|
143
|
+
self.assertEqual(result.exit_code, 0)
|
|
144
|
+
payload = json.loads(result.stdout)
|
|
145
|
+
self.assertEqual(payload["status"], "ok")
|
|
146
|
+
self.assertEqual(payload["summary"]["mcp_catalog"]["tools"], 17)
|
|
147
|
+
|
|
148
|
+
def test_mcp_doctor_exits_nonzero_on_required_failure(self):
|
|
149
|
+
doctor_payload = {
|
|
150
|
+
"status": "failed",
|
|
151
|
+
"checks": [
|
|
152
|
+
{"name": "saved_login", "ok": False, "required": True, "message": "Run login first."},
|
|
153
|
+
],
|
|
154
|
+
"summary": {},
|
|
155
|
+
"next_steps": ["Run login first."],
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
with mock.patch("ventilatepro.mcp_cli.run_doctor", return_value=doctor_payload):
|
|
159
|
+
result = self.runner.invoke(app, ["mcp", "doctor"])
|
|
160
|
+
|
|
161
|
+
self.assertEqual(result.exit_code, 1)
|
|
162
|
+
self.assertIn("FAIL saved_login", result.output)
|
|
163
|
+
|
|
104
164
|
def test_notes_create_queues_on_network_error(self):
|
|
105
165
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
106
166
|
os.environ[config.CONFIG_ENV_VAR] = tmpdir
|
|
@@ -326,6 +386,65 @@ class CliTests(unittest.TestCase):
|
|
|
326
386
|
self.assertIn("Hospital South Wing", result.stdout)
|
|
327
387
|
fake_client.project_show.assert_called_once_with(7)
|
|
328
388
|
|
|
389
|
+
def test_projects_create_reads_json_payload_from_inline_data(self):
|
|
390
|
+
fake_client = mock.Mock()
|
|
391
|
+
fake_client.project_create.return_value = {
|
|
392
|
+
"operation": "created",
|
|
393
|
+
"project": {"id": 9, "name": "CLI Write Project"},
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
397
|
+
result = self.runner.invoke(
|
|
398
|
+
app,
|
|
399
|
+
["projects", "create", "--data", '{"name":"CLI Write Project","time_zone":"America/Los_Angeles"}', "--json"],
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
self.assertEqual(result.exit_code, 0)
|
|
403
|
+
fake_client.project_create.assert_called_once_with(
|
|
404
|
+
{"name": "CLI Write Project", "time_zone": "America/Los_Angeles"}
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
def test_notes_update_passes_patch_payload_to_api_client(self):
|
|
408
|
+
fake_client = mock.Mock()
|
|
409
|
+
fake_client.update_note.return_value = {
|
|
410
|
+
"operation": "updated",
|
|
411
|
+
"note": {"note_ref": "general-info:1"},
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
415
|
+
result = self.runner.invoke(
|
|
416
|
+
app,
|
|
417
|
+
[
|
|
418
|
+
"notes",
|
|
419
|
+
"update",
|
|
420
|
+
"general-info:1",
|
|
421
|
+
"--project",
|
|
422
|
+
"1",
|
|
423
|
+
"--title",
|
|
424
|
+
"Updated note",
|
|
425
|
+
"--tags",
|
|
426
|
+
"field,urgent",
|
|
427
|
+
"--json",
|
|
428
|
+
],
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
self.assertEqual(result.exit_code, 0)
|
|
432
|
+
fake_client.update_note.assert_called_once_with(
|
|
433
|
+
"general-info:1",
|
|
434
|
+
project=1,
|
|
435
|
+
payload={"title": "Updated note", "tags": ["field", "urgent"]},
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def test_rooms_delete_requires_yes_confirmation(self):
|
|
439
|
+
fake_client = mock.Mock()
|
|
440
|
+
|
|
441
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
442
|
+
result = self.runner.invoke(app, ["rooms", "delete", "12"])
|
|
443
|
+
|
|
444
|
+
self.assertNotEqual(result.exit_code, 0)
|
|
445
|
+
self.assertIn("requires --yes", result.output)
|
|
446
|
+
fake_client.delete_room.assert_not_called()
|
|
447
|
+
|
|
329
448
|
def test_rooms_list_passes_filters_to_api_client(self):
|
|
330
449
|
fake_client = mock.Mock()
|
|
331
450
|
fake_client.list_rooms.return_value = {
|
|
@@ -357,6 +476,65 @@ class CliTests(unittest.TestCase):
|
|
|
357
476
|
self.assertEqual(result.exit_code, 0)
|
|
358
477
|
fake_client.list_rooms.assert_called_once_with(1, limit=5, offset=10, query="operating")
|
|
359
478
|
|
|
479
|
+
def test_terminal_units_create_includes_zone_in_payload(self):
|
|
480
|
+
fake_client = mock.Mock()
|
|
481
|
+
fake_client.create_terminal_unit.return_value = {
|
|
482
|
+
"operation": "created",
|
|
483
|
+
"terminal_unit": {"id": 3, "tag": "TU-CLI"},
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
487
|
+
result = self.runner.invoke(
|
|
488
|
+
app,
|
|
489
|
+
[
|
|
490
|
+
"terminal-units",
|
|
491
|
+
"create",
|
|
492
|
+
"--zone",
|
|
493
|
+
"7",
|
|
494
|
+
"--data",
|
|
495
|
+
'{"tag":"TU-CLI","unit_type":"FCU"}',
|
|
496
|
+
"--json",
|
|
497
|
+
],
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
self.assertEqual(result.exit_code, 0)
|
|
501
|
+
fake_client.create_terminal_unit.assert_called_once_with(
|
|
502
|
+
{"zone": 7, "tag": "TU-CLI", "unit_type": "FCU"}
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
def test_zones_bulk_assign_ahu_accepts_assignment_list(self):
|
|
506
|
+
fake_client = mock.Mock()
|
|
507
|
+
fake_client.assign_zones_to_ahu.return_value = {
|
|
508
|
+
"operation": "assigned",
|
|
509
|
+
"updated": 2,
|
|
510
|
+
"errors": 0,
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
514
|
+
result = self.runner.invoke(
|
|
515
|
+
app,
|
|
516
|
+
[
|
|
517
|
+
"zones",
|
|
518
|
+
"bulk-assign-ahu",
|
|
519
|
+
"--project",
|
|
520
|
+
"1",
|
|
521
|
+
"--data",
|
|
522
|
+
'[{"zone_id":1,"ahu_id":2},{"zone_id":3,"ahu_id":2}]',
|
|
523
|
+
"--json",
|
|
524
|
+
],
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
self.assertEqual(result.exit_code, 0)
|
|
528
|
+
fake_client.assign_zones_to_ahu.assert_called_once_with(
|
|
529
|
+
{
|
|
530
|
+
"project": 1,
|
|
531
|
+
"assignments": [
|
|
532
|
+
{"zone_id": 1, "ahu_id": 2},
|
|
533
|
+
{"zone_id": 3, "ahu_id": 2},
|
|
534
|
+
],
|
|
535
|
+
}
|
|
536
|
+
)
|
|
537
|
+
|
|
360
538
|
def test_calc_recalculate_stale_passes_mode_scope_and_force(self):
|
|
361
539
|
fake_client = mock.Mock()
|
|
362
540
|
fake_client.calc_recalculate_stale.return_value = {
|
|
@@ -390,14 +568,15 @@ class CliTests(unittest.TestCase):
|
|
|
390
568
|
"state": "QUEUED",
|
|
391
569
|
}
|
|
392
570
|
|
|
393
|
-
with tempfile.
|
|
394
|
-
|
|
395
|
-
|
|
571
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
572
|
+
payload_path = os.path.join(tmpdir, "design-day-request.json")
|
|
573
|
+
with open(payload_path, "w", encoding="utf-8") as handle:
|
|
574
|
+
json.dump({"zones": [{"zone_id": 1}]}, handle)
|
|
396
575
|
|
|
397
576
|
with mock.patch.object(auth, "get_authenticated_client", return_value=(fake_client, "http://localhost:8000")):
|
|
398
577
|
result = self.runner.invoke(
|
|
399
578
|
app,
|
|
400
|
-
["design-day", "run", "--project", "1", "--input",
|
|
579
|
+
["design-day", "run", "--project", "1", "--input", payload_path, "--json"],
|
|
401
580
|
)
|
|
402
581
|
|
|
403
582
|
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()
|