diff --git a/CLAUDE.md b/CLAUDE.md index 83d2c23..900d3d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,9 +33,6 @@ make test-examples # Run example tests # Documentation make docs-serve # Build and serve docs locally (http://localhost:8000) make docs-build # Build docs for deployment - -# MCP Development -make mcp-inspector # Run MCP server inspector for debugging ``` ## Code Architecture @@ -57,10 +54,6 @@ make mcp-inspector # Run MCP server inspector for debugging - Handles file upload detection (`format: binary` → `type: file`) - Resolves schema references -4. **MCP Server** (`stackone_ai/server.py`): Protocol implementation - - Async tool execution - - CLI interface via `stackmcp` command - ### OpenAPI Specifications All tool definitions are generated from OpenAPI specs in `stackone_ai/oas/`: diff --git a/examples/mcp_server.py b/examples/mcp_server.py deleted file mode 100644 index c564ecf..0000000 --- a/examples/mcp_server.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -This package can also be used as a Model Context Protocol (MCP) server. - -To add this server to and MCP client like Claude Code, use: - -```bash -# install the package -uv pip install stackone-ai - -# add the server to Claude Code -claude mcp add stackone uv stackmcp ["--api-key", ""] -``` - -This implementation is a work in progress and will likely change dramatically in the near future. -""" diff --git a/justfile b/justfile index 8aa9378..6a43beb 100644 --- a/justfile +++ b/justfile @@ -35,8 +35,3 @@ docs-serve: docs-build: uv run scripts/build_docs.py uv run mkdocs build - -# Run MCP server inspector for debugging -mcp-inspector: - uv sync --all-extras - npx @modelcontextprotocol/inspector stackmcp diff --git a/pyproject.toml b/pyproject.toml index 514ce95..7b7b6bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,9 +29,6 @@ dependencies = [ "eval-type-backport; python_version<'3.10'", # TODO: Remove when Python 3.9 support is dropped ] -[project.scripts] -stackmcp = "stackone_ai.server:cli" - [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -48,7 +45,7 @@ packages = ["stackone_ai"] [project.optional-dependencies] # TODO: Remove python_version conditions when Python 3.9 support is dropped mcp = [ - "mcp[cli]>=1.3.0; python_version>='3.10'", + "mcp>=1.3.0; python_version>='3.10'", ] examples = [ "crewai>=0.102.0; python_version>='3.10'", diff --git a/stackone_ai/server.py b/stackone_ai/server.py deleted file mode 100644 index e9b9371..0000000 --- a/stackone_ai/server.py +++ /dev/null @@ -1,241 +0,0 @@ -# TODO: Remove when Python 3.9 support is dropped -from __future__ import annotations - -import argparse -import asyncio -import logging -import os -import sys -from typing import Any, TypeVar - -# Check Python version for MCP server functionality -if sys.version_info < (3, 10): - raise RuntimeError( - "MCP server functionality requires Python 3.10+. Current version: {}.{}.{}".format( - *sys.version_info[:3] - ) - ) - -try: - import mcp.types as types - from mcp.server import NotificationOptions, Server - from mcp.server.models import InitializationOptions - from mcp.server.stdio import stdio_server - from mcp.shared.exceptions import McpError - from mcp.types import EmbeddedResource, ErrorData, ImageContent, TextContent, Tool -except ImportError as e: - raise ImportError("MCP dependencies not found. Install with: pip install 'stackone-ai[mcp]'") from e - -from pydantic import ValidationError - -from stackone_ai import StackOneToolSet -from stackone_ai.models import StackOneAPIError, StackOneError - -# Set up logging -logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") -logger = logging.getLogger("stackone.mcp") - -app: Server = Server("stackone-ai") -toolset: StackOneToolSet | None = None - -NO_ACCOUNT_ID_PREFIXES = [ - "stackone_", -] - -# Type variables for function annotations -T = TypeVar("T") -R = TypeVar("R") - - -def tool_needs_account_id(tool_name: str) -> bool: - for prefix in NO_ACCOUNT_ID_PREFIXES: - if tool_name.startswith(prefix): - return False - - # By default, assume all other tools need account_id - return True - - -@app.list_tools() # type: ignore[misc] -async def list_tools() -> list[Tool]: - """List all available StackOne tools as MCP tools.""" - if not toolset: - logger.error("Toolset not initialized") - raise McpError( - ErrorData( - code=types.INTERNAL_ERROR, - message="Toolset not initialized, please check your STACKONE_API_KEY.", - ) - ) - - try: - mcp_tools: list[Tool] = [] - tools = toolset.fetch_tools() - # Convert to a list if it's not already iterable in the expected way - tool_list = list(tools.tools) if hasattr(tools, "tools") else [] - - for tool in tool_list: - # Convert StackOne tool parameters to MCP schema - properties = {} - required = [] - - # Add account_id parameter only for tools that need it - if tool_needs_account_id(tool.name): - properties["account_id"] = { - "type": "string", - "description": "The StackOne account ID to use for this tool call", - } - - for name, details in tool.parameters.properties.items(): - if isinstance(details, dict): - prop = { - "type": details.get("type", "string"), - "description": details.get("description", ""), - } - if not details.get("nullable", False): - required.append(name) - properties[name] = prop - - schema = {"type": "object", "properties": properties} - if required: - schema["required"] = required - - mcp_tools.append(Tool(name=tool.name, description=tool.description, inputSchema=schema)) - - logger.info(f"Listed {len(mcp_tools)} tools") - return mcp_tools - except Exception as e: - logger.error(f"Error listing tools: {str(e)}", exc_info=True) - raise McpError( - ErrorData( - code=types.INTERNAL_ERROR, - message=f"Error listing tools: {str(e)}", - ) - ) from e - - -@app.call_tool() # type: ignore[misc] -async def call_tool( - name: str, arguments: dict[str, Any] -) -> list[TextContent | ImageContent | EmbeddedResource]: - """Execute a StackOne tool and return its result.""" - if not toolset: - logger.error("Toolset not initialized") - raise McpError( - ErrorData( - code=types.INTERNAL_ERROR, - message="Server configuration error: Toolset not initialized", - ) - ) - - try: - tools = toolset.fetch_tools(actions=[name]) - tool = tools.get_tool(name) - if not tool: - logger.warning(f"Tool not found: {name}") - raise McpError( - ErrorData( - code=types.INVALID_PARAMS, - message=f"Tool not found: {name}", - ) - ) - - if "account_id" in arguments: - tool.set_account_id(arguments.pop("account_id")) - - if tool_needs_account_id(name) and tool.get_account_id() is None: - logger.warning(f"Tool {name} needs account_id but none provided") - raise McpError( - ErrorData( - code=types.INVALID_PARAMS, - message=f"Tool {name} needs account_id but none provided", - ) - ) - - result = tool.execute(arguments) - return [TextContent(type="text", text=str(result))] - - except ValidationError as e: - logger.warning(f"Invalid parameters for tool {name}: {str(e)}") - raise McpError( - ErrorData( - code=types.INVALID_PARAMS, - message=f"Invalid parameters for tool {name}: {str(e)}", - ) - ) from e - except StackOneAPIError as e: - logger.error(f"API error: {str(e)}") - raise McpError( - ErrorData( - code=types.INTERNAL_ERROR, - message=f"API error: {str(e)}", - ) - ) from e - except StackOneError as e: - logger.error(f"Error: {str(e)}") - raise McpError( - ErrorData( - code=types.INTERNAL_ERROR, - message=f"Error: {str(e)}", - ) - ) from e - except Exception as e: - logger.error(f"Unexpected error: {str(e)}", exc_info=True) - raise McpError( - ErrorData( - code=types.INTERNAL_ERROR, - message="An unexpected error occurred. Please try again later.", - ) - ) from e - - -async def main(api_key: str | None = None) -> None: - """Run the MCP server.""" - - if not api_key: - api_key = os.getenv("STACKONE_API_KEY") - if not api_key: - raise ValueError("STACKONE_API_KEY not found in environment variables") - - global toolset - toolset = StackOneToolSet(api_key=api_key) - logger.info("StackOne toolset initialized successfully") - - async with stdio_server() as (read_stream, write_stream): - await app.run( - read_stream, - write_stream, - InitializationOptions( - server_name="stackone-ai", - server_version="0.1.0", - capabilities=app.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -def cli() -> None: - """CLI entry point for the MCP server.""" - parser = argparse.ArgumentParser(description="StackOne AI MCP Server") - parser.add_argument("--api-key", help="StackOne API key (can also be set via STACKONE_API_KEY env var)") - parser.add_argument( - "--log-level", - default="INFO", - choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], - help="Set the logging level", - ) - args = parser.parse_args() - - logger.setLevel(args.log_level) - - try: - asyncio.run(main(args.api_key)) - except Exception as e: - logger.critical(f"Failed to start server: {str(e)}", exc_info=True) - sys.exit(1) - - -if __name__ == "__main__": - cli()