feat: add general GraphQL tools to MCP server

Add two new MCP tools for flexible GraphQL operations:
- get_graphql_schema: Expose complete schema via introspection
- execute_graphql_query: Execute arbitrary queries and mutations

These tools complement existing dedicated member tools by enabling
AI agents to:
- Discover the GraphQL schema dynamically
- Construct custom queries with specific field selection
- Handle complex queries without requiring new dedicated tools
- Work with query variables for parameterized operations

The implementation reuses the existing GraphQLClient class and
adds proper error handling for API unavailability and validation
errors. Schema introspection is formatted as human-readable text
while query results are returned as formatted JSON.

Updated README.md with:
- Documentation for both new tools
- Usage examples for schema discovery and query execution
- Guidance on when to use general vs dedicated tools

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-21 11:15:22 +01:00
co-authored by Claude
parent 3cc9e9283f
commit a94f7d0321
2 changed files with 292 additions and 1 deletions
+69 -1
View File
@@ -216,13 +216,18 @@ The MCP (Model Context Protocol) server enables AI assistants like Claude to man
### What is MCP? ### 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 - **list_members** - List all members with their complete information
- **get_member** - Get detailed information about a specific member by ID - **get_member** - Get detailed information about a specific member by ID
- **create_member** - Create a new member (only firstName required) - **create_member** - Create a new member (only firstName required)
- **update_member** - Update an existing member's information - **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 ### Running the MCP Server
The MCP server requires the GraphQL API to be running first: 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 ## Development Workflow
This project uses [OpenSpec](https://openspec.dev) for specification-driven development: This project uses [OpenSpec](https://openspec.dev) for specification-driven development:
+223
View File
@@ -5,6 +5,7 @@ via the Model Context Protocol (MCP), connecting to the Clubber GraphQL API.
""" """
import asyncio import asyncio
import json
import os import os
from typing import Any, Dict, List, Optional 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.server.stdio import stdio_server
from mcp.types import TextContent, Tool 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: class GraphQLClient:
"""HTTP client for communicating with the Clubber GraphQL API.""" """HTTP client for communicating with the Clubber GraphQL API."""
@@ -181,6 +233,33 @@ class MemberManagementServer:
"required": ["id"], "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() @self.server.call_tool()
@@ -195,6 +274,12 @@ class MemberManagementServer:
return await self._create_member(arguments) return await self._create_member(arguments)
elif name == "update_member": elif name == "update_member":
return await self._update_member(arguments) 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: else:
raise ValueError(f"Unknown tool: {name}") raise ValueError(f"Unknown tool: {name}")
except Exception as e: 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: def _format_member(self, member: Dict[str, Any]) -> str:
"""Format a single member for display.""" """Format a single member for display."""
lines = [ lines = [