diff --git a/README.md b/README.md index a818970..c1f7819 100644 --- a/README.md +++ b/README.md @@ -216,13 +216,18 @@ The MCP (Model Context Protocol) server enables AI assistants like Claude to man ### What is MCP? -The Model Context Protocol (MCP) is a standard protocol that allows AI assistants to use tools and access external systems. The Clubber MCP server provides 4 tools that connect to the GraphQL API: +The Model Context Protocol (MCP) is a standard protocol that allows AI assistants to use tools and access external systems. The Clubber MCP server provides 6 tools that connect to the GraphQL API: +**Dedicated Member Tools** (simple, focused operations): - **list_members** - List all members with their complete information - **get_member** - Get detailed information about a specific member by ID - **create_member** - Create a new member (only firstName required) - **update_member** - Update an existing member's information +**General GraphQL Tools** (flexible, for complex queries): +- **get_graphql_schema** - Get the complete GraphQL schema via introspection +- **execute_graphql_query** - Execute arbitrary GraphQL queries and mutations + ### Running the MCP Server The MCP server requires the GraphQL API to be running first: @@ -311,6 +316,69 @@ Claude uses list_members tool and displays formatted results: ... ``` +**Example 4: Discover GraphQL schema** +``` +User: What fields are available in the Member type? + +Claude uses get_graphql_schema tool to discover: +- The complete schema structure +- All available types (Query, Mutation, Member, etc.) +- Field definitions with types and descriptions +- Available queries and mutations + +Result: Shows Member type with all fields (id, firstName, lastName, email, etc.) +``` + +**Example 5: Execute custom GraphQL query** +``` +User: Get only the first names and emails of all members + +Claude uses execute_graphql_query tool with: +query: | + query { + members { + firstName + email + } + } + +Result: Returns JSON with only the requested fields +``` + +**Example 6: Execute query with variables** +``` +User: Get member 5's contact information + +Claude uses execute_graphql_query tool with: +query: | + query GetMember($id: Int!) { + member(id: $id) { + firstName + lastName + email + phone + city + } + } +variables: {"id": 5} + +Result: Returns member 5's contact details +``` + +### When to Use Which Tools + +**Use dedicated member tools** when: +- Performing simple, common operations +- The tool matches your exact need +- You want a formatted, human-readable response + +**Use general GraphQL tools** when: +- Exploring what's possible (use `get_graphql_schema`) +- Needing custom field selection +- Combining multiple operations +- Working with complex queries or filters (future) +- You need the raw JSON response + ## Development Workflow This project uses [OpenSpec](https://openspec.dev) for specification-driven development: diff --git a/src/mcp_server.py b/src/mcp_server.py index ed9b44c..622c7be 100644 --- a/src/mcp_server.py +++ b/src/mcp_server.py @@ -5,6 +5,7 @@ via the Model Context Protocol (MCP), connecting to the Clubber GraphQL API. """ import asyncio +import json import os from typing import Any, Dict, List, Optional @@ -13,6 +14,57 @@ from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import TextContent, Tool +# GraphQL introspection query to fetch the complete schema +INTROSPECTION_QUERY = """ +query IntrospectionQuery { + __schema { + queryType { name } + mutationType { name } + types { + kind + name + description + fields(includeDeprecated: true) { + name + description + type { + kind + name + ofType { + kind + name + } + } + args { + name + description + type { + kind + name + ofType { + kind + name + } + } + } + } + inputFields { + name + description + type { + kind + name + ofType { + kind + name + } + } + } + } + } +} +""" + class GraphQLClient: """HTTP client for communicating with the Clubber GraphQL API.""" @@ -181,6 +233,33 @@ class MemberManagementServer: "required": ["id"], }, ), + Tool( + name="get_graphql_schema", + description="Get the complete GraphQL schema definition via introspection. This allows AI agents to discover all available types, queries, mutations, and their fields dynamically.", + inputSchema={ + "type": "object", + "properties": {}, + "required": [], + }, + ), + Tool( + name="execute_graphql_query", + description="Execute an arbitrary GraphQL query or mutation. Use this for complex queries beyond the dedicated member tools. The query can include variables for parameterization.", + inputSchema={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The GraphQL query or mutation string", + }, + "variables": { + "type": "object", + "description": "Optional variables for the query as a JSON object", + }, + }, + "required": ["query"], + }, + ), ] @self.server.call_tool() @@ -195,6 +274,12 @@ class MemberManagementServer: return await self._create_member(arguments) elif name == "update_member": return await self._update_member(arguments) + elif name == "get_graphql_schema": + return await self._get_graphql_schema() + elif name == "execute_graphql_query": + return await self._execute_graphql_query( + arguments["query"], arguments.get("variables") + ) else: raise ValueError(f"Unknown tool: {name}") except Exception as e: @@ -330,6 +415,144 @@ class MemberManagementServer: ) ] + async def _get_graphql_schema(self) -> List[TextContent]: + """Get the complete GraphQL schema via introspection.""" + try: + data = await self.graphql.query(INTROSPECTION_QUERY) + schema = data.get("__schema", {}) + + # Format schema as readable text + output = ["GraphQL Schema\n" + "=" * 50 + "\n"] + + # Query type + if schema.get("queryType"): + output.append(f"Query Type: {schema['queryType']['name']}\n") + + # Mutation type + if schema.get("mutationType"): + output.append(f"Mutation Type: {schema['mutationType']['name']}\n") + + output.append("\nAvailable Types:\n" + "-" * 50) + + # List all types (excluding internal types) + for type_info in schema.get("types", []): + type_name = type_info.get("name", "") + # Skip internal GraphQL types + if type_name.startswith("__"): + continue + + output.append(f"\n{type_info.get('kind', 'OBJECT')}: {type_name}") + + if type_info.get("description"): + output.append(f" Description: {type_info['description']}") + + # List fields + fields = type_info.get("fields", []) + if fields: + output.append(" Fields:") + for field in fields: + field_type = self._format_type(field.get("type", {})) + field_desc = ( + f" - {field.get('description')}" + if field.get("description") + else "" + ) + output.append( + f" - {field['name']}: {field_type}{field_desc}" + ) + + # List field arguments + args = field.get("args", []) + if args: + output.append(" Arguments:") + for arg in args: + arg_type = self._format_type(arg.get("type", {})) + arg_desc = ( + f" - {arg.get('description')}" + if arg.get("description") + else "" + ) + output.append( + f" - {arg['name']}: {arg_type}{arg_desc}" + ) + + # List input fields + input_fields = type_info.get("inputFields", []) + if input_fields: + output.append(" Input Fields:") + for field in input_fields: + field_type = self._format_type(field.get("type", {})) + field_desc = ( + f" - {field.get('description')}" + if field.get("description") + else "" + ) + output.append( + f" - {field['name']}: {field_type}{field_desc}" + ) + + return [TextContent(type="text", text="\n".join(output))] + except Exception as e: + error_msg = str(e) + if "Failed to connect" in error_msg: + return [ + TextContent( + type="text", + text=f"Error: Could not connect to GraphQL API.\n\n" + f"Please ensure the API is running at {self.graphql.api_url}\n" + f"Start it with: uv run uvicorn src.main:app --host 127.0.0.1 --port 8000", + ) + ] + raise + + async def _execute_graphql_query( + self, query: str, variables: Optional[Dict[str, Any]] = None + ) -> List[TextContent]: + """Execute an arbitrary GraphQL query or mutation.""" + try: + data = await self.graphql.query(query, variables) + # Format the result as pretty JSON + result_json = json.dumps(data, indent=2) + return [ + TextContent( + type="text", + text=f"Query executed successfully:\n\n{result_json}", + ) + ] + except Exception as e: + error_msg = str(e) + if "GraphQL errors:" in error_msg: + return [ + TextContent( + type="text", + text=f"GraphQL validation error:\n\n{error_msg}\n\n" + f"Please check your query syntax and field names.", + ) + ] + elif "Failed to connect" in error_msg: + return [ + TextContent( + type="text", + text=f"Error: Could not connect to GraphQL API.\n\n" + f"Please ensure the API is running at {self.graphql.api_url}", + ) + ] + raise + + def _format_type(self, type_info: Dict[str, Any]) -> str: + """Format a GraphQL type for display.""" + kind = type_info.get("kind", "") + name = type_info.get("name", "") + + if kind == "NON_NULL": + inner = self._format_type(type_info.get("ofType", {})) + return f"{inner}!" + elif kind == "LIST": + inner = self._format_type(type_info.get("ofType", {})) + return f"[{inner}]" + else: + return name or "Unknown" + def _format_member(self, member: Dict[str, Any]) -> str: """Format a single member for display.""" lines = [