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/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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cli2mcp = cli2mcp.cli:main