Skip to main content
Lesson 8 of 14 · MCP for Developers: Servers and Integrations

OAuth 2.0 for MCP Servers

Lesson 8 of 14 Updated November 3, 2026 Developer
On this page
  1. Why OAuth in MCP?
  2. How the flow works
  3. Configuring OAuth on the Python client
  4. Least privilege, applied to scopes
  5. Storing and renewing tokens
  6. When a simple token suffices
  7. What Vibe handles for you
  8. Key takeaways

Update November 2, 2026 — Le Chat is now Vibe. As of May 28, 2026, Le Chat has been rebranded as Vibe (still at chat.mistral.ai): a single agent for work and code, carrying over your conversations, settings, and subscriptions. The legacy Le Chat experience remains accessible in Chat mode; in Work mode, agents have become skills (invoked with @), memories are now the knowledge base, and deep search is the Deep Research skill. Plans are now called Mistral Free, Pro, Team, and Enterprise. This lesson uses the name Vibe; some menus described here may have changed names or locations. Addendum November 3: The previous example called requires_auth, set_oauth_params, or get_token_from_auth_response on MCPClientStreamableHTTP, which has none of these methods in the mistralai SDK 3.7.0; it now follows the official example async_conversation_run_mcp_remote_auth.py, using MCPClientSSE. Source: client-python, MCP examples.

Why OAuth in MCP?

As long as your MCP server only converts currencies or queries a public database, identity isn’t an issue. It becomes critical the moment the server touches emails, calendars, GitHub repositories, or Linear tickets: it no longer works for itself, it acts on behalf of a specific user. A static token sitting in a config file says nothing about this consent. It doesn’t say who authorized what, for how long, or how far the granted rights extend. OAuth 2.0 addresses exactly these three gaps: explicit consent, rights limited by scopes, and secure renewal.

Recall the three authentication levels covered earlier. The first, no auth, suits a public URL with no sensitive data. The second, static token, corresponds to an API key generated manually, like a GitHub Personal Access Token. The third, OAuth 2.0, adds the full consent flow with scopes and refresh tokens. Moving from the second to the third level isn’t about technical sophistication but responsibility: once multiple people use your server on their own data, a shared token becomes a security hole.

How the flow works

When a user connects an MCP server with OAuth in Vibe, seven steps chain together. It’s worth visualizing them once and for all:

1. User clicks "Connect" in Vibe
       ↓
2. Vibe redirects to the service's authorization page
   (e.g., GitHub, Google, Linear)
       ↓
3. User grants the requested permissions (scopes)
       ↓
4. Service returns an authorization code to the callback
       ↓
5. Client exchanges the code for an access token + refresh token
       ↓
6. Access token is used for every MCP call
       ↓
7. When the access token expires, the refresh token gets a new one

Three different tokens circulate in this choreography, and they’re often confused. The authorization code is temporary and single-use: it’s exchanged immediately for the real tokens. The access token then accompanies every request and lives briefly, typically an hour. The refresh token, finally, serves only to obtain a new access token without asking the user for consent again. This short lifespan of the access token is a safeguard: if a token leaks, its window of exploitation remains narrow.

Configuring OAuth on the Python client

The Mistral SDK supports this flow with the build_oauth_params helper and a few MCP client methods. Watch your client choice: in the mistralai SDK 3.7.0, these OAuth methods exist only on the SSE client, MCPClientSSE, which is what the SDK’s official example uses with the Linear server; MCPClientStreamableHTTP has none of them.

from mistralai.extra.mcp.auth import build_oauth_params
from mistralai.extra.mcp.sse import MCPClientSSE, SSEServerParams

REDIRECT_URL = "http://localhost:16010/oauth/callback"
SERVER_URL = "https://mcp.linear.app/sse"

mcp_client = MCPClientSSE(sse_params=SSEServerParams(url=SERVER_URL))

async def connect():
    global mcp_client
    if not await mcp_client.requires_auth():
        return mcp_client  # Server is open: nothing to do

    # 1. Discover the server's OAuth endpoints
    #    (and dynamically register the client if no client_id provided)
    oauth_params = await build_oauth_params(
        mcp_client.base_url,
        redirect_url=REDIRECT_URL,
    )
    mcp_client.set_oauth_params(oauth_params=oauth_params)

    # 2. Build the consent URL and open it in the browser
    login_url, state = await mcp_client.get_auth_url_and_state(REDIRECT_URL)
    print(f"Authorize access: {login_url}")

    # 3. Catch the callback, then exchange the code for a token,
    #    using a client recreated from the saved OAuth params
    auth_response = await wait_for_callback()  # small local HTTP server
    mcp_client = MCPClientSSE(sse_params=SSEServerParams(url=SERVER_URL), oauth_params=oauth_params)
    token = await mcp_client.get_token_from_auth_response(
        auth_response["url"],
        redirect_url=REDIRECT_URL,
        state=state,
    )
    mcp_client.set_auth_token(token)
    return mcp_client

build_oauth_params does two things and two things only: it reads the OAuth metadata published by the MCP server, and it dynamically registers a client if you pass neither client_id nor client_secret. The rest of the flow is up to you—opening the consent URL, collecting the callback on your redirect_url port, exchanging the code for a token. The SDK doesn’t hide these steps; it tools them, and precisely this breakdown lets you wire the callback into a web app rather than a script.

Least privilege, applied to scopes

Scopes define what your server can do once consent is given, and the consent screen displays them to the user. Asking broadly is tempting: it avoids backtracking if a need arises. It’s also the best way to scare off a cautious user and unnecessarily expose their account. Compare:

# Bad: too many permissions
scope=["repo", "admin:org", "delete_repo", "user"]

# Good: just enough to create issues
scope=["repo:issues", "read:user"]

A server that creates issues has no reason to delete repositories or administer the organization. Each service publishes its own scope nomenclature: consult the target service’s OAuth documentation rather than guessing by analogy.

Storing and renewing tokens

The tokens you obtain are worth the credentials they replace, and deserve the same care. Write them to a dedicated file with restricted system permissions:

import os
import json
from pathlib import Path

TOKEN_FILE = Path.home() / ".mcp" / "tokens.json"

def save_tokens(service: str, access_token: str, refresh_token: str):
    """Save tokens securely."""
    TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
    tokens = {}
    if TOKEN_FILE.exists():
        tokens = json.loads(TOKEN_FILE.read_text())
    tokens[service] = {
        "access_token": access_token,
        "refresh_token": refresh_token,
    }
    TOKEN_FILE.write_text(json.dumps(tokens))
    # Restrict file permissions
    os.chmod(TOKEN_FILE, 0o600)

The chmod to 0o600 isn’t decorative: without it, on a shared machine, any account can read your tokens. Renewal, meanwhile, boils down to a POST call to the service’s token_url with the refresh token in hand. The user sees nothing pass, which is precisely the point:

import httpx

async def refresh_access_token(
    token_url: str,
    client_id: str,
    client_secret: str,
    refresh_token: str,
) -> dict:
    """Renew the access token via the refresh token."""
    async with httpx.AsyncClient() as http:
        response = await http.post(
            token_url,
            data={
                "grant_type": "refresh_token",
                "refresh_token": refresh_token,
                "client_id": client_id,
                "client_secret": client_secret,
            },
        )
        response.raise_for_status()
        return response.json()

When a simple token suffices

Not every server justifies this machinery. For an internal tool consumed by a handful of developers, an API key passed in an HTTP header works just fine, and the MCP client accepts it directly:

from mistralai.extra.mcp.streamable_http import (
    MCPClientStreamableHTTP,
    StreamableHTTPServerParams,
)

# Server with simple token
mcp_client = MCPClientStreamableHTTP(
    params=StreamableHTTPServerParams(
        url="https://my-server.com/mcp",
        headers={"Authorization": "Bearer my_secret_api_token"},
        timeout=60,
    )
)

On the server side, verification fits in a middleware that intercepts the relevant routes before they reach your business logic:

from fastapi import FastAPI, Request, HTTPException

api = FastAPI()

@api.middleware("http")
async def verify_token(request: Request, call_next):
    if request.url.path.startswith("/mcp"):
        token = request.headers.get("Authorization", "")
        if token != "Bearer my_secret_api_token":
            raise HTTPException(status_code=401, detail="Unauthorized")
    return await call_next(request)

What Vibe handles for you

In Vibe, featured connectors—GitHub, Gmail, Linear—come with OAuth already wired: the user clicks, consents, and it’s done. For your custom connectors, the configuration offers the same three options described above: No authentication for a public URL, Token if you paste the key by hand, and OAuth 2.0 for the full flow. One detail has important practical consequences: the token is stored per user, not per organization. In a Mistral team, each member therefore goes through their own consent and works with their own credentials—which prevents a departure or revocation from breaking access for everyone.

Key takeaways

  • OAuth 2.0 enables explicit consent with limited rights (scopes)
  • The flow: authorization → code → tokens (access + refresh)
  • Apply the principle of least privilege to scopes
  • Tokens must be stored securely and renewed automatically
  • For simple cases, a static token in HTTP headers suffices
  • In Vibe, tokens are stored per user