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.
Files changed (32) hide show
  1. {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/PKG-INFO +55 -2
  2. ventilatepro_cli-0.3.0/README.md +99 -0
  3. {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/pyproject.toml +3 -1
  4. {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/tests/test_cli.py +270 -0
  5. ventilatepro_cli-0.3.0/tests/test_mcp_server.py +73 -0
  6. {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro/__init__.py +1 -1
  7. ventilatepro_cli-0.3.0/ventilatepro/api_client.py +422 -0
  8. ventilatepro_cli-0.3.0/ventilatepro/calc.py +142 -0
  9. {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro/cli.py +80 -35
  10. ventilatepro_cli-0.3.0/ventilatepro/design_day.py +95 -0
  11. ventilatepro_cli-0.3.0/ventilatepro/entities.py +538 -0
  12. ventilatepro_cli-0.3.0/ventilatepro/mcp_cli.py +192 -0
  13. ventilatepro_cli-0.3.0/ventilatepro/mcp_server.py +558 -0
  14. ventilatepro_cli-0.3.0/ventilatepro/output.py +31 -0
  15. ventilatepro_cli-0.3.0/ventilatepro/payloads.py +43 -0
  16. ventilatepro_cli-0.3.0/ventilatepro/projects.py +120 -0
  17. {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro/queue.py +8 -1
  18. ventilatepro_cli-0.3.0/ventilatepro/systems.py +76 -0
  19. {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/PKG-INFO +55 -2
  20. {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/SOURCES.txt +10 -0
  21. {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/entry_points.txt +1 -0
  22. {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/requires.txt +1 -0
  23. ventilatepro_cli-0.1.1/README.md +0 -47
  24. ventilatepro_cli-0.1.1/ventilatepro/api_client.py +0 -122
  25. {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/setup.cfg +0 -0
  26. {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/tests/test_config.py +0 -0
  27. {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro/__main__.py +0 -0
  28. {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro/auth.py +0 -0
  29. {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro/config.py +0 -0
  30. {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro/notes.py +0 -0
  31. {ventilatepro_cli-0.1.1 → ventilatepro_cli-0.3.0}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
  32. {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.1.1
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.9
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"
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()
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.1.1"
5
+ __version__ = "0.3.0"