8bcf7296d5
REQ-223: mcp/atelier/server.py plugin-registry MCP server (stdio, D-135). NovaAtelierServer wraps MCPServer (SDK v2, D-137) if installed; degrades to _ToolRegistry fallback if SDK absent (testable in CI without SDK). plugins/principles.py (lookup_principle, list_domains, matrix_lookup) + plugins/validation.py (validate_against_principles — agentic validation beyond Wiz/Checkmarx/Mend). 4 tools, 2 plugins. REQ-224: mcp/atelier/vendor/ pinned Atelier v0.3.6 (D-136) — core/ first-principles, domains/security/first-principles, review/agent-checklist, matrix/principles-matrix. vendor/VERSION.md + scripts/update_atelier_vendor.sh for intentional upgrades. mcp/atelier/README.md (tools, architecture, running, vendoring, extensibility, transport). REQ-225: tests/test_atelier_mcp.py — 16 tests, all pass. Covers: plugin discovery (both loaded), 4 tools registered, lookup_security_P4 (+P1, unknown domain/principle), list_domains (19, security-relevant, ui-ux-not), matrix_lookup (security 10 P-rules, unknown), validation (good-passes, bad-secret-fails, bad-swallowed-error-fails, bad-obfuscated-names-fails, result-structure). ---ci--- project: acdl phase: 5 milestone: v1.18 status: execute requirements: covered: [REQ-223, REQ-224, REQ-225] partial: [] ---/ci---
142 lines
5.3 KiB
Python
142 lines
5.3 KiB
Python
"""mcp/atelier/server.py — Nova Atelier MCP server (REQ-223, D-135, D-137, D-140).
|
|
|
|
Plugin-registry architecture (D-140): plugins/<name>.py modules each expose
|
|
``register(mcp) -> None`` and call ``@mcp.tool()`` for their tools. This file
|
|
scans ``plugins/`` and calls ``register`` on each. Future capabilities drop
|
|
in as new plugin files — no server.py edits.
|
|
|
|
Transport: stdio (D-135). The MCP Python SDK v2 (``modelcontextprotocol/
|
|
python-sdk``, D-137) is the target. If the SDK is not installed, the server
|
|
degrades to a plain-Python tool registry that can be tested directly — the
|
|
tools are callable without MCP. This makes the server testable in CI
|
|
without the SDK installed.
|
|
|
|
Usage (with SDK):
|
|
python3 -m mcp.atelier.server
|
|
|
|
Usage (without SDK, for testing):
|
|
from mcp.atelier.server import NovaAtelierServer
|
|
s = NovaAtelierServer()
|
|
s.load_plugins()
|
|
result = s.call_tool("atelier.lookup_principle", {"domain": "security", "principle_id": "P4"})
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
import types
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Callable
|
|
|
|
_PLUGIN_DIR = pathlib.Path(__file__).parent / "plugins"
|
|
_VENDOR_DIR = pathlib.Path(__file__).parent / "vendor"
|
|
|
|
|
|
class _ToolRegistry:
|
|
"""A minimal tool registry that mimics the MCP ``@mcp.tool()`` decorator.
|
|
|
|
When the MCP SDK is available, ``NovaAtelierServer`` wraps a real
|
|
``MCPServer`` and the decorator registers tools with the SDK. When the
|
|
SDK is absent, this registry is the fallback — tools are callable via
|
|
``call_tool()`` for testing.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._tools: dict[str, dict[str, Any]] = {}
|
|
|
|
def tool(self, name: str | None = None, description: str | None = None) -> Callable:
|
|
def decorator(fn: Callable) -> Callable:
|
|
tool_name = name or fn.__name__
|
|
self._tools[tool_name] = {
|
|
"fn": fn,
|
|
"description": description or fn.__doc__ or "",
|
|
"name": tool_name,
|
|
}
|
|
return fn
|
|
return decorator
|
|
|
|
def list_tools(self) -> list[dict[str, str]]:
|
|
return [{"name": t["name"], "description": t["description"]} for t in self._tools.values()]
|
|
|
|
def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
|
|
if name not in self._tools:
|
|
raise KeyError(f"Unknown tool: {name}")
|
|
return self._tools[name]["fn"](**arguments)
|
|
|
|
|
|
class NovaAtelierServer:
|
|
"""The Nova Atelier MCP server.
|
|
|
|
Wraps an MCP SDK ``MCPServer`` if available; otherwise uses the
|
|
``_ToolRegistry`` fallback. Plugins are loaded from ``plugins/``.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.registry = _ToolRegistry()
|
|
self._mcp = None
|
|
try:
|
|
from mcp.server import MCPServer # type: ignore[import-not-found]
|
|
self._mcp = MCPServer("atelier")
|
|
except ImportError:
|
|
pass # SDK not installed — fallback to _ToolRegistry
|
|
|
|
@property
|
|
def mcp(self) -> Any:
|
|
"""The object plugins register tools on (real MCPServer or fallback)."""
|
|
return self._mcp if self._mcp is not None else self.registry
|
|
|
|
def load_plugins(self) -> list[str]:
|
|
"""Scan plugins/ and call ``register(mcp)`` on each. Returns loaded names."""
|
|
loaded: list[str] = []
|
|
for p in sorted(_PLUGIN_DIR.glob("*.py")):
|
|
if p.stem == "__init__":
|
|
continue
|
|
mod_name = f"mcp.atelier.plugins.{p.stem}"
|
|
mod = importlib.import_module(mod_name)
|
|
if hasattr(mod, "register"):
|
|
mod.register(self.mcp if self._mcp else self.registry)
|
|
loaded.append(p.stem)
|
|
return loaded
|
|
|
|
def list_tools(self) -> list[dict[str, str]]:
|
|
if self._mcp is not None:
|
|
return [{"name": t.name, "description": t.description} for t in self._mcp._tools.values()] # type: ignore[attr-defined]
|
|
return self.registry.list_tools()
|
|
|
|
def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
|
|
if self._mcp is not None:
|
|
raise RuntimeError("MCP SDK call_tool not supported in fallback mode — use the MCP client")
|
|
return self.registry.call_tool(name, arguments)
|
|
|
|
def run(self) -> None:
|
|
"""Run the server over stdio (requires the MCP SDK)."""
|
|
if self._mcp is None:
|
|
raise RuntimeError("MCP SDK not installed — cannot run server. Install: pip install mcp")
|
|
self._mcp.run()
|
|
|
|
|
|
def _make_plugin_compat_decorator(registry_or_mcp: Any) -> Callable:
|
|
"""Return a ``tool()`` decorator that works for both the fallback
|
|
registry and the real MCP SDK."""
|
|
if hasattr(registry_or_mcp, "tool"):
|
|
return registry_or_mcp.tool
|
|
# Fallback: wrap registry.tool() as a decorator factory
|
|
return registry_or_mcp.tool
|
|
|
|
|
|
def main() -> None:
|
|
server = NovaAtelierServer()
|
|
loaded = server.load_plugins()
|
|
print(f"Atelier MCP server — {len(loaded)} plugins loaded: {', '.join(loaded)}", file=sys.stderr)
|
|
if server._mcp is None:
|
|
print("MCP SDK not installed — server is in fallback (test) mode.", file=sys.stderr)
|
|
print("Tools: " + ", ".join(t["name"] for t in server.list_tools()), file=sys.stderr)
|
|
else:
|
|
server.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |