Add M7.3 CmdForge as MCP server with FastMCP, expose/deny policy, and schema mapping
This commit is contained in:
parent
dee48d8cea
commit
3525e2eeae
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Reference in New Issue