mcp2cli 0.1.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.
mcp2cli/__init__.py ADDED
File without changes
mcp2cli/main.py ADDED
@@ -0,0 +1,281 @@
1
+ import asyncio
2
+ import inspect
3
+ import json
4
+ import os
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from typing import Optional, List, Dict, Any, Tuple
9
+
10
+ from mcp.types import Tool, LoggingMessageNotification, SamplingMessage
11
+ from mcp.client.session import ClientSession
12
+ from mcp.client.streamable_http import streamablehttp_client
13
+ from mcp.client.stdio import stdio_client, StdioServerParameters
14
+
15
+
16
+ console = Console()
17
+
18
+
19
+ class MCPCLI:
20
+ """
21
+ A CLI for MCP servers, dynamically generating commands from tool definitions.
22
+ """
23
+
24
+ def __init__(self, config_path: str = "mcp.json"):
25
+ """
26
+ Initializes the MCPCLI, loading server configurations and setting up the Typer app.
27
+ """
28
+ self.config_path = config_path
29
+ self.servers: Dict[str, Dict[str, Any]] = {}
30
+ self.tools: Dict[str, List[str]] = {} # server_name -> list of tool_names
31
+ self.tool_to_servers: Dict[str, List[str]] = {} # tool_name -> list of server_names
32
+ self._app = typer.Typer(rich_markup_mode="rich", add_help_option=True, pretty_exceptions_show_locals=False)
33
+ self._app.callback(invoke_without_command=True)(self.main)
34
+
35
+ def _show_help(self):
36
+ """
37
+ Displays all available tools from all servers in a table, grouped by server.
38
+ """
39
+ table = Table(title="[bold]Available MCP Tools[/bold]")
40
+ table.add_column("Server", style="cyan", no_wrap=True)
41
+ table.add_column("Tool Name", style="magenta")
42
+ table.add_column("Description", style="green")
43
+
44
+ for server_name, tools in sorted(self.tools.items()):
45
+ if tools:
46
+ server_config = self.servers.get(server_name, {})
47
+ for i, tool_name in enumerate(sorted(tools)):
48
+ tool_def = next((t for t in server_config.get("tools", []) if t.name == tool_name), None)
49
+ description = tool_def.description if tool_def else ""
50
+ if i == 0:
51
+ table.add_row(f"[bold]{server_name}[/bold]", tool_name, description, end_section=True if len(tools) == 1 else False)
52
+ else:
53
+ table.add_row("", tool_name, description, end_section=True if i == len(tools) - 1 else False)
54
+
55
+ console.print(table)
56
+
57
+ def main(self, ctx: typer.Context):
58
+ """
59
+ A CLI for MCP servers.
60
+ """
61
+ if ctx.invoked_subcommand is None:
62
+ self._show_help()
63
+
64
+ async def _get_client_session(self, server_name: str):
65
+ server_config = self.servers[server_name]
66
+
67
+ if "url" in server_config:
68
+ return streamablehttp_client(server_config["url"])
69
+ elif "command" in server_config:
70
+ command_parts = server_config["command"].split()
71
+ command = command_parts[0]
72
+ args = command_parts[1:] + server_config.get("args", [])
73
+ env = server_config.get("env", {})
74
+ params = StdioServerParameters(command=command, args=args, env=env)
75
+ return stdio_client(params)
76
+ else:
77
+ raise ValueError(f"Unknown or invalid transport configuration for server {server_name}")
78
+
79
+ async def _load_server_tools(self, server_name: str, server_config: Dict[str, Any]):
80
+ session_context = None
81
+ try:
82
+ session_context = await self._get_client_session(server_name)
83
+ tools = []
84
+ if 'command' in server_config:
85
+ async with session_context as client_streams:
86
+ read, write = client_streams
87
+ async with ClientSession(read, write) as session:
88
+ await session.initialize()
89
+ tool_list = await session.list_tools()
90
+ tools = tool_list.tools
91
+ else:
92
+ async with session_context as (read, write, _):
93
+ async with ClientSession(read, write) as session:
94
+ await session.initialize()
95
+ tool_list = await session.list_tools()
96
+ tools = tool_list.tools
97
+
98
+ self.servers[server_name]["tools"] = tools
99
+ tool_names = [tool.name for tool in tools]
100
+ self.tools[server_name] = tool_names
101
+
102
+ for tool in tools:
103
+ if tool.name not in self.tool_to_servers:
104
+ self.tool_to_servers[tool.name] = []
105
+ self.tool_to_servers[tool.name].append(server_name)
106
+ except Exception as e:
107
+ console.print(f"Error connecting to server '{server_name}': {e}", style="bold red")
108
+ import traceback
109
+ traceback.print_exc()
110
+
111
+ def _add_tool_commands(self):
112
+ for tool_name, server_names in self.tool_to_servers.items():
113
+ first_server_name = server_names[0]
114
+ server_config = self.servers[first_server_name]
115
+ tool_def = next((t for t in server_config.get("tools", []) if t.name == tool_name), None)
116
+
117
+ if tool_def:
118
+ command_callback = self._create_command_callback(tool_name, tool_def, len(server_names) > 1)
119
+ self._app.command(name=tool_name, help=tool_def.description)(command_callback)
120
+
121
+ async def _load_all_servers(self):
122
+ if not os.path.exists(self.config_path):
123
+ console.print(f"Error: Configuration file not found at {self.config_path}", style="bold red")
124
+ raise typer.Exit(code=1)
125
+
126
+ with open(self.config_path, "r") as f:
127
+ config = json.load(f)
128
+
129
+ self.servers = config.get("mcpServers", {})
130
+ for name, conf in self.servers.items():
131
+ try:
132
+ await self._load_server_tools(name, conf)
133
+ except Exception as e:
134
+ console.print(f"Failed to load server '{name}': {e}", style="bold red")
135
+
136
+ def _create_command_callback(self, tool_name: str, tool_def: Tool, needs_server_option: bool):
137
+
138
+ def callback(
139
+ ctx: typer.Context,
140
+ server_name: Optional[str] = typer.Option(None, "--server-name", help="Specify the server for the command."),
141
+ **kwargs
142
+ ):
143
+ async def async_main():
144
+ chosen_server = server_name
145
+ if not chosen_server:
146
+ server_options = self.tool_to_servers.get(tool_name, [])
147
+ if len(server_options) == 1:
148
+ chosen_server = server_options[0]
149
+ else:
150
+ console.print(f"Tool '{tool_name}' is available on multiple servers: {server_options}. Please specify one with --server-name.", style="bold red")
151
+ raise typer.Exit(code=1)
152
+
153
+ params = {k: v for k, v in kwargs.items() if k not in ['server_name'] and v is not None}
154
+
155
+ try:
156
+ server_config = self.servers[chosen_server]
157
+ session_context = await self._get_client_session(chosen_server)
158
+
159
+ async def process_result(session):
160
+ await session.initialize()
161
+ call_result = await session.call_tool(tool_name, params)
162
+
163
+ # If the result is async iterable (streaming)
164
+ if hasattr(call_result, "__aiter__"):
165
+ final_result = None
166
+ async for item in call_result:
167
+ if isinstance(item, LoggingMessageNotification):
168
+ if item.params and hasattr(item.params, 'data') and isinstance(item.params.data, str):
169
+ console.print(item.params.data, end="")
170
+ elif isinstance(item, SamplingMessage) and item.role == "assistant":
171
+ if hasattr(item.content, 'text'):
172
+ final_result = item.content.text
173
+
174
+ if final_result is not None:
175
+ # Try to coerce to int/float if possible
176
+ try:
177
+ result_val = int(final_result)
178
+ except (ValueError, TypeError):
179
+ try:
180
+ result_val = float(final_result)
181
+ except (ValueError, TypeError):
182
+ result_val = final_result
183
+ console.print(json.dumps({"result": result_val}))
184
+ else:
185
+ # Non-streaming result (e.g., CallToolResult)
186
+ result_val = None
187
+ # Prefer structuredContent if available
188
+ if hasattr(call_result, "structuredContent") and call_result.structuredContent is not None:
189
+ sc = call_result.structuredContent
190
+ if isinstance(sc, dict) and "result" in sc:
191
+ result_val = sc["result"]
192
+ else:
193
+ result_val = sc
194
+ elif hasattr(call_result, "content") and call_result.content:
195
+ # content may be list of TextContent objects
196
+ first = call_result.content[0] if isinstance(call_result.content, list) else call_result.content
197
+ if hasattr(first, "text"):
198
+ try:
199
+ result_val = int(first.text)
200
+ except (ValueError, TypeError):
201
+ try:
202
+ result_val = float(first.text)
203
+ except (ValueError, TypeError):
204
+ result_val = first.text
205
+ else:
206
+ # Fallback: the object itself might be JSON serialisable
207
+ result_val = call_result
208
+
209
+ console.print(json.dumps({"result": result_val}))
210
+
211
+ if 'command' in server_config:
212
+ async with session_context as client_streams:
213
+ read, write = client_streams
214
+ async with ClientSession(read, write) as session:
215
+ await process_result(session)
216
+ else:
217
+ async with session_context as (read, write, _):
218
+ async with ClientSession(read, write) as session:
219
+ await process_result(session)
220
+
221
+ except Exception as e:
222
+ console.print(f"Error calling tool {tool_name} on server {chosen_server}: {e}", style="bold red")
223
+
224
+ asyncio.run(async_main())
225
+
226
+ # Dynamically create the signature for Typer
227
+ sig_params = [
228
+ inspect.Parameter('ctx', inspect.Parameter.POSITIONAL_OR_KEYWORD)
229
+ ]
230
+
231
+ if needs_server_option:
232
+ sig_params.append(inspect.Parameter('server_name', inspect.Parameter.KEYWORD_ONLY, annotation=Optional[str], default=typer.Option(None, "--server-name", help="Specify the server for the command.")))
233
+ else:
234
+ sig_params.append(inspect.Parameter('server_name', inspect.Parameter.KEYWORD_ONLY, annotation=Optional[str], default=typer.Option(None, "--server-name", help="Specify the server for the command.", hidden=True)))
235
+
236
+
237
+ for p_name, p_schema in tool_def.inputSchema.get("properties", {}).items():
238
+ schema_type = p_schema.get("type", "string")
239
+ if schema_type == "integer":
240
+ param_type = int
241
+ elif schema_type == "number":
242
+ param_type = float
243
+ elif schema_type == "boolean":
244
+ param_type = bool
245
+ else:
246
+ param_type = str
247
+
248
+ sig_params.append(
249
+ inspect.Parameter(
250
+ p_name,
251
+ inspect.Parameter.KEYWORD_ONLY,
252
+ annotation=param_type,
253
+ default=typer.Option(None, f"--{p_name}", help=p_schema.get("description"))
254
+ )
255
+ )
256
+
257
+ callback.__signature__ = inspect.Signature(parameters=sig_params)
258
+ return callback
259
+
260
+ async def _prepare_cli(self):
261
+ await self._load_all_servers()
262
+ self._add_tool_commands()
263
+
264
+ def run(self):
265
+ """
266
+ Loads all servers and runs the Typer application.
267
+ """
268
+ self._app()
269
+
270
+
271
+ def run_cli():
272
+ """Initializes and runs the MCP-CLI application."""
273
+ cli = MCPCLI()
274
+ try:
275
+ asyncio.run(cli._prepare_cli())
276
+ cli.run()
277
+ except Exception as e:
278
+ console.print(f"Failed to prepare CLI: {e}", style="bold red")
279
+
280
+ if __name__ == "__main__":
281
+ run_cli()
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp2cli
3
+ Version: 0.1.0
4
+ Summary: A command-line tool to interact with Model Context Protocol (MCP) servers.
5
+ Project-URL: Homepage, https://github.com/PsychArch/mcp2cli
6
+ Project-URL: Bug Tracker, https://github.com/PsychArch/mcp2cli/issues
7
+ Author-email: PsychArch <psycharch@users.noreply.github.com>
8
+ License: Copyright (c) 2025 PsychArch
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ License-File: LICENSE
28
+ Classifier: License :: OSI Approved :: MIT License
29
+ Classifier: Operating System :: OS Independent
30
+ Classifier: Programming Language :: Python :: 3
31
+ Classifier: Programming Language :: Python :: 3.13
32
+ Requires-Python: >=3.11
33
+ Requires-Dist: mcp[cli]>=1.10.1
34
+ Requires-Dist: rich>=14.0.0
35
+ Requires-Dist: typer>=0.16.0
36
+ Description-Content-Type: text/markdown
37
+
38
+ # mcp2cli
39
+
40
+ A command-line interface (CLI) for interacting with Model Context Protocol (MCP) servers. It dynamically generates CLI commands from the tools exposed by an MCP server.
41
+
42
+ ## Configuration
43
+
44
+ `mcp2cli` requires a `mcp.json` file in the current directory to define available MCP servers.
45
+
46
+ **`mcp.json` format:**
47
+
48
+ ```json
49
+ {
50
+ "mcpServers": {
51
+ "local_server": {
52
+ "command": "uv",
53
+ "args": ["run", "python", "examples/server.py", "--transport", "stdio"]
54
+ },
55
+ "remote_server": {
56
+ "url": "http://127.0.0.1:8000/mcp"
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ - `command` and `args`: For servers managed by `mcp2cli` (e.g., via `stdio`).
63
+ - `url`: For remotely accessible servers (e.g., via `http`).
64
+
65
+ ## Usage
66
+
67
+ **List all available tools:**
68
+ ```bash
69
+ uvx mcp2cli
70
+ ```
71
+
72
+ **Execute a tool:**
73
+ ```bash
74
+ # Format: uvx mcp2cli <tool_name> [tool_arguments]
75
+ uvx mcp2cli sum --a 5 --b 3
76
+ ```
77
+
78
+ **Get help for a tool:**
79
+ ```bash
80
+ uvx mcp2cli <tool_name> --help
81
+ ```
82
+
83
+ **Target a specific server** (if a tool is on multiple servers):
84
+ ```bash
85
+ uvx mcp2cli <tool_name> --server-name <server_name>
86
+ ```
87
+
88
+ ## Example Server
89
+
90
+ The project includes an example server in `examples/server.py`.
91
+
92
+ 1. **Run the HTTP server:**
93
+ ```bash
94
+ uv run python examples/server.py --transport http
95
+ ```
96
+ 2. **Configure `mcp.json`** to connect to it:
97
+ ```json
98
+ {
99
+ "mcpServers": {
100
+ "http_server": { "url": "http://127.0.0.1:8000/mcp" }
101
+ }
102
+ }
103
+ ```
104
+ 3. **Use the CLI:**
105
+ ```bash
106
+ uvx mcp2cli sum --a 10 --b 20
107
+ ```
@@ -0,0 +1,7 @@
1
+ mcp2cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ mcp2cli/main.py,sha256=Ti2MhiMHNuTETy58yZMRk9lSgu8BmR1iu6rFX5CS2Wg,13072
3
+ mcp2cli-0.1.0.dist-info/METADATA,sha256=4IfvntGqwtoj_irXX8VYUYtMMCk4o-Nme7790RoYHAg,3412
4
+ mcp2cli-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
5
+ mcp2cli-0.1.0.dist-info/entry_points.txt,sha256=c7zbrVmtJ_GTIUAn0FzlM_L6CYG3bTZ20dwjzDMMwcU,49
6
+ mcp2cli-0.1.0.dist-info/licenses/LICENSE,sha256=6seH8A9HOZgZdiqgzNQGscugTmNfrG1A5TVOLm3dy08,1053
7
+ mcp2cli-0.1.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
+ mcp2cli = mcp2cli.main:run_cli
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2025 PsychArch
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.