Merge pull request #2 from donbr/feature/fastmcp-cloud-deployment
feat(mcp_server): Add FastMCP Cloud deployment support
This commit is contained in:
commit
83f03aefc9
5 changed files with 914 additions and 32 deletions
|
|
@ -1,25 +1,99 @@
|
||||||
# Graphiti MCP Server Environment Configuration
|
# Graphiti MCP Server Environment Configuration
|
||||||
|
# =============================================
|
||||||
|
#
|
||||||
|
# For LOCAL development: Copy this file to .env and fill in your values
|
||||||
|
# For FASTMCP CLOUD: Set these in the FastMCP Cloud UI (NOT in .env files)
|
||||||
|
#
|
||||||
|
# FastMCP Cloud ignores .env files - you MUST set secrets in the Cloud UI
|
||||||
|
|
||||||
# Neo4j Database Configuration
|
# =============================================================================
|
||||||
# These settings are used to connect to your Neo4j database
|
# DATABASE CONFIGURATION (Choose ONE provider)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Database Provider Selection
|
||||||
|
# Options: neo4j, falkordb
|
||||||
|
DATABASE_PROVIDER=neo4j
|
||||||
|
|
||||||
|
# --- Neo4j Configuration ---
|
||||||
|
# For local development: bolt://localhost:7687
|
||||||
|
# For Neo4j Aura (cloud): neo4j+s://xxxxx.databases.neo4j.io
|
||||||
NEO4J_URI=bolt://localhost:7687
|
NEO4J_URI=bolt://localhost:7687
|
||||||
NEO4J_USER=neo4j
|
NEO4J_USER=neo4j
|
||||||
NEO4J_PASSWORD=demodemo
|
NEO4J_PASSWORD=your_neo4j_password_here
|
||||||
|
NEO4J_DATABASE=neo4j
|
||||||
|
|
||||||
# OpenAI API Configuration
|
# --- FalkorDB Configuration ---
|
||||||
# Required for LLM operations
|
# For local development: redis://localhost:6379
|
||||||
|
# For FalkorDB Cloud: redis://username:password@host:port
|
||||||
|
# FALKORDB_URI=redis://localhost:6379
|
||||||
|
# FALKORDB_PASSWORD=
|
||||||
|
# FALKORDB_DATABASE=default_db
|
||||||
|
# FALKORDB_USER=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LLM PROVIDER CONFIGURATION (Required)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# OpenAI (Default)
|
||||||
OPENAI_API_KEY=your_openai_api_key_here
|
OPENAI_API_KEY=your_openai_api_key_here
|
||||||
MODEL_NAME=gpt-4.1-mini
|
|
||||||
|
|
||||||
# Optional: Only needed for non-standard OpenAI endpoints
|
# Optional: Override default model
|
||||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
# LLM_MODEL=gpt-4.1-mini
|
||||||
|
|
||||||
# Optional: Group ID for namespacing graph data
|
# --- Alternative LLM Providers ---
|
||||||
# GROUP_ID=my_project
|
|
||||||
|
# Anthropic
|
||||||
|
# ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
|
||||||
|
# Google Gemini
|
||||||
|
# GOOGLE_API_KEY=...
|
||||||
|
# GOOGLE_PROJECT_ID=
|
||||||
|
# GOOGLE_LOCATION=us-central1
|
||||||
|
|
||||||
|
# Groq
|
||||||
|
# GROQ_API_KEY=...
|
||||||
|
|
||||||
|
# Azure OpenAI
|
||||||
|
# AZURE_OPENAI_API_KEY=...
|
||||||
|
# AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com
|
||||||
|
# AZURE_OPENAI_DEPLOYMENT=your-deployment-name
|
||||||
|
# AZURE_OPENAI_API_VERSION=2024-10-21
|
||||||
|
# USE_AZURE_AD=false
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# EMBEDDER CONFIGURATION (Optional - defaults to OpenAI)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Voyage AI (recommended by Anthropic for Claude integrations)
|
||||||
|
# VOYAGE_API_KEY=...
|
||||||
|
# Note: Voyage AI uses 1024 dimensions by default
|
||||||
|
|
||||||
|
# Embedding dimensions (must match your embedding model)
|
||||||
|
# OpenAI text-embedding-3-small: 1536
|
||||||
|
# Voyage AI voyage-3: 1024
|
||||||
|
# EMBEDDING_DIM=1536
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GRAPHITI CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Group ID for namespacing graph data
|
||||||
|
GRAPHITI_GROUP_ID=main
|
||||||
|
|
||||||
|
# User ID for tracking operations
|
||||||
|
USER_ID=mcp_user
|
||||||
|
|
||||||
|
# Episode ID prefix (optional)
|
||||||
|
# EPISODE_ID_PREFIX=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PERFORMANCE TUNING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
# Concurrency Control
|
# Concurrency Control
|
||||||
# Controls how many episodes can be processed simultaneously
|
# Controls how many episodes can be processed simultaneously
|
||||||
# Default: 10 (suitable for OpenAI Tier 3, mid-tier Anthropic)
|
# Default: 10 (suitable for OpenAI Tier 3, mid-tier Anthropic)
|
||||||
|
#
|
||||||
# Adjust based on your LLM provider's rate limits:
|
# Adjust based on your LLM provider's rate limits:
|
||||||
# - OpenAI Tier 1 (free): 1-2
|
# - OpenAI Tier 1 (free): 1-2
|
||||||
# - OpenAI Tier 2: 5-8
|
# - OpenAI Tier 2: 5-8
|
||||||
|
|
@ -27,23 +101,28 @@ MODEL_NAME=gpt-4.1-mini
|
||||||
# - OpenAI Tier 4: 20-50
|
# - OpenAI Tier 4: 20-50
|
||||||
# - Anthropic default: 5-8
|
# - Anthropic default: 5-8
|
||||||
# - Anthropic high tier: 15-30
|
# - Anthropic high tier: 15-30
|
||||||
# - Ollama (local): 1-5
|
#
|
||||||
# See README.md "Concurrency and LLM Provider 429 Rate Limit Errors" for details
|
# See README.md "Concurrency and LLM Provider 429 Rate Limit Errors" for details
|
||||||
SEMAPHORE_LIMIT=10
|
SEMAPHORE_LIMIT=10
|
||||||
|
|
||||||
# Optional: Path configuration for Docker
|
# =============================================================================
|
||||||
# PATH=/root/.local/bin:${PATH}
|
# FASTMCP CLOUD DEPLOYMENT CHECKLIST
|
||||||
|
# =============================================================================
|
||||||
# Optional: Memory settings for Neo4j (used in Docker Compose)
|
# When deploying to FastMCP Cloud, set these in the Cloud UI:
|
||||||
# NEO4J_server_memory_heap_initial__size=512m
|
#
|
||||||
# NEO4J_server_memory_heap_max__size=1G
|
# REQUIRED:
|
||||||
# NEO4J_server_memory_pagecache_size=512m
|
# - OPENAI_API_KEY (or your chosen LLM provider key)
|
||||||
|
# - Database credentials (Neo4j or FalkorDB)
|
||||||
# Azure OpenAI configuration
|
# - For Neo4j Aura: NEO4J_URI, NEO4J_USER, NEO4J_PASSWORD
|
||||||
# Optional: Only needed for Azure OpenAI endpoints
|
# - For FalkorDB Cloud: FALKORDB_URI, FALKORDB_USER, FALKORDB_PASSWORD
|
||||||
# AZURE_OPENAI_ENDPOINT=your_azure_openai_endpoint_here
|
#
|
||||||
# AZURE_OPENAI_API_VERSION=2025-01-01-preview
|
# OPTIONAL:
|
||||||
# AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o-gpt-4o-mini-deployment
|
# - DATABASE_PROVIDER (defaults to falkordb)
|
||||||
# AZURE_OPENAI_EMBEDDING_API_VERSION=2023-05-15
|
# - SEMAPHORE_LIMIT (defaults to 10)
|
||||||
# AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME=text-embedding-3-large-deployment
|
# - GRAPHITI_GROUP_ID (defaults to main)
|
||||||
# AZURE_OPENAI_USE_MANAGED_IDENTITY=false
|
#
|
||||||
|
# IMPORTANT: FastMCP Cloud IGNORES:
|
||||||
|
# - .env files
|
||||||
|
# - config.yaml files
|
||||||
|
# - if __name__ == "__main__" blocks
|
||||||
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,9 @@ llm:
|
||||||
api_url: ${GROQ_API_URL:https://api.groq.com/openai/v1}
|
api_url: ${GROQ_API_URL:https://api.groq.com/openai/v1}
|
||||||
|
|
||||||
embedder:
|
embedder:
|
||||||
provider: "openai" # Options: openai, azure_openai, gemini, voyage
|
provider: ${EMBEDDER_PROVIDER:openai} # Options: openai, azure_openai, gemini, voyage
|
||||||
model: "text-embedding-3-small"
|
model: ${EMBEDDER_MODEL:text-embedding-3-small}
|
||||||
dimensions: 1536
|
dimensions: ${EMBEDDING_DIM:1536} # OpenAI: 1536, Voyage: 1024
|
||||||
|
|
||||||
providers:
|
providers:
|
||||||
openai:
|
openai:
|
||||||
|
|
@ -71,7 +71,7 @@ embedder:
|
||||||
model: "voyage-3"
|
model: "voyage-3"
|
||||||
|
|
||||||
database:
|
database:
|
||||||
provider: "falkordb" # Default: falkordb. Options: neo4j, falkordb
|
provider: ${DATABASE_PROVIDER:falkordb} # Options: neo4j, falkordb. Set DATABASE_PROVIDER env var to override
|
||||||
|
|
||||||
providers:
|
providers:
|
||||||
falkordb:
|
falkordb:
|
||||||
|
|
|
||||||
451
mcp_server/docs/FASTMCP_CLOUD_DEPLOYMENT.md
Normal file
451
mcp_server/docs/FASTMCP_CLOUD_DEPLOYMENT.md
Normal file
|
|
@ -0,0 +1,451 @@
|
||||||
|
# FastMCP Cloud Deployment Guide
|
||||||
|
|
||||||
|
This guide covers deploying the Graphiti MCP server to FastMCP Cloud, a managed hosting platform for MCP servers.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
FastMCP Cloud is a managed platform that:
|
||||||
|
- Automatically builds and deploys your MCP server from GitHub
|
||||||
|
- Provides a unique HTTPS URL for your server
|
||||||
|
- Handles SSL certificates and authentication
|
||||||
|
- Auto-redeploys on pushes to `main` branch
|
||||||
|
- Creates preview deployments for pull requests
|
||||||
|
|
||||||
|
**Note:** FastMCP Cloud is currently **free while in beta**.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before deploying to FastMCP Cloud, you need:
|
||||||
|
|
||||||
|
1. **GitHub Account** - FastMCP Cloud integrates with GitHub repos
|
||||||
|
2. **Cloud Database** - Neo4j Aura or FalkorDB Cloud (must be internet-accessible)
|
||||||
|
3. **API Keys** - OpenAI (required), Anthropic (optional)
|
||||||
|
4. **Verified Repo** - Run the verification script first
|
||||||
|
|
||||||
|
### Pre-Deployment Verification
|
||||||
|
|
||||||
|
Run the verification script to check your server is ready:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mcp_server
|
||||||
|
uv run python scripts/verify_fastmcp_cloud_readiness.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This checks:
|
||||||
|
- Server is discoverable via `fastmcp inspect`
|
||||||
|
- Dependencies are properly declared in `pyproject.toml`
|
||||||
|
- Environment variables are documented
|
||||||
|
- No secrets are committed to git
|
||||||
|
- Server can be imported successfully
|
||||||
|
- Entrypoint format is correct
|
||||||
|
|
||||||
|
All checks should pass before deploying.
|
||||||
|
|
||||||
|
## Deployment Steps
|
||||||
|
|
||||||
|
### Step 0: Validate Your Server Locally
|
||||||
|
|
||||||
|
**Before deploying to FastMCP Cloud, validate with BOTH static and runtime checks.**
|
||||||
|
|
||||||
|
#### Static Validation: `fastmcp inspect`
|
||||||
|
|
||||||
|
Checks that your server module can be imported and tools are registered:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mcp_server
|
||||||
|
uv run fastmcp inspect src/graphiti_mcp_server.py:mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected successful output:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Name: Graphiti Agent Memory
|
||||||
|
Version: <version>
|
||||||
|
Tools: 9 found
|
||||||
|
- add_memory: Add an episode to memory
|
||||||
|
- search_nodes: Search for nodes in the graph memory
|
||||||
|
- search_memory_facts: Search the graph memory for relevant facts
|
||||||
|
- get_episodes: Get episodes from the graph memory
|
||||||
|
- get_entity_edge: Get an entity edge from the graph memory by its UUID
|
||||||
|
- delete_episode: Delete an episode from the graph memory
|
||||||
|
- delete_entity_edge: Delete an entity edge from the graph memory
|
||||||
|
- clear_graph: Clear all data from the graph for specified group IDs
|
||||||
|
- get_status: Get the status of the Graphiti MCP server
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Runtime Validation: `fastmcp dev` (ESSENTIAL!)
|
||||||
|
|
||||||
|
**The `inspect` command only checks imports - it does NOT catch runtime initialization issues.**
|
||||||
|
|
||||||
|
Run the interactive inspector to test actual server initialization:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mcp_server
|
||||||
|
uv run fastmcp dev src/graphiti_mcp_server.py:mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts your server and opens an interactive web UI at `http://localhost:6274`.
|
||||||
|
|
||||||
|
**Critical test in the web UI:**
|
||||||
|
|
||||||
|
1. Open `http://localhost:6274` in your browser
|
||||||
|
2. Click the "get_status" tool
|
||||||
|
3. Click "Execute"
|
||||||
|
4. **Expected:** `{"status": "ok", "message": "Graphiti MCP server is running and connected to Neo4j database"}`
|
||||||
|
5. **If you see:** `{"status": "error", "message": "Graphiti service not initialized"}` - **DO NOT DEPLOY**
|
||||||
|
|
||||||
|
### Step 1: Set Up Cloud Database
|
||||||
|
|
||||||
|
#### Option A: Neo4j Aura (Recommended for Neo4j users)
|
||||||
|
|
||||||
|
1. Visit [Neo4j Aura](https://neo4j.com/cloud/aura/)
|
||||||
|
2. Create a free instance
|
||||||
|
3. Note your connection details:
|
||||||
|
- URI: `neo4j+s://xxxxx.databases.neo4j.io`
|
||||||
|
- Username: `neo4j`
|
||||||
|
- Password: (generated)
|
||||||
|
|
||||||
|
#### Option B: FalkorDB Cloud
|
||||||
|
|
||||||
|
1. Visit [FalkorDB Cloud](https://cloud.falkordb.com)
|
||||||
|
2. Create an instance
|
||||||
|
3. Note your connection details:
|
||||||
|
- URI: `redis://username:password@host:port`
|
||||||
|
- Database: `default_db`
|
||||||
|
|
||||||
|
**Important:** Local databases (localhost) will NOT work with FastMCP Cloud. You must use a cloud-hosted database.
|
||||||
|
|
||||||
|
### Step 2: Prepare Your Repository
|
||||||
|
|
||||||
|
1. **Ensure `pyproject.toml` is complete**
|
||||||
|
|
||||||
|
FastMCP Cloud automatically detects dependencies from `pyproject.toml`:
|
||||||
|
```toml
|
||||||
|
[project]
|
||||||
|
dependencies = [
|
||||||
|
"fastmcp>=2.13.3",
|
||||||
|
"graphiti-core[falkordb]>=0.23.1",
|
||||||
|
"pydantic>=2.0.0",
|
||||||
|
"pydantic-settings>=2.0.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
# ... other dependencies
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Verify `.env` is in `.gitignore`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git check-ignore -v .env
|
||||||
|
# Should output: .gitignore:XX:.env .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Commit and push your code**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Prepare for FastMCP Cloud deployment"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Create FastMCP Cloud Project
|
||||||
|
|
||||||
|
1. **Visit [fastmcp.cloud](https://fastmcp.cloud)**
|
||||||
|
|
||||||
|
2. **Sign in with your GitHub account**
|
||||||
|
|
||||||
|
3. **Create a new project:**
|
||||||
|
- Click "Create Project"
|
||||||
|
- Select your repository
|
||||||
|
- Repository can be public or private
|
||||||
|
|
||||||
|
4. **Configure project settings:**
|
||||||
|
|
||||||
|
| Setting | Value | Notes |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| **Name** | `graphiti-mcp` | Used in your deployment URL |
|
||||||
|
| **Entrypoint** | `mcp_server/src/graphiti_mcp_server.py:mcp` | Points to module-level server instance |
|
||||||
|
| **Authentication** | Enabled | Recommended for production |
|
||||||
|
|
||||||
|
**Important:** The entrypoint must point to a **module-level** `FastMCP` instance.
|
||||||
|
|
||||||
|
### Step 4: Configure Environment Variables
|
||||||
|
|
||||||
|
Set these environment variables in the FastMCP Cloud UI (**NOT** in `.env` files):
|
||||||
|
|
||||||
|
#### For Neo4j:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
NEO4J_URI=neo4j+s://xxxxx.databases.neo4j.io
|
||||||
|
NEO4J_USER=neo4j
|
||||||
|
NEO4J_PASSWORD=your-password
|
||||||
|
DATABASE_PROVIDER=neo4j
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
SEMAPHORE_LIMIT=10
|
||||||
|
GRAPHITI_GROUP_ID=main
|
||||||
|
```
|
||||||
|
|
||||||
|
#### For FalkorDB:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
FALKORDB_URI=redis://host:port
|
||||||
|
FALKORDB_USER=your-username
|
||||||
|
FALKORDB_PASSWORD=your-password
|
||||||
|
FALKORDB_DATABASE=default_db
|
||||||
|
DATABASE_PROVIDER=falkordb
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
SEMAPHORE_LIMIT=10
|
||||||
|
GRAPHITI_GROUP_ID=main
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security Note:** Environment variables set in FastMCP Cloud UI are encrypted at rest and never logged.
|
||||||
|
|
||||||
|
### Step 5: Deploy
|
||||||
|
|
||||||
|
1. **Click "Deploy"**
|
||||||
|
|
||||||
|
FastMCP Cloud will:
|
||||||
|
1. Clone your repository
|
||||||
|
2. Detect dependencies from `pyproject.toml`
|
||||||
|
3. Install dependencies using `uv`
|
||||||
|
4. Build your FastMCP server
|
||||||
|
5. Deploy to a unique URL
|
||||||
|
6. Make it immediately available
|
||||||
|
|
||||||
|
2. **Monitor the build**
|
||||||
|
|
||||||
|
Watch the build logs in the FastMCP Cloud UI. The build typically takes 2-5 minutes.
|
||||||
|
|
||||||
|
3. **Note your deployment URL**
|
||||||
|
|
||||||
|
Your server will be accessible at:
|
||||||
|
```
|
||||||
|
https://your-project-name.fastmcp.app/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Verify Deployment
|
||||||
|
|
||||||
|
1. **Test with `fastmcp inspect`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fastmcp inspect https://your-project-name.fastmcp.app/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see your server info and 9 tools.
|
||||||
|
|
||||||
|
2. **Connect from Claude Desktop**
|
||||||
|
|
||||||
|
FastMCP Cloud provides auto-generated configuration. Click "Connect" in the UI and copy the configuration.
|
||||||
|
|
||||||
|
3. **Test add_memory tool**
|
||||||
|
|
||||||
|
Use Claude Desktop or an MCP client to test:
|
||||||
|
```
|
||||||
|
Add a memory: "John prefers dark mode UI"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Differences
|
||||||
|
|
||||||
|
### FastMCP Cloud vs Local Development
|
||||||
|
|
||||||
|
| Aspect | FastMCP Cloud | Local Development |
|
||||||
|
|--------|---------------|-------------------|
|
||||||
|
| **Entry point** | Module-level instance only | `if __name__ == "__main__"` runs |
|
||||||
|
| **Dependencies** | Auto-detected from `pyproject.toml` | Installed via `uv sync` |
|
||||||
|
| **Environment** | Set in Cloud UI | Loaded from `.env` file |
|
||||||
|
| **Transport** | Managed by platform | Configured via CLI args |
|
||||||
|
| **HTTPS** | Automatic | Manual setup |
|
||||||
|
| **Authentication** | Built-in OAuth | Configure manually |
|
||||||
|
|
||||||
|
### What Gets Ignored
|
||||||
|
|
||||||
|
FastMCP Cloud **ignores**:
|
||||||
|
|
||||||
|
- `if __name__ == "__main__"` blocks
|
||||||
|
- `.env` files (use Cloud UI instead)
|
||||||
|
- `fastmcp.json` config files (use Cloud UI)
|
||||||
|
- YAML config files (use environment variables)
|
||||||
|
- Docker configurations
|
||||||
|
- CLI arguments
|
||||||
|
|
||||||
|
FastMCP Cloud **uses**:
|
||||||
|
|
||||||
|
- Module-level `FastMCP` instance (`mcp = FastMCP(...)`)
|
||||||
|
- `pyproject.toml` or `requirements.txt`
|
||||||
|
- Environment variables from Cloud UI
|
||||||
|
- Code from your `main` branch
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Build Failures
|
||||||
|
|
||||||
|
**Issue:** Dependencies fail to install
|
||||||
|
|
||||||
|
```
|
||||||
|
Solution:
|
||||||
|
1. Verify pyproject.toml syntax
|
||||||
|
2. Check dependency versions are available on PyPI
|
||||||
|
3. Remove any local-only dependencies (like editable installs)
|
||||||
|
4. Check that python-dotenv is included
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue:** Module import errors
|
||||||
|
|
||||||
|
```
|
||||||
|
Solution:
|
||||||
|
1. Ensure all imports use relative paths from src/
|
||||||
|
2. Check that config/, models/, etc. have __init__.py files
|
||||||
|
3. Verify the entrypoint format: mcp_server/src/graphiti_mcp_server.py:mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime Errors
|
||||||
|
|
||||||
|
**Issue:** "API key is not configured"
|
||||||
|
|
||||||
|
```
|
||||||
|
Solution:
|
||||||
|
1. Verify environment variables are set in FastMCP Cloud UI
|
||||||
|
2. Check variable names match exactly (case-sensitive)
|
||||||
|
3. Redeploy after adding environment variables
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue:** Database connection failures
|
||||||
|
|
||||||
|
```
|
||||||
|
Solution:
|
||||||
|
1. Verify database is internet-accessible (not localhost!)
|
||||||
|
2. Check credentials are correct
|
||||||
|
3. For Neo4j Aura: Use neo4j+s:// protocol
|
||||||
|
4. For FalkorDB: Check firewall allows FastMCP Cloud IPs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue:** 429 Rate Limit Errors
|
||||||
|
|
||||||
|
```
|
||||||
|
Solution:
|
||||||
|
1. Lower SEMAPHORE_LIMIT based on your API tier:
|
||||||
|
- OpenAI Tier 1: SEMAPHORE_LIMIT=1-2
|
||||||
|
- OpenAI Tier 2: SEMAPHORE_LIMIT=5-8
|
||||||
|
- OpenAI Tier 3: SEMAPHORE_LIMIT=10-15
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue:** "Graphiti service not initialized"
|
||||||
|
|
||||||
|
```
|
||||||
|
Solution:
|
||||||
|
1. This means initialization failed silently
|
||||||
|
2. Check database credentials
|
||||||
|
3. Check LLM API key
|
||||||
|
4. Run fastmcp dev locally to debug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Use Environment Variables
|
||||||
|
|
||||||
|
All configuration should use environment variables:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Good - FastMCP Cloud compatible
|
||||||
|
import os
|
||||||
|
api_key = os.environ.get('OPENAI_API_KEY')
|
||||||
|
|
||||||
|
# Bad - Won't work on FastMCP Cloud
|
||||||
|
api_key = 'sk-hardcoded-key'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Module-Level Server Instance
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Good - FastMCP Cloud can discover this
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
mcp = FastMCP("Graphiti Agent Memory")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# This block is IGNORED by FastMCP Cloud
|
||||||
|
mcp.run()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Test Locally First
|
||||||
|
|
||||||
|
Always test locally before deploying:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run verification script
|
||||||
|
cd mcp_server
|
||||||
|
uv run python scripts/verify_fastmcp_cloud_readiness.py
|
||||||
|
|
||||||
|
# Test with fastmcp dev
|
||||||
|
uv run fastmcp dev src/graphiti_mcp_server.py:mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Monitor Resource Usage
|
||||||
|
|
||||||
|
- **Neo4j Aura free tier:** Limited connections
|
||||||
|
- **FalkorDB free tier:** 100 MB limit
|
||||||
|
- **OpenAI rate limits:** Tier-dependent
|
||||||
|
- **SEMAPHORE_LIMIT:** Tune based on API tier
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Secrets Management
|
||||||
|
|
||||||
|
- **DO:** Set secrets in FastMCP Cloud UI
|
||||||
|
- **DO:** Add `.env` to `.gitignore`
|
||||||
|
- **DO:** Use `.env.example` for documentation
|
||||||
|
- **DON'T:** Commit `.env` files
|
||||||
|
- **DON'T:** Hardcode API keys
|
||||||
|
- **DON'T:** Store secrets in YAML configs
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
FastMCP Cloud provides built-in authentication:
|
||||||
|
|
||||||
|
- **Enabled:** Only org members can connect (recommended)
|
||||||
|
- **Disabled:** Public access (use for demos only)
|
||||||
|
|
||||||
|
Enable authentication for production deployments.
|
||||||
|
|
||||||
|
## Summary Checklist
|
||||||
|
|
||||||
|
Before deploying to FastMCP Cloud:
|
||||||
|
|
||||||
|
- [ ] Run `uv run python scripts/verify_fastmcp_cloud_readiness.py`
|
||||||
|
- [ ] All checks pass
|
||||||
|
- [ ] Cloud database is running (Neo4j Aura or FalkorDB Cloud)
|
||||||
|
- [ ] API keys are ready (OpenAI required)
|
||||||
|
- [ ] Code is pushed to GitHub `main` branch
|
||||||
|
- [ ] `.env` is in `.gitignore`
|
||||||
|
- [ ] No secrets committed to repo
|
||||||
|
|
||||||
|
During deployment:
|
||||||
|
|
||||||
|
- [ ] Create project on fastmcp.cloud
|
||||||
|
- [ ] Configure entrypoint: `mcp_server/src/graphiti_mcp_server.py:mcp`
|
||||||
|
- [ ] Enable authentication
|
||||||
|
- [ ] Set all required environment variables in UI
|
||||||
|
- [ ] Deploy and monitor build logs
|
||||||
|
|
||||||
|
After deployment:
|
||||||
|
|
||||||
|
- [ ] Test with `fastmcp inspect <URL>`
|
||||||
|
- [ ] Connect from Claude Desktop
|
||||||
|
- [ ] Test add_memory and search tools
|
||||||
|
- [ ] Monitor database usage
|
||||||
|
- [ ] Monitor API rate limits
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- **FastMCP Cloud:** [fastmcp.cloud](https://fastmcp.cloud)
|
||||||
|
- **FastMCP Docs:** [gofastmcp.com](https://gofastmcp.com)
|
||||||
|
- **FastMCP Discord:** [discord.com/invite/aGsSC3yDF4](https://discord.com/invite/aGsSC3yDF4)
|
||||||
|
- **Neo4j Aura:** [neo4j.com/cloud/aura](https://neo4j.com/cloud/aura)
|
||||||
|
- **FalkorDB Cloud:** [cloud.falkordb.com](https://cloud.falkordb.com)
|
||||||
|
- **Verification Script:** [`scripts/verify_fastmcp_cloud_readiness.py`](../scripts/verify_fastmcp_cloud_readiness.py)
|
||||||
|
|
||||||
|
You're now ready to deploy!
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "mcp-server"
|
name = "mcp-server"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
description = "Graphiti MCP Server"
|
description = "Graphiti MCP Server"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10,<4"
|
requires-python = ">=3.10,<4"
|
||||||
|
|
@ -8,8 +8,10 @@ dependencies = [
|
||||||
"fastmcp>=2.13.3",
|
"fastmcp>=2.13.3",
|
||||||
"openai>=1.91.0",
|
"openai>=1.91.0",
|
||||||
"graphiti-core[falkordb]>=0.23.1",
|
"graphiti-core[falkordb]>=0.23.1",
|
||||||
|
"pydantic>=2.0.0",
|
||||||
"pydantic-settings>=2.0.0",
|
"pydantic-settings>=2.0.0",
|
||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
"typing-extensions>=4.0.0",
|
"typing-extensions>=4.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
350
mcp_server/scripts/verify_fastmcp_cloud_readiness.py
Normal file
350
mcp_server/scripts/verify_fastmcp_cloud_readiness.py
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Verify FastMCP Cloud deployment readiness.
|
||||||
|
|
||||||
|
This script checks that your Graphiti MCP server is ready for FastMCP Cloud deployment.
|
||||||
|
Run this before deploying to catch common issues.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cd mcp_server
|
||||||
|
uv run python scripts/verify_fastmcp_cloud_readiness.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def print_header(title: str) -> None:
|
||||||
|
"""Print a section header."""
|
||||||
|
print(f'\n{"=" * 60}')
|
||||||
|
print(f' {title}')
|
||||||
|
print('=' * 60)
|
||||||
|
|
||||||
|
|
||||||
|
def print_result(check: str, passed: bool, details: str = '') -> None:
|
||||||
|
"""Print a check result."""
|
||||||
|
status = '✅ PASSED' if passed else '❌ FAILED'
|
||||||
|
print(f'{status}: {check}')
|
||||||
|
if details:
|
||||||
|
for line in details.split('\n'):
|
||||||
|
print(f' {line}')
|
||||||
|
|
||||||
|
|
||||||
|
def check_server_discoverability() -> bool:
|
||||||
|
"""Check if fastmcp inspect can discover the server."""
|
||||||
|
print_header('Check 1: Server Discoverability')
|
||||||
|
|
||||||
|
# Find the server file
|
||||||
|
script_dir = Path(__file__).parent
|
||||||
|
mcp_server_dir = script_dir.parent
|
||||||
|
server_file = mcp_server_dir / 'src' / 'graphiti_mcp_server.py'
|
||||||
|
|
||||||
|
if not server_file.exists():
|
||||||
|
print_result('Server file exists', False, f'Not found: {server_file}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
print_result('Server file exists', True, str(server_file))
|
||||||
|
|
||||||
|
# Try to run fastmcp inspect
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['uv', 'run', 'fastmcp', 'inspect', f'{server_file}:mcp'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=mcp_server_dir,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Count tools in output
|
||||||
|
output = result.stdout
|
||||||
|
tool_count = output.count('- ') if '- ' in output else 0
|
||||||
|
print_result(
|
||||||
|
'fastmcp inspect succeeds',
|
||||||
|
True,
|
||||||
|
f'Server discovered with {tool_count} tools',
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print_result(
|
||||||
|
'fastmcp inspect succeeds',
|
||||||
|
False,
|
||||||
|
f'Error: {result.stderr[:200] if result.stderr else result.stdout[:200]}',
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print_result('fastmcp inspect succeeds', False, 'Command timed out')
|
||||||
|
return False
|
||||||
|
except FileNotFoundError:
|
||||||
|
print_result(
|
||||||
|
'fastmcp inspect succeeds',
|
||||||
|
False,
|
||||||
|
'fastmcp not found. Run: uv sync',
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print_result('fastmcp inspect succeeds', False, f'Error: {e}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_dependencies() -> bool:
|
||||||
|
"""Check that all dependencies are declared in pyproject.toml."""
|
||||||
|
print_header('Check 2: Dependencies')
|
||||||
|
|
||||||
|
script_dir = Path(__file__).parent
|
||||||
|
mcp_server_dir = script_dir.parent
|
||||||
|
pyproject_file = mcp_server_dir / 'pyproject.toml'
|
||||||
|
|
||||||
|
if not pyproject_file.exists():
|
||||||
|
print_result('pyproject.toml exists', False)
|
||||||
|
return False
|
||||||
|
|
||||||
|
print_result('pyproject.toml exists', True)
|
||||||
|
|
||||||
|
# Read and check for critical dependencies
|
||||||
|
content = pyproject_file.read_text()
|
||||||
|
critical_deps = [
|
||||||
|
'fastmcp',
|
||||||
|
'graphiti-core',
|
||||||
|
'pydantic',
|
||||||
|
'pydantic-settings',
|
||||||
|
'python-dotenv',
|
||||||
|
]
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for dep in critical_deps:
|
||||||
|
if dep not in content:
|
||||||
|
missing.append(dep)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
print_result(
|
||||||
|
'Critical dependencies declared',
|
||||||
|
False,
|
||||||
|
f'Missing: {", ".join(missing)}',
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
print_result('Critical dependencies declared', True, ', '.join(critical_deps))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_env_documentation() -> bool:
|
||||||
|
"""Check that environment variables are documented."""
|
||||||
|
print_header('Check 3: Environment Variable Documentation')
|
||||||
|
|
||||||
|
script_dir = Path(__file__).parent
|
||||||
|
mcp_server_dir = script_dir.parent
|
||||||
|
env_example = mcp_server_dir / '.env.example'
|
||||||
|
|
||||||
|
if not env_example.exists():
|
||||||
|
print_result('.env.example exists', False)
|
||||||
|
return False
|
||||||
|
|
||||||
|
content = env_example.read_text()
|
||||||
|
required_vars = ['OPENAI_API_KEY', 'NEO4J_URI', 'FALKORDB_URI']
|
||||||
|
|
||||||
|
documented = [var for var in required_vars if var in content]
|
||||||
|
|
||||||
|
print_result('.env.example exists', True)
|
||||||
|
print_result(
|
||||||
|
'Required vars documented',
|
||||||
|
len(documented) == len(required_vars),
|
||||||
|
f'Found: {", ".join(documented)}',
|
||||||
|
)
|
||||||
|
|
||||||
|
return len(documented) == len(required_vars)
|
||||||
|
|
||||||
|
|
||||||
|
def check_secrets_safety() -> bool:
|
||||||
|
"""Check that no secrets are committed."""
|
||||||
|
print_header('Check 4: Secrets Safety')
|
||||||
|
|
||||||
|
script_dir = Path(__file__).parent
|
||||||
|
mcp_server_dir = script_dir.parent
|
||||||
|
env_file = mcp_server_dir / '.env'
|
||||||
|
gitignore_file = mcp_server_dir.parent / '.gitignore'
|
||||||
|
|
||||||
|
# Check if .env exists (it shouldn't be committed)
|
||||||
|
if env_file.exists():
|
||||||
|
# Check if it's in gitignore
|
||||||
|
if gitignore_file.exists():
|
||||||
|
gitignore_content = gitignore_file.read_text()
|
||||||
|
if '.env' in gitignore_content:
|
||||||
|
print_result('.env in .gitignore', True)
|
||||||
|
else:
|
||||||
|
print_result('.env in .gitignore', False, 'Add .env to .gitignore!')
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print_result('.gitignore exists', False)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print_result('.env not present (good for cloud)', True)
|
||||||
|
|
||||||
|
# Check for hardcoded secrets in server file
|
||||||
|
server_file = mcp_server_dir / 'src' / 'graphiti_mcp_server.py'
|
||||||
|
if server_file.exists():
|
||||||
|
content = server_file.read_text()
|
||||||
|
secret_patterns = ['sk-', 'api_key=', 'password=']
|
||||||
|
found_secrets = []
|
||||||
|
for pattern in secret_patterns:
|
||||||
|
if pattern in content.lower() and f"'{pattern}" in content:
|
||||||
|
found_secrets.append(pattern)
|
||||||
|
|
||||||
|
if found_secrets:
|
||||||
|
print_result(
|
||||||
|
'No hardcoded secrets in server',
|
||||||
|
False,
|
||||||
|
f'Found patterns: {found_secrets}',
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
print_result('No hardcoded secrets in server', True)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_server_import() -> bool:
|
||||||
|
"""Check that the server can be imported successfully."""
|
||||||
|
print_header('Check 5: Server Import')
|
||||||
|
|
||||||
|
script_dir = Path(__file__).parent
|
||||||
|
mcp_server_dir = script_dir.parent
|
||||||
|
src_dir = mcp_server_dir / 'src'
|
||||||
|
|
||||||
|
# Add src to path temporarily
|
||||||
|
sys.path.insert(0, str(src_dir))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try importing the module
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
'graphiti_mcp_server',
|
||||||
|
src_dir / 'graphiti_mcp_server.py',
|
||||||
|
)
|
||||||
|
if spec and spec.loader:
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
# Don't actually execute - just check if it can be loaded
|
||||||
|
print_result('Server module loadable', True)
|
||||||
|
|
||||||
|
# Check for mcp object
|
||||||
|
server_content = (src_dir / 'graphiti_mcp_server.py').read_text()
|
||||||
|
if 'mcp = FastMCP(' in server_content:
|
||||||
|
print_result('Module-level mcp instance found', True)
|
||||||
|
else:
|
||||||
|
print_result(
|
||||||
|
'Module-level mcp instance found',
|
||||||
|
False,
|
||||||
|
'Need: mcp = FastMCP(...) at module level',
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print_result('Server module loadable', False, 'Could not create module spec')
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print_result('Server module loadable', False, f'Import error: {e}')
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
sys.path.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
def check_entrypoint_format() -> bool:
|
||||||
|
"""Check that the entrypoint format is correct for FastMCP Cloud."""
|
||||||
|
print_header('Check 6: Entrypoint Format')
|
||||||
|
|
||||||
|
script_dir = Path(__file__).parent
|
||||||
|
mcp_server_dir = script_dir.parent
|
||||||
|
server_file = mcp_server_dir / 'src' / 'graphiti_mcp_server.py'
|
||||||
|
|
||||||
|
if not server_file.exists():
|
||||||
|
print_result('Server file exists', False)
|
||||||
|
return False
|
||||||
|
|
||||||
|
content = server_file.read_text()
|
||||||
|
|
||||||
|
# Check for module-level FastMCP instance
|
||||||
|
has_module_level_mcp = 'mcp = FastMCP(' in content
|
||||||
|
|
||||||
|
# Check for if __name__ == "__main__" block (should exist but is ignored)
|
||||||
|
has_main_block = "if __name__ == '__main__':" in content or 'if __name__ == "__main__":' in content
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
'Module-level mcp instance',
|
||||||
|
has_module_level_mcp,
|
||||||
|
'Required for FastMCP Cloud discovery',
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_main_block:
|
||||||
|
print_result(
|
||||||
|
'__main__ block present',
|
||||||
|
True,
|
||||||
|
'Note: This is IGNORED by FastMCP Cloud',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Print the expected entrypoint
|
||||||
|
if has_module_level_mcp:
|
||||||
|
print(f'\n Expected entrypoint for FastMCP Cloud:')
|
||||||
|
print(f' src/graphiti_mcp_server.py:mcp')
|
||||||
|
|
||||||
|
return has_module_level_mcp
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run all verification checks."""
|
||||||
|
print('\n' + '=' * 60)
|
||||||
|
print(' FastMCP Cloud Deployment Readiness Check')
|
||||||
|
print(' Graphiti MCP Server')
|
||||||
|
print('=' * 60)
|
||||||
|
|
||||||
|
checks = [
|
||||||
|
('Server Discoverability', check_server_discoverability),
|
||||||
|
('Dependencies', check_dependencies),
|
||||||
|
('Environment Documentation', check_env_documentation),
|
||||||
|
('Secrets Safety', check_secrets_safety),
|
||||||
|
('Server Import', check_server_import),
|
||||||
|
('Entrypoint Format', check_entrypoint_format),
|
||||||
|
]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for name, check_fn in checks:
|
||||||
|
try:
|
||||||
|
passed = check_fn()
|
||||||
|
results.append((name, passed))
|
||||||
|
except Exception as e:
|
||||||
|
print(f'\n❌ Error in {name}: {e}')
|
||||||
|
results.append((name, False))
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print_header('Summary')
|
||||||
|
passed_count = sum(1 for _, passed in results if passed)
|
||||||
|
total_count = len(results)
|
||||||
|
|
||||||
|
for name, passed in results:
|
||||||
|
status = '✅' if passed else '❌'
|
||||||
|
print(f' {status} {name}')
|
||||||
|
|
||||||
|
print(f'\n Total: {passed_count}/{total_count} checks passed')
|
||||||
|
|
||||||
|
if passed_count == total_count:
|
||||||
|
print('\n' + '=' * 60)
|
||||||
|
print(' 🚀 READY FOR FASTMCP CLOUD DEPLOYMENT!')
|
||||||
|
print('=' * 60)
|
||||||
|
print('\nNext steps:')
|
||||||
|
print(' 1. Push code to GitHub')
|
||||||
|
print(' 2. Visit https://fastmcp.cloud')
|
||||||
|
print(' 3. Create project with entrypoint: src/graphiti_mcp_server.py:mcp')
|
||||||
|
print(' 4. Set environment variables in FastMCP Cloud UI')
|
||||||
|
print(' 5. Deploy!')
|
||||||
|
print('\nSee docs/FASTMCP_CLOUD_DEPLOYMENT.md for detailed instructions.')
|
||||||
|
else:
|
||||||
|
print('\n' + '=' * 60)
|
||||||
|
print(' ⚠️ NOT READY - Please fix the failing checks above')
|
||||||
|
print('=' * 60)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Loading…
Add table
Reference in a new issue