command-gate 0.2.4__py3-none-any.whl

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 (63) hide show
  1. cgate/__init__.py +26 -0
  2. cgate/__main__.py +8 -0
  3. cgate/_version.py +24 -0
  4. cgate/cli/__init__.py +1 -0
  5. cgate/cli/_console.py +17 -0
  6. cgate/cli/connections.py +212 -0
  7. cgate/cli/history.py +191 -0
  8. cgate/cli/install.py +182 -0
  9. cgate/cli/main.py +115 -0
  10. cgate/cli/mcp.py +197 -0
  11. cgate/cli/uninstall.py +403 -0
  12. cgate/cli/update.py +538 -0
  13. cgate/cli/watch.py +20 -0
  14. cgate/connections/__init__.py +1 -0
  15. cgate/connections/auth.py +93 -0
  16. cgate/connections/detect.py +78 -0
  17. cgate/connections/store.py +88 -0
  18. cgate/core/__init__.py +1 -0
  19. cgate/core/path_env.py +218 -0
  20. cgate/core/paths.py +35 -0
  21. cgate/core/update_log.py +36 -0
  22. cgate/db/__init__.py +1 -0
  23. cgate/db/batches.py +111 -0
  24. cgate/db/commands.py +191 -0
  25. cgate/db/connection.py +104 -0
  26. cgate/db/mode.py +74 -0
  27. cgate/db/rows.py +99 -0
  28. cgate/db/schema.py +54 -0
  29. cgate/db/server_settings.py +105 -0
  30. cgate/db/types.py +77 -0
  31. cgate/executor/__init__.py +7 -0
  32. cgate/executor/base.py +71 -0
  33. cgate/executor/selector.py +61 -0
  34. cgate/executor/ssh.py +157 -0
  35. cgate/executor/winrm.py +129 -0
  36. cgate/helper/__init__.py +10 -0
  37. cgate/helper/__main__.py +112 -0
  38. cgate/helper/waiter.py +123 -0
  39. cgate/mcp_installer.py +161 -0
  40. cgate/mcp_server/__init__.py +6 -0
  41. cgate/mcp_server/__main__.py +6 -0
  42. cgate/mcp_server/auto_resolution.py +80 -0
  43. cgate/mcp_server/server.py +271 -0
  44. cgate/mcp_server/tools.py +351 -0
  45. cgate/risk.py +129 -0
  46. cgate/update.py +713 -0
  47. cgate/watch/__init__.py +7 -0
  48. cgate/watch/app.py +560 -0
  49. cgate/watch/approval.py +237 -0
  50. cgate/watch/command_detail_modal.py +68 -0
  51. cgate/watch/history_modal.py +242 -0
  52. cgate/watch/mode_modal.py +110 -0
  53. cgate/watch/queue.py +106 -0
  54. cgate/watch/render.py +156 -0
  55. cgate/watch/server_settings_modal.py +179 -0
  56. cgate/watch/session.py +40 -0
  57. cgate/watch/theme.py +32 -0
  58. cgate/watch/widgets.py +35 -0
  59. command_gate-0.2.4.dist-info/METADATA +204 -0
  60. command_gate-0.2.4.dist-info/RECORD +63 -0
  61. command_gate-0.2.4.dist-info/WHEEL +4 -0
  62. command_gate-0.2.4.dist-info/entry_points.txt +2 -0
  63. command_gate-0.2.4.dist-info/licenses/LICENSE +21 -0
cgate/helper/waiter.py ADDED
@@ -0,0 +1,123 @@
1
+ """Pure, mockable primitives used by ``cgate.helper.__main__``.
2
+
3
+ Kept separate from the argparse entry point so tests can exercise the
4
+ actual wait/retry/safety logic without going through subprocess spawning.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ctypes
10
+ import sys
11
+ import time
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from pathlib import Path
16
+
17
+ # WaitForSingleObject return codes (winbase.h).
18
+ _WAIT_OBJECT_0 = 0x00000000
19
+ _WAIT_TIMEOUT = 0x00000102
20
+ # Rights needed only to wait on the handle, not to inspect/control the process.
21
+ _SYNCHRONIZE = 0x00100000
22
+
23
+
24
+ def _pid_alive(pid: int) -> bool:
25
+ """Return whether a process with this PID is still running.
26
+
27
+ Windows-only: uses ``OpenProcess`` + ``WaitForSingleObject`` (a zero
28
+ timeout, so this never blocks) instead of shelling out to
29
+ ``tasklist``/WMI, since this runs in a tight poll loop. A process
30
+ handle becomes signaled the instant the process terminates, so a
31
+ ``WAIT_TIMEOUT`` result means "still running"; ``OpenProcess`` failing
32
+ outright means the PID has already exited (or never existed).
33
+ """
34
+ if sys.platform != "win32":
35
+ return False
36
+ kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
37
+ handle = kernel32.OpenProcess(_SYNCHRONIZE, False, pid) # noqa: FBT003 -- Win32 ABI positional arg
38
+ if not handle:
39
+ return False
40
+ try:
41
+ return kernel32.WaitForSingleObject(handle, 0) == _WAIT_TIMEOUT
42
+ finally:
43
+ kernel32.CloseHandle(handle)
44
+
45
+
46
+ def wait_for_pids(pids: list[int], *, timeout: float = 30.0, poll_interval: float = 0.25) -> bool:
47
+ """Block until every PID in ``pids`` has exited, or ``timeout`` elapses.
48
+
49
+ Returns True if all exited within the timeout, False otherwise. This
50
+ is a best-effort wait, not a hard precondition -- the caller proceeds
51
+ with the file operation (and its own retry loop) either way, so a
52
+ timeout here just means "start the retries a bit early."
53
+ """
54
+ deadline = time.monotonic() + timeout
55
+ remaining = set(pids)
56
+ while remaining and time.monotonic() < deadline:
57
+ remaining = {pid for pid in remaining if _pid_alive(pid)}
58
+ if remaining:
59
+ time.sleep(poll_interval)
60
+ return not remaining
61
+
62
+
63
+ def retry_replace(
64
+ source: Path, target: Path, *, total_seconds: float = 10.0, interval: float = 1.0
65
+ ) -> str | None:
66
+ """Retry moving ``source`` onto ``target`` for up to ``total_seconds``.
67
+
68
+ Mirrors ``cgate.update.replace_binary``'s return contract: None on
69
+ success, an error description string on failure. Retries absorb a
70
+ lingering AV scan or a slow-to-release OS handle after the process
71
+ being replaced has already exited.
72
+ """
73
+ deadline = time.monotonic() + total_seconds
74
+ last_error = "no attempts made"
75
+ while True:
76
+ try:
77
+ source.replace(target)
78
+ except OSError as exc:
79
+ last_error = f"{type(exc).__name__}: {exc}"
80
+ if time.monotonic() >= deadline:
81
+ return last_error
82
+ time.sleep(interval)
83
+ else:
84
+ return None
85
+
86
+
87
+ def retry_delete(target: Path, *, total_seconds: float = 10.0, interval: float = 1.0) -> str | None:
88
+ """Retry deleting ``target`` for up to ``total_seconds``. Same contract as ``retry_replace``."""
89
+ deadline = time.monotonic() + total_seconds
90
+ last_error = "no attempts made"
91
+ while True:
92
+ try:
93
+ target.unlink()
94
+ except OSError as exc:
95
+ last_error = f"{type(exc).__name__}: {exc}"
96
+ if time.monotonic() >= deadline:
97
+ return last_error
98
+ time.sleep(interval)
99
+ else:
100
+ return None
101
+
102
+
103
+ TARGET_NAMES: frozenset[str] = frozenset({"cgate.exe", "cgate-helper.exe"})
104
+ SOURCE_NAMES: frozenset[str] = frozenset({"cgate.exe.new", "cgate-helper.exe.new"})
105
+
106
+
107
+ def is_safe_target(
108
+ path: Path, *, helper_dir: Path, allowed_names: frozenset[str] = TARGET_NAMES
109
+ ) -> bool:
110
+ """Refuse to touch anything outside the helper's own install directory.
111
+
112
+ Not a security boundary -- a caller that controls the helper's
113
+ location also controls this argument. It's a sanity rail against
114
+ bugs: a wrong path slipping through should fail loudly instead of
115
+ silently deleting or overwriting something unexpected. ``allowed_names``
116
+ differs for a ``replace``'s source (the staged ``*.new`` download) vs.
117
+ either operation's target (the live ``cgate.exe``/``cgate-helper.exe``).
118
+ """
119
+ try:
120
+ same_dir = path.resolve().parent == helper_dir.resolve()
121
+ except OSError:
122
+ return False
123
+ return same_dir and path.name.lower() in allowed_names
cgate/mcp_installer.py ADDED
@@ -0,0 +1,161 @@
1
+ """Detect IA clients and register cgate's MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Final, TypeAlias
12
+
13
+ JsonScalar: TypeAlias = str | int | float | bool | None
14
+ JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
15
+ JsonObject: TypeAlias = dict[str, JsonValue]
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class ClientSpec:
20
+ """Everything cgate needs to know about one supported IA client.
21
+
22
+ Kept as a single record per client (issue #18) so adding a client is
23
+ one atomic edit to ``CLIENTS`` instead of three parallel dicts that
24
+ can silently drift out of sync and surface as a ``KeyError`` far from
25
+ the point where a client was actually added.
26
+ """
27
+
28
+ label: str
29
+ # Multiple paths because some clients (notably Claude Code) store MCP
30
+ # config in more than one location, ordered by precedence: when a
31
+ # client is detected, the FIRST existing path is used as the primary
32
+ # config_path for register/unregister/is_registered. Paths are home-
33
+ # relative and joined with Path.home() at detection time.
34
+ config_paths: tuple[str, ...]
35
+ config_key: str
36
+
37
+
38
+ CLIENTS: Final[dict[str, ClientSpec]] = {
39
+ "claude": ClientSpec(
40
+ label="Claude Code",
41
+ # Claude Code does NOT use ``~/.claude/mcp.json`` -- it stores MCP
42
+ # servers in ``~/.claude.json`` (legacy global) or
43
+ # ``~/.claude/settings.json`` (project-level global). The first
44
+ # existing one wins. See issue #2.
45
+ config_paths=(".claude.json", ".claude/settings.json"),
46
+ config_key="mcpServers",
47
+ ),
48
+ "opencode": ClientSpec(
49
+ label="opencode",
50
+ config_paths=(".config/opencode/opencode.jsonc",),
51
+ config_key="mcp",
52
+ ),
53
+ "cursor": ClientSpec(
54
+ label="Cursor",
55
+ config_paths=(".cursor/mcp.json",),
56
+ config_key="mcpServers",
57
+ ),
58
+ }
59
+ CONFIG_SERVER_ENTRY_KEY: Final = "cgate"
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class ClientInstall:
64
+ """One installed IA client and its config file path."""
65
+
66
+ name: str
67
+ label: str
68
+ config_path: Path
69
+
70
+
71
+ def detect_clients() -> list[ClientInstall]:
72
+ """Return IA clients whose config file exists under the user's home.
73
+
74
+ A client is considered "installed" if ANY of the paths listed for it
75
+ in ``CLIENTS`` exists. When several paths exist (Claude Code can have
76
+ both ``~/.claude.json`` and ``~/.claude/settings.json``), the FIRST
77
+ existing path is returned so register/unregister write back to the
78
+ same file that was detected.
79
+ """
80
+ home = Path.home()
81
+ found: list[ClientInstall] = []
82
+ for name, spec in CLIENTS.items():
83
+ for relative in spec.config_paths:
84
+ candidate = home / relative
85
+ if candidate.exists():
86
+ found.append(ClientInstall(name=name, label=spec.label, config_path=candidate))
87
+ break
88
+ return found
89
+
90
+
91
+ def read_json(path: Path) -> JsonObject:
92
+ """Read a JSON config, returning an empty config when missing or invalid."""
93
+ try:
94
+ with path.open(encoding="utf-8") as stream:
95
+ data: JsonObject = json.load(stream)
96
+ except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
97
+ return {}
98
+ return data
99
+
100
+
101
+ def write_json_atomic(path: Path, data: JsonObject) -> None:
102
+ """Back up a config, then durably write and atomically replace it."""
103
+ path.parent.mkdir(parents=True, exist_ok=True)
104
+ temporary = path.with_suffix(path.suffix + ".tmp")
105
+ try:
106
+ if path.exists():
107
+ _ = shutil.copyfile(path, path.with_suffix(path.suffix + ".bak"))
108
+ with temporary.open("w", encoding="utf-8") as stream:
109
+ json.dump(data, stream, indent=2)
110
+ _ = stream.write("\n")
111
+ stream.flush()
112
+ _ = os.fsync(stream.fileno())
113
+ _ = temporary.replace(path)
114
+ except OSError:
115
+ temporary.unlink(missing_ok=True)
116
+ raise
117
+
118
+
119
+ def current_binary_command() -> tuple[str, list[str]]:
120
+ """Return the command and arguments clients use to launch cgate's MCP server."""
121
+ executable = Path(sys.executable).resolve()
122
+ if executable.name.startswith("cgate"):
123
+ return str(executable), ["mcp", "serve"]
124
+ return sys.executable, ["-m", "cgate.mcp_server"]
125
+
126
+
127
+ def register(client: ClientInstall, command: str, args: list[str]) -> None:
128
+ """Add or update the client's cgate entry idempotently."""
129
+ config = read_json(client.config_path)
130
+ config_key = CLIENTS[client.name].config_key
131
+ bucket_value = config.get(config_key)
132
+ bucket: JsonObject = bucket_value if isinstance(bucket_value, dict) else {}
133
+ entry_args: list[JsonValue] = [*args]
134
+ if client.name == "opencode":
135
+ entry: JsonObject = {
136
+ "type": "local",
137
+ "command": [command, *entry_args],
138
+ }
139
+ else:
140
+ entry = {"command": command, "args": entry_args}
141
+ bucket[CONFIG_SERVER_ENTRY_KEY] = entry
142
+ config[config_key] = bucket
143
+ write_json_atomic(client.config_path, config)
144
+
145
+
146
+ def unregister(client: ClientInstall) -> bool:
147
+ """Remove cgate from a client's config and report whether it was present."""
148
+ config = read_json(client.config_path)
149
+ config_key = CLIENTS[client.name].config_key
150
+ bucket_value = config.get(config_key)
151
+ if not isinstance(bucket_value, dict) or CONFIG_SERVER_ENTRY_KEY not in bucket_value:
152
+ return False
153
+ del bucket_value[CONFIG_SERVER_ENTRY_KEY]
154
+ write_json_atomic(client.config_path, config)
155
+ return True
156
+
157
+
158
+ def is_registered(client: ClientInstall) -> bool:
159
+ """Return whether the client's config contains a cgate MCP entry."""
160
+ bucket = read_json(client.config_path).get(CLIENTS[client.name].config_key)
161
+ return isinstance(bucket, dict) and CONFIG_SERVER_ENTRY_KEY in bucket
@@ -0,0 +1,6 @@
1
+ """MCP server exposing three tools to AI agents (spec §The MCP server)."""
2
+ from __future__ import annotations
3
+
4
+ from cgate.mcp_server.server import build_server
5
+
6
+ __all__ = ["build_server"]
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m cgate.mcp_server`` to run the stdio server."""
2
+
3
+ from cgate.mcp_server.server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,80 @@
1
+ """Resolve whether a proposed command queues for approval or executes immediately."""
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING, Literal, TypedDict
5
+
6
+ from cgate.db.mode import AppModeNotSetError, Mode
7
+
8
+ if TYPE_CHECKING:
9
+ from cgate.db.mode import AppModeRepo
10
+ from cgate.db.server_settings import ServerSettingsRepo
11
+
12
+
13
+ class BehaviorDecision(TypedDict):
14
+ """The resolved behavior for one proposed command, with audit context."""
15
+
16
+ action: Literal["queue", "execute"]
17
+ mode: str
18
+ server_auto_allowed: bool
19
+ reason: Literal["global_propose", "server_not_opted_in", "both_allowed", "risky_command"]
20
+ risk_label: str | None
21
+
22
+
23
+ def resolve_auto_behavior(
24
+ *,
25
+ mode_repo: AppModeRepo,
26
+ settings_repo: ServerSettingsRepo,
27
+ server_alias: str,
28
+ risk_label: str | None,
29
+ ) -> BehaviorDecision:
30
+ """Execute only when the global mode is AUTO and the server has opted in.
31
+
32
+ An unset global mode (AppModeNotSetError) behaves as PROPOSE, and an
33
+ alias without an explicit server_settings row behaves as not opted in:
34
+ auto-execution requires both sides to be explicit.
35
+
36
+ ``risk_label`` (from ``cgate.risk.find_risk``, computed by the caller)
37
+ overrides ``both_allowed`` -- a command matching a known high-blast-
38
+ radius pattern always queues for a human, even with AUTO mode on and
39
+ the server opted in. It's carried through regardless of the decision
40
+ reason so the caller (and the human reviewing the queue) always knows
41
+ whether this command was flagged, not just when it changed the outcome.
42
+ """
43
+ try:
44
+ mode = mode_repo.get().mode
45
+ except AppModeNotSetError:
46
+ mode = Mode.PROPOSE
47
+ auto_allowed = settings_repo.get_or_default(server_alias).auto_allowed
48
+ match mode:
49
+ case Mode.PROPOSE:
50
+ return {
51
+ "action": "queue",
52
+ "mode": mode.value,
53
+ "server_auto_allowed": auto_allowed,
54
+ "reason": "risky_command" if risk_label is not None else "global_propose",
55
+ "risk_label": risk_label,
56
+ }
57
+ case Mode.AUTO:
58
+ if not auto_allowed:
59
+ return {
60
+ "action": "queue",
61
+ "mode": mode.value,
62
+ "server_auto_allowed": auto_allowed,
63
+ "reason": "server_not_opted_in",
64
+ "risk_label": risk_label,
65
+ }
66
+ if risk_label is not None:
67
+ return {
68
+ "action": "queue",
69
+ "mode": mode.value,
70
+ "server_auto_allowed": auto_allowed,
71
+ "reason": "risky_command",
72
+ "risk_label": risk_label,
73
+ }
74
+ return {
75
+ "action": "execute",
76
+ "mode": mode.value,
77
+ "server_auto_allowed": auto_allowed,
78
+ "reason": "both_allowed",
79
+ "risk_label": None,
80
+ }
@@ -0,0 +1,271 @@
1
+ """MCP server factory exposing command-gate tools over the stdio transport."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from contextlib import asynccontextmanager
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, TypeAlias
8
+
9
+ import anyio
10
+ import mcp.server.stdio
11
+ from mcp import types
12
+ from mcp.server import Server, ServerRequestContext
13
+
14
+ from cgate import __version__
15
+ from cgate.connections.store import ConnectionsRepo
16
+ from cgate.core.paths import data_dir
17
+ from cgate.db.batches import BatchesRepo
18
+ from cgate.db.commands import CommandsRepo
19
+ from cgate.db.connection import Database, init_database
20
+ from cgate.db.mode import AppModeRepo
21
+ from cgate.db.server_settings import ServerSettingsRepo
22
+ from cgate.mcp_server.tools import (
23
+ BatchStatusResult,
24
+ ConnectionResult,
25
+ ModeResult,
26
+ ProposeCommandResult,
27
+ ToolError,
28
+ check_status,
29
+ get_mode,
30
+ list_connections,
31
+ propose_command,
32
+ )
33
+ from cgate.update import maybe_heal_pending_update
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import AsyncGenerator
37
+
38
+ ToolPayload: TypeAlias = (
39
+ ProposeCommandResult
40
+ | BatchStatusResult
41
+ | ModeResult
42
+ | list[ConnectionResult]
43
+ | dict[str, str]
44
+ )
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class _ToolDeps:
49
+ """Repositories opened independently for one MCP tool invocation."""
50
+
51
+ batches: BatchesRepo
52
+ commands: CommandsRepo
53
+ connections: ConnectionsRepo
54
+ mode: AppModeRepo
55
+ settings: ServerSettingsRepo
56
+
57
+ @classmethod
58
+ def from_db(cls, db: Database) -> _ToolDeps:
59
+ """Create the repository set backed by one database path."""
60
+ return cls(
61
+ batches=BatchesRepo(db),
62
+ commands=CommandsRepo(db),
63
+ connections=ConnectionsRepo(db),
64
+ mode=AppModeRepo(db),
65
+ settings=ServerSettingsRepo(db),
66
+ )
67
+
68
+
69
+ def _content(payload: ToolPayload) -> list[types.ContentBlock]:
70
+ return [types.TextContent(type="text", text=json.dumps(payload, indent=2))]
71
+
72
+
73
+ def _result(payload: ToolPayload, *, is_error: bool = False) -> types.CallToolResult:
74
+ return types.CallToolResult(content=_content(payload), is_error=is_error)
75
+
76
+
77
+ def _tools() -> list[types.Tool]:
78
+ return [
79
+ types.Tool(
80
+ name="propose_command",
81
+ description=(
82
+ "Register a command for execution. Normally it queues for human "
83
+ "approval in cgate watch, but if cgate's global mode is AUTO AND "
84
+ "the target server has opted into auto-execution, it runs immediately "
85
+ "and the response includes the result. The response always carries "
86
+ "`mode`, `server_auto_allowed`, and `effective_reason` so you can tell "
87
+ "which path it took. A command matching a known high-blast-radius "
88
+ "pattern (e.g. a recursive force-delete, a raw disk write, deleting "
89
+ "shadow copies/backups) always queues for a human regardless of AUTO "
90
+ "mode -- `effective_reason` comes back as `risky_command` and "
91
+ "`risk_label` names what was matched; this is a heuristic, not a "
92
+ "guarantee, so don't rely on its absence to mean a command is safe. "
93
+ "batch_title is required; batch_description is optional. If batch_id "
94
+ "is omitted, a new batch is created."
95
+ ),
96
+ input_schema={
97
+ "type": "object",
98
+ "properties": {
99
+ "server_alias": {
100
+ "type": "string",
101
+ "description": "Alias of a saved connection from list_connections.",
102
+ },
103
+ "command": {
104
+ "type": "string",
105
+ "description": "Exact shell or PowerShell command to register.",
106
+ },
107
+ "batch_id": {
108
+ "type": "string",
109
+ "description": "Optional existing batch ID to append to.",
110
+ },
111
+ "batch_title": {
112
+ "type": "string",
113
+ "description": "Required one-line purpose of the batch.",
114
+ },
115
+ "batch_description": {
116
+ "type": "string",
117
+ "description": "Optional one-line explanation of why the batch is needed.",
118
+ },
119
+ "reason": {
120
+ "type": "string",
121
+ "description": (
122
+ "Optional justification for why this command is needed. "
123
+ "Stored and shown to the human reviewer in cgate watch."
124
+ ),
125
+ },
126
+ },
127
+ "required": ["server_alias", "command", "batch_title"],
128
+ },
129
+ ),
130
+ types.Tool(
131
+ name="list_connections",
132
+ description=(
133
+ "List saved server connections and their server_type so an agent can "
134
+ "select a valid alias and command dialect. Each entry also carries "
135
+ "`auto_allowed`, the per-server auto-execution opt-in flag."
136
+ ),
137
+ input_schema={
138
+ "type": "object",
139
+ "properties": {},
140
+ "additionalProperties": False,
141
+ },
142
+ ),
143
+ types.Tool(
144
+ name="get_mode",
145
+ description=(
146
+ "Report the current global execution mode and which servers are "
147
+ "opted in for auto-execution. Read-only; safe to call any time to "
148
+ "learn what `propose_command` would do before invoking it."
149
+ ),
150
+ input_schema={
151
+ "type": "object",
152
+ "properties": {},
153
+ "additionalProperties": False,
154
+ },
155
+ ),
156
+ types.Tool(
157
+ name="check_status",
158
+ description=(
159
+ "Return every command in a batch with status, result, and audit fields. "
160
+ "States are pending, approved, rejected, executed, and failed."
161
+ ),
162
+ input_schema={
163
+ "type": "object",
164
+ "properties": {
165
+ "batch_id": {
166
+ "type": "string",
167
+ "description": "ID of the batch to inspect.",
168
+ }
169
+ },
170
+ "required": ["batch_id"],
171
+ },
172
+ ),
173
+ ]
174
+
175
+
176
+ @asynccontextmanager
177
+ async def _lifespan(_server: Server[None]) -> AsyncGenerator[None]:
178
+ yield None
179
+
180
+
181
+ def build_server() -> Server[None]:
182
+ """Build a stateless MCP server configured with command-gate's four tools."""
183
+
184
+ async def on_list_tools(
185
+ _context: ServerRequestContext[None, types.PaginatedRequestParams],
186
+ _params: types.PaginatedRequestParams | None,
187
+ ) -> types.ListToolsResult:
188
+ return types.ListToolsResult(tools=_tools())
189
+
190
+ async def on_call_tool(
191
+ _context: ServerRequestContext[None, types.CallToolRequestParams],
192
+ params: types.CallToolRequestParams,
193
+ ) -> types.CallToolResult:
194
+ db = Database(path=data_dir() / "cgate.db")
195
+ init_database(db)
196
+ deps = _ToolDeps.from_db(db)
197
+ arguments = params.arguments or {}
198
+ try:
199
+ match params.name:
200
+ case "propose_command":
201
+ payload = propose_command(
202
+ batches_repo=deps.batches,
203
+ commands_repo=deps.commands,
204
+ connections_repo=deps.connections,
205
+ mode_repo=deps.mode,
206
+ settings_repo=deps.settings,
207
+ server_alias=arguments["server_alias"],
208
+ command=arguments["command"],
209
+ batch_title=arguments.get("batch_title"),
210
+ batch_description=arguments.get("batch_description"),
211
+ batch_id=arguments.get("batch_id"),
212
+ reason=arguments.get("reason"),
213
+ )
214
+ case "list_connections":
215
+ payload = list_connections(
216
+ connections_repo=deps.connections,
217
+ settings_repo=deps.settings,
218
+ )
219
+ case "get_mode":
220
+ payload = get_mode(mode_repo=deps.mode, settings_repo=deps.settings)
221
+ case "check_status":
222
+ payload = check_status(
223
+ batches_repo=deps.batches,
224
+ commands_repo=deps.commands,
225
+ batch_id=arguments["batch_id"],
226
+ )
227
+ case _:
228
+ return _result(
229
+ {"error": "unknown_tool", "message": f"unknown tool '{params.name}'"},
230
+ is_error=True,
231
+ )
232
+ except ToolError as exc:
233
+ return _result({"error": exc.code, "message": exc.message}, is_error=True)
234
+ except KeyError as exc:
235
+ return _result(
236
+ {
237
+ "error": "missing_argument",
238
+ "message": f"missing required argument: {exc.args[0]}",
239
+ },
240
+ is_error=True,
241
+ )
242
+ return _result(payload)
243
+
244
+ return Server(
245
+ "command-gate",
246
+ version=__version__,
247
+ on_list_tools=on_list_tools,
248
+ on_call_tool=on_call_tool,
249
+ lifespan=_lifespan,
250
+ )
251
+
252
+
253
+ async def _serve_stdio() -> None:
254
+ server = build_server()
255
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
256
+ await server.run(
257
+ read_stream,
258
+ write_stream,
259
+ server.create_initialization_options(),
260
+ )
261
+
262
+
263
+ def main() -> None:
264
+ """Run the command-gate MCP server over standard input and output.
265
+
266
+ Spawns the helper to self-heal any staged update before we enter the
267
+ long-running stdio loop; the helper waits on our PID and does the
268
+ swap the instant the IA client that owns us closes the connection.
269
+ """
270
+ maybe_heal_pending_update()
271
+ anyio.run(_serve_stdio)