diff --git a/README.md b/README.md index 8baadd3..f44fb42 100644 --- a/README.md +++ b/README.md @@ -176,13 +176,13 @@ want to use Serena. "mcpServers": { "serena": { "command": "/abs/path/to/uv", - "args": ["run", "--directory", "/abs/path/to/serena", "serena-mcp-server", "/abs/path/to/myproject.yml"] + "args": ["run", "--directory", "/abs/path/to/serena", "serena-mcp-server", "--project-file", "/abs/path/to/myproject.yml"] } } } ``` - :info: The path to the project file is optional if you have set `enable_project_activation` in your configuration, + :info: passing the project file is optional if you have set `enable_project_activation` in your configuration, as this setting will allow you to simply instruct Claude to activate the project you want to work on. If you are using paths containing backslashes for paths on Windows diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 2de742a..dc31e64 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -7,7 +7,9 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import dataclass from logging import Formatter, Logger, StreamHandler +from typing import Literal +import click # Add click import from mcp.server.fastmcp import server from mcp.server.fastmcp.server import FastMCP, Settings from mcp.server.fastmcp.tools.base import Tool as MCPTool @@ -68,30 +70,16 @@ def make_tool( ) -def create_mcp_server() -> FastMCP: - argv = sys.argv[1:] - - if (len(argv) == 1 and argv[0] == "--help") or len(argv) > 1: - print("\nUsage: mcp_server [.yml project file]", file=sys.stderr) - sys.exit(0) +def create_mcp_server(project_file_path: str | None, host: str = "0.0.0.0", port: int = 8000) -> FastMCP: + """ + Create an MCP server. + :param project_file_path: The path to the project file, or None. + :param host: The host to bind to + :param port: The port to bind to + """ mcp: FastMCP | None = None - def update_tools() -> None: - """Update the tools in the MCP server.""" - # Tools may change as a result of project activation. - # NOTE: While we could pass updated tool information on to the MCP server via the callback, Claude Desktop does not, - # unfortunately, query for changed tools. It only queries for changed resources and prompts regularly, - # so we need to register all tools at startup, unfortunately. - nonlocal mcp - tools = agent.get_exposed_tools() - if mcp is not None: - mcp._tool_manager._tools = {} - for tool in tools: - # noinspection PyProtectedMember - mcp._tool_manager._tools[tool.get_name()] = make_tool(tool) - - project_file_path = argv[0] if len(argv) == 1 else None try: agent = SerenaAgent( project_file_path, @@ -102,14 +90,27 @@ def create_mcp_server() -> FastMCP: show_fatal_exception_safe(e) raise + def update_tools() -> None: + """Update the tools in the MCP server.""" + # Tools may change as a result of project activation. + # NOTE: While we could pass updated tool information on to the MCP server via the callback, Claude Desktop does not, + # unfortunately, query for changed tools. It only queries for changed resources and prompts regularly, + # so we need to register all tools at startup, unfortunately. + nonlocal mcp, agent + tools = agent.get_exposed_tools() + if mcp is not None: + mcp._tool_manager._tools = {} + for tool in tools: + # noinspection PyProtectedMember + mcp._tool_manager._tools[tool.get_name()] = make_tool(tool) + @asynccontextmanager async def server_lifespan(mcp_server: FastMCP) -> AsyncIterator[None]: """Manage server startup and shutdown lifecycle.""" - nonlocal agent mark_used(mcp_server) yield - mcp_settings = Settings(lifespan=server_lifespan) + mcp_settings = Settings(lifespan=server_lifespan, host=host, port=port) mcp = FastMCP(**mcp_settings.model_dump()) update_tools() @@ -117,5 +118,60 @@ def create_mcp_server() -> FastMCP: return mcp -def start_mcp_server() -> None: - create_mcp_server().run() +@click.command() +@click.option( + "--project-file", + "project_file_opt", # Rename to avoid conflict with argument + type=click.Path(exists=True, dir_okay=False, resolve_path=True), + default=None, + help="Optional path to the .yml project file via option." + "Does not need to be provided at startup since you can activate a project later by simply asking the agent to do so " + "(there is a dedicated tool for this purpose).", +) +@click.argument( + "project_file_arg", + type=click.Path(exists=True, dir_okay=False, resolve_path=True), + required=False, + default=None, +) +@click.option( + "--transport", + type=click.Choice(["stdio", "sse"]), + default="stdio", + show_default=True, + help="Transport protocol.", +) +@click.option( + "--host", + type=str, + default="0.0.0.0", + show_default=True, + help="Host to bind to (for SSE transport).", +) +@click.option( + "--port", + type=int, + default=8000, + show_default=True, + help="Port to bind to (for SSE transport).", +) +def start_mcp_server( + project_file_opt: str | None, project_file_arg: str | None, transport: Literal["stdio", "sse"], host: str, port: int +) -> None: + """Starts the Serena MCP server. + + Accepts the project file path either via the --project-file option or as a positional argument. + """ + # Prioritize the positional argument if provided + # This is for backward compatibility with the old CLI, should be removed in the future! + project_file = project_file_arg if project_file_arg is not None else project_file_opt + mcp_server = create_mcp_server(project_file_path=project_file, host=host, port=port) + + # log after server creation such that the log appears in the GUI + if project_file_arg is not None: + log.warning( + "The positional argument for the project file path is deprecated and will be removed in the future!" + "Please pass the project file path via the `--project-file` option instead.\n" + f"Used path: {project_file}" + ) + mcp_server.run(transport=transport)