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 +6 -0
- pycli_mcp/__main__.py +8 -0
- pycli_mcp/cli.py +209 -0
- pycli_mcp/metadata/__init__.py +2 -0
- pycli_mcp/metadata/interface.py +23 -0
- pycli_mcp/metadata/query.py +94 -0
- pycli_mcp/metadata/types/__init__.py +2 -0
- pycli_mcp/metadata/types/click.py +479 -0
- pycli_mcp/py.typed +0 -0
- pycli_mcp/server.py +202 -0
- pycli_mcp-0.2.0.dist-info/METADATA +57 -0
- pycli_mcp-0.2.0.dist-info/RECORD +15 -0
- pycli_mcp-0.2.0.dist-info/WHEEL +4 -0
- pycli_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- pycli_mcp-0.2.0.dist-info/licenses/LICENSE.txt +9 -0
pycli_mcp/__init__.py
ADDED
pycli_mcp/__main__.py
ADDED
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,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,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 | [](https://github.com/ofek/pycli-mcp/actions/workflows/test.yml) [](https://github.com/ofek/pycli-mcp/actions/workflows/build.yml) |
|
33
|
+
| Docs | [](https://github.com/ofek/pycli-mcp/actions/workflows/docs.yml) |
|
34
|
+
| Package | [](https://pypi.org/project/pycli-mcp/) [](https://pypi.org/project/pycli-mcp/) |
|
35
|
+
| Meta | [](https://github.com/ofek/pycli-mcp) [](https://github.com/astral-sh/ruff) [](https://spdx.org/licenses/) [](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,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.
|