pycli-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.
pycli_mcp/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ # SPDX-FileCopyrightText: 2025-present Ofek Lev <oss@ofek.dev>
2
+ # SPDX-License-Identifier: MIT
3
+ from pycli_mcp.metadata.query import CommandQuery
4
+ from pycli_mcp.server import CommandMCPServer
5
+
6
+ __all__ = ["CommandMCPServer", "CommandQuery"]
pycli_mcp/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ # SPDX-FileCopyrightText: 2025-present Ofek Lev <oss@ofek.dev>
2
+ # SPDX-License-Identifier: MIT
3
+ from __future__ import annotations
4
+
5
+ from pycli_mcp.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
pycli_mcp/cli.py ADDED
@@ -0,0 +1,209 @@
1
+ # SPDX-FileCopyrightText: 2025-present Ofek Lev <oss@ofek.dev>
2
+ # SPDX-License-Identifier: MIT
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ from importlib import import_module
8
+ from typing import Any
9
+
10
+ import click
11
+
12
+ from pycli_mcp import CommandMCPServer, CommandQuery
13
+
14
+
15
+ def parse_target_option(specs: dict[str, Any], raw_value: str) -> tuple[str, str]:
16
+ target_spec, sep, value = raw_value.partition("=")
17
+
18
+ if not sep:
19
+ if len(specs) == 1:
20
+ return next(iter(specs.keys())), raw_value
21
+
22
+ msg = f"Multiple specs provided, but no separator found in option: {raw_value}"
23
+ raise ValueError(msg)
24
+
25
+ if not target_spec:
26
+ msg = f"No target spec in option: {raw_value}"
27
+ raise ValueError(msg)
28
+
29
+ if target_spec not in specs:
30
+ msg = f"Unknown target spec `{target_spec}` in option: {raw_value}"
31
+ raise ValueError(msg)
32
+
33
+ return target_spec, value
34
+
35
+
36
+ @click.command(
37
+ context_settings={
38
+ "help_option_names": ["-h", "--help"],
39
+ "max_content_width": shutil.get_terminal_size().columns,
40
+ },
41
+ )
42
+ @click.argument("specs", nargs=-1)
43
+ @click.option(
44
+ "--aggregate",
45
+ "-a",
46
+ "aggregations",
47
+ type=click.Choice(["root", "group", "none"]),
48
+ multiple=True,
49
+ help=(
50
+ "The level of aggregation to use, with less improving type information at the expense "
51
+ "of more tools (default: root). Multiple specs make the format: spec=aggregation"
52
+ ),
53
+ )
54
+ @click.option(
55
+ "--name",
56
+ "-n",
57
+ "names",
58
+ multiple=True,
59
+ help=(
60
+ "The expected name of the executable, overriding the default (name of the callback). "
61
+ "Multiple specs make the format: spec=name"
62
+ ),
63
+ )
64
+ @click.option(
65
+ "--include",
66
+ "-i",
67
+ "includes",
68
+ multiple=True,
69
+ help="The regular expression filter to include subcommands. Multiple specs make the format: spec=regex",
70
+ )
71
+ @click.option(
72
+ "--exclude",
73
+ "-e",
74
+ "excludes",
75
+ multiple=True,
76
+ help="The regular expression filter to exclude subcommands. Multiple specs make the format: spec=regex",
77
+ )
78
+ @click.option("--strict-types", is_flag=True, help="Error on unknown types")
79
+ @click.option("--debug", is_flag=True, help="Enable debug mode")
80
+ @click.option("--host", help="The host used to run the server (default: 127.0.0.1)")
81
+ @click.option("--port", type=int, help="The port used to run the server (default: 8000)")
82
+ @click.option("--log-level", help="The log level used to run the server (default: info)")
83
+ @click.option("--log-config", help="The path to a file passed to the [`logging.config.fileConfig`][] function")
84
+ @click.option(
85
+ "--option",
86
+ "-o",
87
+ "options",
88
+ type=(str, str),
89
+ multiple=True,
90
+ help="Arbitrary server options (multiple allowed) e.g. -o key1 value1 -o key2 value2",
91
+ )
92
+ @click.pass_context
93
+ def pycli_mcp(
94
+ ctx: click.Context,
95
+ *,
96
+ specs: tuple[str, ...],
97
+ aggregations: tuple[str, ...],
98
+ names: tuple[str, ...],
99
+ includes: tuple[str, ...],
100
+ excludes: tuple[str, ...],
101
+ strict_types: bool,
102
+ debug: bool,
103
+ host: str | None,
104
+ port: int | None,
105
+ log_level: str | None,
106
+ log_config: str | None,
107
+ options: tuple[tuple[str, str], ...],
108
+ ) -> None:
109
+ """
110
+ \b
111
+ ______ _______ _ _ _______ _______ ______
112
+ (_____ \\ (_______|_) | | (_______|_______|_____ \\
113
+ _____) ) _ _ _ | | _ _ _ _ _____) )
114
+ | ____/ | | | | | | | | | ||_|| | | | ____/
115
+ | | | |_| | |_____| |_____| | | | | | |_____| |
116
+ |_| \\__ |\\______)_______)_| |_| |_|\\______)_|
117
+ (____/
118
+
119
+ Run an MCP server using a list of import paths to commands:
120
+
121
+ \b
122
+ ```
123
+ pycli-mcp pkg1.cli:foo pkg2.cli:bar
124
+ ```
125
+
126
+ Filtering is supported. For example, if you have a CLI named `foo` and you only want to expose the
127
+ subcommands `bar` and `baz`, excluding the `baz` subcommands `sub2` and `sub3`, you can do:
128
+
129
+ \b
130
+ ```
131
+ pycli-mcp pkg.cli:foo -i "bar|baz" -e "baz (sub2|sub3)"
132
+ ```
133
+ """
134
+ if not specs:
135
+ click.echo(ctx.get_help())
136
+ return
137
+
138
+ # Deduplicate
139
+ command_specs: dict[str, dict[str, Any]] = {spec: {} for spec in dict.fromkeys(specs)}
140
+
141
+ for aggregation_entry in aggregations:
142
+ target_spec, aggregation = parse_target_option(command_specs, aggregation_entry)
143
+ command_specs[target_spec]["aggregate"] = aggregation
144
+
145
+ for name_entry in names:
146
+ target_spec, name = parse_target_option(command_specs, name_entry)
147
+ command_specs[target_spec]["name"] = name
148
+
149
+ for include_entry in includes:
150
+ target_spec, include_pattern = parse_target_option(command_specs, include_entry)
151
+ command_specs[target_spec]["include"] = re.compile(include_pattern)
152
+
153
+ for exclude_entry in excludes:
154
+ target_spec, exclude_pattern = parse_target_option(command_specs, exclude_entry)
155
+ command_specs[target_spec]["exclude"] = re.compile(exclude_pattern)
156
+
157
+ command_queries: list[CommandQuery] = []
158
+ spec_pattern = re.compile(r"^(?P<spec>(?P<module>[\w.]+):(?P<attr>[\w.]+))$")
159
+ for spec, data in command_specs.items():
160
+ match = spec_pattern.search(spec)
161
+ if match is None:
162
+ msg = f"Invalid spec: {spec}"
163
+ raise ValueError(msg)
164
+
165
+ obj = import_module(match.group("module"))
166
+ for attr in match.group("attr").split("."):
167
+ obj = getattr(obj, attr)
168
+
169
+ command_query = CommandQuery(
170
+ obj,
171
+ aggregate=data.get("aggregate"),
172
+ name=data.get("name"),
173
+ include=data.get("include"),
174
+ exclude=data.get("exclude"),
175
+ strict_types=strict_types,
176
+ )
177
+ command_queries.append(command_query)
178
+
179
+ app_settings: dict[str, Any] = {}
180
+ if debug:
181
+ app_settings["debug"] = True
182
+
183
+ server = CommandMCPServer(command_queries, stateless=True, **app_settings)
184
+ if debug:
185
+ from pprint import pprint
186
+
187
+ pprint({c.metadata.path: c.metadata.schema for c in server.commands.values()})
188
+ else:
189
+ for command in server.commands.values():
190
+ print(f"Serving: {command.metadata.path}")
191
+
192
+ server_settings: dict[str, Any] = {}
193
+ if host is not None:
194
+ server_settings["host"] = host
195
+ if port is not None:
196
+ server_settings["port"] = port
197
+ if log_level is not None:
198
+ server_settings["log_level"] = log_level
199
+ if log_config is not None:
200
+ server_settings["log_config"] = log_config
201
+
202
+ for key, value in options:
203
+ server_settings.setdefault(key, value)
204
+
205
+ server.run(**server_settings)
206
+
207
+
208
+ def main() -> None:
209
+ pycli_mcp(windows_expand_args=False)
@@ -0,0 +1,2 @@
1
+ # SPDX-FileCopyrightText: 2025-present Ofek Lev <oss@ofek.dev>
2
+ # SPDX-License-Identifier: MIT
@@ -0,0 +1,23 @@
1
+ # SPDX-FileCopyrightText: 2025-present Ofek Lev <oss@ofek.dev>
2
+ # SPDX-License-Identifier: MIT
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+
9
+ class CommandMetadata(ABC):
10
+ def __init__(self, *, path: str, schema: dict[str, Any]) -> None:
11
+ self.__path = path
12
+ self.__schema = schema
13
+
14
+ @property
15
+ def path(self) -> str:
16
+ return self.__path
17
+
18
+ @property
19
+ def schema(self) -> dict[str, Any]:
20
+ return self.__schema
21
+
22
+ @abstractmethod
23
+ def construct(self, arguments: dict[str, Any] | None = None) -> list[str]: ...
@@ -0,0 +1,94 @@
1
+ # SPDX-FileCopyrightText: 2025-present Ofek Lev <oss@ofek.dev>
2
+ # SPDX-License-Identifier: MIT
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Literal
6
+
7
+ if TYPE_CHECKING:
8
+ import re
9
+ from collections.abc import Iterator
10
+
11
+ from pycli_mcp.metadata.interface import CommandMetadata
12
+
13
+
14
+ class CommandQuery:
15
+ """
16
+ A wrapper around a root command object that influences the collection behavior. Example usage:
17
+
18
+ ```python
19
+ from pycli_mcp import CommandMCPServer, CommandQuery
20
+
21
+ from mypkg.cli import cmd
22
+
23
+ # Only expose the `foo` subcommand
24
+ query = CommandQuery(cmd, include=r"^foo$")
25
+ server = CommandMCPServer(commands=[query])
26
+ server.run()
27
+ ```
28
+
29
+ Parameters:
30
+ command: The command to inspect.
31
+ aggregate: The level of aggregation to use.
32
+ name: The expected name of the root command.
33
+ include: A regular expression to include in the query.
34
+ exclude: A regular expression to exclude in the query.
35
+ strict_types: Whether to error on unknown types.
36
+ """
37
+
38
+ __slots__ = ("__aggregate", "__command", "__exclude", "__include", "__name", "__strict_types")
39
+
40
+ def __init__(
41
+ self,
42
+ command: Any,
43
+ *,
44
+ aggregate: Literal["root", "group", "none"] | None = None,
45
+ name: str | None = None,
46
+ include: str | re.Pattern | None = None,
47
+ exclude: str | re.Pattern | None = None,
48
+ strict_types: bool = False,
49
+ ) -> None:
50
+ self.__command = command
51
+ self.__aggregate = aggregate
52
+ self.__name = name
53
+ self.__include = include
54
+ self.__exclude = exclude
55
+ self.__strict_types = strict_types
56
+
57
+ def __iter__(self) -> Iterator[CommandMetadata]:
58
+ yield from walk_commands(
59
+ self.__command,
60
+ aggregate=self.__aggregate,
61
+ name=self.__name,
62
+ include=self.__include,
63
+ exclude=self.__exclude,
64
+ strict_types=self.__strict_types,
65
+ )
66
+
67
+
68
+ def walk_commands(
69
+ command: Any,
70
+ *,
71
+ aggregate: Literal["root", "group", "none"] | None = None,
72
+ name: str | None = None,
73
+ include: str | re.Pattern | None = None,
74
+ exclude: str | re.Pattern | None = None,
75
+ strict_types: bool = False,
76
+ ) -> Iterator[CommandMetadata]:
77
+ if aggregate is None:
78
+ aggregate = "root"
79
+
80
+ # Click
81
+ if hasattr(command, "context_class"):
82
+ from pycli_mcp.metadata.types.click import walk_commands
83
+
84
+ yield from walk_commands(
85
+ command,
86
+ aggregate=aggregate,
87
+ name=name,
88
+ include=include,
89
+ exclude=exclude,
90
+ strict_types=strict_types,
91
+ )
92
+ else:
93
+ msg = f"Unsupported command type: {type(command)}"
94
+ raise NotImplementedError(msg)
@@ -0,0 +1,2 @@
1
+ # SPDX-FileCopyrightText: 2025-present Ofek Lev <oss@ofek.dev>
2
+ # SPDX-License-Identifier: MIT
@@ -0,0 +1,479 @@
1
+ # SPDX-FileCopyrightText: 2025-present Ofek Lev <oss@ofek.dev>
2
+ # SPDX-License-Identifier: MIT
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import re
7
+ from typing import TYPE_CHECKING, Any, Literal
8
+
9
+ import click
10
+
11
+ from pycli_mcp.metadata.interface import CommandMetadata
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Iterator
15
+
16
+
17
+ class ClickCommandMetadata(CommandMetadata):
18
+ def __init__(self, *, path: str, schema: dict[str, Any], options: dict[str, ClickCommandOption]) -> None:
19
+ super().__init__(path=path, schema=schema)
20
+
21
+ self.__options = options
22
+
23
+ @property
24
+ def options(self) -> dict[str, ClickCommandOption]:
25
+ return self.__options
26
+
27
+ def construct(self, arguments: dict[str, Any] | None = None) -> list[str]:
28
+ command = self.path.split()
29
+ if arguments and self.options:
30
+ args: list[Any] = []
31
+ opts: list[Any] = []
32
+ flags: list[str] = []
33
+ for option_name, value in arguments.items():
34
+ option = self.options[option_name]
35
+ if option.type == "argument":
36
+ if isinstance(value, list):
37
+ args.extend(value)
38
+ else:
39
+ args.append(value)
40
+
41
+ continue
42
+
43
+ if option.flag:
44
+ if value:
45
+ flags.append(option.flag_name)
46
+ elif option.multiple:
47
+ if option.container:
48
+ for v in value:
49
+ opts.append(option.flag_name)
50
+ opts.extend(v)
51
+ else:
52
+ for v in value:
53
+ opts.extend((option.flag_name, v))
54
+ elif option.container:
55
+ opts.append(option.flag_name)
56
+ opts.extend(value)
57
+ else:
58
+ opts.extend((option.flag_name, value))
59
+
60
+ command.extend(flags)
61
+ command.extend(map(str, opts))
62
+ if args:
63
+ command.append("--")
64
+ command.extend(map(str, args))
65
+
66
+ return command
67
+
68
+
69
+ class ClickCommandOption:
70
+ __slots__ = ("__container", "__description", "__flag", "__flag_name", "__multiple", "__required", "__type")
71
+
72
+ def __init__(
73
+ self,
74
+ *,
75
+ type: Literal["argument", "option"], # noqa: A002
76
+ required: bool,
77
+ description: str,
78
+ multiple: bool = False,
79
+ container: bool = False,
80
+ flag: bool = False,
81
+ flag_name: str = "",
82
+ ) -> None:
83
+ self.__type = type
84
+ self.__required = required
85
+ self.__description = description
86
+ self.__multiple = multiple
87
+ self.__container = container
88
+ self.__flag = flag
89
+ self.__flag_name = flag_name
90
+
91
+ @property
92
+ def type(self) -> Literal["argument", "option"]:
93
+ return self.__type
94
+
95
+ @property
96
+ def required(self) -> bool:
97
+ return self.__required
98
+
99
+ @property
100
+ def description(self) -> str:
101
+ return self.__description
102
+
103
+ @property
104
+ def multiple(self) -> bool:
105
+ return self.__multiple
106
+
107
+ @property
108
+ def container(self) -> bool:
109
+ return self.__container
110
+
111
+ @property
112
+ def flag(self) -> bool:
113
+ return self.__flag
114
+
115
+ @property
116
+ def flag_name(self) -> str:
117
+ return self.__flag_name
118
+
119
+
120
+ def get_longest_flag(flags: list[str]) -> str:
121
+ return sorted(flags, key=len)[-1] # noqa: FURB192
122
+
123
+
124
+ def get_command_description(command: click.Command) -> str:
125
+ # Truncate the help text to the first form feed
126
+ return inspect.cleandoc(command.help or command.short_help or "").split("\f")[0].strip()
127
+
128
+
129
+ def get_command_options_block(ctx: click.Context) -> str:
130
+ import textwrap
131
+
132
+ options = ""
133
+ for param in ctx.command.get_params(ctx):
134
+ info = param.to_info_dict()
135
+ if info.get("hidden", False) or "--help" in info["opts"]:
136
+ continue
137
+
138
+ help_record = param.get_help_record(ctx)
139
+ if help_record is None:
140
+ continue
141
+
142
+ metadata, help_text = help_record
143
+ options += f"\n{metadata}"
144
+ help_text = textwrap.dedent(help_text).strip()
145
+ if help_text:
146
+ separator = " " * 2
147
+ options += separator
148
+ options += textwrap.indent(help_text, " " * (len(metadata) + len(separator))).strip()
149
+
150
+ return options.lstrip()
151
+
152
+
153
+ def get_command_full_usage(ctx: click.Context) -> str:
154
+ usage_pieces = [f"Usage: {ctx.command_path}"]
155
+ usage_pieces.extend(ctx.command.collect_usage_pieces(ctx))
156
+ usage = " ".join(usage_pieces)
157
+
158
+ if description := get_command_description(ctx.command):
159
+ usage += f"\n\n{description}"
160
+
161
+ if options := get_command_options_block(ctx):
162
+ usage += f"\n\nOptions:\n{options}"
163
+
164
+ return usage
165
+
166
+
167
+ def walk_command_tree(
168
+ command: click.Command,
169
+ *,
170
+ name: str | None = None,
171
+ include: str | re.Pattern | None = None,
172
+ exclude: str | re.Pattern | None = None,
173
+ parent: click.Context | None = None,
174
+ ) -> Iterator[click.Context]:
175
+ if command.hidden:
176
+ return
177
+
178
+ ctx = command.context_class(command, parent=parent, info_name=name, **command.context_settings)
179
+ if not isinstance(command, click.Group):
180
+ subcommand_path = " ".join(ctx.command_path.split()[1:])
181
+ if exclude is not None and re.search(exclude, subcommand_path):
182
+ return
183
+ if include is not None and not re.search(include, subcommand_path):
184
+ return
185
+
186
+ yield ctx
187
+ return
188
+
189
+ for subcommand_name in command.list_commands(ctx):
190
+ subcommand = command.get_command(ctx, subcommand_name)
191
+ if subcommand is None:
192
+ continue
193
+ yield from walk_command_tree(subcommand, name=subcommand_name, include=include, exclude=exclude, parent=ctx)
194
+
195
+
196
+ def walk_commands_no_aggregation(
197
+ command: click.Command,
198
+ *,
199
+ name: str | None = None,
200
+ include: str | re.Pattern | None = None,
201
+ exclude: str | re.Pattern | None = None,
202
+ strict_types: bool = False,
203
+ ) -> Iterator[ClickCommandMetadata]:
204
+ for ctx in walk_command_tree(command, name=name or command.name, include=include, exclude=exclude):
205
+ properties: dict[str, Any] = {}
206
+ options: dict[str, ClickCommandOption] = {}
207
+ for param in ctx.command.get_params(ctx):
208
+ info = param.to_info_dict()
209
+ flags = info["opts"]
210
+ if info.get("hidden", False) or "--help" in flags:
211
+ continue
212
+
213
+ flag_name = get_longest_flag(flags)
214
+ option_name = flag_name.lstrip("-").replace("-", "_")
215
+
216
+ option_data = {
217
+ "type": info["param_type_name"],
218
+ "required": info["required"],
219
+ "multiple": info["multiple"],
220
+ }
221
+ if info["param_type_name"] == "option":
222
+ option_data["flag_name"] = flag_name
223
+
224
+ prop: dict[str, Any] = {"title": option_name}
225
+ if help_text := (info.get("help") or "").strip():
226
+ prop["description"] = help_text
227
+
228
+ type_data = info["type"]
229
+ type_name = type_data["param_type"]
230
+
231
+ # Some types are just strings
232
+ if type_name in {"Path", "File"}:
233
+ type_name = "String"
234
+
235
+ if type_name == "String":
236
+ if info["nargs"] == -1 or info["multiple"]:
237
+ prop["type"] = "array"
238
+ prop["items"] = {"type": "string"}
239
+ else:
240
+ prop["type"] = "string"
241
+ elif type_name == "Bool":
242
+ option_data["flag"] = True
243
+ prop["type"] = "boolean"
244
+ elif type_name == "Int":
245
+ if info["multiple"]:
246
+ prop["type"] = "array"
247
+ prop["items"] = {"type": "integer"}
248
+ else:
249
+ prop["type"] = "integer"
250
+ elif type_name == "Float":
251
+ if info["multiple"]:
252
+ prop["type"] = "array"
253
+ prop["items"] = {"type": "number"}
254
+ else:
255
+ prop["type"] = "number"
256
+ elif type_name == "Choice":
257
+ prop["type"] = "string"
258
+ prop["enum"] = list(type_data["choices"])
259
+ elif type_name == "Tuple":
260
+ option_data["container"] = True
261
+ if info["multiple"]:
262
+ prop["type"] = "array"
263
+ prop["items"] = {"type": "array", "items": {"type": "string"}}
264
+ else:
265
+ prop["type"] = "array"
266
+ prop["items"] = {"type": "string"}
267
+ elif strict_types:
268
+ msg = f"Unknown type: {type_data}\n{info}"
269
+ raise ValueError(msg)
270
+ else:
271
+ prop["type"] = "string"
272
+
273
+ if not info["required"]:
274
+ prop["default"] = None if callable(info["default"]) else info["default"]
275
+
276
+ properties[option_name] = prop
277
+ option_data["description"] = prop.get("description", "")
278
+ options[option_name] = ClickCommandOption(**option_data)
279
+
280
+ schema = {
281
+ "type": "object",
282
+ "properties": properties,
283
+ "title": ctx.command_path,
284
+ "description": get_command_description(ctx.command),
285
+ }
286
+ required = [option_name for option_name, option in options.items() if option.required]
287
+ if required:
288
+ schema["required"] = required
289
+
290
+ yield ClickCommandMetadata(path=ctx.command_path, schema=schema, options=options)
291
+
292
+
293
+ def walk_commands_group_aggregation(
294
+ command: click.Command,
295
+ *,
296
+ name: str | None = None,
297
+ include: str | re.Pattern | None = None,
298
+ exclude: str | re.Pattern | None = None,
299
+ ) -> Iterator[ClickCommandMetadata]:
300
+ groups: dict[str, dict[str, click.Context]] = {}
301
+ for ctx in walk_command_tree(command, name=name or command.name, include=include, exclude=exclude):
302
+ if ctx.parent is None:
303
+ group_path = ctx.command_path
304
+ command_name = ""
305
+ else:
306
+ group_path = ctx.parent.command_path
307
+ command_name = ctx.command_path.split()[-1]
308
+
309
+ groups.setdefault(group_path, {})[command_name] = ctx
310
+
311
+ for group_path, commands in groups.items():
312
+ # Root is a command rather than a group
313
+ if "" in commands:
314
+ yield ClickCommandMetadata(
315
+ path=group_path,
316
+ schema={
317
+ "type": "object",
318
+ "properties": {
319
+ "args": {
320
+ "type": "array",
321
+ "items": {
322
+ "type": "string",
323
+ },
324
+ "title": "args",
325
+ "description": "The arguments to pass to the command",
326
+ },
327
+ },
328
+ "title": group_path,
329
+ "description": f"{get_command_full_usage(ctx)}\n",
330
+ },
331
+ options={
332
+ "args": ClickCommandOption(
333
+ type="argument",
334
+ required=False,
335
+ description="The arguments to pass to the command",
336
+ ),
337
+ },
338
+ )
339
+ continue
340
+
341
+ description = f"""\
342
+ Usage: {group_path} SUBCOMMAND [ARGS]...
343
+
344
+ # Available subcommands
345
+ """
346
+ for command_name, ctx in commands.items():
347
+ description += f"""
348
+ ## {command_name}
349
+
350
+ {get_command_full_usage(ctx)}
351
+ """
352
+
353
+ yield ClickCommandMetadata(
354
+ path=group_path,
355
+ schema={
356
+ "type": "object",
357
+ "properties": {
358
+ "subcommand": {
359
+ "type": "string",
360
+ "enum": list(commands.keys()),
361
+ "title": "subcommand",
362
+ "description": "The subcommand to execute",
363
+ },
364
+ "args": {
365
+ "type": "array",
366
+ "items": {
367
+ "type": "string",
368
+ },
369
+ "title": "args",
370
+ "description": "The arguments to pass to the subcommand",
371
+ },
372
+ },
373
+ "title": group_path,
374
+ "description": description.lstrip(),
375
+ },
376
+ options={
377
+ "subcommand": ClickCommandOption(
378
+ type="argument",
379
+ required=True,
380
+ description="The subcommand to execute",
381
+ ),
382
+ "args": ClickCommandOption(
383
+ type="argument",
384
+ required=False,
385
+ description="The arguments to pass to the subcommand",
386
+ ),
387
+ },
388
+ )
389
+
390
+
391
+ def walk_commands_root_aggregation(
392
+ command: click.Command,
393
+ *,
394
+ name: str | None = None,
395
+ include: str | re.Pattern | None = None,
396
+ exclude: str | re.Pattern | None = None,
397
+ ) -> Iterator[ClickCommandMetadata]:
398
+ root_command_name = name or command.name
399
+ description = ""
400
+ if isinstance(command, click.Group):
401
+ ctx = command.context_class(command, info_name=root_command_name, **command.context_settings)
402
+ description += f"""\
403
+ # {root_command_name}
404
+
405
+ Usage: {root_command_name} [OPTIONS] SUBCOMMAND [ARGS]...
406
+ """
407
+ if root_command_description := get_command_description(command):
408
+ description += f"\n{root_command_description}\n"
409
+ if root_command_options := get_command_options_block(ctx):
410
+ description += f"\nOptions:\n{root_command_options}\n"
411
+
412
+ for ctx in walk_command_tree(command, name=root_command_name, include=include, exclude=exclude):
413
+ description += f"""
414
+ ## {ctx.command_path}
415
+
416
+ {get_command_full_usage(ctx)}
417
+ """
418
+
419
+ yield ClickCommandMetadata(
420
+ path=root_command_name, # type: ignore[arg-type]
421
+ schema={
422
+ "type": "object",
423
+ "properties": {
424
+ "args": {
425
+ "type": "array",
426
+ "items": {
427
+ "type": "string",
428
+ },
429
+ "title": "args",
430
+ "description": "The arguments to pass to the root command",
431
+ },
432
+ },
433
+ "title": root_command_name,
434
+ "description": description.lstrip(),
435
+ },
436
+ options={
437
+ "args": ClickCommandOption(
438
+ type="argument",
439
+ required=False,
440
+ description="The arguments to pass to the root command",
441
+ ),
442
+ },
443
+ )
444
+
445
+
446
+ def walk_commands(
447
+ command: click.Command,
448
+ *,
449
+ aggregate: Literal["root", "group", "none"],
450
+ name: str | None = None,
451
+ include: str | re.Pattern | None = None,
452
+ exclude: str | re.Pattern | None = None,
453
+ strict_types: bool = False,
454
+ ) -> Iterator[ClickCommandMetadata]:
455
+ if aggregate == "root":
456
+ yield from walk_commands_root_aggregation(
457
+ command,
458
+ name=name,
459
+ include=include,
460
+ exclude=exclude,
461
+ )
462
+ elif aggregate == "group":
463
+ yield from walk_commands_group_aggregation(
464
+ command,
465
+ name=name,
466
+ include=include,
467
+ exclude=exclude,
468
+ )
469
+ elif aggregate == "none":
470
+ yield from walk_commands_no_aggregation(
471
+ command,
472
+ name=name,
473
+ include=include,
474
+ exclude=exclude,
475
+ strict_types=strict_types,
476
+ )
477
+ else:
478
+ msg = f"Invalid aggregate value: {aggregate}"
479
+ raise ValueError(msg)
pycli_mcp/py.typed ADDED
File without changes
pycli_mcp/server.py ADDED
@@ -0,0 +1,202 @@
1
+ # SPDX-FileCopyrightText: 2025-present Ofek Lev <oss@ofek.dev>
2
+ # SPDX-License-Identifier: MIT
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from contextlib import asynccontextmanager
7
+ from functools import cached_property
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ import uvicorn
11
+ from mcp.server.lowlevel import Server
12
+ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
13
+ from mcp.types import (
14
+ CallToolRequest,
15
+ CallToolResult,
16
+ ListToolsRequest,
17
+ ListToolsResult,
18
+ ServerResult,
19
+ TextContent,
20
+ Tool,
21
+ )
22
+ from starlette.applications import Starlette
23
+ from starlette.routing import Mount
24
+
25
+ from pycli_mcp.metadata.query import CommandQuery
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import AsyncIterator, Sequence
29
+
30
+ from mcp.server.streamable_http import EventStore
31
+
32
+ from pycli_mcp.metadata.interface import CommandMetadata
33
+
34
+
35
+ class Command:
36
+ __slots__ = ("__metadata", "__tool")
37
+
38
+ def __init__(self, metadata: CommandMetadata, tool: Tool):
39
+ self.__metadata = metadata
40
+ self.__tool = tool
41
+
42
+ @property
43
+ def metadata(self) -> CommandMetadata:
44
+ return self.__metadata
45
+
46
+ @property
47
+ def tool(self) -> Tool:
48
+ return self.__tool
49
+
50
+
51
+ class CommandMCPServer:
52
+ """
53
+ An MCP server that can be used to run Python CLIs, backed by [Starlette](https://github.com/encode/starlette)
54
+ and [Uvicorn](https://github.com/encode/uvicorn). Example usage:
55
+
56
+ ```python
57
+ from pycli_mcp import CommandMCPServer
58
+
59
+ from mypkg.cli import cmd
60
+
61
+ server = CommandMCPServer(commands=[cmd], stateless=True)
62
+ server.run()
63
+ ```
64
+
65
+ Parameters:
66
+ commands: The commands to expose as MCP tools.
67
+
68
+ Other parameters:
69
+ event_store: Optional [event store](https://github.com/modelcontextprotocol/python-sdk/blob/v1.9.4/src/mcp/server/streamable_http.py#L79)
70
+ that allows clients to reconnect and receive missed events. If `None`, sessions are still tracked but not
71
+ resumable.
72
+ stateless: Whether to create a completely fresh transport for each request with no session tracking or state
73
+ persistence between requests.
74
+ **app_settings: Additional settings to pass to the Starlette [application][starlette.applications.Starlette].
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ commands: Sequence[Any],
80
+ *,
81
+ event_store: EventStore | None = None,
82
+ stateless: bool = False,
83
+ **app_settings: Any,
84
+ ) -> None:
85
+ self.__command_queries = [c if isinstance(c, CommandQuery) else CommandQuery(c) for c in commands]
86
+ self.__app_settings = app_settings
87
+ self.__server: Server = Server("pycli_mcp")
88
+ self.__session_manager = StreamableHTTPSessionManager(
89
+ app=self.__server,
90
+ event_store=event_store,
91
+ stateless=stateless,
92
+ json_response=True,
93
+ )
94
+
95
+ # Register handlers
96
+ self.__server.request_handlers[ListToolsRequest] = self.list_tools_handler
97
+ self.__server.request_handlers[CallToolRequest] = self.call_tool_handler
98
+
99
+ @property
100
+ def server(self) -> Server:
101
+ """
102
+ Returns:
103
+ The underlying [low-level server](https://github.com/modelcontextprotocol/python-sdk/blob/v1.9.4/src/mcp/server/lowlevel/server.py)
104
+ instance. You can use this to register additional handlers.
105
+ """
106
+ return self.__server
107
+
108
+ @property
109
+ def session_manager(self) -> StreamableHTTPSessionManager:
110
+ """
111
+ Returns:
112
+ The underlying [session manager](https://github.com/modelcontextprotocol/python-sdk/blob/v1.9.4/src/mcp/server/streamable_http_manager.py#L29)
113
+ instance. You only need to use this if you want to override the `lifetime` context manager
114
+ """
115
+ return self.__session_manager
116
+
117
+ @cached_property
118
+ def commands(self) -> dict[str, Command]:
119
+ """
120
+ Returns:
121
+ Dictionary used internally to store metadata about the exposed commands. Although it should not be modified,
122
+ the keys are the available MCP tool names and useful to know when overriding the default handlers.
123
+ """
124
+ commands: dict[str, Command] = {}
125
+ for query in self.__command_queries:
126
+ for metadata in query:
127
+ tool_name = metadata.path.replace(" ", ".").replace("-", "_")
128
+ tool = Tool(
129
+ name=tool_name,
130
+ description=metadata.schema["description"],
131
+ inputSchema=metadata.schema,
132
+ )
133
+ commands[tool_name] = Command(metadata, tool)
134
+
135
+ return commands
136
+
137
+ @cached_property
138
+ def routes(self) -> list[Mount]:
139
+ """
140
+ This would only be used directly if you want to add more routes in addition to the default `/mcp` route.
141
+
142
+ Returns:
143
+ The [routes](https://www.starlette.io/routing/#http-routing) to mount in the Starlette
144
+ [application][starlette.applications.Starlette].
145
+ """
146
+ return [Mount("/mcp", app=self.session_manager.handle_request)]
147
+
148
+ @asynccontextmanager
149
+ async def lifespan(self, app: Starlette) -> AsyncIterator[None]: # noqa: ARG002
150
+ """
151
+ The default lifespan context manager used by the Starlette [application][starlette.applications.Starlette].
152
+ """
153
+ async with self.session_manager.run():
154
+ yield
155
+
156
+ def list_command_tools(self) -> list[Tool]:
157
+ """
158
+ This would only be used directly if you want to override the handler for the `ListToolsRequest`.
159
+
160
+ Returns:
161
+ The MCP tools for the commands.
162
+ """
163
+ return [command.tool for command in self.commands.values()]
164
+
165
+ async def list_tools_handler(self, _: ListToolsRequest) -> ServerResult:
166
+ """
167
+ The default handler for the `ListToolsRequest`.
168
+ """
169
+ return ServerResult(ListToolsResult(tools=self.list_command_tools()))
170
+
171
+ async def call_tool_handler(self, req: CallToolRequest) -> ServerResult:
172
+ """
173
+ The default handler for the `CallToolRequest`.
174
+ """
175
+ command = self.commands[req.params.name].metadata.construct(req.params.arguments)
176
+ try:
177
+ process = subprocess.run( # noqa: PLW1510
178
+ command,
179
+ encoding="utf-8",
180
+ stdout=subprocess.PIPE,
181
+ stderr=subprocess.STDOUT,
182
+ )
183
+ # This can happen if the command is not found
184
+ except subprocess.CalledProcessError as e:
185
+ return ServerResult(CallToolResult(content=[TextContent(type="text", text=str(e))], isError=True))
186
+
187
+ if process.returncode:
188
+ msg = f"{process.stdout}\nThis command exited with non-zero exit code `{process.returncode}`: {command}"
189
+ return ServerResult(CallToolResult(content=[TextContent(type="text", text=msg)], isError=True))
190
+
191
+ return ServerResult(CallToolResult(content=[TextContent(type="text", text=process.stdout)]))
192
+
193
+ def run(self, **kwargs: Any) -> None:
194
+ """
195
+ Other parameters:
196
+ **kwargs: Additional settings to pass to the [`uvicorn.run`](https://www.uvicorn.org/#uvicornrun) function.
197
+ """
198
+ app_settings = self.__app_settings.copy()
199
+ app_settings["routes"] = self.routes
200
+ app_settings.setdefault("lifespan", self.lifespan)
201
+ app = Starlette(**app_settings)
202
+ uvicorn.run(app, **kwargs)
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: pycli-mcp
3
+ Version: 0.2.0
4
+ Summary: MCP server for any Python command line application
5
+ Project-URL: Homepage, https://ofek.dev/pycli-mcp/
6
+ Project-URL: Sponsor, https://github.com/sponsors/ofek
7
+ Project-URL: Changelog, https://ofek.dev/pycli-mcp/changelog/
8
+ Project-URL: Tracker, https://github.com/ofek/pycli-mcp/issues
9
+ Project-URL: Source, https://github.com/ofek/pycli-mcp
10
+ Author-email: Ofek Lev <oss@ofek.dev>
11
+ License-Expression: MIT
12
+ License-File: LICENSE.txt
13
+ Keywords: ai,cli,click,mcp
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Programming Language :: Python :: Implementation :: CPython
22
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: click
25
+ Requires-Dist: mcp
26
+ Description-Content-Type: text/markdown
27
+
28
+ # PyCLI MCP
29
+
30
+ | | |
31
+ | --- | --- |
32
+ | CI/CD | [![CI - Test](https://github.com/ofek/pycli-mcp/actions/workflows/test.yml/badge.svg)](https://github.com/ofek/pycli-mcp/actions/workflows/test.yml) [![CD - Build](https://github.com/ofek/pycli-mcp/actions/workflows/build.yml/badge.svg)](https://github.com/ofek/pycli-mcp/actions/workflows/build.yml) |
33
+ | Docs | [![Docs](https://github.com/ofek/pycli-mcp/actions/workflows/docs.yml/badge.svg)](https://github.com/ofek/pycli-mcp/actions/workflows/docs.yml) |
34
+ | Package | [![PyPI - Version](https://img.shields.io/pypi/v/pycli-mcp.svg?logo=pypi&label=PyPI&logoColor=gold)](https://pypi.org/project/pycli-mcp/) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pycli-mcp.svg?logo=python&label=Python&logoColor=gold)](https://pypi.org/project/pycli-mcp/) |
35
+ | Meta | [![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/ofek/pycli-mcp) [![linting - Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![License - MIT](https://img.shields.io/badge/license-MIT-9400d3.svg)](https://spdx.org/licenses/) [![GitHub Sponsors](https://img.shields.io/github/sponsors/ofek?logo=GitHub%20Sponsors&style=social)](https://github.com/sponsors/ofek) |
36
+
37
+ -----
38
+
39
+ This provides an extensible [MCP](https://modelcontextprotocol.io) server that is compatible with any Python command line application.
40
+
41
+ Supported frameworks:
42
+
43
+ - [Click](https://github.com/pallets/click)
44
+
45
+ ## Installation
46
+
47
+ ```console
48
+ pip install pycli-mcp
49
+ ```
50
+
51
+ ## Documentation
52
+
53
+ The [documentation](https://ofek.dev/pycli-mcp/) is made with [Material for MkDocs](https://github.com/squidfunk/mkdocs-material) and is hosted by [GitHub Pages](https://docs.github.com/en/pages).
54
+
55
+ ## License
56
+
57
+ `pycli-mcp` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
@@ -0,0 +1,15 @@
1
+ pycli_mcp/__init__.py,sha256=0Zk8fHsXlkL4T05OOuveQDYIlHXPzDY160Iw6_d66vA,238
2
+ pycli_mcp/__main__.py,sha256=U6VwYYMGbUUvvJoOj-P1W1PldA5uqF6k332Rl2iP4ew,200
3
+ pycli_mcp/cli.py,sha256=LH7j3yuOQRx3SlWEQlp_9FYOXBRERCB9FhAw8UyjnI4,6647
4
+ pycli_mcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ pycli_mcp/server.py,sha256=tO4FaBqwsTZ0zui-FxL4ENxxBhWfaUz2eu5JgCGB4X0,7464
6
+ pycli_mcp/metadata/__init__.py,sha256=PnnoAlXqi2DRI8VBmupkhbjtp3TvAxUVNz43PUhjw1c,94
7
+ pycli_mcp/metadata/interface.py,sha256=0n4gLE33kcjuf7WaFpmNkeJ3TdW0NwqLmM_CW0EqDB0,604
8
+ pycli_mcp/metadata/query.py,sha256=xWmD4GqJLMORZNGANIFZtrDoQGFbEbkdBfa3jwzfeDI,2787
9
+ pycli_mcp/metadata/types/__init__.py,sha256=PnnoAlXqi2DRI8VBmupkhbjtp3TvAxUVNz43PUhjw1c,94
10
+ pycli_mcp/metadata/types/click.py,sha256=XAo3UCEHATnO5LWO6ABcRrGaRHfIOxbp-KpvKP4T_nQ,15779
11
+ pycli_mcp-0.2.0.dist-info/METADATA,sha256=WRhXvEkkT1Oq_OuD8uHDrkGRAajxCskuhUME-y9CgKc,3036
12
+ pycli_mcp-0.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
13
+ pycli_mcp-0.2.0.dist-info/entry_points.txt,sha256=gE9XVHPk2schyTKuRhzXxosPR9zKf2konk2__iDp0lI,49
14
+ pycli_mcp-0.2.0.dist-info/licenses/LICENSE.txt,sha256=3Eg4UX5X6MAcpfSmIoNHKBV2JCXPPAUjitjT86t3evQ,1088
15
+ pycli_mcp-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pycli-mcp = pycli_mcp.cli:main
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Ofek Lev <oss@ofek.dev>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.