How to Build an MCP-Authenticated Multi-Tenant Agent Gateway with Arcade’s MCP Gateway Toolkit

How to Build an MCP-Authenticated Multi-Tenant Agent Gateway with Arcade’s MCP Gateway Toolkit

Arcade.dev Team's avatar
Arcade.dev Team
OCTOBER 21, 2025
8 MIN READ
THOUGHT LEADERSHIP
Rays decoration image
Ghost Icon

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:

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:

Documentation: https://docs.arcade.dev

API Reference: https://reference.arcade.dev

SHARE THIS POST

RECENT ARTICLES

Rays decoration image
THOUGHT LEADERSHIP

How to Query Postgres from GPT-5 via Arcade (MCP)

Large language models need structured data access to provide accurate, data-driven insights. This guide demonstrates how to connect GPT-5 to PostgreSQL databases through Arcade's Model Context Protocol implementation, enabling secure database queries without exposing credentials directly to language models. Prerequisites Before implementing database connectivity, ensure you have: * Python 3.8 or higher installed * PostgreSQL database with connection credentials * Arcade API key (free t

Rays decoration image
THOUGHT LEADERSHIP

How to Connect GPT-5 to Slack with Arcade (MCP)

Building AI agents that interact with Slack requires secure OAuth authentication, proper token management, and reliable tool execution. This guide shows you how to connect GPT-5 to Slack using Arcade's Model Context Protocol (MCP) implementation, enabling your agents to send messages, read conversations, and manage channels with production-grade security. Prerequisites Before starting, ensure you have: * Arcade.dev account with API key * Python 3.10+ or Node.js 18+ installed * OpenAI A

Rays decoration image
THOUGHT LEADERSHIP

How to Build a GPT-5 Gmail Agent with Arcade (MCP)

Building AI agents that can access and act on Gmail data represents a significant challenge in production environments. This guide demonstrates how to build a fully functional Gmail agent using OpenAI's latest models through Arcade's Model Context Protocol implementation, enabling secure OAuth-based authentication and real-world email operations. Prerequisites Before starting, ensure you have: * Active Arcade.dev account with API key * Python 3.10 or higher installed * OpenAI API key w

Blog CTA Icon

Get early access to Arcade, and start building now.