Most MCP servers handle single users only. When building AI agents that serve multiple users - each with their own Gmail, Slack, or GitHub access - standard MCP implementations fail. This guide shows how to build a multi-tenant MCP gateway using Arcade's authentication infrastructure and HTTP transport capabilities.
Multi-Tenant MCP Gateway: What It Solves
A multi-tenant MCP gateway provides:
- Single endpoint serving tools to multiple users simultaneously
- Per-user OAuth authentication without credential exposure
- Automatic token management and refresh
- Request routing with user context isolation
- HTTP-based MCP server for cloud deployment
Standard MCP servers require hardcoded API keys, making them unsuitable for production multi-user applications. Arcade Engine acts as an MCP server with OAuth 2.0, HTTP transport, and user isolation built in.
Prerequisites
Required setup before starting:
- Arcade API key from https://api.arcade.dev/dashboard
- Python 3.10+ or Node.js 18+
- OAuth credentials for target services (Google, Slack, GitHub)
- Docker for containerized deployment (optional)
- Basic knowledge of async programming patterns
Installation: Arcade Engine Setup
Install Arcade Engine locally:
macOS via Homebrew:
brew tap arcadeai/arcade
brew install arcade-engine
Linux via APT:
wget -qO - https://deb.arcade.dev/public-key.asc | sudo apt-key add -
echo "deb https://deb.arcade.dev/ubuntu stable main" | sudo tee /etc/apt/sources.list.d/arcade-ai.list
sudo apt update
sudo apt install arcade-engine
Reference: https://docs.arcade.dev/en/home/local-deployment/install/local
Authentication Configuration
Configure OAuth providers in engine.yaml. File locations:
- macOS:
~/Library/Application Support/Arcade/engine.yaml - Linux:
~/.config/arcade/engine.yaml - Windows:
%APPDATA%\Arcade\engine.yaml
Basic multi-provider configuration:
auth:
providers:
- id: google-provider
description: "Google OAuth for Gmail access"
enabled: true
type: oauth2
provider_id: google
client_id: ${env:GOOGLE_CLIENT_ID}
client_secret: ${env:GOOGLE_CLIENT_SECRET}
- id: slack-provider
description: "Slack workspace OAuth"
enabled: true
type: oauth2
provider_id: slack
client_id: ${env:SLACK_CLIENT_ID}
client_secret: ${env:SLACK_CLIENT_SECRET}
- id: github-provider
description: "GitHub repository OAuth"
enabled: true
type: oauth2
provider_id: github
client_id: ${env:GITHUB_CLIENT_ID}
client_secret: ${env:GITHUB_CLIENT_SECRET}
api:
host: 0.0.0.0
port: 9099
development: false
http:
read_timeout: 30s
write_timeout: 1m
Set credentials in engine.env (same directory):
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
SLACK_CLIENT_ID=your_slack_client_id
SLACK_CLIENT_SECRET=your_slack_client_secret
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
OPENAI_API_KEY=your_openai_key
Provider setup guides:
- Google: https://docs.arcade.dev/home/auth-providers/google
- Slack: https://docs.arcade.dev/home/auth-providers/slack
- GitHub: https://docs.arcade.dev/home/auth-providers/github
Worker Configuration: Three Approaches
Workers host tool implementations. The Engine routes requests to workers based on toolkit.
Approach 1: Pre-Built MCP Servers
Install from Arcade's toolkit registry:
pip install arcade-gmail arcade-slack arcade-github
View available toolkits: https://docs.arcade.dev/en/mcp-servers
Approach 2: External MCP Servers
Connect upstream MCP servers via HTTP transport. Add to engine.yaml:
workers:
- id: "external-mcp-server"
enabled: true
http:
uri: "https://your-mcp-server.com"
secret: ${env:MCP_SERVER_SECRET}
timeout: 30s
Reference: https://blog.arcade.dev/announcing-native-support-for-mcp-servers
Approach 3: Custom Tools
Build tools with Arcade Tool Development Kit:
pip install arcade-ai
arcade new my_custom_toolkit
cd my_custom_toolkit
Create authenticated tool in toolkit.py:
from arcade_tdk import ToolContext, tool
from arcade_tdk.auth import Google
import httpx
@tool(
requires_auth=Google(
scopes=["https://www.googleapis.com/auth/gmail.send"]
)
)
async def send_email_with_template(
context: ToolContext,
to: str,
subject: str,
template_name: str,
) -> dict:
"""Send email using predefined template with user auth."""
token = context.authorization.token
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
async with httpx.AsyncClient() as client:
response = await client.post(
"https://gmail.googleapis.com/gmail/v1/users/me/messages/send",
headers=headers,
json={"raw": "email_content_here"}
)
return response.json()
Start worker:
arcade serve --host 0.0.0.0 --port 8002
Register in engine.yaml:
workers:
- id: "custom-toolkit-worker"
enabled: true
http:
uri: "http://localhost:8002"
secret: ${env:WORKER_SECRET}
timeout: 30s
Tool creation guide: https://docs.arcade.dev/en/home/build-tools/create-toolkit
Start Gateway
Launch Engine with configuration:
arcade-engine
Expected output:
INFO Arcade Engine starting on http://0.0.0.0:9099
INFO OAuth providers initialized: google-provider, slack-provider, github-provider
INFO Workers registered: custom-toolkit-worker
INFO MCP server ready at http://0.0.0.0:9099/v1/mcps/{user_id}/mcp
Gateway serves MCP over HTTP on port 9099.
Multi-User Authentication Implementation
Implement per-user auth flows in your application layer.
Python SDK Implementation
import asyncio
from arcadepy import Arcade
import os
class MultiTenantGateway:
def __init__(self):
self.client = Arcade(api_key=os.environ.get("ARCADE_API_KEY"))
self.user_sessions = {}
async def authenticate_user(self, user_id: str, provider: str, scopes: list):
"""Start OAuth flow for user."""
auth_response = await self.client.auth.start(
user_id=user_id,
provider=provider,
scopes=scopes
)
if auth_response.status != "completed":
return {
"status": "authorization_required",
"url": auth_response.url,
"authorization_id": auth_response.authorization_id
}
self.user_sessions[user_id] = {
"authenticated": True,
"provider": provider
}
return {"status": "authorized"}
async def execute_tool(self, user_id: str, tool_name: str, inputs: dict):
"""Execute tool with user context."""
if user_id not in self.user_sessions:
return {"error": "User not authenticated"}
result = await self.client.tools.execute(
tool_name=tool_name,
input=inputs,
user_id=user_id
)
return result.output
JavaScript SDK Implementation
import { Arcade } from "@arcadeai/arcadejs";
class MultiTenantGateway {
constructor() {
this.client = new Arcade({
apiKey: process.env.ARCADE_API_KEY
});
this.userSessions = new Map();
}
async authenticateUser(userId, provider, scopes) {
const authResponse = await this.client.auth.start({
userId,
provider,
scopes
});
if (authResponse.status !== "completed") {
return {
status: "authorization_required",
url: authResponse.url,
authorizationId: authResponse.authorizationId
};
}
this.userSessions.set(userId, {
authenticated: true,
provider
});
return { status: "authorized" };
}
async executeTool(userId, toolName, inputs) {
if (!this.userSessions.has(userId)) {
throw new Error("User not authenticated");
}
const result = await this.client.tools.execute({
toolName,
input: inputs,
userId
});
return result.output;
}
}
Authorization guide: https://docs.arcade.dev/en/home/oai-agents/user-auth-interrupts
OAuth Callback Handling
Process OAuth completion callbacks:
from fastapi import FastAPI, Request
from arcadepy import Arcade
app = FastAPI()
arcade = Arcade()
@app.post("/api/oauth/callback")
async def handle_callback(request: Request):
data = await request.json()
user_id = data.get("userId")
auth_id = data.get("authorizationId")
auth_response = await arcade.auth.wait_for_completion(
authorization_id=auth_id
)
if auth_response.status == "completed":
tools = await arcade.tools.list(
toolkit="gmail",
user_id=user_id
)
return {
"success": True,
"tools": [tool.name for tool in tools.items]
}
return {
"success": False,
"error": "Authorization incomplete"
}
AI Agent Integration
Claude Desktop Configuration
Connect Claude Desktop to gateway:
arcade configure claude --from-local
This updates ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"arcade-gateway": {
"url": "http://localhost:9099/v1/mcps/arcade-anon/mcp"
}
}
}
Guide: https://docs.arcade.dev/en/home/mcp/mcp-clients/claude-desktop
VS Code Configuration
Add to .vscode/mcp.json:
{
"mcp": {
"servers": {
"arcade-gateway": {
"url": "http://localhost:9099/v1/mcps/arcade-anon/mcp"
}
}
}
}
Guide: https://docs.arcade.dev/en/home/mcp/mcp-clients/vscode
LangGraph Integration
Use Arcade tools in LangGraph:
from langchain_arcade import ArcadeToolManager
manager = ArcadeToolManager(api_key="your_arcade_key")
tools = manager.get_tools(
toolkits=["gmail", "slack", "github"],
user_id="user@example.com"
)
from langgraph.prebuilt import create_react_agent
agent = create_react_agent(
model=llm,
tools=tools
)
Integration guide: https://docs.arcade.dev/en/home/langchain/use-arcade-tools
Production Deployment
Docker Deployment
Create Dockerfile:
FROM python:3.11-slim
RUN apt-get update && \
apt-get install -y wget gnupg && \
wget -qO - https://deb.arcade.dev/public-key.asc | apt-key add - && \
echo "deb https://deb.arcade.dev/ubuntu stable main" | tee /etc/apt/sources.list.d/arcade-ai.list && \
apt-get update && \
apt-get install -y arcade-engine
RUN pip install arcade-gmail arcade-slack arcade-github
COPY engine.yaml /root/.config/arcade/
COPY engine.env /root/.config/arcade/
EXPOSE 9099
CMD ["arcade-engine"]
Build and run:
docker build -t arcade-gateway .
docker run -p 9099:9099 arcade-gateway
Kubernetes Deployment
Deploy with scaling:
apiVersion: apps/v1
kind: Deployment
metadata:
name: arcade-gateway
spec:
replicas: 3
selector:
matchLabels:
app: arcade-gateway
template:
metadata:
labels:
app: arcade-gateway
spec:
containers:
- name: arcade-engine
image: your-registry/arcade-gateway:latest
ports:
- containerPort: 9099
env:
- name: GOOGLE_CLIENT_ID
valueFrom:
secretKeyRef:
name: oauth-secrets
key: google-client-id
- name: GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: oauth-secrets
key: google-client-secret
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
---
apiVersion: v1
kind: Service
metadata:
name: arcade-gateway
spec:
selector:
app: arcade-gateway
ports:
- port: 9099
targetPort: 9099
type: LoadBalancer
Arcade Cloud Deployment
Deploy to managed infrastructure with Arcade Deploy.
Create worker.toml:
[[worker]]
[worker.config]
id = "production-gateway"
secret = "${env:WORKER_SECRET}"
[worker.local_source]
packages = ["./my_custom_toolkit"]
Deploy:
arcade deploy --deployment-file worker.toml
Deployment guide: https://docs.arcade.dev/en/home/serve-tools/arcade-deploy
Request Routing Implementation
Route requests based on toolkit and user:
class GatewayRouter:
def __init__(self, arcade_client):
self.client = arcade_client
self.toolkit_cache = {}
async def route_request(self, user_id: str, tool_name: str, inputs: dict):
toolkit = tool_name.split(".")[0].lower()
if (user_id, toolkit) not in self.toolkit_cache:
tools = await self.client.tools.list(
toolkit=toolkit,
user_id=user_id
)
self.toolkit_cache[(user_id, toolkit)] = tools.items
result = await self.client.tools.execute(
tool_name=tool_name,
input=inputs,
user_id=user_id
)
return result.output
Monitoring and Operations
Enable Observability
Add OpenTelemetry to workers:
arcade serve --otel-enable
Check Worker Status
arcade worker list
Output example:
┏━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┓
┃ ID ┃ Enabled ┃ Toolkits ┃ Status ┃
┡━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━┩
│ gmail-wkr │ True │ Gmail │ Running │
│ slack-wkr │ True │ Slack │ Running │
└───────────┴──────────┴────────────┴───────────┘
Authentication Error Handling
Common errors:
authorization_required
- Return auth URL to user
- Wait for callback completion
- Retry request after auth
token_expired
- Arcade auto-refreshes tokens
- Retry request automatically
- Check provider configuration if persistent
insufficient_scope
- Re-authenticate with correct scopes
- Update tool scope requirements
- Document required permissions
View Logs
# Worker logs
arcade worker logs <worker-id>
# Engine logs (Linux/macOS)
journalctl -u arcade-engine -f
# Docker logs
docker logs arcade-gateway
CLI reference: https://docs.arcade.dev/en/home/arcade-cli
Security Implementation
Token Isolation
Arcade enforces per-user boundaries:
- Tokens never exposed to LLMs
- Automatic encryption at rest
- Server-side token refresh
- Zero cross-user access
TLS Configuration
Production network security:
api:
host: 0.0.0.0
port: 9099
tls:
enabled: true
cert_file: /path/to/cert.pem
key_file: /path/to/key.pem
http:
cors:
allowed_origins:
- https://yourdomain.com
allowed_methods:
- GET
- POST
Rate Limiting
Per-user rate limits:
from collections import defaultdict
from datetime import datetime, timedelta
class RateLimiter:
def __init__(self, requests_per_minute=60):
self.limits = defaultdict(list)
self.rpm = requests_per_minute
def check_limit(self, user_id: str) -> bool:
now = datetime.now()
cutoff = now - timedelta(minutes=1)
self.limits[user_id] = [
ts for ts in self.limits[user_id]
if ts > cutoff
]
if len(self.limits[user_id]) >= self.rpm:
return False
self.limits[user_id].append(now)
return True
Performance Optimization
Toolkit Caching
from datetime import datetime, timedelta
class ToolkitCache:
def __init__(self, ttl_seconds=3600):
self.cache = {}
self.ttl = timedelta(seconds=ttl_seconds)
async def get_tools(self, arcade_client, user_id: str, toolkit: str):
cache_key = f"{user_id}:{toolkit}"
if cache_key in self.cache:
entry = self.cache[cache_key]
if datetime.now() - entry["timestamp"] < self.ttl:
return entry["tools"]
tools = await arcade_client.tools.list(
toolkit=toolkit,
user_id=user_id
)
self.cache[cache_key] = {
"tools": tools.items,
"timestamp": datetime.now()
}
return tools.items
Connection Pooling
High-throughput configuration:
import httpx
class GatewayClient:
def __init__(self):
self.session = None
async def __aenter__(self):
self.session = httpx.AsyncClient(
limits=httpx.Limits(
max_keepalive_connections=20,
max_connections=100
),
timeout=httpx.Timeout(30.0)
)
return self
async def __aexit__(self, *args):
await self.session.aclose()
Testing Strategy
Unit Tests
import pytest
from arcadepy import Arcade
@pytest.mark.asyncio
async def test_user_authentication():
gateway = MultiTenantGateway()
result = await gateway.authenticate_user(
user_id="test@example.com",
provider="google",
scopes=["gmail.send"]
)
assert result["status"] in ["authorized", "authorization_required"]
@pytest.mark.asyncio
async def test_tool_execution():
gateway = MultiTenantGateway()
await gateway.authenticate_user("test@example.com", "google", ["gmail.send"])
result = await gateway.execute_tool(
user_id="test@example.com",
tool_name="Gmail.SendEmail",
inputs={
"to": "recipient@example.com",
"subject": "Test",
"body": "Test email"
}
)
assert "message_id" in result
Load Testing
Concurrent user testing:
import asyncio
async def load_test():
gateway = MultiTenantGateway()
tasks = [
gateway.execute_tool(
user_id=f"user{i}@example.com",
tool_name="Gmail.ListEmails",
inputs={"max_results": 10}
)
for i in range(100)
]
results = await asyncio.gather(*tasks, return_exceptions=True)
success = sum(1 for r in results if not isinstance(r, Exception))
print(f"Success rate: {success}/100")
Tool evaluation guide: https://docs.arcade.dev/en/home/evaluate-tools/why-evaluate-tools
Advanced Configuration
Multiple Providers for Same Service
Configure separate providers for user segmentation:
auth:
providers:
- id: google-internal
description: "Internal users"
enabled: true
type: oauth2
provider_id: google
client_id: ${env:GOOGLE_INTERNAL_CLIENT_ID}
client_secret: ${env:GOOGLE_INTERNAL_CLIENT_SECRET}
- id: google-external
description: "External users"
enabled: true
type: oauth2
provider_id: google
client_id: ${env:GOOGLE_EXTERNAL_CLIENT_ID}
client_secret: ${env:GOOGLE_EXTERNAL_CLIENT_SECRET}
Specify provider in auth:
auth_response = await client.auth.start(
user_id=user_id,
provider="google-internal",
scopes=scopes
)
Custom OAuth 2.0 Services
Add any OAuth 2.0-compatible service:
auth:
providers:
- id: custom-service
description: "Custom OAuth service"
enabled: true
type: oauth2
authorization:
url: https://auth.example.com/oauth/authorize
method: GET
token:
url: https://auth.example.com/oauth/token
method: POST
auth_method: client_secret_post
client_id: ${env:CUSTOM_CLIENT_ID}
client_secret: ${env:CUSTOM_CLIENT_SECRET}
scopes:
- read
- write
OAuth 2.0 provider guide: https://docs.arcade.dev/en/home/auth-providers/oauth2
Next Steps
Extend your gateway with:
- Browse https://docs.arcade.dev/en/mcp-servers for 100+ pre-built toolkits
- Set up https://docs.arcade.dev/en/home/evaluate-tools/create-evaluation-suite for tool reliability testing
- Build domain-specific tools with https://docs.arcade.dev/en/home/build-tools/create-toolkit
- Add monitoring with OpenTelemetry integration
- Scale horizontally with load balancer
Documentation: https://docs.arcade.dev
API Reference: https://reference.arcade.dev



