refactor: Convert MCP server implementation from Python to TypeScript.
This commit is contained in:
@@ -31,3 +31,6 @@ ENV/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
+13
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
Generated
+1111
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user