remote-server-mcp 0.2.0__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.
@@ -0,0 +1,12 @@
1
+ """
2
+ Remote Server MCP - Main entry point
3
+ """
4
+
5
+ from .server import cli, main, mcp
6
+
7
+ __version__ = "0.2.0"
8
+
9
+ __all__ = ["__version__", "cli", "main", "mcp"]
10
+
11
+ if __name__ == "__main__":
12
+ cli()
@@ -0,0 +1,241 @@
1
+ """
2
+ Operation catalog for the remote-server MCP.
3
+
4
+ Commands are built as argument vectors and rendered with :func:`shlex.join`, so
5
+ no user-controlled value is ever concatenated into a shell string. Operations
6
+ also declare their safety level, which is used for MCP tool annotations and
7
+ audit records.
8
+
9
+ The container CLI is configurable (``container_runtime``): Docker, Podman and
10
+ nerdctl share the same command surface for the operations below. The module
11
+ level constants remain the Docker catalog for backwards compatibility and for
12
+ the command-policy tests.
13
+ """
14
+
15
+ from dataclasses import dataclass
16
+
17
+ from server_management_lib import Operation
18
+
19
+ # ``docker ps``/``docker stats`` expand ``\\n`` and ``\\t`` inside their format
20
+ # strings, but ``docker inspect`` does not: it passes the text to the Go
21
+ # template verbatim, so a literal ``\\n`` ends up in the output and every
22
+ # field stays on one line. Emitting the newline from inside a template action
23
+ # (``{{"\\n"}}``) keeps the argv free of control characters (the command
24
+ # policy rejects those) and still produces real line breaks.
25
+ NEWLINE = '{{"\\n"}}'
26
+
27
+ INSPECT_FORMAT = (
28
+ f"Name: {{{{.Name}}}}{NEWLINE}"
29
+ f"State: {{{{.State.Status}}}}{NEWLINE}"
30
+ f"Running: {{{{.State.Running}}}}{NEWLINE}"
31
+ f"Health: {{{{if .State.Health}}}}{{{{.State.Health.Status}}}}{{{{else}}}}none"
32
+ f"{{{{end}}}}{NEWLINE}"
33
+ f"FailingStreak: {{{{if .State.Health}}}}{{{{.State.Health.FailingStreak}}}}"
34
+ f"{{{{else}}}}0{{{{end}}}}{NEWLINE}"
35
+ f"RestartCount: {{{{.RestartCount}}}}{NEWLINE}"
36
+ f"ExitCode: {{{{.State.ExitCode}}}}{NEWLINE}"
37
+ f"StartedAt: {{{{.State.StartedAt}}}}"
38
+ )
39
+
40
+ # ``docker stats`` *does* expand ``\\n`` before it parses the template, so it
41
+ # must keep the plain escape. Using the ``NEWLINE`` action here would turn the
42
+ # string literal into a real newline and fail with "unterminated quoted
43
+ # string".
44
+ STATS_FORMAT = "CPU: {{.CPUPerc}}\\nMemory: {{.MemUsage}}\\nNet I/O: {{.NetIO}}"
45
+ HEALTH_FORMAT = "{{json .State.Health}}"
46
+
47
+ # One line per container, including stopped and crashed ones. The compose
48
+ # project label is how a service directory resolves to its containers.
49
+ CONTAINER_LIST_FORMAT = (
50
+ '{{.Names}}\\t{{.Label "com.docker.compose.project"}}\\t{{.Status}}'
51
+ )
52
+
53
+
54
+ def container_logs_operation(
55
+ *, runtime: str = "docker", since: str | None = None, until: str | None = None
56
+ ) -> Operation:
57
+ """Build a ``<runtime> logs`` operation with optional time filters.
58
+
59
+ ``--timestamps`` is always enabled so log lines can be correlated with
60
+ host events. Time values are validated by the security layer before they
61
+ reach this function.
62
+ """
63
+ argv = [runtime, "logs", "--tail", "$lines", "--timestamps"]
64
+ if since:
65
+ argv.extend(("--since", "$since"))
66
+ if until:
67
+ argv.extend(("--until", "$until"))
68
+ argv.extend(("--", "$service"))
69
+ return Operation(tuple(argv))
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class ContainerCatalog:
74
+ """Every container operation for one runtime."""
75
+
76
+ runtime: str
77
+ ps: Operation
78
+ ps_all: Operation
79
+ ps_unhealthy: Operation
80
+ inspect: Operation
81
+ stats: Operation
82
+ ports: Operation
83
+ health: Operation
84
+ restart: Operation
85
+ start: Operation
86
+ stop: Operation
87
+ compose_ps: Operation
88
+ compose_pull: Operation
89
+ compose_up: Operation
90
+ compose_down: Operation
91
+
92
+ def logs(self, *, since: str | None = None, until: str | None = None) -> Operation:
93
+ return container_logs_operation(runtime=self.runtime, since=since, until=until)
94
+
95
+
96
+ def build_container_catalog(runtime: str = "docker") -> ContainerCatalog:
97
+ """Build the container operation catalog for ``runtime``.
98
+
99
+ ``runtime`` is validated by :class:`SecurityValidator` before it reaches
100
+ this function; the command policy also rejects unknown runtimes.
101
+ """
102
+ return ContainerCatalog(
103
+ runtime=runtime,
104
+ ps=Operation((runtime, "ps", "--format", "{{.Names}}\\t{{.Status}}")),
105
+ ps_all=Operation((runtime, "ps", "-a", "--format", CONTAINER_LIST_FORMAT)),
106
+ ps_unhealthy=Operation(
107
+ (
108
+ runtime,
109
+ "ps",
110
+ "-a",
111
+ "--filter",
112
+ "health=unhealthy",
113
+ "--format",
114
+ "{{.Names}}\\t{{.Status}}",
115
+ )
116
+ ),
117
+ inspect=Operation(
118
+ (runtime, "inspect", "--format", INSPECT_FORMAT, "--", "$service")
119
+ ),
120
+ stats=Operation(
121
+ (
122
+ runtime,
123
+ "stats",
124
+ "--no-stream",
125
+ "--format",
126
+ STATS_FORMAT,
127
+ "--",
128
+ "$service",
129
+ )
130
+ ),
131
+ ports=Operation((runtime, "port", "--", "$service")),
132
+ health=Operation(
133
+ (runtime, "inspect", "--format", HEALTH_FORMAT, "--", "$service")
134
+ ),
135
+ restart=Operation(
136
+ (runtime, "restart", "--", "$service"),
137
+ read_only=False,
138
+ destructive=True,
139
+ ),
140
+ start=Operation((runtime, "start", "--", "$service"), read_only=False),
141
+ stop=Operation(
142
+ (runtime, "stop", "--", "$service"),
143
+ read_only=False,
144
+ destructive=True,
145
+ ),
146
+ # Compose commands are scoped to a service directory below
147
+ # services_path. The security layer validates the directory and the
148
+ # exact subcommand arguments.
149
+ compose_ps=Operation(
150
+ (
151
+ runtime,
152
+ "compose",
153
+ "--project-directory",
154
+ "$directory",
155
+ "ps",
156
+ "--format",
157
+ "{{.Name}} {{.Status}}",
158
+ )
159
+ ),
160
+ compose_pull=Operation(
161
+ (runtime, "compose", "--project-directory", "$directory", "pull"),
162
+ read_only=False,
163
+ ),
164
+ compose_up=Operation(
165
+ (
166
+ runtime,
167
+ "compose",
168
+ "--project-directory",
169
+ "$directory",
170
+ "up",
171
+ "-d",
172
+ "--remove-orphans",
173
+ ),
174
+ read_only=False,
175
+ ),
176
+ compose_down=Operation(
177
+ (
178
+ runtime,
179
+ "compose",
180
+ "--project-directory",
181
+ "$directory",
182
+ "down",
183
+ "--remove-orphans",
184
+ ),
185
+ read_only=False,
186
+ destructive=True,
187
+ ),
188
+ )
189
+
190
+
191
+ DEFAULT_CATALOG = build_container_catalog("docker")
192
+
193
+ ECHO_CONNECTION = Operation(("echo", "Connection successful"))
194
+ LS_SERVICES = Operation(("ls", "-1", "$path"))
195
+ # The trailing slash from -p distinguishes directories from plain files.
196
+ LS_SERVICE_DIRS = Operation(("ls", "-1", "-p", "$path"))
197
+ DOCKER_PS = DEFAULT_CATALOG.ps
198
+ DOCKER_PS_ALL = DEFAULT_CATALOG.ps_all
199
+ # Health status is only reported for containers with a healthcheck. Including
200
+ # stopped containers keeps a crashed or exited unhealthy service visible.
201
+ DOCKER_PS_UNHEALTHY = DEFAULT_CATALOG.ps_unhealthy
202
+ DOCKER_LOGS = DEFAULT_CATALOG.logs()
203
+ DOCKER_INSPECT = DEFAULT_CATALOG.inspect
204
+ DOCKER_STATS = DEFAULT_CATALOG.stats
205
+ DOCKER_PORTS = DEFAULT_CATALOG.ports
206
+ DOCKER_HEALTH = DEFAULT_CATALOG.health
207
+ DOCKER_RESTART = DEFAULT_CATALOG.restart
208
+ DOCKER_START = DEFAULT_CATALOG.start
209
+ DOCKER_STOP = DEFAULT_CATALOG.stop
210
+ COMPOSE_PS = DEFAULT_CATALOG.compose_ps
211
+ COMPOSE_PULL = DEFAULT_CATALOG.compose_pull
212
+ COMPOSE_UP = DEFAULT_CATALOG.compose_up
213
+ COMPOSE_DOWN = DEFAULT_CATALOG.compose_down
214
+
215
+ UPTIME = Operation(("uptime",))
216
+ FREE_MEMORY = Operation(("free", "-h"))
217
+ DISK_USAGE = Operation(("df", "-h", "$path"))
218
+
219
+ # Host infrastructure services are systemd units, not Docker containers. The
220
+ # unit name is validated against security.host_units before rendering.
221
+ SYSTEMCTL_STATUS = Operation(("systemctl", "status", "--no-pager", "--full", "$unit"))
222
+ JOURNALCTL_UNIT = Operation(
223
+ (
224
+ "journalctl",
225
+ "-u",
226
+ "$unit",
227
+ "-n",
228
+ "$lines",
229
+ "--no-pager",
230
+ "--output",
231
+ "short-iso",
232
+ )
233
+ )
234
+
235
+
236
+ # Backwards-compatible alias used by the server and its tests.
237
+ def docker_logs_operation(
238
+ *, since: str | None = None, until: str | None = None
239
+ ) -> Operation:
240
+ """Docker ``logs`` operation (kept for existing callers)."""
241
+ return DEFAULT_CATALOG.logs(since=since, until=until)
File without changes
@@ -0,0 +1,265 @@
1
+ """
2
+ Resolve a service directory or a container name to Docker containers.
3
+
4
+ A directory below ``services_path`` is a service. Its containers are
5
+ discovered through the ``com.docker.compose.project`` label, so the directory
6
+ and the container name no longer have to match. A container name is accepted
7
+ directly when it belongs to a service directory or is listed in
8
+ ``security.allowed_containers``.
9
+
10
+ The resolver never builds a command from untrusted values: it only feeds
11
+ validated names into the operation catalog (``docker ps -a`` and
12
+ ``test -d`` via the executor).
13
+ """
14
+
15
+ import re
16
+ from collections.abc import Iterable, Sequence
17
+ from dataclasses import dataclass
18
+
19
+ from server_management_lib import Executor, SecurityValidator
20
+
21
+ from .operations import DEFAULT_CATALOG, LS_SERVICE_DIRS, ContainerCatalog
22
+
23
+ EXIT_CODE_PATTERN = re.compile(r"⚠️ Exit code:\s*(-?\d+)")
24
+ ERROR_MARKER = "❌"
25
+
26
+
27
+ class ResolutionError(RuntimeError):
28
+ """Raised when a target cannot be resolved to an allowed container."""
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Container:
33
+ """One row from ``docker ps -a``."""
34
+
35
+ name: str
36
+ project: str
37
+ status: str
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Resolution:
42
+ """The containers a service directory or container name maps to."""
43
+
44
+ target: str
45
+ service: str | None
46
+ containers: tuple[str, ...]
47
+ #: ``service`` (directory), ``container`` (via its compose project) or
48
+ #: ``allowlist`` (``security.allowed_containers``).
49
+ source: str
50
+
51
+ @property
52
+ def multiple(self) -> bool:
53
+ return len(self.containers) > 1
54
+
55
+
56
+ def command_failure(output: str) -> str | None:
57
+ """Return an error description when a command result represents a failure."""
58
+ if ERROR_MARKER in output:
59
+ return output.strip()
60
+ match = EXIT_CODE_PATTERN.search(output)
61
+ if match and int(match.group(1)) != 0:
62
+ return output.strip()
63
+ return None
64
+
65
+
66
+ def parse_container_listing(output: str) -> list[Container]:
67
+ """Parse ``docker ps -a --format`` output into container records."""
68
+ containers = []
69
+ for line in output.splitlines():
70
+ if not line.strip() or line.startswith(ERROR_MARKER):
71
+ continue
72
+ parts = line.split("\t")
73
+ if len(parts) < 2 or not parts[0].strip():
74
+ continue
75
+ name = parts[0].strip()
76
+ project = parts[1].strip() if len(parts) > 1 else ""
77
+ status = parts[2].strip() if len(parts) > 2 else ""
78
+ containers.append(Container(name=name, project=project, status=status))
79
+ return containers
80
+
81
+
82
+ async def collect_containers(
83
+ executor: Executor, catalog: ContainerCatalog = DEFAULT_CATALOG
84
+ ) -> list[Container]:
85
+ """List every container, including stopped and crashed ones."""
86
+ output = await executor.execute_safe_command(catalog.ps_all.render())
87
+ failure = command_failure(output)
88
+ if failure is not None:
89
+ raise ResolutionError(f"❌ Could not list Docker containers:\n{failure}")
90
+ return parse_container_listing(output)
91
+
92
+
93
+ async def collect_service_directories(
94
+ executor: Executor, security: SecurityValidator
95
+ ) -> set[str]:
96
+ """Return the directory names below ``services_path``."""
97
+ base_path = security.services_base_path.rstrip("/") + "/"
98
+ output = await executor.execute_safe_command(LS_SERVICE_DIRS.render(path=base_path))
99
+ failure = command_failure(output)
100
+ if failure is not None:
101
+ raise ResolutionError(
102
+ f"❌ Could not list service directories in {base_path}:\n{failure}"
103
+ )
104
+ return {
105
+ line.strip().rstrip("/")
106
+ for line in output.splitlines()
107
+ if line.strip().endswith("/")
108
+ }
109
+
110
+
111
+ def containers_for_service(
112
+ containers: Iterable[Container], service: str
113
+ ) -> list[Container]:
114
+ """Return the containers of a compose project, falling back to its name."""
115
+ by_project = [container for container in containers if container.project == service]
116
+ if by_project:
117
+ return by_project
118
+ return [container for container in containers if container.name == service]
119
+
120
+
121
+ def _unknown_target(target: str, base_path: str) -> ResolutionError:
122
+ return ResolutionError(
123
+ f"❌ Unknown service or container: '{target}'\n"
124
+ f"No directory at {base_path.rstrip('/')}/{target} and no Docker "
125
+ f"container named '{target}'.\n"
126
+ "Use list_services() for directories and list_containers() for "
127
+ "Docker containers."
128
+ )
129
+
130
+
131
+ def _service_without_containers(target: str, base_path: str) -> ResolutionError:
132
+ return ResolutionError(
133
+ f"❌ Service '{target}' exists in {base_path.rstrip('/')}/ but no Docker "
134
+ "container is associated with it.\n"
135
+ f"Expected a container whose com.docker.compose.project label or name "
136
+ f"is '{target}'. Use list_containers() to inspect the labels."
137
+ )
138
+
139
+
140
+ def _container_not_allowed(
141
+ target: str, project: str, base_path: str
142
+ ) -> ResolutionError:
143
+ project_hint = (
144
+ f"Its compose project is '{project}'."
145
+ if project
146
+ else "It is not part of a compose project."
147
+ )
148
+ return ResolutionError(
149
+ f"❌ Container '{target}' is not backed by a service directory under "
150
+ f"{base_path.rstrip('/')}/.\n"
151
+ f"{project_hint}\n"
152
+ "Create the matching directory or add the container name to "
153
+ "security.allowed_containers."
154
+ )
155
+
156
+
157
+ async def resolve_service_target(
158
+ executor: Executor,
159
+ security: SecurityValidator,
160
+ target: str,
161
+ catalog: ContainerCatalog = DEFAULT_CATALOG,
162
+ ) -> Resolution:
163
+ """Resolve a service directory or container name to allowed containers.
164
+
165
+ The caller must validate ``target`` with
166
+ :meth:`SecurityValidator.validate_service_name` first.
167
+ """
168
+ base_path = security.services_base_path
169
+ is_service_dir = await executor.check_service_exists(target)
170
+ containers = await collect_containers(executor, catalog)
171
+
172
+ if is_service_dir:
173
+ matches = containers_for_service(containers, target)
174
+ if not matches:
175
+ if containers:
176
+ raise _service_without_containers(target, base_path)
177
+ # An empty listing can mean "no containers" or an environment where
178
+ # the listing could not be parsed. Keep the legacy behavior and let
179
+ # Docker report the missing object.
180
+ return Resolution(
181
+ target=target,
182
+ service=target,
183
+ containers=(target,),
184
+ source="service",
185
+ )
186
+ return Resolution(
187
+ target=target,
188
+ service=target,
189
+ containers=tuple(container.name for container in matches),
190
+ source="service",
191
+ )
192
+
193
+ match = next(
194
+ (container for container in containers if container.name == target), None
195
+ )
196
+ if match is None:
197
+ raise _unknown_target(target, base_path)
198
+
199
+ if match.name in security.allowed_containers:
200
+ return Resolution(
201
+ target=target,
202
+ service=match.project or None,
203
+ containers=(match.name,),
204
+ source="allowlist",
205
+ )
206
+
207
+ project = match.project
208
+ if (
209
+ project
210
+ and security.validate_service_name(project)
211
+ and await executor.check_service_exists(project)
212
+ ):
213
+ return Resolution(
214
+ target=target,
215
+ service=project,
216
+ containers=(match.name,),
217
+ source="container",
218
+ )
219
+
220
+ raise _container_not_allowed(target, project, base_path)
221
+
222
+
223
+ def service_target(
224
+ container: Container,
225
+ directories: set[str] | frozenset[str] = frozenset(),
226
+ allowed_containers: frozenset[str] = frozenset(),
227
+ ) -> str:
228
+ """Return how a container can be addressed, or ``-`` when it cannot."""
229
+ if container.name in allowed_containers:
230
+ return "(allowlisted)"
231
+ if container.project and container.project in directories:
232
+ return container.project
233
+ if container.name in directories:
234
+ return container.name
235
+ if container.project:
236
+ return "(no directory)"
237
+ return "-"
238
+
239
+
240
+ def format_container_table(
241
+ containers: Sequence[Container],
242
+ *,
243
+ directories: set[str] | frozenset[str] = frozenset(),
244
+ allowed_containers: frozenset[str] = frozenset(),
245
+ ) -> str:
246
+ """Render a readable ``docker ps -a`` table with a service mapping."""
247
+ rows = [("NAME", "PROJECT", "SERVICE", "STATUS")]
248
+ for container in sorted(containers, key=lambda item: item.name):
249
+ rows.append(
250
+ (
251
+ container.name,
252
+ container.project or "-",
253
+ service_target(container, directories, allowed_containers),
254
+ container.status or "-",
255
+ )
256
+ )
257
+
258
+ widths = [max(len(row[index]) for row in rows) for index in range(3)]
259
+ lines = []
260
+ for row in rows:
261
+ lines.append(
262
+ " ".join(row[index].ljust(widths[index]) for index in range(3))
263
+ + f" {row[3]}"
264
+ )
265
+ return "\n".join(lines)