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
TUTORIALS
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

Enterprise MCP Guide For Retail Banking & Payments: Use Cases, Best Practices, and Trends

The global payments industry processes $2.0 quadrillion in value flows annually, generating $2.5 trillion in revenue. Yet despite decades of digital transformation investment, critical banking operations,anti-money laundering investigation, KYC onboarding, payment reconciliation,remain largely manual. Model Context Protocol (MCP) represents the infrastructure breakthrough that enables financial institutions to move beyond chatbot pilots to production-grade AI agents that take multi-user authoriz

Rays decoration image
THOUGHT LEADERSHIP

Enterprise MCP Guide For Capital Markets & Trading: Use Cases, Best Practices, and Trends

Capital markets technology leaders face a critical infrastructure challenge: scattered AI pilots, disconnected integrations, and fragmented, domain-specific systems that turn engineers into human APIs manually stitching together trading platforms, market data feeds, and risk management tools. The Model Context Protocol (MCP) represents a fundamental shift from this costly one-off integration approach to a universal standardization layer that acts as the backbone for AI-native financial enterpris

Rays decoration image
THOUGHT LEADERSHIP

Enterprise MCP Guide For InsurTech: Use Cases, Best Practices, and Trends

The insurance industry faces a pivotal transformation moment. Model Context Protocol (MCP) has moved from experimental technology to production infrastructure, with 16,000+ active servers deployed across enterprises and millions of weekly SDK downloads. For InsurTech leaders, the question is no longer whether to adopt MCP, but how to implement it securely and effectively. Arcade's platform provides the MCP runtime for secure, multi-user authorization so AI agents can act on behalf of users acros

Blog CTA Icon

Get early access to Arcade, and start building now.