diff --git a/pyproject.toml b/pyproject.toml index 4717cea92..e1ef574d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ [project.optional-dependencies] rich = ["rich>=13.9.4"] -cli = ["typer>=0.16.0", "python-dotenv>=1.0.0"] +cli = ["typer>=0.16.0", "python-dotenv>=1.0.0", "requests>=2.32.5"] ws = ["websockets>=15.0.1"] [project.scripts] @@ -58,7 +58,7 @@ dev = [ "pytest-examples>=0.0.14", "pytest-pretty>=1.2.0", "inline-snapshot>=0.23.0", - "dirty-equals>=0.9.0", + "dirty-equals>=0.9.0" ] docs = [ "mkdocs>=1.6.1", diff --git a/src/mcp/cli/cli.py b/src/mcp/cli/cli.py index 4a7257a11..ff82b98a3 100644 --- a/src/mcp/cli/cli.py +++ b/src/mcp/cli/cli.py @@ -7,6 +7,7 @@ import sys from pathlib import Path from typing import Annotated, Any +import tempfile from mcp.server import FastMCP from mcp.server import Server as LowLevelServer @@ -149,6 +150,7 @@ def _check_server_object(server_object: Any, object_name: str): Returns: True if it's supported. """ + if not isinstance(server_object, FastMCP): logger.error(f"The server object {object_name} is of type {type(server_object)} (expecting {FastMCP}).") if isinstance(server_object, LowLevelServer): @@ -304,10 +306,12 @@ def dev( @app.command() def run( - file_spec: str = typer.Argument( - ..., - help="Python file to run, optionally with :object suffix", - ), + file_spec: Annotated[ + str | None, + typer.Argument( + help="Python file to run, optionally with :object suffix (omit when using --from)", + ), + ] = None, transport: Annotated[ str | None, typer.Option( @@ -316,6 +320,13 @@ def run( help="Transport protocol to use (stdio or sse)", ), ] = None, + from_url: Annotated[ + str | None, + typer.Option( + "--from-github", + help="Run a remote MCP server directly from a Github URL (raw Github file or Github repo URL)", + ), + ] = None, ) -> None: """Run a MCP server. @@ -327,6 +338,48 @@ def run( all dependencies are available.\n For dependency management, use `mcp install` or `mcp dev` instead. """ # noqa: E501 + if not file_spec and not from_url: + typer.echo("Error: You must provide either a file path or --from ") + raise typer.Exit(code=1) + + if file_spec and from_url: + typer.echo("Error: You cannot specify both a file path and --from URL") + raise typer.Exit(code=1) + + if not file_spec and from_url: + from urllib.parse import urlparse + + ALLOWED_DOMAINS = {"github.com", "raw.githubusercontent.com"} + + parsed = urlparse(from_url) + if parsed.netloc not in ALLOWED_DOMAINS: + logger.error(f"Invalid domain: {parsed.netloc}. Only GitHub URLs are supported.") + sys.exit(1) + + if parsed.netloc == "github.com": + from_url = from_url.replace("github.com", "raw.githubusercontent.com").replace("/blob/", "/") + logger.info(f"Converted GitHub URL to raw: {from_url}") + + logger.info(f"Fetching MCP server from {from_url}") + + try: + import requests + + with requests.get(from_url, stream=True, timeout=10) as res: + res.raise_for_status() + temp_dir = tempfile.mkdtemp(prefix="mcp_from_") + temp_path = Path(temp_dir) / "remote_server.py" + + with open(temp_path, "wb") as f: + for chunk in res.iter_content(chunk_size=8192): + f.write(chunk) + except Exception as e: + logger.error(f"Failed to fetch server file: {e}") + sys.exit(1) + + file_spec = str(temp_path) + + assert file_spec is not None file, server_object = _parse_file_path(file_spec) logger.debug( @@ -346,7 +399,6 @@ def run( kwargs = {} if transport: kwargs["transport"] = transport - server.run(**kwargs) except Exception: diff --git a/uv.lock b/uv.lock index 6c6b13a6e..290822770 100644 --- a/uv.lock +++ b/uv.lock @@ -624,6 +624,7 @@ dependencies = [ [package.optional-dependencies] cli = [ { name = "python-dotenv" }, + { name = "requests" }, { name = "typer" }, ] rich = [ @@ -664,6 +665,7 @@ requires-dist = [ { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=310" }, + { name = "requests", marker = "extra == 'cli'", specifier = ">=2.32.5" }, { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, { name = "sse-starlette", specifier = ">=1.6.1" }, { name = "starlette", specifier = ">=0.27" },