Skip to main content
When building an AI agent that uses MCP tools, authentication can be confusing. This guide helps you understand which token(s) to pass and when, based on your specific use case.

The Two Fundamental Questions

Before writing any code, answer these two questions:
QuestionWhat It Determines
Who is calling the Gateway?The Gateway Token - identifies the caller to TrueFoundry
Whose credentials access the downstream service?The MCP Server Auth - determines whose data/permissions are used
These are separate authentication layers. Understanding this is key to implementing authentication correctly.

Quick Start: Which Scenario Are You?

Your agent performs actions using your organization’s credentials - like a service account that does work on behalf of your company.Examples:
  • An internal support bot that queries your company’s knowledge base
  • A code assistant that uses a shared GitHub service account
  • A data analysis agent that accesses shared analytics databases
What tokens to use:
Token TypeWhat to UseWhy
Gateway TokenVirtual Account tokenIdentifies your service/application
MCP Server AuthShared credentials (pre-configured)All requests use the same service account
Key point: You pass ONE token (Virtual Account). The MCP server’s shared credentials are configured in the UI, not in your code.
import os
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport

# Only pass the Virtual Account token
# MCP server auth is pre-configured with shared credentials in the UI
VA_TOKEN = os.environ["VIRTUAL_ACCOUNT_TOKEN"]

async def call_tool_as_service():
    transport = StreamableHttpTransport(
        url="https://<control-plane-url>/api/llm/mcp/<integration-id>/server",
        headers={"Authorization": f"Bearer {VA_TOKEN}"}
    )
    
    async with Client(transport) as client:
        # All users of your agent get the same level of access
        result = await client.call_tool("query_knowledge_base", {"question": "..."})
        return result
With this approach, all requests from your agent have the same permissions. User A and User B get identical access to the downstream service.
Your agent performs actions using the end user’s credentials - the agent accesses each user’s own data and respects their permissions.Examples:
  • A productivity agent that accesses the user’s own Gmail, Slack, or Calendar
  • A development assistant that accesses the user’s GitHub repositories
  • A CRM agent that sees only what the logged-in user can see
What tokens to use:
Token TypeWhat to UseWhy
Gateway TokenUser’s token (TFY PAT or IdP JWT)Identifies which user is making the request
MCP Server AuthPer-user OAuth (managed by TrueFoundry)Each user’s own credentials
Key point: You pass the USER’s token. TrueFoundry looks up and injects the OAuth token for that specific user.
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport

async def call_tool_as_user(user_token: str):
    # Pass the USER's token (not a shared service token)
    # TrueFoundry looks up the OAuth token for THIS user
    transport = StreamableHttpTransport(
        url="https://<control-plane-url>/api/llm/mcp/<integration-id>/server",
        headers={"Authorization": f"Bearer {user_token}"}
    )
    
    async with Client(transport) as client:
        # User A sees their emails, User B sees their emails
        result = await client.call_tool("search_emails", {"query": "..."})
        return result
With this approach, User A and User B get different data based on their own OAuth authorizations. Each user must complete OAuth consent once.
Your agent uses some tools with shared credentials and other tools with per-user credentials.Examples:
  • An assistant that searches the web (shared API key) and sends emails on user’s behalf (per-user OAuth)
  • A dev tool that queries documentation (shared) but accesses user’s GitHub (per-user)
What tokens to use:
Token TypeWhat to UseWhy
Gateway TokenUser’s tokenIdentifies the caller
MCP Server AuthMixed - configured per MCP serverGateway handles it automatically
Key point: You pass ONE Gateway token. The Gateway routes to different MCP servers, each with its own auth model configured.
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport

async def call_mixed_tools(user_token: str):
    # Pass ONE token - the Gateway handles the rest
    transport = StreamableHttpTransport(
        url="https://<control-plane-url>/api/llm/mcp/<virtual-mcp-id>/server",
        headers={"Authorization": f"Bearer {user_token}"}
    )
    
    async with Client(transport) as client:
        # Web search uses shared API key (configured on MCP server)
        await client.call_tool("web_search", {"query": "..."})
        
        # Gmail uses user's OAuth token (user must have completed OAuth)
        await client.call_tool("send_email", {"to": "...", "body": "..."})
The Gateway abstracts away the complexity. You don’t need to pass different tokens for different MCP servers.

Quick Decision Guide

Is your agent acting on behalf of individual users?

├─ NO → Use Virtual Account + Shared Credentials
│       • Gateway Token: Virtual Account
│       • MCP Auth: Static headers (configured once in UI)
│       • All users get same access level

└─ YES → Does the user have a TrueFoundry account?

         ├─ YES → Use TrueFoundry PAT + Per-User OAuth
         │        • Gateway Token: User's TFY API Key
         │        • MCP Auth: Per-user OAuth (user completes consent)

         └─ NO → Use External Identity + Per-User OAuth
                  • Gateway Token: Your IdP's JWT
                  • MCP Auth: Per-user OAuth (user completes consent)

Summary: Answering the Key Questions

Use:
  • Gateway Token: Virtual Account token
  • MCP Server Auth: Static headers (configured once in UI)
All requests use your service credentials. Users don’t need to authorize anything.
# Virtual Account token - identifies your service
headers = {"Authorization": f"Bearer {VIRTUAL_ACCOUNT_TOKEN}"}
Use:
  • Gateway Token: User’s TFY PAT or IdP JWT
  • MCP Server Auth: OAuth2 (per-user)
Each user authorizes once, then gets access to their own data.
# User's token - identifies this specific user
headers = {"Authorization": f"Bearer {user_token}"}
Configure each MCP server with its appropriate auth model in the UI. Pass one Gateway token. The Gateway handles injecting the right credentials for each server automatically.You don’t need different code paths for different auth types.
You always pass ONE Gateway token in the Authorization header.The MCP server auth is handled by the Gateway based on each server’s configuration:
  • OAuth servers: Gateway looks up stored tokens for the user
  • Static Header servers: Gateway injects pre-configured credentials
  • Passthrough servers: Gateway passes your JWT unchanged
You only use x-tfy-mcp-headers if you want to override the default auth.

Prerequisites

Before you begin, ensure you have:
  1. Access to TrueFoundry: A TrueFoundry account with access to the AI Gateway
  2. MCP server registered: At least one MCP server registered in the gateway (see Getting Started)
  3. Authentication token: One of the following:
    • TrueFoundry Personal Access Token (PAT)
    • Virtual Account token
    • JWT from your Identity Provider (if using External Identity)
  4. Python environment with the fastmcp package:
pip install fastmcp

Connecting to an MCP Server

The MCP Gateway exposes each registered MCP server at a unique URL.

MCP Server URL Format

https://<control-plane-url>/api/llm/mcp/<integration-id>/server
  • <control-plane-url>: Your TrueFoundry control plane URL
  • <integration-id>: The unique identifier for the MCP server (visible in the UI)
You can copy the MCP server URL directly from the TrueFoundry UI. Navigate to the MCP server details page and click the Copy URL button.

Basic Connection Example

import asyncio
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport


async def call_mcp_tool():
    # MCP server URL from TrueFoundry UI
    mcp_url = "https://<control-plane-url>/api/llm/mcp/<integration-id>/server"
    
    # Your authentication token (see scenarios above)
    auth_token = "your-token"
    
    transport = StreamableHttpTransport(
        url=mcp_url,
        headers={"Authorization": f"Bearer {auth_token}"}
    )
    
    async with Client(transport) as client:
        # List available tools
        tools = await client.list_tools()
        print(f"Available tools: {[t.name for t in tools]}")
        
        # Call a specific tool
        result = await client.call_tool("tool_name", {"arg1": "value1"})
        print(f"Result: {result}")


asyncio.run(call_mcp_tool())

Detailed Implementation Examples

Using TrueFoundry Personal Access Token (PAT)

For internal developers with TrueFoundry accounts.
1

Generate a Personal Access Token

  1. Navigate to Settings > API Keys in the TrueFoundry UI
  2. Click Generate New API Key
  3. Store the token securely
2

Use the token in your code

import os
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport

TFY_API_KEY = os.environ["TFY_API_KEY"]

async def call_with_pat():
    transport = StreamableHttpTransport(
        url="https://<control-plane-url>/api/llm/mcp/<integration-id>/server",
        headers={"Authorization": f"Bearer {TFY_API_KEY}"}
    )
    
    async with Client(transport) as client:
        result = await client.call_tool("search", {"query": "AI agents"})
        return result

Using Virtual Accounts (Service-to-Service)

For backend services, automated pipelines, or when individual user identity isn’t required.
1

Create a Virtual Account

  1. Navigate to Settings > Virtual Accounts in the TrueFoundry UI
  2. Click Create Virtual Account
  3. Give it a descriptive name (e.g., my-agent-mcp-access)
  4. Add permissions for the MCP servers your application needs
  5. Generate and securely store the token
2

Use the Virtual Account token

import os
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport

VA_TOKEN = os.environ["VIRTUAL_ACCOUNT_TOKEN"]

async def call_mcp_tool(tool_name: str, tool_args: dict):
    transport = StreamableHttpTransport(
        url="https://<control-plane-url>/api/llm/mcp/<integration-id>/server",
        headers={"Authorization": f"Bearer {VA_TOKEN}"}
    )
    
    async with Client(transport) as client:
        return await client.call_tool(tool_name, tool_args)
Virtual Accounts cannot have per-user OAuth tokens. If your MCP server requires OAuth for user-specific data, use a user token (TFY PAT or IdP JWT) instead.

Handling OAuth-Protected MCP Servers

When an MCP server is configured with OAuth2 and the user hasn’t completed the OAuth flow, the Gateway returns an error with an authorization URL. Handling OAuth Errors in Code:
import re
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport


def extract_auth_urls(exception: Exception) -> list[str]:
    """Extract OAuth authorization URLs from MCP Gateway errors."""
    current = exception
    while current is not None:
        try:
            data = str(current.error.data)
            if "Please visit:" in data:
                match = re.search(r"Please visit:\s*(.+)$", data)
                if match:
                    return [url.strip() for url in match.group(1).split(" , ")]
        except AttributeError:
            pass
        current = current.__cause__
    return []


async def call_mcp_with_oauth(
    mcp_url: str,
    user_token: str,
    tool_name: str,
    tool_args: dict
):
    """Call an MCP tool, handling OAuth authorization if needed."""
    transport = StreamableHttpTransport(
        url=mcp_url,
        headers={"Authorization": f"Bearer {user_token}"}
    )
    
    try:
        async with Client(transport) as client:
            result = await client.call_tool(tool_name, tool_args)
            return {"success": True, "result": result}
            
    except Exception as e:
        auth_urls = extract_auth_urls(e)
        if auth_urls:
            return {
                "success": False,
                "auth_required": True,
                "authorization_urls": auth_urls,
                "message": "User needs to complete OAuth authorization"
            }
        raise


# Example usage
async def main():
    result = await call_mcp_with_oauth(
        mcp_url="https://<control-plane-url>/api/llm/mcp/<integration-id>/server",
        user_token="user-tfy-token-or-idp-jwt",
        tool_name="get_repositories",
        tool_args={}
    )

    if result.get("auth_required"):
        for url in result["authorization_urls"]:
            print(f"Please authorize access: {url}")
        # After user completes OAuth, retry the request
    else:
        print(f"Success: {result['result']}")
OAuth errors occur during connection, not when calling individual tools. The Gateway checks for valid OAuth tokens during the initial handshake.
After OAuth completion: Once the user completes the OAuth consent flow, TrueFoundry stores their token. Future requests automatically include the OAuth credentials—no code changes needed.

Token Passthrough for Custom MCP Servers

For MCP servers that validate JWTs from your Identity Provider directly.
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport


async def call_passthrough_mcp(
    mcp_url: str,
    user_jwt: str,  # JWT from your IdP (Auth0, Okta, Cognito, etc.)
    tool_name: str,
    tool_args: dict
):
    """Call an MCP tool with token passthrough."""
    transport = StreamableHttpTransport(
        url=mcp_url,
        headers={"Authorization": f"Bearer {user_jwt}"}
    )
    
    async with Client(transport) as client:
        return await client.call_tool(tool_name, tool_args)
With Token Passthrough, the MCP server validates the JWT directly against your IdP’s configuration. The Gateway passes the JWT unchanged.

Mixing OAuth and Header-Based MCP Servers

A common question: “My agent uses Gmail (OAuth) and a web search API (header-based). Do I need to handle them differently?” Answer: No. The Gateway handles this for you.

How It Works

  1. Configure each MCP server with its auth model in the UI:
    • Gmail MCP Server → OAuth2
    • Web Search MCP Server → Static Header (API key)
  2. In your code, pass ONE Gateway token:
# Same code for all MCP servers - Gateway handles auth injection
transport = StreamableHttpTransport(
    url="https://<control-plane-url>/api/llm/mcp/<virtual-mcp-id>/server",
    headers={"Authorization": f"Bearer {user_token}"}
)

async with Client(transport) as client:
    # Web search - Gateway injects the pre-configured API key
    await client.call_tool("web_search", {"query": "AI news"})
    
    # Gmail - Gateway injects THIS user's OAuth token
    await client.call_tool("search_emails", {"query": "from:boss"})
  1. The Gateway handles auth per server:
MCP ServerAuth ModelWhat Gateway Does
Web SearchStatic HeaderInjects pre-configured API key
GmailOAuth2Looks up user’s OAuth token, injects it
CalculatorNo AuthPasses request as-is

Overriding Authentication with Custom Headers

If you need to override the default auth for a specific MCP server, use the x-tfy-mcp-headers header.

For a Single MCP Server

import json
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport

async def call_with_custom_headers():
    custom_headers = json.dumps({
        "Authorization": "Bearer custom-token",
        "X-Custom-Header": "custom-value"
    })
    
    transport = StreamableHttpTransport(
        url="https://<control-plane-url>/api/llm/mcp/<integration-id>/server",
        headers={
            "Authorization": "Bearer your-gateway-token",
            "x-tfy-mcp-headers": custom_headers
        }
    )
    
    async with Client(transport) as client:
        return await client.call_tool("tool_name", {})

For Virtual MCP Servers

When connecting to a Virtual MCP Server (which aggregates multiple MCP servers), use a nested structure:
import json
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport

async def call_virtual_mcp_with_headers():
    # Headers for each underlying MCP server
    custom_headers = json.dumps({
        "server-1": {"Authorization": "Bearer token-for-server-1"},
        "server-2": {"Authorization": "Bearer token-for-server-2"}
    })
    
    transport = StreamableHttpTransport(
        url="https://<control-plane-url>/api/llm/mcp/<virtual-mcp-id>/server",
        headers={
            "Authorization": "Bearer your-gateway-token",
            "x-tfy-mcp-headers": custom_headers
        }
    )
    
    async with Client(transport) as client:
        return await client.call_tool("tool_from_server_1", {})
Custom headers override the default authentication. Use this sparingly, as it bypasses TrueFoundry’s token management.

Complete Example: Building an MCP-Enabled Agent

Here’s a complete example with proper error handling for multiple MCP servers:
import asyncio
import os
import re
from dataclasses import dataclass
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport


@dataclass
class MCPConfig:
    url: str
    name: str


class MCPAgent:
    """An agent that can call tools from multiple MCP servers."""
    
    def __init__(self, auth_token: str):
        self.auth_token = auth_token
        self.mcp_configs: dict[str, MCPConfig] = {}
    
    def register_mcp_server(self, name: str, url: str):
        self.mcp_configs[name] = MCPConfig(url=url, name=name)
    
    async def call_tool(self, server_name: str, tool_name: str, tool_args: dict) -> dict:
        if server_name not in self.mcp_configs:
            raise ValueError(f"MCP server '{server_name}' not registered")
        
        config = self.mcp_configs[server_name]
        transport = StreamableHttpTransport(
            url=config.url,
            headers={"Authorization": f"Bearer {self.auth_token}"}
        )
        
        try:
            async with Client(transport) as client:
                result = await client.call_tool(tool_name, tool_args)
                return {"success": True, "result": result}
        except Exception as e:
            auth_urls = self._extract_oauth_urls(e)
            if auth_urls:
                return {
                    "success": False,
                    "auth_required": True,
                    "authorization_urls": auth_urls,
                    "server": server_name
                }
            return {"success": False, "error": str(e)}
    
    async def list_tools(self, server_name: str) -> list[str]:
        if server_name not in self.mcp_configs:
            raise ValueError(f"MCP server '{server_name}' not registered")
        
        config = self.mcp_configs[server_name]
        transport = StreamableHttpTransport(
            url=config.url,
            headers={"Authorization": f"Bearer {self.auth_token}"}
        )
        
        async with Client(transport) as client:
            tools = await client.list_tools()
            return [tool.name for tool in tools]
    
    def _extract_oauth_urls(self, exception: Exception) -> list[str]:
        current = exception
        while current is not None:
            try:
                data = current.error.data
                if data and "Please visit:" in str(data):
                    match = re.search(r"Please visit:\s*(.+)$", str(data))
                    if match:
                        return [url.strip() for url in match.group(1).split(" , ")]
            except AttributeError:
                pass
            current = current.__cause__
        return []


# Example usage
async def main():
    # Initialize with user token for per-user access
    # Or use Virtual Account token for shared access
    agent = MCPAgent(auth_token=os.environ["TFY_API_KEY"])
    
    agent.register_mcp_server(
        name="github",
        url="https://<control-plane-url>/api/llm/mcp/github-integration/server"
    )
    agent.register_mcp_server(
        name="web-search",
        url="https://<control-plane-url>/api/llm/mcp/web-search/server"
    )
    
    # Call a tool
    result = await agent.call_tool(
        server_name="github",
        tool_name="list_repositories",
        tool_args={"owner": "truefoundry"}
    )
    
    if result.get("auth_required"):
        print("OAuth authorization required:")
        for url in result["authorization_urls"]:
            print(f"  → {url}")
    elif result.get("success"):
        print(f"Result: {result['result']}")
    else:
        print(f"Error: {result.get('error')}")


if __name__ == "__main__":
    asyncio.run(main())

Troubleshooting

  • Verify the MCP server URL is correct (copy from TrueFoundry UI)
  • Check that the MCP server is running and healthy
  • Ensure your network can reach the TrueFoundry control plane
  • Verify your authentication token is valid and not expired
  • Check that you have access to the MCP server (see collaborators in UI)
  • For Virtual Accounts, ensure the account has the required permissions
  • The user needs to complete the OAuth consent flow
  • Redirect them to the authorization URL provided in the error
  • After consent, tokens are stored automatically—retry the request
  • List available tools using client.list_tools() to see what’s available
  • Tool names are case-sensitive
  • The MCP server may have been updated—refresh the tool list
Not directly. Virtual Accounts don’t have per-user OAuth tokens.Options:
  1. Use TFY PAT or IdP JWT for per-user OAuth
  2. Configure the MCP server with Static Header auth instead (shared access)

Next Steps