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 ADDED
@@ -0,0 +1,3 @@
1
+ """cli2mcp -- Turn any CLI tool into an MCP server."""
2
+
3
+ __version__ = "0.1.0"
cli2mcp/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running cli2mcp as ``python -m cli2mcp``."""
2
+
3
+ from cli2mcp.cli import main
4
+
5
+ main()
cli2mcp/cli.py ADDED
@@ -0,0 +1,94 @@
1
+ """Command-line entry point for cli2mcp.
2
+
3
+ Usage::
4
+
5
+ cli2mcp scan <command> [-o tools.json]
6
+ cli2mcp serve <tools.json>
7
+ """
8
+
9
+ import argparse
10
+ import sys
11
+
12
+
13
+ def main():
14
+ parser = argparse.ArgumentParser(
15
+ prog="cli2mcp",
16
+ description="Turn any CLI tool into an MCP server.",
17
+ )
18
+ subparsers = parser.add_subparsers(dest="action")
19
+
20
+ # --- scan ---
21
+ scan_parser = subparsers.add_parser(
22
+ "scan",
23
+ help="Scan a CLI tool's --help and generate a tools JSON file.",
24
+ )
25
+ scan_parser.add_argument(
26
+ "command",
27
+ help="The CLI command to scan (e.g. git, curl, docker).",
28
+ )
29
+ scan_parser.add_argument(
30
+ "-o",
31
+ "--output",
32
+ default=None,
33
+ help="Output JSON file path (default: <command>.tools.json).",
34
+ )
35
+
36
+ # --- serve ---
37
+ serve_parser = subparsers.add_parser(
38
+ "serve",
39
+ help="Start an MCP server from a tools JSON file.",
40
+ )
41
+ serve_parser.add_argument(
42
+ "tools_file",
43
+ help="Path to the tools JSON file.",
44
+ )
45
+ serve_parser.add_argument(
46
+ "-t",
47
+ "--transport",
48
+ choices=["stdio", "streamable-http"],
49
+ default="stdio",
50
+ help="Transport protocol (default: stdio).",
51
+ )
52
+
53
+ args = parser.parse_args()
54
+
55
+ if args.action is None:
56
+ parser.print_help()
57
+ sys.exit(1)
58
+
59
+ if args.action == "scan":
60
+ from cli2mcp.scanner import scan, save
61
+
62
+ output = args.output or f"{args.command}.tools.json"
63
+ tools = scan(args.command)
64
+ save(tools, output)
65
+
66
+ elif args.action == "serve":
67
+ import json
68
+ import signal
69
+ from cli2mcp.server import create_server
70
+
71
+ with open(args.tools_file) as f:
72
+ data = json.load(f)
73
+
74
+ tool_count = len(data.get("tools", []))
75
+ command = data.get("command", "unknown")
76
+
77
+ print("cli2mcp server starting")
78
+ print(f" Command: {command}")
79
+ print(f" Tools: {tool_count}")
80
+ print(f" File: {args.tools_file}")
81
+ print(f" Transport: {args.transport}")
82
+ print()
83
+ print("Press Ctrl+C to stop the server.")
84
+ sys.stdout.flush()
85
+
86
+ def _shutdown(signum, frame):
87
+ print("\nShutting down.")
88
+ sys.exit(0)
89
+
90
+ signal.signal(signal.SIGINT, _shutdown)
91
+ signal.signal(signal.SIGTERM, _shutdown)
92
+
93
+ server = create_server(args.tools_file)
94
+ server.run(transport=args.transport)
cli2mcp/parser.py ADDED
@@ -0,0 +1,30 @@
1
+ """Auto-detect CLI help style and delegate to the right parser.
2
+
3
+ Detection order: Cobra first (most distinctive), then GNU, then Plain
4
+ (catch-all).
5
+ """
6
+
7
+ from cli2mcp.parsers.cobra import can_parse as _is_cobra, parse as _parse_cobra
8
+ from cli2mcp.parsers.gnu import can_parse as _is_gnu, parse as _parse_gnu
9
+ from cli2mcp.parsers.plain import parse as _parse_plain
10
+
11
+ _PARSERS = [
12
+ ("cobra", _is_cobra, _parse_cobra),
13
+ ("gnu", _is_gnu, _parse_gnu),
14
+ ]
15
+
16
+
17
+ def detect_style(text):
18
+ """Return 'cobra', 'gnu', or 'plain'."""
19
+ for name, can_parse, _ in _PARSERS:
20
+ if can_parse(text):
21
+ return name
22
+ return "plain"
23
+
24
+
25
+ def parse_help(text):
26
+ """Parse --help output into {description, args, subcommands}."""
27
+ for _, can_parse, parse_fn in _PARSERS:
28
+ if can_parse(text):
29
+ return parse_fn(text)
30
+ return _parse_plain(text)
@@ -0,0 +1,6 @@
1
+ """Help-text parsers for different CLI styles.
2
+
3
+ - ``plain`` -- curl, busybox (no section headers)
4
+ - ``gnu`` -- argparse, click (section headers, single-line flags)
5
+ - ``cobra`` -- kubectl, oc, docker (multi-line flags)
6
+ """
@@ -0,0 +1,175 @@
1
+ """Parser for Go Cobra style help text (kubectl, oc, docker, gh, ...).
2
+
3
+ Flags span two lines: name + default on line 1, description on line 2.
4
+ Subcommands are listed under category headers.
5
+
6
+ Manage your cluster:
7
+ get Display one or many resources
8
+ describe Show details of a resource
9
+
10
+ Options:
11
+ --all-namespaces=false:
12
+ If present, list across all namespaces.
13
+
14
+ -o, --output='':
15
+ Output format. One of: json|yaml|wide|name.
16
+ """
17
+
18
+ import re
19
+
20
+ from cli2mcp.parsers.common import extract_positional_args, make_description
21
+
22
+
23
+ def can_parse(text):
24
+ """True if text has Cobra-style multi-line flags or the Cobra footer."""
25
+ for line in text.splitlines():
26
+ stripped = line.strip()
27
+ if stripped.startswith("-") and "=" in stripped and stripped.endswith(":"):
28
+ return True
29
+ if re.match(r'Use ".+ <command> --help"', stripped):
30
+ return True
31
+ return False
32
+
33
+
34
+ def parse(text):
35
+ """Parse Cobra-style help using a state machine.
36
+
37
+ States: description, options, subcommands, skip.
38
+ The key pattern is "pending flag": when we see '--flag=default:',
39
+ we store the name and read the next line as its description.
40
+ """
41
+ description_lines = []
42
+ args = []
43
+ subcommands = []
44
+ state = "description"
45
+ pending_flag = None
46
+
47
+ for line in text.splitlines():
48
+ # If the previous line was a flag header, this line is its description.
49
+ if pending_flag is not None:
50
+ desc = line.strip()
51
+ if desc:
52
+ args.append(
53
+ {
54
+ "name": pending_flag,
55
+ "description": desc,
56
+ "required": False,
57
+ }
58
+ )
59
+ pending_flag = None
60
+ continue
61
+
62
+ header = _detect_header(line)
63
+ if header is not None:
64
+ if header in ("options", "flags"):
65
+ state = "options"
66
+ elif header in ("examples", "usage", "global flags"):
67
+ state = "skip"
68
+ else:
69
+ state = "subcommands"
70
+ continue
71
+
72
+ if state == "description":
73
+ stripped = line.strip()
74
+ if stripped.lower().startswith("usage"):
75
+ state = "skip"
76
+ continue
77
+ if stripped:
78
+ description_lines.append(stripped)
79
+ elif description_lines:
80
+ state = "skip"
81
+
82
+ elif state == "options":
83
+ flag_name = _parse_flag_header(line)
84
+ if flag_name is not None:
85
+ pending_flag = flag_name
86
+ else:
87
+ arg = _parse_single_line_flag(line)
88
+ if arg is not None:
89
+ args.append(arg)
90
+
91
+ elif state == "subcommands":
92
+ stripped = line.strip()
93
+ if not stripped:
94
+ continue
95
+ if stripped.startswith("-"):
96
+ flag_name = _parse_flag_header(line)
97
+ if flag_name is not None:
98
+ pending_flag = flag_name
99
+ else:
100
+ name = _parse_subcommand_line(line)
101
+ if name is not None:
102
+ subcommands.append(name)
103
+
104
+ args.extend(extract_positional_args(text))
105
+
106
+ return {
107
+ "description": make_description(description_lines, text),
108
+ "args": args,
109
+ "subcommands": subcommands,
110
+ }
111
+
112
+
113
+ def _detect_header(line):
114
+ """Return section name if line is a header like 'Options:', else None."""
115
+ stripped = line.strip()
116
+ if not stripped.endswith(":") or stripped.startswith("-"):
117
+ return None
118
+ if len(line) - len(line.lstrip()) > 4:
119
+ return None
120
+ return stripped[:-1].strip().lower()
121
+
122
+
123
+ def _parse_flag_header(line):
124
+ """Parse '--output=default:' and return the flag name."""
125
+ stripped = line.strip()
126
+ if not stripped.startswith("-"):
127
+ return None
128
+
129
+ match = re.match(
130
+ r"(-\S+(?:,\s*-\S+)*)" # flag name(s)
131
+ r"(?:=\S*)?" # optional default
132
+ r":$", # trailing colon
133
+ stripped,
134
+ )
135
+ if not match:
136
+ return None
137
+
138
+ flags = match.group(1)
139
+ names = [f.strip() for f in flags.split(",")]
140
+ return max(names, key=len)
141
+
142
+
143
+ def _parse_single_line_flag(line):
144
+ """Parse '--output string Output format' (single-line modern Cobra flags)."""
145
+ stripped = line.strip()
146
+ if not stripped or not stripped.startswith("-"):
147
+ return None
148
+
149
+ match = re.match(
150
+ r"(-\S+(?:,\s*-\S+)*)" # flag name(s)
151
+ r"(?:\s+\S+)?" # optional type/metavar
152
+ r"\s{2,}" # gap (2+ spaces)
153
+ r"(.+)", # description
154
+ stripped,
155
+ )
156
+ if match:
157
+ flags = match.group(1)
158
+ names = [f.strip() for f in flags.split(",")]
159
+ return {
160
+ "name": max(names, key=len),
161
+ "description": match.group(2).strip(),
162
+ "required": False,
163
+ }
164
+ return None
165
+
166
+
167
+ def _parse_subcommand_line(line):
168
+ """Parse ' get Display one or many resources' into the name."""
169
+ stripped = line.strip()
170
+ if not stripped:
171
+ return None
172
+ match = re.match(r"(\w[\w-]*)\s{2,}(.+)", stripped)
173
+ if match:
174
+ return match.group(1)
175
+ return None
@@ -0,0 +1,42 @@
1
+ """Shared helpers used by all parsers."""
2
+
3
+ import re
4
+
5
+
6
+ def make_description(description_lines, text):
7
+ """Join description lines, or fall back to the command name from Usage."""
8
+ if description_lines:
9
+ return " ".join(description_lines)
10
+
11
+ for line in text.splitlines():
12
+ stripped = line.strip()
13
+ if stripped.lower().startswith("usage"):
14
+ # Capture "git commit" from "usage: git commit [-a] ..."
15
+ match = re.match(r"[Uu]sage:\s*([\w][\w.-]*(?:\s+[\w][\w.-]*)*)", stripped)
16
+ if match:
17
+ return f"{match.group(1)} command"
18
+ return ""
19
+
20
+
21
+ def extract_positional_args(text):
22
+ """Extract positional args from the Usage line (e.g. <url>)."""
23
+ args = []
24
+ seen = set()
25
+
26
+ for line in text.splitlines():
27
+ if not line.strip().lower().startswith("usage"):
28
+ continue
29
+
30
+ for match in re.finditer(r"<(\w[\w-]*)>", line):
31
+ name = match.group(1).lower()
32
+ if name not in seen:
33
+ args.append(
34
+ {
35
+ "name": name,
36
+ "description": name,
37
+ "required": True,
38
+ }
39
+ )
40
+ seen.add(name)
41
+
42
+ return args
cli2mcp/parsers/gnu.py ADDED
@@ -0,0 +1,142 @@
1
+ """Parser for GNU / Python argparse style help text.
2
+
3
+ These tools have section headers like "Options:" and "Commands:".
4
+ Flags and descriptions sit on the same line:
5
+
6
+ usage: mytool [-v] [-o FILE] filename
7
+
8
+ My awesome tool for processing files.
9
+
10
+ Options:
11
+ -v, --verbose Enable verbose output
12
+ -o, --output FILE Output file path
13
+
14
+ Commands:
15
+ run Run the tool
16
+ check Check the input
17
+ """
18
+
19
+ import re
20
+
21
+ from cli2mcp.parsers.common import extract_positional_args, make_description
22
+
23
+ _POSITIONAL_HEADERS = {"positional arguments", "positional"}
24
+ _OPTION_HEADERS = {"options", "optional arguments", "flags"}
25
+ _SUBCOMMAND_HEADERS = {"commands", "subcommands", "available commands"}
26
+
27
+
28
+ def can_parse(text):
29
+ """True if text has at least one known section header."""
30
+ for line in text.splitlines():
31
+ stripped = line.strip()
32
+ if not stripped.endswith(":") or stripped.startswith("-"):
33
+ continue
34
+ name = stripped[:-1].strip().lower()
35
+ if name in _POSITIONAL_HEADERS | _OPTION_HEADERS | _SUBCOMMAND_HEADERS:
36
+ return True
37
+ return False
38
+
39
+
40
+ def parse(text):
41
+ """Parse GNU-style help using a simple state machine.
42
+
43
+ States: description, positional, options, subcommands, skip.
44
+ Transitions happen when we hit a section header.
45
+ """
46
+ description_lines = []
47
+ args = []
48
+ subcommands = []
49
+ state = "description"
50
+
51
+ for line in text.splitlines():
52
+ header = _detect_header(line)
53
+ if header is not None:
54
+ if header in _POSITIONAL_HEADERS:
55
+ state = "positional"
56
+ elif header in _OPTION_HEADERS:
57
+ state = "options"
58
+ elif header in _SUBCOMMAND_HEADERS:
59
+ state = "subcommands"
60
+ else:
61
+ state = "skip"
62
+ continue
63
+
64
+ if state == "description":
65
+ stripped = line.strip()
66
+ if stripped.lower().startswith("usage"):
67
+ state = "skip"
68
+ continue
69
+ if stripped:
70
+ description_lines.append(stripped)
71
+ elif description_lines:
72
+ state = "skip"
73
+
74
+ elif state == "positional":
75
+ arg = _parse_flag_line(line)
76
+ if arg is not None:
77
+ arg["required"] = True
78
+ args.append(arg)
79
+
80
+ elif state == "options":
81
+ arg = _parse_flag_line(line)
82
+ if arg is not None:
83
+ arg["required"] = False
84
+ args.append(arg)
85
+
86
+ elif state == "subcommands":
87
+ name = _parse_subcommand_line(line)
88
+ if name is not None:
89
+ subcommands.append(name)
90
+
91
+ args.extend(extract_positional_args(text))
92
+
93
+ return {
94
+ "description": make_description(description_lines, text),
95
+ "args": args,
96
+ "subcommands": subcommands,
97
+ }
98
+
99
+
100
+ def _detect_header(line):
101
+ """Return section name if line is a header like 'Options:', else None."""
102
+ stripped = line.strip()
103
+ if not stripped.endswith(":") or stripped.startswith("-"):
104
+ return None
105
+ if len(line) - len(line.lstrip()) > 4:
106
+ return None
107
+ return stripped[:-1].strip().lower()
108
+
109
+
110
+ def _parse_flag_line(line):
111
+ """Parse '-v, --verbose Enable verbose output' into a dict."""
112
+ stripped = line.strip()
113
+ if not stripped or stripped.startswith("---"):
114
+ return None
115
+
116
+ match = re.match(
117
+ r"(-\S+(?:,\s*-\S+)*)" # flag name(s)
118
+ r"(?:\s+\S+)?" # optional metavar
119
+ r"\s{2,}" # gap
120
+ r"(.+)", # description
121
+ stripped,
122
+ )
123
+ if match:
124
+ flags = match.group(1)
125
+ names = [f.strip() for f in flags.split(",")]
126
+ return {
127
+ "name": max(names, key=len),
128
+ "description": match.group(2).strip(),
129
+ "required": False,
130
+ }
131
+ return None
132
+
133
+
134
+ def _parse_subcommand_line(line):
135
+ """Parse ' commit Record changes' into the subcommand name."""
136
+ stripped = line.strip()
137
+ if not stripped:
138
+ return None
139
+ match = re.match(r"(\w[\w-]*)\s{2,}(.+)", stripped)
140
+ if match:
141
+ return match.group(1)
142
+ return None
@@ -0,0 +1,112 @@
1
+ """Parser for plain / minimal help text (curl, busybox, ...).
2
+
3
+ These tools print flags directly after a Usage: line with no
4
+ section headers like "Options:" or "Commands:".
5
+
6
+ Usage: curl [options...] <url>
7
+ -d, --data <data> HTTP POST data
8
+ -f, --fail Fail fast with no output on HTTP errors
9
+ """
10
+
11
+ import re
12
+
13
+ from cli2mcp.parsers.common import extract_positional_args, make_description
14
+
15
+
16
+ def can_parse(text):
17
+ """True if text has flag lines but no section headers."""
18
+ has_flags = False
19
+ has_headers = False
20
+
21
+ for line in text.splitlines():
22
+ stripped = line.strip()
23
+ if stripped.startswith("-") and " " in stripped:
24
+ has_flags = True
25
+ if (
26
+ stripped.endswith(":")
27
+ and not stripped.startswith("-")
28
+ and len(line) - len(line.lstrip()) <= 4
29
+ ):
30
+ name = stripped[:-1].strip().lower()
31
+ if name in (
32
+ "options",
33
+ "commands",
34
+ "positional arguments",
35
+ "optional arguments",
36
+ "subcommands",
37
+ "flags",
38
+ ):
39
+ has_headers = True
40
+
41
+ return has_flags and not has_headers
42
+
43
+
44
+ def parse(text):
45
+ """Parse plain-style help text.
46
+
47
+ 1. Collect description lines before "Usage:"
48
+ 2. After Usage:, parse "-" lines as flags and "word desc" as subcommands
49
+ """
50
+ description_lines = []
51
+ args = []
52
+ subcommands = []
53
+ past_usage = False
54
+
55
+ for line in text.splitlines():
56
+ stripped = line.strip()
57
+
58
+ if not past_usage:
59
+ if stripped.lower().startswith("usage"):
60
+ past_usage = True
61
+ continue
62
+ if stripped:
63
+ description_lines.append(stripped)
64
+ continue
65
+
66
+ if not stripped:
67
+ continue
68
+
69
+ if stripped.startswith("-"):
70
+ arg = _parse_flag_line(stripped)
71
+ if arg is not None:
72
+ args.append(arg)
73
+ else:
74
+ name = _parse_subcommand_line(stripped)
75
+ if name is not None:
76
+ subcommands.append(name)
77
+
78
+ args.extend(extract_positional_args(text))
79
+
80
+ return {
81
+ "description": make_description(description_lines, text),
82
+ "args": args,
83
+ "subcommands": subcommands,
84
+ }
85
+
86
+
87
+ def _parse_flag_line(stripped):
88
+ """Parse '-d, --data <data> HTTP POST data' into a dict."""
89
+ match = re.match(
90
+ r"(-\S+(?:,\s*-\S+)*)" # flag name(s)
91
+ r"(?:\s+\S+)?" # optional metavar
92
+ r"\s{2,}" # gap
93
+ r"(.+)", # description
94
+ stripped,
95
+ )
96
+ if match:
97
+ flags = match.group(1)
98
+ names = [f.strip() for f in flags.split(",")]
99
+ return {
100
+ "name": max(names, key=len),
101
+ "description": match.group(2).strip(),
102
+ "required": False,
103
+ }
104
+ return None
105
+
106
+
107
+ def _parse_subcommand_line(stripped):
108
+ """Parse ' clone Clone a repository' into the subcommand name."""
109
+ match = re.match(r"(\w[\w-]*)\s{2,}(.+)", stripped)
110
+ if match:
111
+ return match.group(1)
112
+ return None