refactor: Convert MCP server implementation from Python to TypeScript.
This commit is contained in:
@@ -1,622 +0,0 @@
|
||||
"""MCP Server for Clubber Member Management.
|
||||
|
||||
This server provides AI assistants with tools to manage society members
|
||||
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
|
||||
|
||||
import httpx
|
||||
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."""
|
||||
|
||||
def __init__(self, api_url: str):
|
||||
"""Initialize the GraphQL client.
|
||||
|
||||
Args:
|
||||
api_url: The GraphQL API endpoint URL
|
||||
"""
|
||||
self.api_url = api_url
|
||||
self.client = httpx.AsyncClient(timeout=30.0)
|
||||
|
||||
async def query(
|
||||
self, query: str, variables: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a GraphQL query.
|
||||
|
||||
Args:
|
||||
query: The GraphQL query string
|
||||
variables: Optional query variables
|
||||
|
||||
Returns:
|
||||
The GraphQL response data
|
||||
|
||||
Raises:
|
||||
Exception: If the query fails or returns errors
|
||||
"""
|
||||
payload = {"query": query}
|
||||
if variables:
|
||||
payload["variables"] = variables
|
||||
|
||||
try:
|
||||
response = await self.client.post(self.api_url, json=payload)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if "errors" in result:
|
||||
error_messages = [e.get("message", str(e)) for e in result["errors"]]
|
||||
raise Exception(f"GraphQL errors: {', '.join(error_messages)}")
|
||||
|
||||
return result.get("data", {})
|
||||
except httpx.RequestError as e:
|
||||
raise Exception(f"Failed to connect to GraphQL API at {self.api_url}: {e}")
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client."""
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
class MemberManagementServer:
|
||||
"""MCP server providing member management tools."""
|
||||
|
||||
def __init__(self, api_url: str):
|
||||
"""Initialize the MCP server.
|
||||
|
||||
Args:
|
||||
api_url: The GraphQL API endpoint URL
|
||||
"""
|
||||
self.graphql = GraphQLClient(api_url)
|
||||
self.server = Server("clubber-mcp-server")
|
||||
self._register_handlers()
|
||||
|
||||
def _register_handlers(self):
|
||||
"""Register MCP protocol handlers."""
|
||||
|
||||
@self.server.list_tools()
|
||||
async def list_tools() -> List[Tool]:
|
||||
"""List available tools."""
|
||||
return [
|
||||
Tool(
|
||||
name="list_members",
|
||||
description="List all members in the society with their complete information",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="get_member",
|
||||
description="Get detailed information about a specific member by their ID",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "The member's unique ID",
|
||||
}
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="create_member",
|
||||
description="Create a new member. Only firstName is required, all other fields are optional.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"firstName": {
|
||||
"type": "string",
|
||||
"description": "Member's first name (required)",
|
||||
},
|
||||
"lastName": {
|
||||
"type": "string",
|
||||
"description": "Member's last name",
|
||||
},
|
||||
"street": {
|
||||
"type": "string",
|
||||
"description": "Street address",
|
||||
},
|
||||
"apartmentNumber": {
|
||||
"type": "string",
|
||||
"description": "Apartment or unit number",
|
||||
},
|
||||
"zip": {"type": "string", "description": "Postal code"},
|
||||
"city": {"type": "string", "description": "City"},
|
||||
"country": {"type": "string", "description": "Country"},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"description": "Email address (validated when provided)",
|
||||
},
|
||||
"phone": {
|
||||
"type": "string",
|
||||
"description": "Phone number in E.164 format (e.g., +14155551234)",
|
||||
},
|
||||
},
|
||||
"required": ["firstName"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="update_member",
|
||||
description="Update an existing member's information. All fields except id are optional.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "The member's unique ID (required)",
|
||||
},
|
||||
"firstName": {
|
||||
"type": "string",
|
||||
"description": "Member's first name",
|
||||
},
|
||||
"lastName": {
|
||||
"type": "string",
|
||||
"description": "Member's last name",
|
||||
},
|
||||
"street": {
|
||||
"type": "string",
|
||||
"description": "Street address",
|
||||
},
|
||||
"apartmentNumber": {
|
||||
"type": "string",
|
||||
"description": "Apartment or unit number",
|
||||
},
|
||||
"zip": {"type": "string", "description": "Postal code"},
|
||||
"city": {"type": "string", "description": "City"},
|
||||
"country": {"type": "string", "description": "Country"},
|
||||
"email": {"type": "string", "description": "Email address"},
|
||||
"phone": {
|
||||
"type": "string",
|
||||
"description": "Phone number in E.164 format",
|
||||
},
|
||||
},
|
||||
"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()
|
||||
async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]:
|
||||
"""Handle tool execution."""
|
||||
try:
|
||||
if name == "list_members":
|
||||
return await self._list_members()
|
||||
elif name == "get_member":
|
||||
return await self._get_member(arguments["id"])
|
||||
elif name == "create_member":
|
||||
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:
|
||||
return [TextContent(type="text", text=f"Error: {str(e)}")]
|
||||
|
||||
async def _list_members(self) -> List[TextContent]:
|
||||
"""List all members."""
|
||||
query = """
|
||||
query {
|
||||
members {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
street
|
||||
apartmentNumber
|
||||
zip
|
||||
city
|
||||
country
|
||||
email
|
||||
phone
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
"""
|
||||
data = await self.graphql.query(query)
|
||||
members = data.get("members", [])
|
||||
return [
|
||||
TextContent(
|
||||
type="text",
|
||||
text=f"Found {len(members)} member(s):\n\n{self._format_members(members)}",
|
||||
)
|
||||
]
|
||||
|
||||
async def _get_member(self, member_id: int) -> List[TextContent]:
|
||||
"""Get a specific member by ID."""
|
||||
query = """
|
||||
query GetMember($id: Int!) {
|
||||
member(id: $id) {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
street
|
||||
apartmentNumber
|
||||
zip
|
||||
city
|
||||
country
|
||||
email
|
||||
phone
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
"""
|
||||
data = await self.graphql.query(query, {"id": member_id})
|
||||
member = data.get("member")
|
||||
|
||||
if not member:
|
||||
return [
|
||||
TextContent(type="text", text=f"Member with ID {member_id} not found")
|
||||
]
|
||||
|
||||
return [
|
||||
TextContent(
|
||||
type="text", text=f"Member found:\n\n{self._format_member(member)}"
|
||||
)
|
||||
]
|
||||
|
||||
async def _create_member(self, input_data: Dict[str, Any]) -> List[TextContent]:
|
||||
"""Create a new member."""
|
||||
mutation = """
|
||||
mutation CreateMember($input: CreateMemberInput!) {
|
||||
createMember(input: $input) {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
street
|
||||
apartmentNumber
|
||||
zip
|
||||
city
|
||||
country
|
||||
email
|
||||
phone
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
"""
|
||||
data = await self.graphql.query(mutation, {"input": input_data})
|
||||
member = data.get("createMember")
|
||||
return [
|
||||
TextContent(
|
||||
type="text",
|
||||
text=f"Member created successfully:\n\n{self._format_member(member)}",
|
||||
)
|
||||
]
|
||||
|
||||
async def _update_member(self, input_data: Dict[str, Any]) -> List[TextContent]:
|
||||
"""Update an existing member."""
|
||||
mutation = """
|
||||
mutation UpdateMember($input: UpdateMemberInput!) {
|
||||
updateMember(input: $input) {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
street
|
||||
apartmentNumber
|
||||
zip
|
||||
city
|
||||
country
|
||||
email
|
||||
phone
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
"""
|
||||
data = await self.graphql.query(mutation, {"input": input_data})
|
||||
member = data.get("updateMember")
|
||||
|
||||
if not member:
|
||||
return [
|
||||
TextContent(
|
||||
type="text",
|
||||
text=f"Failed to update member with ID {input_data.get('id')}",
|
||||
)
|
||||
]
|
||||
|
||||
return [
|
||||
TextContent(
|
||||
type="text",
|
||||
text=f"Member updated successfully:\n\n{self._format_member(member)}",
|
||||
)
|
||||
]
|
||||
|
||||
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 = [
|
||||
f"ID: {member.get('id')}",
|
||||
f"Name: {member.get('firstName', '')} {member.get('lastName', '')}".strip(),
|
||||
]
|
||||
|
||||
if member.get("street"):
|
||||
address_parts = [member.get("street")]
|
||||
if member.get("apartmentNumber"):
|
||||
address_parts.append(f"Apt {member.get('apartmentNumber')}")
|
||||
lines.append(f"Street: {' '.join(address_parts)}")
|
||||
|
||||
if member.get("zip") or member.get("city"):
|
||||
location = f"{member.get('zip', '')} {member.get('city', '')}".strip()
|
||||
lines.append(f"Location: {location}")
|
||||
|
||||
if member.get("country"):
|
||||
lines.append(f"Country: {member.get('country')}")
|
||||
|
||||
if member.get("email"):
|
||||
lines.append(f"Email: {member.get('email')}")
|
||||
|
||||
if member.get("phone"):
|
||||
lines.append(f"Phone: {member.get('phone')}")
|
||||
|
||||
if member.get("createdAt"):
|
||||
lines.append(f"Created: {member.get('createdAt')}")
|
||||
|
||||
if member.get("updatedAt"):
|
||||
lines.append(f"Updated: {member.get('updatedAt')}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_members(self, members: List[Dict[str, Any]]) -> str:
|
||||
"""Format multiple members for display."""
|
||||
if not members:
|
||||
return "No members found."
|
||||
|
||||
return "\n\n---\n\n".join(self._format_member(m) for m in members)
|
||||
|
||||
async def run(self):
|
||||
"""Run the MCP server."""
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await self.server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
self.server.create_initialization_options(),
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point."""
|
||||
# Get API URL from environment or use default
|
||||
api_url = os.getenv("CLUBBER_API_URL", "http://127.0.0.1:8000/graphql")
|
||||
print("Starting Clubber MCP Server...", flush=True)
|
||||
print(f"GraphQL API URL: {api_url}", flush=True)
|
||||
|
||||
server = MemberManagementServer(api_url)
|
||||
try:
|
||||
await server.run()
|
||||
finally:
|
||||
await server.graphql.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,649 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
// GraphQL introspection query
|
||||
const 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 {
|
||||
private apiUrl: string;
|
||||
|
||||
constructor(apiUrl: string) {
|
||||
this.apiUrl = apiUrl;
|
||||
}
|
||||
|
||||
getApiUrl(): string {
|
||||
return this.apiUrl;
|
||||
}
|
||||
|
||||
async query(query: string, variables?: Record<string, any>): Promise<any> {
|
||||
const payload: any = { query };
|
||||
if (variables) {
|
||||
payload.variables = variables;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(this.apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as any;
|
||||
|
||||
if (result.errors) {
|
||||
const errorMessages = result.errors.map((e: any) => e.message || String(e));
|
||||
throw new Error(`GraphQL errors: ${errorMessages.join(", ")}`);
|
||||
}
|
||||
|
||||
return result.data || {};
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to connect to GraphQL API at ${this.apiUrl}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MemberManagementServer {
|
||||
private graphql: GraphQLClient;
|
||||
private server: Server;
|
||||
|
||||
constructor(apiUrl: string) {
|
||||
this.graphql = new GraphQLClient(apiUrl);
|
||||
this.server = new Server(
|
||||
{
|
||||
name: "clubber-mcp-server",
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers() {
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: "list_members",
|
||||
description: "List all members in the society with their complete information",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_member",
|
||||
description: "Get detailed information about a specific member by their ID",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: {
|
||||
type: "integer",
|
||||
description: "The member's unique ID",
|
||||
},
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create_member",
|
||||
description: "Create a new member. Only firstName is required, all other fields are optional.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
firstName: {
|
||||
type: "string",
|
||||
description: "Member's first name (required)",
|
||||
},
|
||||
lastName: {
|
||||
type: "string",
|
||||
description: "Member's last name",
|
||||
},
|
||||
street: {
|
||||
type: "string",
|
||||
description: "Street address",
|
||||
},
|
||||
apartmentNumber: {
|
||||
type: "string",
|
||||
description: "Apartment or unit number",
|
||||
},
|
||||
zip: { type: "string", description: "Postal code" },
|
||||
city: { type: "string", description: "City" },
|
||||
country: { type: "string", description: "Country" },
|
||||
email: {
|
||||
type: "string",
|
||||
description: "Email address (validated when provided)",
|
||||
},
|
||||
phone: {
|
||||
type: "string",
|
||||
description: "Phone number in E.164 format (e.g., +14155551234)",
|
||||
},
|
||||
},
|
||||
required: ["firstName"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update_member",
|
||||
description: "Update an existing member's information. All fields except id are optional.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: {
|
||||
type: "integer",
|
||||
description: "The member's unique ID (required)",
|
||||
},
|
||||
firstName: {
|
||||
type: "string",
|
||||
description: "Member's first name",
|
||||
},
|
||||
lastName: {
|
||||
type: "string",
|
||||
description: "Member's last name",
|
||||
},
|
||||
street: {
|
||||
type: "string",
|
||||
description: "Street address",
|
||||
},
|
||||
apartmentNumber: {
|
||||
type: "string",
|
||||
description: "Apartment or unit number",
|
||||
},
|
||||
zip: { type: "string", description: "Postal code" },
|
||||
city: { type: "string", description: "City" },
|
||||
country: { type: "string", description: "Country" },
|
||||
email: { type: "string", description: "Email address" },
|
||||
phone: {
|
||||
type: "string",
|
||||
description: "Phone number in E.164 format",
|
||||
},
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
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"],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case "list_members":
|
||||
return await this.listMembers();
|
||||
case "get_member":
|
||||
return await this.getMember(args as any);
|
||||
case "create_member":
|
||||
return await this.createMember(args as any);
|
||||
case "update_member":
|
||||
return await this.updateMember(args as any);
|
||||
case "get_graphql_schema":
|
||||
return await this.getGraphqlSchema();
|
||||
case "execute_graphql_query":
|
||||
return await this.executeGraphqlQuery(args as any);
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${error.message}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async listMembers() {
|
||||
const query = `
|
||||
query {
|
||||
members {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
street
|
||||
apartmentNumber
|
||||
zip
|
||||
city
|
||||
country
|
||||
email
|
||||
phone
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
const data = await this.graphql.query(query);
|
||||
const members = data.members || [];
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Found ${members.length} member(s):\n\n${this.formatMembers(members)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async getMember(args: { id: number }) {
|
||||
const query = `
|
||||
query GetMember($id: Int!) {
|
||||
member(id: $id) {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
street
|
||||
apartmentNumber
|
||||
zip
|
||||
city
|
||||
country
|
||||
email
|
||||
phone
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
const data = await this.graphql.query(query, { id: args.id });
|
||||
const member = data.member;
|
||||
|
||||
if (!member) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Member with ID ${args.id} not found`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Member found:\n\n${this.formatMember(member)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async createMember(inputData: any) {
|
||||
const mutation = `
|
||||
mutation CreateMember($input: CreateMemberInput!) {
|
||||
createMember(input: $input) {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
street
|
||||
apartmentNumber
|
||||
zip
|
||||
city
|
||||
country
|
||||
email
|
||||
phone
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
const data = await this.graphql.query(mutation, { input: inputData });
|
||||
const member = data.createMember;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Member created successfully:\n\n${this.formatMember(member)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async updateMember(inputData: any) {
|
||||
const mutation = `
|
||||
mutation UpdateMember($input: UpdateMemberInput!) {
|
||||
updateMember(input: $input) {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
street
|
||||
apartmentNumber
|
||||
zip
|
||||
city
|
||||
country
|
||||
email
|
||||
phone
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
const data = await this.graphql.query(mutation, { input: inputData });
|
||||
const member = data.updateMember;
|
||||
|
||||
if (!member) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to update member with ID ${inputData.id}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Member updated successfully:\n\n${this.formatMember(member)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async getGraphqlSchema() {
|
||||
try {
|
||||
const data = await this.graphql.query(INTROSPECTION_QUERY);
|
||||
const schema = data.__schema || {};
|
||||
|
||||
const output = ["GraphQL Schema\n" + "=".repeat(50) + "\n"];
|
||||
|
||||
if (schema.queryType) {
|
||||
output.push(`Query Type: ${schema.queryType.name}\n`);
|
||||
}
|
||||
|
||||
if (schema.mutationType) {
|
||||
output.push(`Mutation Type: ${schema.mutationType.name}\n`);
|
||||
}
|
||||
|
||||
output.push("\nAvailable Types:\n" + "-".repeat(50));
|
||||
|
||||
for (const typeInfo of schema.types || []) {
|
||||
const typeName = typeInfo.name || "";
|
||||
if (typeName.startsWith("__")) continue;
|
||||
|
||||
output.push(`\n${typeInfo.kind || "OBJECT"}: ${typeName}`);
|
||||
|
||||
if (typeInfo.description) {
|
||||
output.push(` Description: ${typeInfo.description}`);
|
||||
}
|
||||
|
||||
const fields = typeInfo.fields || [];
|
||||
if (fields.length > 0) {
|
||||
output.push(" Fields:");
|
||||
for (const field of fields) {
|
||||
const fieldType = this.formatType(field.type || {});
|
||||
const fieldDesc = field.description ? ` - ${field.description}` : "";
|
||||
output.push(` - ${field.name}: ${fieldType}${fieldDesc}`);
|
||||
|
||||
const args = field.args || [];
|
||||
if (args.length > 0) {
|
||||
output.push(" Arguments:");
|
||||
for (const arg of args) {
|
||||
const argType = this.formatType(arg.type || {});
|
||||
const argDesc = arg.description ? ` - ${arg.description}` : "";
|
||||
output.push(` - ${arg.name}: ${argType}${argDesc}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inputFields = typeInfo.inputFields || [];
|
||||
if (inputFields.length > 0) {
|
||||
output.push(" Input Fields:");
|
||||
for (const field of inputFields) {
|
||||
const fieldType = this.formatType(field.type || {});
|
||||
const fieldDesc = field.description ? ` - ${field.description}` : "";
|
||||
output.push(` - ${field.name}: ${fieldType}${fieldDesc}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: output.join("\n"),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (e: any) {
|
||||
const errorMsg = String(e);
|
||||
if (errorMsg.includes("Failed to connect")) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: Could not connect to GraphQL API.\n\nPlease ensure the API is running at ${this.graphql.getApiUrl()}\nStart it with: uv run uvicorn src.main:app --host 127.0.0.1 --port 8000`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private async executeGraphqlQuery(args: { query: string; variables?: Record<string, any> }) {
|
||||
try {
|
||||
const data = await this.graphql.query(args.query, args.variables);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Query executed successfully:\n\n${JSON.stringify(data, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (e: any) {
|
||||
const errorMsg = String(e);
|
||||
if (errorMsg.includes("GraphQL errors:")) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `GraphQL validation error:\n\n${errorMsg}\n\nPlease check your query syntax and field names.`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
} else if (errorMsg.includes("Failed to connect")) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: Could not connect to GraphQL API.\n\nPlease ensure the API is running at ${this.graphql.getApiUrl()}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private formatType(typeInfo: any): string {
|
||||
const kind = typeInfo.kind || "";
|
||||
const name = typeInfo.name || "";
|
||||
|
||||
if (kind === "NON_NULL") {
|
||||
const inner = this.formatType(typeInfo.ofType || {});
|
||||
return `${inner}!`;
|
||||
} else if (kind === "LIST") {
|
||||
const inner = this.formatType(typeInfo.ofType || {});
|
||||
return `[${inner}]`;
|
||||
} else {
|
||||
return name || "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private formatMember(member: any): string {
|
||||
const lines = [
|
||||
`ID: ${member.id}`,
|
||||
`Name: ${(member.firstName || "") + " " + (member.lastName || "")}`.trim(),
|
||||
];
|
||||
|
||||
if (member.street) {
|
||||
const addressParts = [member.street];
|
||||
if (member.apartmentNumber) {
|
||||
addressParts.push(`Apt ${member.apartmentNumber}`);
|
||||
}
|
||||
lines.push(`Street: ${addressParts.join(" ")}`);
|
||||
}
|
||||
|
||||
if (member.zip || member.city) {
|
||||
const location = `${member.zip || ""} ${member.city || ""}`.trim();
|
||||
lines.push(`Location: ${location}`);
|
||||
}
|
||||
|
||||
if (member.country) {
|
||||
lines.push(`Country: ${member.country}`);
|
||||
}
|
||||
|
||||
if (member.email) {
|
||||
lines.push(`Email: ${member.email}`);
|
||||
}
|
||||
|
||||
if (member.phone) {
|
||||
lines.push(`Phone: ${member.phone}`);
|
||||
}
|
||||
|
||||
if (member.createdAt) {
|
||||
lines.push(`Created: ${member.createdAt}`);
|
||||
}
|
||||
|
||||
if (member.updatedAt) {
|
||||
lines.push(`Updated: ${member.updatedAt}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
private formatMembers(members: any[]): string {
|
||||
if (!members || members.length === 0) {
|
||||
return "No members found.";
|
||||
}
|
||||
|
||||
return members.map((m) => this.formatMember(m)).join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
async run() {
|
||||
const transport = new StdioServerTransport();
|
||||
await this.server.connect(transport);
|
||||
console.error("Clubber MCP Server running on stdio");
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const apiUrl = process.env.CLUBBER_API_URL || "http://127.0.0.1:8000/graphql";
|
||||
console.error("Starting Clubber MCP Server...");
|
||||
console.error(`GraphQL API URL: ${apiUrl}`);
|
||||
|
||||
const server = new MemberManagementServer(apiUrl);
|
||||
await server.run();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user