cli2mcp 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.
- cli2mcp/__init__.py +3 -0
- cli2mcp/__main__.py +5 -0
- cli2mcp/cli.py +94 -0
- cli2mcp/parser.py +30 -0
- cli2mcp/parsers/__init__.py +6 -0
- cli2mcp/parsers/cobra.py +175 -0
- cli2mcp/parsers/common.py +42 -0
- cli2mcp/parsers/gnu.py +142 -0
- cli2mcp/parsers/plain.py +112 -0
- cli2mcp/scanner.py +89 -0
- cli2mcp/server.py +164 -0
- cli2mcp-0.1.1.dist-info/METADATA +153 -0
- cli2mcp-0.1.1.dist-info/RECORD +16 -0
- cli2mcp-0.1.1.dist-info/WHEEL +4 -0
- cli2mcp-0.1.1.dist-info/entry_points.txt +2 -0
- cli2mcp-0.1.1.dist-info/licenses/LICENSE +674 -0
cli2mcp/scanner.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Scan a CLI tool and produce a tools descriptor.
|
|
2
|
+
|
|
3
|
+
Runs '<command> -h', parses the output, recurses into subcommands,
|
|
4
|
+
and assembles a dictionary that can be written to JSON.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from cli2mcp.parser import parse_help
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _run_help(command_parts):
|
|
15
|
+
"""Run '<command> -h' and return the output text."""
|
|
16
|
+
cmd = list(command_parts) + ["-h"]
|
|
17
|
+
try:
|
|
18
|
+
result = subprocess.run(
|
|
19
|
+
cmd,
|
|
20
|
+
capture_output=True,
|
|
21
|
+
text=True,
|
|
22
|
+
timeout=10,
|
|
23
|
+
)
|
|
24
|
+
except FileNotFoundError:
|
|
25
|
+
print(f"Error: command not found: {cmd[0]}", file=sys.stderr)
|
|
26
|
+
sys.exit(1)
|
|
27
|
+
except subprocess.TimeoutExpired:
|
|
28
|
+
return ""
|
|
29
|
+
|
|
30
|
+
return result.stdout or result.stderr or ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_MAX_DEPTH = 5
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def scan(command):
|
|
37
|
+
"""Scan a CLI tool and return a tools descriptor dictionary.
|
|
38
|
+
|
|
39
|
+
Recursively descends into subcommands until leaf commands (those
|
|
40
|
+
without further subcommands) are reached. Only leaf commands
|
|
41
|
+
become tool entries.
|
|
42
|
+
"""
|
|
43
|
+
tools = _scan_recursive([command], command, depth=0)
|
|
44
|
+
return {"command": command, "tools": tools}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _scan_recursive(command_parts, prefix, depth):
|
|
48
|
+
"""Recursively scan subcommands, returning tools for leaf commands."""
|
|
49
|
+
if depth > _MAX_DEPTH:
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
help_text = _run_help(command_parts)
|
|
53
|
+
parsed = parse_help(help_text)
|
|
54
|
+
|
|
55
|
+
tools = []
|
|
56
|
+
if parsed["subcommands"]:
|
|
57
|
+
for sub in parsed["subcommands"]:
|
|
58
|
+
tools.extend(
|
|
59
|
+
_scan_recursive(command_parts + [sub], f"{prefix}_{sub}", depth + 1)
|
|
60
|
+
)
|
|
61
|
+
else:
|
|
62
|
+
tools.append(
|
|
63
|
+
{
|
|
64
|
+
"name": prefix,
|
|
65
|
+
"description": parsed["description"],
|
|
66
|
+
"args": _format_args(parsed["args"]),
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
return tools
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _format_args(args):
|
|
73
|
+
"""Normalise argument dicts for the JSON output."""
|
|
74
|
+
return [
|
|
75
|
+
{
|
|
76
|
+
"name": arg["name"],
|
|
77
|
+
"description": arg["description"],
|
|
78
|
+
"type": "string",
|
|
79
|
+
"required": arg["required"],
|
|
80
|
+
}
|
|
81
|
+
for arg in args
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def save(tools_dict, path):
|
|
86
|
+
"""Write a tools descriptor dictionary to a JSON file."""
|
|
87
|
+
with open(path, "w") as f:
|
|
88
|
+
json.dump(tools_dict, f, indent=2)
|
|
89
|
+
print(f"Wrote {path}")
|
cli2mcp/server.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""MCP server that exposes CLI tools described in a JSON file.
|
|
2
|
+
|
|
3
|
+
This module loads a ``tools.json`` file produced by the scanner, registers
|
|
4
|
+
every tool entry with FastMCP, and -- when a tool is called -- translates
|
|
5
|
+
the MCP arguments back into a CLI command and runs it.
|
|
6
|
+
|
|
7
|
+
**The core challenge:**
|
|
8
|
+
|
|
9
|
+
FastMCP inspects Python function *signatures* to build the JSON Schema
|
|
10
|
+
that tells MCP clients which parameters a tool accepts. But our tools
|
|
11
|
+
are defined at runtime (from JSON), not at coding time. So we
|
|
12
|
+
dynamically build a function whose signature matches the args listed in
|
|
13
|
+
the JSON -- giving FastMCP the type information it needs.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import inspect
|
|
17
|
+
import json
|
|
18
|
+
import subprocess
|
|
19
|
+
|
|
20
|
+
from mcp.server.fastmcp import FastMCP
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _to_param_name(arg_name):
|
|
24
|
+
"""Convert a CLI arg name to a valid Python parameter name.
|
|
25
|
+
|
|
26
|
+
Strips leading dashes and replaces hyphens with underscores,
|
|
27
|
+
because Python identifiers cannot contain hyphens::
|
|
28
|
+
|
|
29
|
+
"--upload-file" -> "upload_file"
|
|
30
|
+
"pathspec" -> "pathspec"
|
|
31
|
+
"-v" -> "v"
|
|
32
|
+
"""
|
|
33
|
+
return arg_name.lstrip("-").replace("-", "_")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _build_command(base_command, tool, call_args):
|
|
37
|
+
"""Turn MCP tool-call arguments back into a CLI command list.
|
|
38
|
+
|
|
39
|
+
Flags (args whose name starts with ``-``) are emitted as
|
|
40
|
+
``--flag value`` pairs. Positional args are appended at the end
|
|
41
|
+
in the order they appear in the tool definition.
|
|
42
|
+
|
|
43
|
+
Example::
|
|
44
|
+
|
|
45
|
+
tool = {"name": "git_commit", "args": [
|
|
46
|
+
{"name": "--message", ...},
|
|
47
|
+
{"name": "pathspec", ...},
|
|
48
|
+
]}
|
|
49
|
+
call_args = {"message": "fix bug", "pathspec": "main.py"}
|
|
50
|
+
|
|
51
|
+
result = ["git", "commit", "--message", "fix bug", "main.py"]
|
|
52
|
+
"""
|
|
53
|
+
cmd = [base_command]
|
|
54
|
+
|
|
55
|
+
# Derive subcommand path from tool name (e.g. "kubectl-mtv_get_plan" -> ["get", "plan"]).
|
|
56
|
+
prefix = base_command + "_"
|
|
57
|
+
if tool["name"].startswith(prefix):
|
|
58
|
+
subcommand = tool["name"][len(prefix) :]
|
|
59
|
+
cmd.extend(subcommand.split("_"))
|
|
60
|
+
|
|
61
|
+
positionals = []
|
|
62
|
+
|
|
63
|
+
for arg_def in tool["args"]:
|
|
64
|
+
arg_name = arg_def["name"] # e.g. "--upload-file"
|
|
65
|
+
key = _to_param_name(arg_name) # e.g. "upload_file"
|
|
66
|
+
|
|
67
|
+
if key not in call_args:
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
value = str(call_args[key])
|
|
71
|
+
|
|
72
|
+
if arg_name.startswith("-"):
|
|
73
|
+
cmd.extend([arg_name, value])
|
|
74
|
+
else:
|
|
75
|
+
positionals.append(value)
|
|
76
|
+
|
|
77
|
+
cmd.extend(positionals)
|
|
78
|
+
return cmd
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _make_handler(base_command, tool):
|
|
82
|
+
"""Build an async handler function with a proper signature.
|
|
83
|
+
|
|
84
|
+
FastMCP reads the function's parameter list to generate the tool's
|
|
85
|
+
JSON Schema. A plain ``**kwargs`` handler would produce a useless
|
|
86
|
+
schema. Instead, we create a function whose parameters match the
|
|
87
|
+
args defined in the JSON file.
|
|
88
|
+
|
|
89
|
+
For example, if the JSON says the tool has args ``--output`` and
|
|
90
|
+
``url``, we produce a function equivalent to::
|
|
91
|
+
|
|
92
|
+
async def curl(output: str = "", url: str = "") -> str:
|
|
93
|
+
...
|
|
94
|
+
|
|
95
|
+
We use ``inspect.Parameter`` to build the signature dynamically.
|
|
96
|
+
"""
|
|
97
|
+
tool_args = tool["args"]
|
|
98
|
+
|
|
99
|
+
# Sort so required params come first (Python requires this).
|
|
100
|
+
sorted_args = sorted(tool_args, key=lambda a: not a.get("required"))
|
|
101
|
+
|
|
102
|
+
params = []
|
|
103
|
+
for arg_def in sorted_args:
|
|
104
|
+
key = _to_param_name(arg_def["name"])
|
|
105
|
+
if arg_def.get("required"):
|
|
106
|
+
# Required args have no default value.
|
|
107
|
+
param = inspect.Parameter(
|
|
108
|
+
key,
|
|
109
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
110
|
+
annotation=str,
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
# Optional args default to empty string.
|
|
114
|
+
param = inspect.Parameter(
|
|
115
|
+
key,
|
|
116
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
117
|
+
default="",
|
|
118
|
+
annotation=str,
|
|
119
|
+
)
|
|
120
|
+
params.append(param)
|
|
121
|
+
|
|
122
|
+
# The actual handler that runs the CLI command.
|
|
123
|
+
async def handler(**kwargs):
|
|
124
|
+
# Remove args the caller left at their default (empty string).
|
|
125
|
+
call_args = {k: v for k, v in kwargs.items() if v != ""}
|
|
126
|
+
cmd = _build_command(base_command, tool, call_args)
|
|
127
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
128
|
+
output = result.stdout
|
|
129
|
+
if result.returncode != 0:
|
|
130
|
+
output += result.stderr
|
|
131
|
+
return output or "(no output)"
|
|
132
|
+
|
|
133
|
+
# Attach the proper signature so FastMCP can inspect it.
|
|
134
|
+
handler.__signature__ = inspect.Signature(params)
|
|
135
|
+
handler.__name__ = tool["name"]
|
|
136
|
+
handler.__doc__ = tool["description"]
|
|
137
|
+
|
|
138
|
+
return handler
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def create_server(tools_file):
|
|
142
|
+
"""Create a FastMCP server from a tools JSON file.
|
|
143
|
+
|
|
144
|
+
Reads the JSON, then registers one MCP tool per entry. Each tool,
|
|
145
|
+
when called, translates the MCP arguments back into a CLI command
|
|
146
|
+
and runs it with ``subprocess.run()``.
|
|
147
|
+
|
|
148
|
+
Returns the FastMCP server instance (call ``.run()`` to start it).
|
|
149
|
+
"""
|
|
150
|
+
with open(tools_file) as f:
|
|
151
|
+
data = json.load(f)
|
|
152
|
+
|
|
153
|
+
base_command = data["command"]
|
|
154
|
+
server = FastMCP(base_command)
|
|
155
|
+
|
|
156
|
+
for tool in data["tools"]:
|
|
157
|
+
handler = _make_handler(base_command, tool)
|
|
158
|
+
server.add_tool(
|
|
159
|
+
handler,
|
|
160
|
+
name=tool["name"],
|
|
161
|
+
description=tool["description"],
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return server
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cli2mcp
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Turn any CLI tool into an MCP server
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: mcp[cli]
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# cli2mcp -- Turn any CLI into an MCP Server
|
|
11
|
+
|
|
12
|
+
An educational Python library that bridges the gap between traditional
|
|
13
|
+
command-line tools and the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/).
|
|
14
|
+
|
|
15
|
+
**cli2mcp** scans a CLI tool's `--help` output, extracts its arguments and
|
|
16
|
+
subcommands, and generates a JSON descriptor file. It can then serve that
|
|
17
|
+
file as a fully functional MCP server -- letting AI assistants call the CLI
|
|
18
|
+
tool through a standard protocol.
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv sync
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### 1. Scan a CLI tool
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv run cli2mcp scan curl
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
This runs `curl --help`, parses the output, and writes `curl.tools.json`.
|
|
33
|
+
|
|
34
|
+
For tools with subcommands (like `git`):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uv run cli2mcp scan git -o git.tools.json
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Each subcommand becomes its own MCP tool.
|
|
41
|
+
|
|
42
|
+
### 2. Serve as an MCP server
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv run cli2mcp serve curl.tools.json
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
This starts an MCP server (stdio transport) that exposes every entry in
|
|
49
|
+
the JSON file as a callable tool.
|
|
50
|
+
|
|
51
|
+
To use HTTP transport instead:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv run cli2mcp serve curl.tools.json -t streamable-http
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 3. Connect to an AI assistant
|
|
58
|
+
|
|
59
|
+
Add the server to your assistant's MCP configuration.
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"mcpServers": {
|
|
64
|
+
"curl": {
|
|
65
|
+
"command": "cli2mcp",
|
|
66
|
+
"args": ["serve", "curl.tools.json"]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## How it works
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
MCP client
|
|
76
|
+
|
|
|
77
|
+
cli2mcp scan curl --> curl.tools.json |
|
|
78
|
+
| |
|
|
79
|
+
cli2mcp serve <---+
|
|
80
|
+
|
|
|
81
|
+
subprocess.run(["curl", ...])
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### The JSON schema
|
|
85
|
+
|
|
86
|
+
The generated file looks like this:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"command": "curl",
|
|
91
|
+
"tools": [
|
|
92
|
+
{
|
|
93
|
+
"name": "curl",
|
|
94
|
+
"description": "transfer a URL",
|
|
95
|
+
"args": [
|
|
96
|
+
{
|
|
97
|
+
"name": "url",
|
|
98
|
+
"description": "URL to transfer",
|
|
99
|
+
"type": "string",
|
|
100
|
+
"required": true
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"name": "--output",
|
|
104
|
+
"description": "Write output to file instead of stdout",
|
|
105
|
+
"type": "string",
|
|
106
|
+
"required": false
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- **`command`** -- the base CLI binary to run.
|
|
115
|
+
- **`tools`** -- one entry per tool (or per subcommand).
|
|
116
|
+
- **`args`** -- each argument has a `name`, `description`, `type` (always
|
|
117
|
+
`"string"`), and `required` flag.
|
|
118
|
+
- Argument names starting with `--` are flags; others are positional.
|
|
119
|
+
- You can hand-edit this file to add, remove, or rename tools.
|
|
120
|
+
|
|
121
|
+
### How arguments map back to CLI commands
|
|
122
|
+
|
|
123
|
+
When the MCP server receives a tool call like:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{"name": "git_commit", "arguments": {"message": "fix bug", "all": "true"}}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
It reconstructs the CLI command:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
git commit --message "fix bug" --all true
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Flags (names starting with `--`) are emitted as `--flag value`.
|
|
136
|
+
Positional arguments are appended at the end.
|
|
137
|
+
|
|
138
|
+
## Supported help styles
|
|
139
|
+
|
|
140
|
+
Different CLI frameworks produce different `--help` formats.
|
|
141
|
+
cli2mcp auto-detects the style and uses the right parser:
|
|
142
|
+
|
|
143
|
+
| Style | Frameworks | Flag format |
|
|
144
|
+
|-----------|-------------------------|--------------------------------------|
|
|
145
|
+
| **GNU** | argparse, click, GNU | `--flag description` (one line) |
|
|
146
|
+
| **Cobra** | kubectl, oc, docker, gh | `--flag=default:` + next line |
|
|
147
|
+
| **Plain** | curl, busybox | flags listed without section headers |
|
|
148
|
+
|
|
149
|
+
## Requirements
|
|
150
|
+
|
|
151
|
+
- Python 3.10+
|
|
152
|
+
- [uv](https://docs.astral.sh/uv/) (recommended) or pip
|
|
153
|
+
- `mcp[cli]` (the official MCP Python SDK, installed automatically)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
cli2mcp/__init__.py,sha256=XZQBMBINEPCzbQ-vMBn2GWMhCItK_F7d45ZjyFZFdug,78
|
|
2
|
+
cli2mcp/__main__.py,sha256=fbJVELr1ik3yGNBtUvx3aZTVYdvpC7UxeSz6Q26PlXQ,92
|
|
3
|
+
cli2mcp/cli.py,sha256=Y6XDFyNb6i4lEbGp71Y7Z6QF0ROB9q6ESEpaBL6AUlc,2461
|
|
4
|
+
cli2mcp/parser.py,sha256=1ei1mFeZqytedsY5eelNyP1ihSX1iYzthfggc_THs0w,864
|
|
5
|
+
cli2mcp/scanner.py,sha256=yXMXDW-dO0T-WoM-1KH5HoG-WGHO53Hi6t8iHiJcCNo,2318
|
|
6
|
+
cli2mcp/server.py,sha256=JR3cfbBrkJ2ITBkEflxIWvUae5Uw8YOnbdmn2Z6zdak,5300
|
|
7
|
+
cli2mcp/parsers/__init__.py,sha256=JRWfTDc9W838xcOtiDcI7sAViYDUlr7ymNEdOJodsBs,224
|
|
8
|
+
cli2mcp/parsers/cobra.py,sha256=t9C1vpvFYSU6uqO6YoG8bKh5a8n8axh9COBlXXklv2M,5243
|
|
9
|
+
cli2mcp/parsers/common.py,sha256=-6zY5W0jfTpBqvK9IFaAWOCCsIIqT1Ew6YZvTHL9BQw,1258
|
|
10
|
+
cli2mcp/parsers/gnu.py,sha256=Rjzl3OnUVkoXAzIJHmjDS5rXVHJpG7Ps6z9E7t7IFU8,4157
|
|
11
|
+
cli2mcp/parsers/plain.py,sha256=JMs1oRxBdu-mHEPSW_snu_MfU_9aCe21Gzj5DfrgEmY,3112
|
|
12
|
+
cli2mcp-0.1.1.dist-info/METADATA,sha256=J1KG-3xJ_R3imgroKjUvzgBCV2EgWu_rziLe_0goInA,3805
|
|
13
|
+
cli2mcp-0.1.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
14
|
+
cli2mcp-0.1.1.dist-info/entry_points.txt,sha256=b13fSNFMlSP7lpmNhVIVawGZdTrt-CmgCdwJ-oVvsUg,45
|
|
15
|
+
cli2mcp-0.1.1.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
16
|
+
cli2mcp-0.1.1.dist-info/RECORD,,
|