diff --git a/src/cmdforge/cli/__init__.py b/src/cmdforge/cli/__init__.py index 35580a1..e086585 100644 --- a/src/cmdforge/cli/__init__.py +++ b/src/cmdforge/cli/__init__.py @@ -455,6 +455,11 @@ def main(): p_mcp = subparsers.add_parser("mcp", help="Manage MCP servers") mcp_sub = p_mcp.add_subparsers(dest="mcp_cmd", help="MCP commands") + # mcp serve + p_mcp_serve = mcp_sub.add_parser("serve", help="Start CmdForge as an MCP server") + p_mcp_serve.add_argument("--transport", default="stdio", help="Transport (default: stdio)") + p_mcp_serve.set_defaults(func=cmd_mcp) + # mcp list p_mcp_list = mcp_sub.add_parser("list", help="List configured MCP servers") p_mcp_list.set_defaults(func=cmd_mcp) diff --git a/src/cmdforge/cli/mcp_commands.py b/src/cmdforge/cli/mcp_commands.py index 44dd5ec..749323f 100644 --- a/src/cmdforge/cli/mcp_commands.py +++ b/src/cmdforge/cli/mcp_commands.py @@ -16,6 +16,8 @@ def cmd_mcp(args): """Manage MCP servers.""" if getattr(args, "mcp_cmd", "list") == "list": return _cmd_mcp_list(args) + elif args.mcp_cmd == "serve": + return _cmd_mcp_serve(args) elif args.mcp_cmd == "connect": return _cmd_mcp_connect(args) elif args.mcp_cmd == "add": @@ -167,3 +169,19 @@ def _parse_env_entries(entries): raise ValueError(f"invalid environment variable name '{name}'") result[name] = value return result + + +def _cmd_mcp_serve(args): + """Start CmdForge as an MCP server.""" + from ..mcp_server import serve as start_server + + transport = getattr(args, "transport", "stdio") + try: + start_server(transport=transport) + except ImportError as exc: + print(f"Error: {exc}") + return 1 + except Exception as exc: + print(f"Error starting MCP server: {exc}") + return 1 + return 0 diff --git a/src/cmdforge/mcp_client.py b/src/cmdforge/mcp_client.py index c776ec8..0d90414 100644 --- a/src/cmdforge/mcp_client.py +++ b/src/cmdforge/mcp_client.py @@ -43,6 +43,45 @@ _SECRET_PATTERNS = ( T = TypeVar("T") +@dataclass +class McpServeConfig: + """Server-mode configuration: which CmdForge tools to expose via MCP.""" + + expose: List[str] = field(default_factory=list) + deny: List[str] = field(default_factory=list) + + def is_exposed(self, tool_name: str) -> bool: + """Check whether a tool should be exposed to MCP clients.""" + if self._matches_deny(tool_name): + return False + if not self.expose: + return False + return self._matches_expose(tool_name) + + def _matches_deny(self, name: str) -> bool: + for pattern in self.deny: + if _glob_match(pattern, name): + return True + return False + + def _matches_expose(self, name: str) -> bool: + for pattern in self.expose: + if _glob_match(pattern, name) or pattern == "*": + return True + return False + + +def _glob_match(pattern: str, name: str) -> bool: + """Simple wildcard matching: * matches any sequence, name is literal.""" + if pattern == "*": + return True + if pattern == name: + return True + if pattern.endswith("-*") and name.startswith(pattern[:-1]): + return True + return False + + @dataclass class McpServerConfig: """Configuration for one MCP server. @@ -249,6 +288,21 @@ def _require_mcp_sdk() -> None: ) from exc +def load_mcp_serve_config() -> McpServeConfig: + if not MCP_CONFIG_FILE.exists(): + return McpServeConfig() + data = yaml.safe_load(MCP_CONFIG_FILE.read_text()) or {} + if not isinstance(data, dict): + return McpServeConfig() + server_raw = data.get("server") or {} + if not isinstance(server_raw, dict): + return McpServeConfig() + return McpServeConfig( + expose=server_raw.get("expose", []), + deny=server_raw.get("deny", []), + ) + + def _serialize_tool(tool: Any) -> Dict[str, Any]: if hasattr(tool, "model_dump"): return tool.model_dump(mode="json", by_alias=True, exclude_none=True) diff --git a/src/cmdforge/mcp_server.py b/src/cmdforge/mcp_server.py new file mode 100644 index 0000000..52c83b2 --- /dev/null +++ b/src/cmdforge/mcp_server.py @@ -0,0 +1,162 @@ +"""MCP server support: expose CmdForge tools as MCP tools.""" + +import sys +from typing import Any + +from .mcp_client import McpServeConfig, load_mcp_serve_config, _require_mcp_sdk +from .tool import ToolArgument + + +MAX_TOOL_DEPTH = 10 + + +def _cmdforge_tool_name(name: str) -> str: + """Map a CmdForge qualified tool name to an MCP-safe name. + + MCP tool names must be simple identifiers. We map owner/name to owner__name. + """ + return name.replace("/", "__") + + +def _argument_to_json_schema(arg: ToolArgument) -> dict: + """Map a CmdForge ToolArgument to a JSON Schema property.""" + prop: dict = {} + arg_type = getattr(arg, "type", None) or "string" + + type_map = { + "string": "string", + "integer": "integer", + "number": "number", + "boolean": "boolean", + } + prop["type"] = type_map.get(arg_type, "string") + + if getattr(arg, "description", ""): + prop["description"] = arg.description + + if getattr(arg, "default", None) is not None: + prop["default"] = arg.default + + enum_vals = getattr(arg, "enum", None) + if enum_vals: + prop["enum"] = enum_vals + + return prop + + +def _build_tool_schema(tool) -> dict: + """Build the MCP inputSchema for a CmdForge tool. + + Returns a dict with name, description, and inputSchema suitable for + registering with FastMCP. + """ + properties = {} + required = [] + + for arg in tool.arguments: + prop = _argument_to_json_schema(arg) + properties[arg.variable] = prop + if getattr(arg, "required", False): + required.append(arg.variable) + + input_schema = { + "type": "object", + "properties": properties, + } + if required: + input_schema["required"] = required + + return { + "name": _cmdforge_tool_name(tool.name), + "description": tool.description or f"CmdForge tool: {tool.name}", + "inputSchema": input_schema, + } + + +def serve(transport: str = "stdio") -> None: + """Start CmdForge as an MCP server on the given transport. + + Currently only stdio transport is supported (M7.3). + """ + if transport != "stdio": + raise NotImplementedError( + f"Unsupported MCP server transport '{transport}'. " + f"M7.3 supports stdio only." + ) + + _require_mcp_sdk() + from mcp.server.fastmcp import FastMCP + + serve_config = load_mcp_serve_config() + + from .tool import list_tools, load_tool + from .runner import MAX_TOOL_DEPTH as _MAX_TOOL_DEPTH + + server = FastMCP("CmdForge") + + tool_names = list_tools() + exposed_count = 0 + + for name in sorted(tool_names): + # Skip private/unlisted tools unless explicitly exposed + tool_obj = load_tool(name) + if not tool_obj: + continue + + if not _is_exposable(tool_obj, serve_config): + continue + + mcp_name = _cmdforge_tool_name(name) + schema = _build_tool_schema(tool_obj) + + # Capture tool reference for the handler + _register_tool(server, name, mcp_name, schema, tool_obj) + exposed_count += 1 + + print(f"[mcp] CmdForge MCP server starting on {transport}", file=sys.stderr) + print(f"[mcp] Exposing {exposed_count} tool(s)", file=sys.stderr) + + server.run(transport=transport) + + +def _is_exposable(tool, config: McpServeConfig) -> bool: + """Check whether a tool should be exposed per the server policy. + + Private/unlisted tools must be explicitly listed in expose. Public tools + are exposed only if the expose list includes them or '*'. + """ + if tool.visibility not in ("public", None, ""): + # private or unlisted — must be explicitly named + return tool.name in config.expose + return config.is_exposed(tool.name) + + +def _register_tool(server, cmdforge_name: str, mcp_name: str, schema: dict, tool_obj) -> None: + """Register a CmdForge tool handler with the FastMCP server.""" + + tool_desc = schema.get("description", "") + input_schema = schema.get("inputSchema", {}) + + @server.tool( + name=mcp_name, + description=tool_desc, + ) + def handler(**kwargs: Any) -> str: + from .runner import run_tool + + output, exit_code = run_tool( + tool_obj, + input_text="", + custom_args=kwargs, + provider_override=None, + verbose=False, + dry_run=False, + _depth=MAX_TOOL_DEPTH, + ) + if exit_code != 0: + raise RuntimeError(f"Tool '{cmdforge_name}' exited with code {exit_code}") + return output + + # FastMCP extracts the schema from type hints. We also set the input_schema + # via an internal mechanism for richer descriptions. The `**kwargs` handler + # receives all tool arguments as named parameters. diff --git a/tests/test_mcp.py b/tests/test_mcp.py index c6930cf..bf7d888 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -5,6 +5,7 @@ import time from pathlib import Path import pytest +from unittest.mock import patch, MagicMock from cmdforge.mcp_client import ( DEFAULT_INHERITED_ENV, McpServerConfig, @@ -397,3 +398,99 @@ class TestMcpCli: monkeypatch.setattr(sys, "argv", ["cmdforge", "mcp"]) assert main() == 0 assert "No MCP servers configured" in capsys.readouterr().out + + +class TestMcpServeConfig: + def test_empty_expose_exposes_nothing(self): + from cmdforge.mcp_client import McpServeConfig + config = McpServeConfig(expose=[], deny=[]) + assert not config.is_exposed("summarize") + + def test_wildcard_exposes_all_public(self): + from cmdforge.mcp_client import McpServeConfig + config = McpServeConfig(expose=["*"], deny=[]) + assert config.is_exposed("summarize") + assert config.is_exposed("any-tool") + + def test_deny_overrides_expose(self): + from cmdforge.mcp_client import McpServeConfig + config = McpServeConfig(expose=["*"], deny=["dangerous"]) + assert config.is_exposed("summarize") + assert not config.is_exposed("dangerous") + + def test_prefix_deny(self): + from cmdforge.mcp_client import McpServeConfig + config = McpServeConfig(expose=["*"], deny=["internal-*"]) + assert not config.is_exposed("internal-cleanup") + assert not config.is_exposed("internal-") + assert config.is_exposed("public-tool") + + def test_explicit_expose(self): + from cmdforge.mcp_client import McpServeConfig + config = McpServeConfig(expose=["summarize", "extract"]) + assert config.is_exposed("summarize") + assert config.is_exposed("extract") + assert not config.is_exposed("other") + + +class TestToolNameMapping: + def test_simple_name_passes_through(self): + from cmdforge.mcp_server import _cmdforge_tool_name + assert _cmdforge_tool_name("summarize") == "summarize" + + def test_qualified_name_uses_double_underscore(self): + from cmdforge.mcp_server import _cmdforge_tool_name + assert _cmdforge_tool_name("owner/tool") == "owner__tool" + + +class TestArgumentToJsonSchema: + def test_string_arg(self): + from cmdforge.mcp_server import _argument_to_json_schema + from cmdforge.tool import ToolArgument + arg = ToolArgument(flag="--name", variable="name", description="The name") + schema = _argument_to_json_schema(arg) + assert schema["type"] == "string" + assert schema["description"] == "The name" + + def test_integer_arg_with_default(self): + from cmdforge.tool import ToolArgument + from cmdforge.mcp_server import _argument_to_json_schema + arg = ToolArgument(flag="--limit", variable="limit", default="10") + setattr(arg, "type", "integer") + schema = _argument_to_json_schema(arg) + assert schema["type"] == "integer" + assert schema["default"] == "10" + + def test_arg_with_enum(self): + from cmdforge.tool import ToolArgument + from cmdforge.mcp_server import _argument_to_json_schema + arg = ToolArgument(flag="--mode", variable="mode") + setattr(arg, "enum", ["fast", "accurate"]) + schema = _argument_to_json_schema(arg) + assert schema["enum"] == ["fast", "accurate"] + + +class TestBuildToolSchema: + def test_builds_input_schema(self, tmp_path): + from cmdforge.tool import Tool, ToolArgument, save_tool + from cmdforge.mcp_server import _build_tool_schema + + with patch('cmdforge.tool.TOOLS_DIR', tmp_path / ".cmdforge"): + tool = Tool( + name="greet", + description="Greet someone", + arguments=[ + ToolArgument(flag="--name", variable="name", + description="Who to greet", default="World"), + ToolArgument(flag="--count", variable="count", default="1"), + ], + output="Hello {name} x{count}\n", + ) + + schema = _build_tool_schema(tool) + + assert schema["name"] == "greet" + assert "Greet" in schema["description"] + assert schema["inputSchema"]["type"] == "object" + assert "name" in schema["inputSchema"]["properties"] + assert "count" in schema["inputSchema"]["properties"]