cli-web-codewiki 0.1.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,290 @@
1
+ """MCP server adapter — expose a cli-web-* Click CLI as MCP tools.
2
+
3
+ CANONICAL SOURCE: cli-web-core/cli_web_core/mcp_server.py
4
+ Vendored into every generated CLI at cli_web/<app>/utils/mcp_server.py by
5
+ `cli-web-devkit resync`. Do not edit vendored copies by hand.
6
+
7
+ Every cli-web-* command already speaks ``--json``, so the Click command
8
+ tree maps 1:1 onto MCP tools: tool names are ``group_subcommand``, input
9
+ schemas are derived from Click parameters, and each ``tools/call`` spawns
10
+ the CLI as a fresh subprocess with ``--json`` forced, returning the JSON
11
+ envelope as the tool result. Spawning per call (rather than running
12
+ in-process) gives every tool the same clean-process isolation as a normal
13
+ CLI invocation — no auth/session/global state leaks between calls, and a
14
+ wedged command cannot hang the server. Transport: MCP stdio
15
+ (newline-delimited JSON-RPC 2.0).
16
+
17
+ Usage (wired automatically into generated CLIs)::
18
+
19
+ cli-web-<app> mcp-serve
20
+
21
+ Then point any MCP client at that command.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import sys
28
+ from collections.abc import Callable
29
+ from typing import Any
30
+
31
+ PROTOCOL_VERSION = "2024-11-05"
32
+
33
+ _CLICK_TYPE_MAP = {
34
+ "integer": "integer",
35
+ "int": "integer",
36
+ "float": "number",
37
+ "boolean": "boolean",
38
+ "bool": "boolean",
39
+ }
40
+
41
+
42
+ def _is_multi_valued(param: Any) -> bool:
43
+ return bool(getattr(param, "multiple", False)) or getattr(param, "nargs", 1) != 1
44
+
45
+
46
+ def _param_schema(param: Any) -> dict[str, Any]:
47
+ type_name = getattr(param.type, "name", "text") or "text"
48
+ json_type = _CLICK_TYPE_MAP.get(type_name.lower(), "string")
49
+ schema: dict[str, Any] = {"type": json_type}
50
+ choices = getattr(param.type, "choices", None)
51
+ if choices:
52
+ schema["enum"] = list(choices)
53
+ if _is_multi_valued(param):
54
+ schema = {"type": "array", "items": schema}
55
+ help_text = getattr(param, "help", None)
56
+ if help_text:
57
+ schema["description"] = help_text
58
+ return schema
59
+
60
+
61
+ def _iter_leaf_commands(
62
+ group: Any, prefix: tuple[str, ...] = ()
63
+ ) -> list[tuple[tuple[str, ...], Any]]:
64
+ """Flatten a Click group into (path, command) leaves."""
65
+ import click
66
+
67
+ leaves: list[tuple[tuple[str, ...], Any]] = []
68
+ for name in sorted(group.commands):
69
+ cmd = group.commands[name]
70
+ if getattr(cmd, "hidden", False) or name == "mcp-serve":
71
+ continue
72
+ path = (*prefix, name)
73
+ if isinstance(cmd, click.Group):
74
+ leaves.extend(_iter_leaf_commands(cmd, path))
75
+ else:
76
+ leaves.append((path, cmd))
77
+ return leaves
78
+
79
+
80
+ def _is_json_flag(param: Any) -> bool:
81
+ return "--json" in getattr(param, "opts", ()) or param.name in ("json_mode", "as_json")
82
+
83
+
84
+ def _tool_for(path: tuple[str, ...], cmd: Any) -> dict[str, Any]:
85
+ properties: dict[str, Any] = {}
86
+ required: list[str] = []
87
+ for param in cmd.params:
88
+ if _is_json_flag(param) or param.name == "help":
89
+ continue
90
+ properties[param.name] = _param_schema(param)
91
+ if getattr(param, "required", False):
92
+ required.append(param.name)
93
+ schema: dict[str, Any] = {"type": "object", "properties": properties}
94
+ if required:
95
+ schema["required"] = required
96
+ return {
97
+ "name": "_".join(path).replace("-", "_"),
98
+ "description": (cmd.help or cmd.short_help or " ".join(path)).strip(),
99
+ "inputSchema": schema,
100
+ }
101
+
102
+
103
+ def _build_argv(
104
+ path: tuple[str, ...], cmd: Any, arguments: dict[str, Any], json_flag: bool
105
+ ) -> list[str]:
106
+ """Translate MCP tool arguments back into a Click argv."""
107
+ import click
108
+
109
+ argv = list(path)
110
+ for param in cmd.params:
111
+ if _is_json_flag(param) or param.name not in arguments:
112
+ continue
113
+ value = arguments[param.name]
114
+ if value is None:
115
+ continue
116
+ values = list(value) if isinstance(value, (list, tuple)) else [value]
117
+ if isinstance(param, click.Argument):
118
+ argv.extend(str(v) for v in values)
119
+ elif getattr(param, "is_flag", False):
120
+ if value:
121
+ argv.append(param.opts[0])
122
+ elif _is_multi_valued(param):
123
+ for v in values:
124
+ argv.extend([param.opts[0], str(v)])
125
+ else:
126
+ argv.extend([param.opts[0], str(value)])
127
+ if json_flag:
128
+ argv.append("--json")
129
+ return argv
130
+
131
+
132
+ def _cmd_supports_json(cmd: Any) -> bool:
133
+ return any(_is_json_flag(p) for p in cmd.params)
134
+
135
+
136
+ class McpServer:
137
+ def __init__(
138
+ self,
139
+ cli: Any,
140
+ app_name: str,
141
+ version: str = "0.1.0",
142
+ pkg: str | None = None,
143
+ *,
144
+ timeout: float = 300.0,
145
+ executor: Callable[[list[str]], tuple[str, bool]] | None = None,
146
+ ):
147
+ self.cli = cli
148
+ self.app_name = app_name
149
+ self.version = version
150
+ #: Namespace sub-package, for the ``python -m`` subprocess fallback.
151
+ self.pkg = pkg or app_name.replace("-", "_")
152
+ #: Per-call subprocess timeout (seconds).
153
+ self.timeout = timeout
154
+ #: Optional ``(argv) -> (text, is_error)`` override. Defaults to a
155
+ #: fresh subprocess per call; injected in tests to run an in-memory
156
+ #: Click group without an installed binary.
157
+ self._executor = executor
158
+ self._leaves: dict[str, tuple[tuple[str, ...], Any]] = {}
159
+ for path, cmd in _iter_leaf_commands(cli):
160
+ tool_name = "_".join(path).replace("-", "_")
161
+ self._leaves[tool_name] = (path, cmd)
162
+
163
+ # ── JSON-RPC handlers ────────────────────────────────────────────
164
+
165
+ def handle(self, message: dict[str, Any]) -> dict[str, Any] | None:
166
+ method = message.get("method", "")
167
+ msg_id = message.get("id")
168
+ if method == "initialize":
169
+ return self._result(
170
+ msg_id,
171
+ {
172
+ "protocolVersion": PROTOCOL_VERSION,
173
+ "capabilities": {"tools": {}},
174
+ "serverInfo": {
175
+ "name": f"cli-web-{self.app_name}",
176
+ "version": self.version,
177
+ },
178
+ },
179
+ )
180
+ if method.startswith("notifications/"):
181
+ return None
182
+ if method == "tools/list":
183
+ tools = [_tool_for(path, cmd) for path, cmd in self._leaves.values()]
184
+ return self._result(msg_id, {"tools": tools})
185
+ if method == "tools/call":
186
+ return self._call_tool(msg_id, message.get("params") or {})
187
+ if method == "ping":
188
+ return self._result(msg_id, {})
189
+ return self._error(msg_id, -32601, f"Method not found: {method}")
190
+
191
+ def _call_tool(self, msg_id: Any, params: dict[str, Any]) -> dict[str, Any]:
192
+ name = params.get("name", "")
193
+ if name not in self._leaves:
194
+ return self._error(msg_id, -32602, f"Unknown tool: {name}")
195
+ path, cmd = self._leaves[name]
196
+ arguments = params.get("arguments") or {}
197
+ argv = _build_argv(path, cmd, arguments, json_flag=_cmd_supports_json(cmd))
198
+
199
+ executor = self._executor or self._subprocess_execute
200
+ text, is_error = executor(argv)
201
+ return self._result(
202
+ msg_id,
203
+ {
204
+ "content": [{"type": "text", "text": text}],
205
+ "isError": is_error,
206
+ },
207
+ )
208
+
209
+ # ── command execution ────────────────────────────────────────────
210
+
211
+ def _base_command(self) -> list[str]:
212
+ """Resolve how to invoke this CLI as a subprocess.
213
+
214
+ Prefer the installed console script; fall back to ``python -m`` so the
215
+ server still works from a source checkout without ``pip install``.
216
+ """
217
+ import shutil
218
+
219
+ binary = shutil.which(f"cli-web-{self.app_name}")
220
+ if binary:
221
+ return [binary]
222
+ return [sys.executable, "-m", f"cli_web.{self.pkg}"]
223
+
224
+ def _subprocess_execute(self, argv: list[str]) -> tuple[str, bool]:
225
+ """Run one tool call as a fresh subprocess — full process isolation.
226
+
227
+ Each call is a clean process, identical to how a user (and the test
228
+ suite) invokes the CLI, so no auth/session/global state leaks between
229
+ calls and a stuck command cannot wedge the server.
230
+ """
231
+ import subprocess
232
+
233
+ command = [*self._base_command(), *argv]
234
+ try:
235
+ proc = subprocess.run(
236
+ command,
237
+ capture_output=True,
238
+ text=True,
239
+ timeout=self.timeout,
240
+ check=False,
241
+ )
242
+ except subprocess.TimeoutExpired:
243
+ return (f"command timed out after {self.timeout}s", True)
244
+ except (FileNotFoundError, OSError) as exc:
245
+ return (f"failed to run cli-web-{self.app_name}: {exc}", True)
246
+ # The --json success and error envelopes both go to stdout; fall back
247
+ # to stderr for hard failures (e.g. a Click usage error, exit 2).
248
+ text = proc.stdout.strip() or proc.stderr.strip()
249
+ return (text, proc.returncode != 0)
250
+
251
+ @staticmethod
252
+ def _result(msg_id: Any, result: dict[str, Any]) -> dict[str, Any]:
253
+ return {"jsonrpc": "2.0", "id": msg_id, "result": result}
254
+
255
+ @staticmethod
256
+ def _error(msg_id: Any, code: int, message: str) -> dict[str, Any]:
257
+ return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
258
+
259
+ # ── stdio loop ───────────────────────────────────────────────────
260
+
261
+ def serve_stdio(self) -> None:
262
+ for line in sys.stdin:
263
+ line = line.strip()
264
+ if not line:
265
+ continue
266
+ try:
267
+ message = json.loads(line)
268
+ except json.JSONDecodeError:
269
+ print(
270
+ json.dumps(self._error(None, -32700, "Parse error")),
271
+ flush=True,
272
+ )
273
+ continue
274
+ response = self.handle(message)
275
+ if response is not None:
276
+ print(json.dumps(response), flush=True)
277
+
278
+
279
+ def register_mcp_command(
280
+ cli: Any, app_name: str, version: str = "0.1.0", pkg: str | None = None
281
+ ) -> None:
282
+ """Attach an ``mcp-serve`` command to a cli-web-* Click group."""
283
+ import click
284
+
285
+ @cli.command("mcp-serve", hidden=False)
286
+ def mcp_serve() -> None:
287
+ """Serve this CLI as an MCP server over stdio (newline JSON-RPC)."""
288
+ McpServer(cli, app_name=app_name, version=version, pkg=pkg).serve_stdio()
289
+
290
+ _ = click # imported for parity with vendored runtime deps
@@ -0,0 +1,11 @@
1
+ """Output formatting for cli-web-codewiki (JSON and human-readable tables)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ def print_json(data: Any) -> None:
10
+ """Print data as formatted JSON to stdout."""
11
+ print(json.dumps(data, ensure_ascii=False, indent=2, default=str))