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.
- cgate/__init__.py +26 -0
- cgate/__main__.py +8 -0
- cgate/_version.py +24 -0
- cgate/cli/__init__.py +1 -0
- cgate/cli/_console.py +17 -0
- cgate/cli/connections.py +212 -0
- cgate/cli/history.py +191 -0
- cgate/cli/install.py +182 -0
- cgate/cli/main.py +115 -0
- cgate/cli/mcp.py +197 -0
- cgate/cli/uninstall.py +403 -0
- cgate/cli/update.py +538 -0
- cgate/cli/watch.py +20 -0
- cgate/connections/__init__.py +1 -0
- cgate/connections/auth.py +93 -0
- cgate/connections/detect.py +78 -0
- cgate/connections/store.py +88 -0
- cgate/core/__init__.py +1 -0
- cgate/core/path_env.py +218 -0
- cgate/core/paths.py +35 -0
- cgate/core/update_log.py +36 -0
- cgate/db/__init__.py +1 -0
- cgate/db/batches.py +111 -0
- cgate/db/commands.py +191 -0
- cgate/db/connection.py +104 -0
- cgate/db/mode.py +74 -0
- cgate/db/rows.py +99 -0
- cgate/db/schema.py +54 -0
- cgate/db/server_settings.py +105 -0
- cgate/db/types.py +77 -0
- cgate/executor/__init__.py +7 -0
- cgate/executor/base.py +71 -0
- cgate/executor/selector.py +61 -0
- cgate/executor/ssh.py +157 -0
- cgate/executor/winrm.py +129 -0
- cgate/helper/__init__.py +10 -0
- cgate/helper/__main__.py +112 -0
- cgate/helper/waiter.py +123 -0
- cgate/mcp_installer.py +161 -0
- cgate/mcp_server/__init__.py +6 -0
- cgate/mcp_server/__main__.py +6 -0
- cgate/mcp_server/auto_resolution.py +80 -0
- cgate/mcp_server/server.py +271 -0
- cgate/mcp_server/tools.py +351 -0
- cgate/risk.py +129 -0
- cgate/update.py +713 -0
- cgate/watch/__init__.py +7 -0
- cgate/watch/app.py +560 -0
- cgate/watch/approval.py +237 -0
- cgate/watch/command_detail_modal.py +68 -0
- cgate/watch/history_modal.py +242 -0
- cgate/watch/mode_modal.py +110 -0
- cgate/watch/queue.py +106 -0
- cgate/watch/render.py +156 -0
- cgate/watch/server_settings_modal.py +179 -0
- cgate/watch/session.py +40 -0
- cgate/watch/theme.py +32 -0
- cgate/watch/widgets.py +35 -0
- command_gate-0.2.4.dist-info/METADATA +204 -0
- command_gate-0.2.4.dist-info/RECORD +63 -0
- command_gate-0.2.4.dist-info/WHEEL +4 -0
- command_gate-0.2.4.dist-info/entry_points.txt +2 -0
- 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,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)
|