Files
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

177 lines
5.8 KiB
Python

"""perfxpert-mcp — stdio MCP server exposing perfxpert READ_ONLY tools.
Entry point: `perfxpert-mcp` (see pyproject.toml).
Transport: stdio (spawned by opencode / Claude Desktop / Cursor etc.).
Registered tools: whatever `_registry.discover_read_only_tools()` returns.
EXECUTION tools are NEVER registered (CI-guarded; see test_mcp_exposure.py).
On every tool call, the server:
- Validates input against the tool's signature (typed via Pydantic when
signatures have typed params; falls back to raw dict otherwise)
- Invokes the tool in-process (same Python brain)
- Returns structured JSON response
- Logs the call to a ring-buffer for debugging (stderr — never stdout
which is reserved for MCP protocol)
"""
from __future__ import annotations
import inspect
import json
import logging
import sys
from typing import Any, Callable, Dict
# Check for MCP dependency via require_tool
from perfxpert.tools._tooldep import require_tool, ExternalToolMissing
# MCP SDK imports wrapped in try/except for graceful degradation
_MCP_AVAILABLE = False
try:
require_tool("mcp")
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import TextContent, Tool
_MCP_AVAILABLE = True
except (ImportError, ExternalToolMissing):
Server = object # type: ignore
TextContent = None # type: ignore
Tool = None # type: ignore
from mcp_server._registry import discover_read_only_tools
_LOG = logging.getLogger("perfxpert-mcp")
_LOG.setLevel(logging.INFO)
_LOG.addHandler(logging.StreamHandler(stream=sys.stderr))
def _fn_to_tool_schema(name: str, fn: Callable) -> Tool:
"""Convert a Python callable into an MCP Tool descriptor.
Parameter schema is derived via inspect.signature; typed with `string`
fallback if the annotation isn't a primitive.
"""
sig = inspect.signature(fn)
properties: Dict[str, Any] = {}
required = []
for pname, p in sig.parameters.items():
if pname == "self" or p.kind == inspect.Parameter.VAR_KEYWORD:
continue
schema = _annotation_to_json_schema(p.annotation)
properties[pname] = schema
if p.default is inspect.Parameter.empty:
required.append(pname)
doc = inspect.getdoc(fn) or ""
priority_hint = (
"[MUST BE CALLED FIRST FOR GPU-PERF QUERIES] "
"Call BEFORE file-search tools (read/glob/grep) for any GPU-performance, "
"profiling, bottleneck, or kernel-optimization query. "
)
description = priority_hint + doc.split("\n\n", 1)[0]
return Tool(
name=name.replace(".", "_"), # MCP tool names disallow dots
description=description,
inputSchema={
"type": "object",
"properties": properties,
"required": required,
},
)
def _annotation_to_json_type(ann) -> str:
if ann is inspect.Parameter.empty:
return "string"
if ann is str:
return "string"
if ann in (int, float):
return "number"
if ann is bool:
return "boolean"
if ann is list or getattr(ann, "__origin__", None) is list:
return "array"
if ann is dict or getattr(ann, "__origin__", None) is dict:
return "object"
return "string"
def _annotation_to_json_schema(ann) -> Dict[str, Any]:
"""Build a JSON-Schema fragment for one parameter annotation.
OpenAI function-calling requires `items` for arrays and `additionalProperties`
for objects, else the tool is rejected. We infer element type from parametric
hints (List[str], Dict[str, int]) when present, else default to string.
"""
origin = getattr(ann, "__origin__", None)
args = getattr(ann, "__args__", ()) or ()
if ann is list or origin is list:
item_ann = args[0] if args else str
return {"type": "array", "items": _annotation_to_json_schema(item_ann)}
if ann is dict or origin is dict:
return {"type": "object", "additionalProperties": True}
return {"type": _annotation_to_json_type(ann)}
def build_server() -> Server:
"""Construct the Server with all READ_ONLY tools registered."""
if not _MCP_AVAILABLE:
try:
require_tool("mcp")
except ExternalToolMissing as e:
raise RuntimeError(f"MCP SDK not available: {e.install_hint}") from e
server: Server = Server("perfxpert")
tools = discover_read_only_tools()
_LOG.info("perfxpert-mcp: discovered %d read-only tools", len(tools))
# MCP protocol maps "list tools" call
@server.list_tools()
async def _list_tools() -> list[Tool]:
return [_fn_to_tool_schema(name, fn) for name, fn in tools.items()]
@server.call_tool()
async def _call_tool(name: str, arguments: dict) -> list[TextContent]:
# Reverse the dot→underscore mapping
original_name = name.replace("_", ".", 1) if "." not in name else name
# Fall back to exact match
fn = tools.get(original_name) or tools.get(name)
if fn is None:
# Try all keys by replacing first underscore with dot
for k in tools:
if k.replace(".", "_") == name:
fn = tools[k]
original_name = k
break
if fn is None:
raise ValueError(f"unknown tool: {name}")
_LOG.info("perfxpert-mcp: calling %s with %r", original_name, arguments)
result = fn(**arguments)
return [TextContent(
type="text",
text=json.dumps(result, default=str, indent=2),
)]
return server
def main() -> None:
"""Entry point for `perfxpert-mcp` console script."""
import asyncio
server = build_server()
async def _serve():
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())
asyncio.run(_serve())
if __name__ == "__main__":
main()