Add M7.3 CmdForge as MCP server with FastMCP, expose/deny policy, and schema mapping

This commit is contained in:
rob 2026-07-20 00:24:54 -03:00
parent dee48d8cea
commit 3525e2eeae
5 changed files with 336 additions and 0 deletions

View File

@ -455,6 +455,11 @@ def main():
p_mcp = subparsers.add_parser("mcp", help="Manage MCP servers") p_mcp = subparsers.add_parser("mcp", help="Manage MCP servers")
mcp_sub = p_mcp.add_subparsers(dest="mcp_cmd", help="MCP commands") 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 # mcp list
p_mcp_list = mcp_sub.add_parser("list", help="List configured MCP servers") p_mcp_list = mcp_sub.add_parser("list", help="List configured MCP servers")
p_mcp_list.set_defaults(func=cmd_mcp) p_mcp_list.set_defaults(func=cmd_mcp)

View File

@ -16,6 +16,8 @@ def cmd_mcp(args):
"""Manage MCP servers.""" """Manage MCP servers."""
if getattr(args, "mcp_cmd", "list") == "list": if getattr(args, "mcp_cmd", "list") == "list":
return _cmd_mcp_list(args) return _cmd_mcp_list(args)
elif args.mcp_cmd == "serve":
return _cmd_mcp_serve(args)
elif args.mcp_cmd == "connect": elif args.mcp_cmd == "connect":
return _cmd_mcp_connect(args) return _cmd_mcp_connect(args)
elif args.mcp_cmd == "add": elif args.mcp_cmd == "add":
@ -167,3 +169,19 @@ def _parse_env_entries(entries):
raise ValueError(f"invalid environment variable name '{name}'") raise ValueError(f"invalid environment variable name '{name}'")
result[name] = value result[name] = value
return result 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

View File

@ -43,6 +43,45 @@ _SECRET_PATTERNS = (
T = TypeVar("T") 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 @dataclass
class McpServerConfig: class McpServerConfig:
"""Configuration for one MCP server. """Configuration for one MCP server.
@ -249,6 +288,21 @@ def _require_mcp_sdk() -> None:
) from exc ) 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]: def _serialize_tool(tool: Any) -> Dict[str, Any]:
if hasattr(tool, "model_dump"): if hasattr(tool, "model_dump"):
return tool.model_dump(mode="json", by_alias=True, exclude_none=True) return tool.model_dump(mode="json", by_alias=True, exclude_none=True)

162
src/cmdforge/mcp_server.py Normal file
View File

@ -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.

View File

@ -5,6 +5,7 @@ import time
from pathlib import Path from pathlib import Path
import pytest import pytest
from unittest.mock import patch, MagicMock
from cmdforge.mcp_client import ( from cmdforge.mcp_client import (
DEFAULT_INHERITED_ENV, DEFAULT_INHERITED_ENV,
McpServerConfig, McpServerConfig,
@ -397,3 +398,99 @@ class TestMcpCli:
monkeypatch.setattr(sys, "argv", ["cmdforge", "mcp"]) monkeypatch.setattr(sys, "argv", ["cmdforge", "mcp"])
assert main() == 0 assert main() == 0
assert "No MCP servers configured" in capsys.readouterr().out 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"]