refactor: Convert MCP server implementation from Python to TypeScript.

This commit is contained in:
2025-12-01 20:46:50 +01:00
parent 7d96a657b3
commit 2a6584078c
12 changed files with 1868 additions and 643 deletions
+3
View File
@@ -31,3 +31,6 @@ ENV/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Node
node_modules/
+4 -2
View File
@@ -1,8 +1,10 @@
{ {
"mcpServers": { "mcpServers": {
"clubber": { "clubber": {
"command": "uv", "command": "node",
"args": ["run", "python", "-m", "src.mcp_server"], "args": [
"dist/mcp_server.js"
],
"env": { "env": {
"CLUBBER_API_URL": "http://127.0.0.1:8000/graphql" "CLUBBER_API_URL": "http://127.0.0.1:8000/graphql"
} }
+26 -18
View File
@@ -24,8 +24,11 @@ The system consists of two main components:
## Tech Stack ## Tech Stack
- **Python 3.11+** - Primary language - **Python 3.11+** - Primary language for GraphQL API
- **uv** - Fast package and project manager - **TypeScript** - Language for MCP Server
- **Node.js** - Runtime for MCP Server
- **uv** - Fast package and project manager for Python
- **npm** - Package manager for TypeScript
- **FastAPI** - Async web framework - **FastAPI** - Async web framework
- **Strawberry GraphQL** - Type-safe GraphQL with Python type hints - **Strawberry GraphQL** - Type-safe GraphQL with Python type hints
- **SQLAlchemy 2.0** - Database ORM - **SQLAlchemy 2.0** - Database ORM
@@ -42,8 +45,12 @@ clubber/
│ ├── config.py # Application configuration │ ├── config.py # Application configuration
│ ├── database.py # Database setup and session management │ ├── database.py # Database setup and session management
│ ├── main.py # FastAPI application entry point │ ├── main.py # FastAPI application entry point
│ ├── mcp_server.py # MCP server implementation │ ├── mcp_server.ts # MCP server implementation (TypeScript)
│ └── validation.py # Input validation logic │ └── validation.py # Input validation logic
├── dist/ # Compiled TypeScript code
├── node_modules/ # Node.js dependencies
├── package.json # Node.js project configuration
├── tsconfig.json # TypeScript configuration
├── tests/ # Test suite ├── tests/ # Test suite
│ ├── unit/ # Unit tests (validation logic) │ ├── unit/ # Unit tests (validation logic)
│ ├── integration/ # Integration tests (database, resolvers) │ ├── integration/ # Integration tests (database, resolvers)
@@ -256,28 +263,26 @@ The Model Context Protocol (MCP) is a standard protocol that allows AI assistant
- **get_graphql_schema** - Get the complete GraphQL schema via introspection - **get_graphql_schema** - Get the complete GraphQL schema via introspection
- **execute_graphql_query** - Execute arbitrary GraphQL queries and mutations - **execute_graphql_query** - Execute arbitrary GraphQL queries and mutations
### Running the MCP Server ### Setup & Build
The MCP server requires the GraphQL API to be running first: The MCP server connects to the GraphQL API, so ensure the API is running:
```bash ```bash
# Terminal 1: Start the GraphQL API # Start the GraphQL API (Backend)
uv run uvicorn src.main:app --host 127.0.0.1 --port 8000 uv run uvicorn src.main:app --host 127.0.0.1 --port 8000
# Terminal 2: Run the MCP server
python -m src.mcp_server
``` ```
### Configuration Then, install dependencies and build the MCP server:
The MCP server can be configured via environment variables:
```bash ```bash
# Use a custom API URL (default: http://127.0.0.1:8000/graphql) npm install
export CLUBBER_API_URL="http://localhost:3000/graphql" npm run build
python -m src.mcp_server
``` ```
*Note: You don't need to run the MCP server manually in a separate terminal. It will be started automatically by your AI client (Claude) based on the configuration below.*
### Integrating with Claude Code ### Integrating with Claude Code
To use the MCP server with Claude Code, add it to your MCP configuration: To use the MCP server with Claude Code, add it to your MCP configuration:
@@ -288,8 +293,8 @@ To use the MCP server with Claude Code, add it to your MCP configuration:
{ {
"mcpServers": { "mcpServers": {
"clubber": { "clubber": {
"command": "uv", "command": "node",
"args": ["run", "python", "-m", "src.mcp_server"], "args": ["/absolute/path/to/clubber/dist/mcp_server.js"],
"env": { "env": {
"CLUBBER_API_URL": "http://127.0.0.1:8000/graphql" "CLUBBER_API_URL": "http://127.0.0.1:8000/graphql"
} }
@@ -298,7 +303,10 @@ To use the MCP server with Claude Code, add it to your MCP configuration:
} }
``` ```
**Note**: The `uv run` command ensures the virtual environment and dependencies are properly activated. The MCP server will use the current working directory where Claude Code is running, so make sure to open Claude Code from the clubber project directory. **Configuration Options**:
- `CLUBBER_API_URL`: URL of the GraphQL API (default: `http://127.0.0.1:8000/graphql`)
**Note**: Make sure to provide the absolute path to the `dist/mcp_server.js` file.
After adding the configuration, restart Claude Code. You can then use natural language to manage members: After adding the configuration, restart Claude Code. You can then use natural language to manage members:
@@ -0,0 +1,13 @@
## Why
The MCP server was originally implemented in Python, but the ecosystem for MCP is stronger in TypeScript/Node.js. Converting to TypeScript allows us to leverage the official SDK more effectively and aligns with the broader MCP community.
## What Changes
- Convert `src/mcp_server.py` to `src/mcp_server.ts`
- Add `package.json` and `tsconfig.json` for Node.js/TypeScript support
- Update `README.md` with new build and run instructions
- Update `.mcp.json` to point to the new Node.js server
- Update `.gitignore` to exclude `node_modules` and `dist`
## Impact
- Affected specs: `mcp-integration`
- Affected code: `src/mcp_server.py` (removed), `src/mcp_server.ts` (added), build configuration
@@ -0,0 +1,13 @@
## MODIFIED Requirements
### Requirement: MCP Server Implementation
The system SHALL provide an MCP (Model Context Protocol) server that exposes member management functionality as standardized AI tools.
#### Scenario: MCP server starts successfully
- **GIVEN** the GraphQL API is running at http://127.0.0.1:8000/graphql
- **WHEN** the MCP server is started with `node dist/mcp_server.js` (after building)
- **THEN** the server SHALL initialize and listen for MCP protocol requests via stdio transport
#### Scenario: MCP server connects to GraphQL API
- **GIVEN** the MCP server is running
- **WHEN** an MCP tool is invoked
- **THEN** the server SHALL send GraphQL queries to the configured API endpoint via HTTP
@@ -0,0 +1,9 @@
## 1. Implementation
- [x] 1.1 Initialize Node.js project (package.json, tsconfig.json)
- [x] 1.2 Install dependencies (@modelcontextprotocol/sdk, zod, typescript)
- [x] 1.3 Implement `src/mcp_server.ts`
- [x] 1.4 Add build scripts
- [x] 1.5 Remove `src/mcp_server.py`
- [x] 1.6 Update `.mcp.json` configuration
- [x] 1.7 Update README.md instructions
- [x] 1.8 Update .gitignore
+1 -1
View File
@@ -8,7 +8,7 @@ The system SHALL provide an MCP (Model Context Protocol) server that exposes mem
#### Scenario: MCP server starts successfully #### Scenario: MCP server starts successfully
- **GIVEN** the GraphQL API is running at http://127.0.0.1:8000/graphql - **GIVEN** the GraphQL API is running at http://127.0.0.1:8000/graphql
- **WHEN** the MCP server is started with `python -m src.mcp_server` - **WHEN** the MCP server is started with `node dist/mcp_server.js` (after building)
- **THEN** the server SHALL initialize and listen for MCP protocol requests via stdio transport - **THEN** the server SHALL initialize and listen for MCP protocol requests via stdio transport
#### Scenario: MCP server connects to GraphQL API #### Scenario: MCP server connects to GraphQL API
+1111
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "clubber",
"version": "1.0.0",
"description": "A toolset for managing members of non-profit societies (Vereine) with AI-powered administration.",
"main": "dist/mcp_server.js",
"directories": {
"test": "tests"
},
"scripts": {
"build": "tsc",
"start": "node dist/mcp_server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
"zod": "^4.1.13"
},
"devDependencies": {
"@types/node": "^24.10.1",
"typescript": "^5.9.3"
}
}
-622
View File
@@ -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())
+649
View File
@@ -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);
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}