qualys-cli 0.1.1__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.
qualys_cli/history.py ADDED
@@ -0,0 +1,133 @@
1
+ """
2
+ Per-profile invocation history — feeds ``qualys-cli last`` and ``replay``.
3
+
4
+ Each successful invocation that produced output writes a small JSON record at
5
+ ``~/.config/qualys-cli/history/<profile>/<correlation>.json``. We keep the
6
+ last 200 invocations per profile (oldest pruned on write); enough for "I just
7
+ ran this with --format table, give me the same data as JSON" without unbounded
8
+ disk growth.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import json
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from .config import HISTORY_DIR
20
+
21
+ _MAX_PER_PROFILE = 200
22
+
23
+
24
+ def _safe_component(value: str, *, what: str) -> str:
25
+ """Reject any path-separator or traversal sequence in a filename component.
26
+
27
+ ``profile`` names and correlation IDs both end up interpolated directly
28
+ into a filesystem path. Without this guard a crafted ``--profile`` or
29
+ ``qualys-cli replay <id>`` argument (e.g. ``../../../../etc/passwd``)
30
+ could escape :data:`HISTORY_DIR` and read/write arbitrary files.
31
+ """
32
+ v = value or ""
33
+ if not v or ".." in v or "/" in v or "\\" in v or "\x00" in v:
34
+ raise ValueError(f"Invalid {what}: {value!r}")
35
+ return v
36
+
37
+
38
+ def _dir(profile: str) -> Path:
39
+ name = _safe_component(profile or "default", what="profile name")
40
+ d = HISTORY_DIR / name
41
+ # Defence in depth — even if the check above is ever loosened, refuse to
42
+ # operate on anything that resolves outside HISTORY_DIR.
43
+ try:
44
+ d.resolve().relative_to(HISTORY_DIR.resolve())
45
+ except ValueError as exc:
46
+ raise ValueError(
47
+ f"Refusing to operate on {d} — path escapes the history directory"
48
+ ) from exc
49
+ d.mkdir(parents=True, exist_ok=True)
50
+ return d
51
+
52
+
53
+ def record(
54
+ profile: str,
55
+ correlation: str,
56
+ argv: list[str],
57
+ response: Any,
58
+ ) -> None:
59
+ """Persist one invocation result for later replay."""
60
+ if not correlation:
61
+ return
62
+ try:
63
+ corr = _safe_component(correlation, what="correlation ID")
64
+ d = _dir(profile)
65
+ d.parent.chmod(0o700)
66
+ with contextlib.suppress(Exception):
67
+ d.chmod(0o700)
68
+ rec = {
69
+ "ts": int(time.time()),
70
+ "argv": argv,
71
+ "correlation": correlation,
72
+ "response": response,
73
+ }
74
+ path = d / f"{corr}.json"
75
+ path.write_text(json.dumps(rec, default=str))
76
+ with contextlib.suppress(Exception):
77
+ path.chmod(0o600)
78
+ # Prune oldest if we exceed the cap.
79
+ # Use a safe sort that handles concurrent deletions (file may vanish
80
+ # between glob and stat).
81
+ files_with_mtime: list[tuple[float, Path]] = []
82
+ for f in d.glob("*.json"):
83
+ try:
84
+ files_with_mtime.append((f.stat().st_mtime, f))
85
+ except (OSError, FileNotFoundError):
86
+ continue
87
+ files_with_mtime.sort(reverse=True)
88
+ for _, old in files_with_mtime[_MAX_PER_PROFILE:]:
89
+ with contextlib.suppress(OSError, FileNotFoundError):
90
+ old.unlink()
91
+ except Exception:
92
+ # Never let history persistence break a command.
93
+ pass
94
+
95
+
96
+ def latest(profile: str) -> dict[str, Any] | None:
97
+ """Return the most recent invocation record for *profile*, or None."""
98
+ try:
99
+ d = _dir(profile)
100
+ files = sorted(d.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
101
+ if not files:
102
+ return None
103
+ rec_obj = json.loads(files[0].read_text())
104
+ return rec_obj if isinstance(rec_obj, dict) else None
105
+ except Exception:
106
+ return None
107
+
108
+
109
+ def lookup(profile: str, correlation: str) -> dict[str, Any] | None:
110
+ try:
111
+ corr = _safe_component(correlation, what="correlation ID")
112
+ p = _dir(profile) / f"{corr}.json"
113
+ if not p.exists():
114
+ return None
115
+ rec: dict[str, Any] = json.loads(p.read_text())
116
+ return rec
117
+ except Exception:
118
+ return None
119
+
120
+
121
+ def list_recent(profile: str, n: int = 25) -> list[dict[str, Any]]:
122
+ try:
123
+ d = _dir(profile)
124
+ except ValueError:
125
+ return []
126
+ files = sorted(d.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:n]
127
+ out: list[dict[str, Any]] = []
128
+ for f in files:
129
+ try:
130
+ out.append(json.loads(f.read_text()))
131
+ except Exception:
132
+ continue
133
+ return out
@@ -0,0 +1,126 @@
1
+ """
2
+ Minimal local HTTP server that exposes the CLI as a JSON-in / JSON-out API.
3
+
4
+ Why minimal: stdlib ``http.server`` is enough for a localhost-only operator
5
+ tool — no need to pull FastAPI / uvicorn into the runtime dependency set.
6
+
7
+ Endpoints:
8
+
9
+ GET / Health probe (returns {"ok": true})
10
+ GET /tools List every registered command + JSONSchema
11
+ POST /run Run one command. Body:
12
+ {"argv": ["vm", "host", "list"], "profile": "prod"}
13
+ Returns: {"ok": ..., "exit_code": N, "stdout": ..., "stderr": ...}
14
+
15
+ Defaults bind to 127.0.0.1 — there's no auth on the wire, so do NOT expose
16
+ this server to a network. Operators who want network access should put it
17
+ behind a reverse proxy that adds AuthN/Z.
18
+
19
+ No CORS headers are sent. A wildcard ``Access-Control-Allow-Origin: *``
20
+ would let any website the operator's browser visits issue cross-origin
21
+ requests to this server (a classic "drive-by RCE against localhost tooling"
22
+ pattern) since ``POST /run`` executes arbitrary ``qualys-cli`` commands.
23
+ Browser-based clients must be served from the same origin or proxied.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import subprocess
31
+ from http.server import BaseHTTPRequestHandler, HTTPServer
32
+ from typing import Any
33
+
34
+
35
+ def _build_handler(profile: str) -> type:
36
+ from .mcp_server import _build_catalog
37
+
38
+ catalog = _build_catalog()
39
+
40
+ class Handler(BaseHTTPRequestHandler):
41
+ # Quiet: the default logger spams stderr — we already audit centrally.
42
+ def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
43
+ return
44
+
45
+ def _send_json(self, obj: dict[str, Any], status: int = 200) -> None:
46
+ body = json.dumps(obj, default=str).encode("utf-8")
47
+ self.send_response(status)
48
+ self.send_header("Content-Type", "application/json")
49
+ self.send_header("Content-Length", str(len(body)))
50
+ # No CORS headers — see module docstring. A wildcard origin here
51
+ # would let any web page the operator's browser visits drive
52
+ # POST /run (arbitrary command execution) cross-origin.
53
+ self.end_headers()
54
+ self.wfile.write(body)
55
+
56
+ def do_OPTIONS(self) -> None: # noqa: N802 — http.server contract
57
+ """No CORS preflight support — cross-origin browser clients are
58
+ intentionally not supported. See module docstring."""
59
+ self._send_json({"error": "not found"}, status=404)
60
+
61
+ def do_GET(self) -> None: # noqa: N802 — http.server contract
62
+ if self.path == "/":
63
+ self._send_json({"ok": True, "service": "qualys-cli", "tools": len(catalog)})
64
+ elif self.path.startswith("/tools"):
65
+ self._send_json({"tools": catalog})
66
+ else:
67
+ self._send_json({"error": "not found"}, status=404)
68
+
69
+ def do_POST(self) -> None: # noqa: N802
70
+ if self.path != "/run":
71
+ self._send_json({"error": "not found"}, status=404)
72
+ return
73
+ length = int(self.headers.get("Content-Length", "0") or 0)
74
+ try:
75
+ body = json.loads(self.rfile.read(length) or b"{}")
76
+ except json.JSONDecodeError:
77
+ self._send_json({"error": "invalid JSON body"}, status=400)
78
+ return
79
+ argv = list(body.get("argv") or [])
80
+ if not argv:
81
+ self._send_json({"error": "argv is required"}, status=400)
82
+ return
83
+ # Validate argv elements are strings and don't contain shell metacharacters
84
+ # that could be exploited if subprocess were ever changed to use shell=True
85
+ for arg in argv:
86
+ if not isinstance(arg, str):
87
+ self._send_json({"error": "argv elements must be strings"}, status=400)
88
+ return
89
+ # Block any attempt to inject shell commands or escape sequences
90
+ if any(c in arg for c in [";", "|", "&", "`", "$", "\n", "\r", "\x00"]):
91
+ self._send_json({"error": "argv contains invalid characters"}, status=400)
92
+ return
93
+ run_profile = str(body.get("profile") or profile)
94
+ # Validate profile name doesn't contain path traversal or injection
95
+ if any(c in run_profile for c in ["/", "\\", ";", "|", "&", "`", "$", "\n", "\r", "\x00"]):
96
+ self._send_json({"error": "invalid profile name"}, status=400)
97
+ return
98
+ full = ["qualys-cli", "--profile", run_profile, *argv]
99
+ env = os.environ | {"QUALYS_OUTPUT_MODE": "agentic"}
100
+ proc = subprocess.run(full, capture_output=True, text=True, env=env, check=False)
101
+ try:
102
+ stdout: Any = json.loads(proc.stdout) if proc.stdout.strip() else None
103
+ except json.JSONDecodeError:
104
+ stdout = proc.stdout
105
+ self._send_json({
106
+ "ok": proc.returncode == 0,
107
+ "exit_code": proc.returncode,
108
+ "stdout": stdout,
109
+ "stderr": proc.stderr.strip()[-500:] if proc.stderr else "",
110
+ }, status=200 if proc.returncode == 0 else 207)
111
+
112
+ return Handler
113
+
114
+
115
+ def run(*, host: str = "127.0.0.1", port: int = 8765, profile: str = "default") -> None:
116
+ handler = _build_handler(profile)
117
+ server = HTTPServer((host, port), handler)
118
+ print(f"qualys-cli HTTP server listening on http://{host}:{port}")
119
+ print(f" GET http://{host}:{port}/ health probe")
120
+ print(f" GET http://{host}:{port}/tools tool catalog")
121
+ print(f" POST http://{host}:{port}/run run a command")
122
+ print("Press Ctrl-C to stop.")
123
+ try:
124
+ server.serve_forever()
125
+ except KeyboardInterrupt:
126
+ server.server_close()
@@ -0,0 +1,341 @@
1
+ """
2
+ Minimal MCP (Model Context Protocol) server adapter.
3
+
4
+ Why minimal: we want this feature to ship without adding the MCP SDK as a
5
+ runtime dependency. The MCP wire protocol is JSON-RPC 2.0 over stdio with a
6
+ small set of methods (``initialize``, ``tools/list``, ``tools/call``). We
7
+ implement only those methods — sufficient for Claude Desktop, Cursor, and
8
+ the official MCP inspector to drive the CLI.
9
+
10
+ Each registered Typer command becomes one MCP tool. The tool name is the
11
+ dotted command path with hyphens (``vm.host.list``); the input schema is
12
+ generated from the command's option signature via the same introspection
13
+ the HTML doc generator uses. Tool execution shells out to ``qualys-cli`` in
14
+ agentic mode and returns the parsed JSON response as the tool result.
15
+
16
+ Run with ``qualys-cli mcp``. To wire it into Claude Desktop, add::
17
+
18
+ {
19
+ "mcpServers": {
20
+ "qualys-cli": {
21
+ "command": "qualys-cli",
22
+ "args": ["mcp"]
23
+ }
24
+ }
25
+ }
26
+
27
+ to ``~/Library/Application Support/Claude/claude_desktop_config.json``.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import inspect
33
+ import json
34
+ import os
35
+ import subprocess
36
+ import sys
37
+ import types
38
+ import typing
39
+ from typing import Any
40
+
41
+ import typer
42
+
43
+
44
+ def _get_version() -> str:
45
+ """Get the package version dynamically."""
46
+ try:
47
+ from importlib.metadata import version
48
+ return version("qualys-cli")
49
+ except Exception:
50
+ return "0.0.0"
51
+
52
+
53
+ def _walk(t: typer.Typer, prefix: str = "") -> list[tuple[str, Any]]:
54
+ out: list[tuple[str, Any]] = []
55
+ for c in (t.registered_commands or []):
56
+ if c.callback is None:
57
+ continue
58
+ cname = c.name if isinstance(c.name, str) else c.callback.__name__.replace("_", "-")
59
+ full = f"{prefix} {cname}".strip()
60
+ out.append((full, c.callback))
61
+ for g in (t.registered_groups or []):
62
+ sub = g.typer_instance
63
+ if sub is None:
64
+ continue
65
+ gname = sub.info.name if sub.info and isinstance(sub.info.name, str) else g.name
66
+ if not isinstance(gname, str):
67
+ continue
68
+ out.extend(_walk(sub, f"{prefix} {gname}".strip()))
69
+ return out
70
+
71
+
72
+ def _type_to_jsonschema(annotation: object) -> dict[str, Any]:
73
+ a = annotation
74
+ if isinstance(a, types.UnionType) or typing.get_origin(a) is typing.Union:
75
+ args = [t for t in typing.get_args(a) if t is not type(None)]
76
+ if args:
77
+ a = args[0]
78
+ if a is bool:
79
+ return {"type": "boolean"}
80
+ if a is int:
81
+ return {"type": "integer"}
82
+ if a is float:
83
+ return {"type": "number"}
84
+ if typing.get_origin(a) is list:
85
+ return {"type": "array", "items": {"type": "string"}}
86
+ return {"type": "string"}
87
+
88
+
89
+ def _schema_for(callback: Any) -> dict[str, Any]:
90
+ sig = inspect.signature(callback)
91
+ try:
92
+ hints = typing.get_type_hints(callback)
93
+ except Exception:
94
+ hints = {}
95
+ props: dict[str, Any] = {}
96
+ required: list[str] = []
97
+ for pname, p in sig.parameters.items():
98
+ annot = hints.get(pname, p.annotation)
99
+ if annot is typer.Context:
100
+ continue
101
+ info = p.default
102
+ if not isinstance(info, (typer.models.OptionInfo, typer.models.ArgumentInfo)):
103
+ continue
104
+ is_arg = isinstance(info, typer.models.ArgumentInfo)
105
+ decls = info.param_decls or ((pname.upper(),) if is_arg else (f"--{pname.replace('_', '-')}",))
106
+ flag = decls[0]
107
+ # MCP tool argument names mirror the long-flag form, normalised
108
+ key = flag.lstrip("-").replace("-", "_")
109
+ sch = _type_to_jsonschema(annot)
110
+ if info.help:
111
+ sch["description"] = info.help
112
+ props[key] = sch
113
+ if info.default is ...:
114
+ required.append(key)
115
+ return {"type": "object", "properties": props, "required": required}
116
+
117
+
118
+ def _normalise_tool_name(full: str) -> str:
119
+ """Convert a CLI command path into an MCP tool name.
120
+
121
+ Anthropic's tool-name validator (FrontendRemoteMcpToolDefinition) requires
122
+ ``^[a-zA-Z0-9_-]{1,64}$`` — dots are rejected. We map space-separators in
123
+ the command path to underscores and any leftover dots to underscores too.
124
+ Hyphens that occur naturally in CLI verbs (e.g. ``rotate-jwt``,
125
+ ``list-easm``) are kept since they are inside the allowed character set.
126
+ The 64-character limit is plenty for every real Qualys command path.
127
+ """
128
+ name = full.replace(" ", "_").replace(".", "_")
129
+ # Defensive scrub: replace anything outside the allowed alphabet with `_`
130
+ # so an unexpected docstring-derived character can't break the validator.
131
+ safe = "".join(ch if (ch.isalnum() or ch in {"_", "-"}) else "_" for ch in name)
132
+ return safe[:64]
133
+
134
+
135
+ def _denormalise_tool_name(tool_name: str) -> list[str]:
136
+ """Inverse of :func:`_normalise_tool_name` for argv reconstruction.
137
+
138
+ A tool name like ``vm_scan_launch`` maps back to the argv ``["vm", "scan",
139
+ "launch"]``. Verbs that contain hyphens stay intact (``rotate-jwt`` →
140
+ ``["rotate-jwt"]``) because we only split on the underscore separator.
141
+ """
142
+ return [seg for seg in tool_name.split("_") if seg]
143
+
144
+
145
+ def _build_catalog() -> list[dict[str, Any]]:
146
+ """Build the MCP tool catalog by walking the live Typer app."""
147
+ from .cli import app as root_app
148
+ tools: list[dict[str, Any]] = []
149
+ for grp in (root_app.registered_groups or []):
150
+ sub = grp.typer_instance
151
+ if sub is None:
152
+ continue
153
+ mod_key = sub.info.name if sub.info else grp.name
154
+ if not isinstance(mod_key, str):
155
+ continue
156
+ for full, cb in _walk(sub, mod_key):
157
+ doc = (cb.__doc__ or "").strip().splitlines()
158
+ summary = doc[0].strip() if doc else full
159
+ tools.append({
160
+ "name": _normalise_tool_name(full),
161
+ "description": summary,
162
+ "inputSchema": _schema_for(cb),
163
+ })
164
+ return tools
165
+
166
+
167
+ def _argv_for(tool_name: str, args: dict[str, Any]) -> list[str]:
168
+ parts = ["qualys-cli", *_denormalise_tool_name(tool_name)]
169
+ for k, v in args.items():
170
+ if v is None:
171
+ continue
172
+ flag = "--" + k.replace("_", "-")
173
+ if isinstance(v, bool):
174
+ if v:
175
+ parts.append(flag)
176
+ elif isinstance(v, list):
177
+ for item in v:
178
+ parts.append(flag)
179
+ parts.append(str(item))
180
+ else:
181
+ parts.append(flag)
182
+ parts.append(str(v))
183
+ return parts
184
+
185
+
186
+ # subprocess.run is invoked with a list (no shell) below, so traditional
187
+ # shell-metachar blocking is not needed for safety. We only reject NULs
188
+ # (which subprocess can't carry anyway) and ASCII control characters that
189
+ # would corrupt the audit log or terminal output.
190
+ def _validate_arg(arg: str) -> bool:
191
+ """Reject only characters that could corrupt downstream logging or that
192
+ cannot be safely transported by ``subprocess.run`` with a list argv.
193
+
194
+ Shell metacharacters (``;|&`` etc.) are *not* rejected — list-form
195
+ subprocess never invokes a shell, so injecting them does nothing. Blocking
196
+ them previously rejected legitimate inputs like single-quoted JSON bodies,
197
+ regex predicates, and shell-style globs.
198
+ """
199
+ if "\x00" in arg:
200
+ return False
201
+ return all(c >= " " or c in ("\t",) for c in arg)
202
+
203
+
204
+ def _invoke(tool_name: str, args: dict[str, Any], profile: str) -> dict[str, Any]:
205
+ if not _validate_arg(tool_name) or (profile and not _validate_arg(profile)):
206
+ return {
207
+ "ok": False,
208
+ "exit_code": 1,
209
+ "stdout": None,
210
+ "stderr": "Argument contains NUL or unprintable control characters",
211
+ }
212
+ for k, v in args.items():
213
+ if isinstance(v, str) and not _validate_arg(v):
214
+ return {
215
+ "ok": False,
216
+ "exit_code": 1,
217
+ "stdout": None,
218
+ "stderr": f"Argument contains NUL or unprintable control characters: {k}",
219
+ }
220
+ if isinstance(v, list):
221
+ for item in v:
222
+ if isinstance(item, str) and not _validate_arg(item):
223
+ return {
224
+ "ok": False,
225
+ "exit_code": 1,
226
+ "stdout": None,
227
+ "stderr": f"Argument contains NUL or unprintable control characters: {k}",
228
+ }
229
+ argv = _argv_for(tool_name, args)
230
+ if profile and "--profile" not in argv:
231
+ argv.insert(1, "--profile")
232
+ argv.insert(2, profile)
233
+ env = os.environ | {"QUALYS_OUTPUT_MODE": "agentic"}
234
+ proc = subprocess.run(argv, capture_output=True, text=True, env=env, check=False)
235
+ try:
236
+ data: Any = json.loads(proc.stdout) if proc.stdout.strip() else None
237
+ except json.JSONDecodeError:
238
+ data = proc.stdout
239
+ return {
240
+ "ok": proc.returncode == 0,
241
+ "exit_code": proc.returncode,
242
+ "stdout": data,
243
+ "stderr": proc.stderr.strip()[-500:] if proc.stderr else "",
244
+ }
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # JSON-RPC stdio loop
249
+ # ---------------------------------------------------------------------------
250
+
251
+ _PROTOCOL_VERSION = "2024-11-05"
252
+
253
+
254
+ def _send(obj: dict[str, Any]) -> None:
255
+ sys.stdout.write(json.dumps(obj, default=str) + "\n")
256
+ sys.stdout.flush()
257
+
258
+
259
+ def _is_notification(msg: dict[str, Any]) -> bool:
260
+ """JSON-RPC 2.0: a notification is a request without an ``id`` field.
261
+
262
+ The spec is explicit — servers MUST NOT respond to notifications. Sending
263
+ any frame back (even an error) violates strict validators (Anthropic's
264
+ MCP validator rejects ``id: null`` outright, and ``id`` is missing here)
265
+ so we silently ignore them.
266
+ """
267
+ return "id" not in msg
268
+
269
+
270
+ def run(*, profile: str = "default") -> None:
271
+ """Read JSON-RPC requests from stdin, write responses to stdout.
272
+
273
+ Implements the request/response side of MCP (initialize, tools/list,
274
+ tools/call, ping) and silently absorbs the standard notifications that
275
+ Claude Desktop / Cursor / other clients emit during their boot dance
276
+ (notifications/initialized, prompts/list, resources/list, …).
277
+ """
278
+ catalog = _build_catalog()
279
+
280
+ for raw in sys.stdin:
281
+ raw = raw.strip()
282
+ if not raw:
283
+ continue
284
+ try:
285
+ msg = json.loads(raw)
286
+ except json.JSONDecodeError:
287
+ # Per JSON-RPC, a parse error gets a response with id=null. We
288
+ # write one only if this looks like a real attempt at JSON; bare
289
+ # newlines / control chars get silently dropped.
290
+ continue
291
+ if not isinstance(msg, dict):
292
+ continue
293
+ method = msg.get("method")
294
+ if not isinstance(method, str) or not method:
295
+ # Not a valid JSON-RPC request/notification — ignore.
296
+ continue
297
+
298
+ # Notifications never get a response. This includes Claude Desktop's
299
+ # post-handshake "notifications/initialized" frame and any other
300
+ # unsolicited message the client sends without an id.
301
+ if _is_notification(msg):
302
+ continue
303
+
304
+ rpc_id = msg["id"]
305
+
306
+ if method == "initialize":
307
+ _send({
308
+ "jsonrpc": "2.0", "id": rpc_id,
309
+ "result": {
310
+ "protocolVersion": _PROTOCOL_VERSION,
311
+ "capabilities": {"tools": {"listChanged": False}},
312
+ "serverInfo": {"name": "qualys-cli", "version": _get_version()},
313
+ },
314
+ })
315
+ elif method == "tools/list":
316
+ _send({"jsonrpc": "2.0", "id": rpc_id, "result": {"tools": catalog}})
317
+ elif method == "tools/call":
318
+ params = msg.get("params") or {}
319
+ tool_name = params.get("name", "")
320
+ args = params.get("arguments") or {}
321
+ result = _invoke(tool_name, args, profile)
322
+ content = [{"type": "text", "text": json.dumps(result, default=str, indent=2)}]
323
+ _send({
324
+ "jsonrpc": "2.0", "id": rpc_id,
325
+ "result": {"content": content, "isError": not result["ok"]},
326
+ })
327
+ elif method == "ping":
328
+ _send({"jsonrpc": "2.0", "id": rpc_id, "result": {}})
329
+ elif method in ("prompts/list", "resources/list", "resources/templates/list"):
330
+ # Capabilities we don't expose. Return an empty list rather than
331
+ # method-not-found so clients that probe these on startup get a
332
+ # clean answer and don't log warnings.
333
+ key = "prompts" if "prompts" in method else (
334
+ "resourceTemplates" if "templates" in method else "resources"
335
+ )
336
+ _send({"jsonrpc": "2.0", "id": rpc_id, "result": {key: []}})
337
+ else:
338
+ _send({
339
+ "jsonrpc": "2.0", "id": rpc_id,
340
+ "error": {"code": -32601, "message": f"Method not found: {method}"},
341
+ })