Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions click_to_mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ def run(app: Any, prefix: str = "", name: str = "") -> None:
)


def run_http(app: Any, prefix: str = "", name: str = "",
host: str = "127.0.0.1", port: int = 8000) -> None:
def run_http(app: Any, prefix: str = "", name: str = "", host: str = "127.0.0.1", port: int = 8000) -> None:
"""High-level entry point: serve a Click/typer app as an MCP server over HTTP+SSE.

Requires optional dependencies: pip install 'click-to-mcp[http]'
Expand All @@ -95,8 +94,7 @@ def run_http(app: Any, prefix: str = "", name: str = "",
)


def run_http_streamable(app: Any, prefix: str = "", name: str = "",
host: str = "127.0.0.1", port: int = 8001) -> None:
def run_http_streamable(app: Any, prefix: str = "", name: str = "", host: str = "127.0.0.1", port: int = 8001) -> None:
"""High-level entry point: serve a Click/typer app as an MCP Streamable HTTP server.

Uses a single POST /message endpoint — no SSE required. Simpler than the
Expand Down Expand Up @@ -126,7 +124,14 @@ def run_http_streamable(app: Any, prefix: str = "", name: str = "",

__all__ = [
"__version__",
"cli_to_mcp_tools", "CliToolDef", "serve_stdio", "run", "run_http",
"cli_to_mcp_tools",
"CliToolDef",
"serve_stdio",
"run",
"run_http",
"run_http_streamable",
"scan_entry_points", "load_cli", "find_our_clis", "DiscoveredCLI",
"scan_entry_points",
"load_cli",
"find_our_clis",
"DiscoveredCLI",
]
1 change: 1 addition & 0 deletions click_to_mcp/__main__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Entry point for running click-to-mcp as 'python -m click_to_mcp'."""

from .cli import main

main()
1 change: 1 addition & 0 deletions click_to_mcp/_version.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
"""Package version."""

__version__ = "0.5.0"
10 changes: 4 additions & 6 deletions click_to_mcp/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def _click_type_to_json_schema(param: click.Parameter) -> dict[str, Any]:
else:
base["type"] = "string"

base["description"] = getattr(param, 'help', None) or ""
base["description"] = getattr(param, "help", None) or ""
if not param.required:
default = param.default if param.default is not None and param.default != () else None
# Check for Click Sentinel (UNSET) values
Expand Down Expand Up @@ -121,8 +121,7 @@ def handler(**kwargs: Any) -> str:
result = runner.invoke(cmd, args, catch_exceptions=False)
if result.exit_code != 0:
raise RuntimeError(
f"Command '{full_name}' failed (exit {result.exit_code}):\n"
f"{result.output}\n{result.exception}"
f"Command '{full_name}' failed (exit {result.exit_code}):\n{result.output}\n{result.exception}"
)
return result.output

Expand Down Expand Up @@ -152,6 +151,7 @@ def _get_click_group(cli: Any) -> click.Group:
# typer.Typer — use Typer's own get_command to produce a click Group
if hasattr(cli, "registered_commands") and hasattr(cli, "registered_groups"):
from typer.main import get_command as typer_get_command

click_cmd = typer_get_command(cli)
if isinstance(click_cmd, click.Group):
return click_cmd
Expand All @@ -160,9 +160,7 @@ def _get_click_group(cli: Any) -> click.Group:
group.add_command(click_cmd)
return group

raise TypeError(
f"Expected click.Group or typer.Typer, got {type(cli).__name__}"
)
raise TypeError(f"Expected click.Group or typer.Typer, got {type(cli).__name__}")


def cli_to_mcp_tools(cli, prefix: str = "") -> list[CliToolDef]:
Expand Down
50 changes: 34 additions & 16 deletions click_to_mcp/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,13 @@ def list_tools(name: str | None, list_all: bool, json_output: bool) -> None:
if json_output:
output = []
for tool in all_tools:
output.append({
"name": tool.name,
"description": tool.description,
"input_schema": tool.input_schema,
})
output.append(
{
"name": tool.name,
"description": tool.description,
"input_schema": tool.input_schema,
}
)
click.echo(_json.dumps(output, indent=2))
else:
click.echo(f"Found {len(all_tools)} MCP tool(s):")
Expand Down Expand Up @@ -290,20 +292,33 @@ def demo_http_streamable(host: str, port: int) -> None:

@cli.command("config")
@click.argument("name", required=False, default=None)
@click.option("--client", "-c", type=click.Choice(
["claude-desktop", "cursor", "vscode", "windsurf", "cline"],
case_sensitive=False,
), default="claude-desktop", help="MCP client to generate config for (default: claude-desktop)")
@click.option("--transport", "-t", type=click.Choice(
["stdio", "http", "streamable-http"],
case_sensitive=False,
), default="stdio", help="Transport type (default: stdio)")
@click.option(
"--client",
"-c",
type=click.Choice(
["claude-desktop", "cursor", "vscode", "windsurf", "cline"],
case_sensitive=False,
),
default="claude-desktop",
help="MCP client to generate config for (default: claude-desktop)",
)
@click.option(
"--transport",
"-t",
type=click.Choice(
["stdio", "http", "streamable-http"],
case_sensitive=False,
),
default="stdio",
help="Transport type (default: stdio)",
)
@click.option("--host", default="127.0.0.1", help="Host for HTTP transport (default: 127.0.0.1)")
@click.option("--port", default=8000, type=int, help="Port for HTTP transport (default: 8000; streamable-http: 8001)")
@click.option("--all", "config_all", is_flag=True, help="Generate config for all discoverable CLIs")
@click.option("--copy", "copy_to_clipboard", is_flag=True, help="Copy to clipboard instead of stdout")
def config(name: str | None, client: str, transport: str, host: str, port: int,
config_all: bool, copy_to_clipboard: bool):
def config(
name: str | None, client: str, transport: str, host: str, port: int, config_all: bool, copy_to_clipboard: bool
):
"""Generate MCP client configuration JSON.

Prints the JSON snippet to add to your MCP client config file.
Expand Down Expand Up @@ -393,6 +408,7 @@ def config(name: str | None, client: str, transport: str, host: str, port: int,

if copy_to_clipboard:
import subprocess as _sp

try:
platform_cmds = {
"win32": ["clip"],
Expand Down Expand Up @@ -423,7 +439,9 @@ def config(name: str | None, client: str, transport: str, host: str, port: int,
click.echo(f" {config_paths.get(client_norm, 'client config file')}", err=True)
if transport != "stdio":
transport_cmd = "serve-http-streamable" if transport == "streamable-http" else "serve-http"
click.echo(f"\nNote: Start the server first: click-to-mcp {transport_cmd} {cli_names[0]} --port {port}", err=True)
click.echo(
f"\nNote: Start the server first: click-to-mcp {transport_cmd} {cli_names[0]} --port {port}", err=True
)


def main() -> None:
Expand Down
26 changes: 16 additions & 10 deletions click_to_mcp/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
@dataclass
class DiscoveredCLI:
"""Information about a discovered Click/typer CLI."""

name: str
module_path: str
attr_name: str
Expand Down Expand Up @@ -76,15 +77,17 @@ def scan_entry_points() -> list[DiscoveredCLI]:
pkg_version = entry_point.dist.version if entry_point.dist else "?"
summary = _get_package_metadata(pkg_name)

discovered.append(DiscoveredCLI(
name=entry_point.name,
module_path=entry_point.module,
attr_name=entry_point.attr if entry_point.attr else "",
package_name=pkg_name,
package_version=pkg_version,
summary=summary,
is_typer=(cli_type == "typer"),
))
discovered.append(
DiscoveredCLI(
name=entry_point.name,
module_path=entry_point.module,
attr_name=entry_point.attr if entry_point.attr else "",
package_name=pkg_name,
package_version=pkg_version,
summary=summary,
is_typer=(cli_type == "typer"),
)
)
except (Exception, SystemExit):
continue

Expand Down Expand Up @@ -153,7 +156,10 @@ def find_our_clis() -> dict[str, Any]:
eps = eps_all.get("console_scripts", []) # type: ignore[attr-defined]

our_modules = [
"api_contract_guardian", "json2sql", "deploydiff", "configdrift",
"api_contract_guardian",
"json2sql",
"deploydiff",
"configdrift",
]

for ep in eps:
Expand Down
16 changes: 9 additions & 7 deletions click_to_mcp/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ def serve_http(
"description": description or f"MCP server for {name}",
}


def _make_jsonrpc_response(request_id: Any, result: Any = None, error: dict | None = None) -> dict:
resp: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id}
if error:
Expand All @@ -97,6 +96,7 @@ def _make_jsonrpc_response(request_id: Any, result: Any = None, error: dict | No

async def handle_sse(request: Request) -> EventSourceResponse:
"""SSE endpoint for server-to-client messages."""

async def event_generator() -> AsyncGenerator:
yield {
"event": "endpoint",
Expand Down Expand Up @@ -195,12 +195,14 @@ async def handle_messages(request: Request) -> Response:

async def handle_health(request: Request) -> JSONResponse:
"""Health check endpoint."""
return JSONResponse({
"status": "ok",
"name": name,
"tools": len(mcp_tool_list),
"transport": "http+sse",
})
return JSONResponse(
{
"status": "ok",
"name": name,
"tools": len(mcp_tool_list),
"transport": "http+sse",
}
)

app = Starlette(
routes=[
Expand Down
29 changes: 19 additions & 10 deletions click_to_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ def _write_response(response: str) -> None:


def serve_stdio(
cli_group, name: str = "cli", description: str = "", prefix: str = "",
cli_group,
name: str = "cli",
description: str = "",
prefix: str = "",
) -> None:
"""Start an MCP stdio server that exposes a Click CLI as MCP tools.

Expand Down Expand Up @@ -76,19 +79,25 @@ def serve_stdio(
try:
if method == "initialize":
# Respond with capabilities
response = _make_jsonrpc_response(req_id, {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {},
response = _make_jsonrpc_response(
req_id,
{
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {},
},
"serverInfo": server_info,
},
"serverInfo": server_info,
})
)
_write_response(response)

elif method == "tools/list":
response = _make_jsonrpc_response(req_id, {
"tools": mcp_tool_list,
})
response = _make_jsonrpc_response(
req_id,
{
"tools": mcp_tool_list,
},
)
_write_response(response)

elif method == "tools/call":
Expand Down
Loading
Loading