Partner integration guide

Connect to Litmus over MCP

How to let your users connect their Litmus account to your product, and call Litmus's tools on their behalf over the Model Context Protocol.

What Litmus gives you

Once you're registered as a partner, you get three values. No client secret — see the FAQ for why.

Client ID
client_01…

Identifies your app to Litmus's OAuth server. Not secret — safe to embed in distributed client code.

Authorization domain
Environment-specific

Litmus's OAuth server — where the /oauth2/authorize and /oauth2/token endpoints live. It differs per environment (testing, staging, production); reach out to your Litmus contact to get the right one for each environment you need.

MCP server URL
https://api.litmus.science/mcp

Base URL for calling Litmus's tools once a user is connected. This is production — it's also environment-specific, so confirm the staging/testing equivalent with your Litmus contact if you need one.

What we need from you first

Before you get a client ID, tell your Litmus contact one thing:

Your redirect URI — where should a user land after approving the connection? This gets registered alongside your client ID; it can't be added or changed later without a new client.

The flow your users will see

Worth knowing before you build the button — they leave your product for two screens they don't control.

1

Clicks “Connect to Litmus” in your product.

Their browser leaves your app.

2

Logs into Litmus — not your app.

Their real Litmus account, Litmus's own login screen. If they're already signed in, this is skipped.

3

Chooses what your app may do.

A Litmus consent screen asks whether your app may create and submit order requests for them, and they choose Allow or Don't allow. Identifying the connection needs no choice; see Consent and permissions.

4

Lands back in your product, connected.

Your redirect URI receives the result — nothing further for them to do.

What you build

A
The connect action

A route that sends the user's browser to Litmus's authorize endpoint, so they can log in and approve the connection. Register litmus once with Authlib and authorize_redirect builds the PKCE challenge and CSRF state for you — no crypto to write by hand.

Python — AuthlibShow code
from fastapi import FastAPI, Request
from starlette.middleware.sessions import SessionMiddleware
from authlib.integrations.starlette_client import OAuth

app = FastAPI()
# Required — gives requests a signed-cookie session, which is where
# Authlib stashes state + the PKCE verifier between this route and the
# callback. SESSION_SECRET is your own app secret (e.g. secrets.token_urlsafe(32)),
# not anything Litmus issues — same role as Django's SECRET_KEY.
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET)

oauth = OAuth()
oauth.register(
    name="litmus",
    client_id=CLIENT_ID,  # no client_secret — Litmus issues public, PKCE-only clients
    authorize_url=f"{LITMUS_OAUTH_SERVER_DOMAIN}/oauth2/authorize",
    access_token_url=f"{LITMUS_OAUTH_SERVER_DOMAIN}/oauth2/token",
    client_kwargs={
        "scope": "openid offline_access",  # both required — see below
        "code_challenge_method": "S256",  # turns on PKCE end to end, automatically
    },
)

@app.get("/connect/litmus")
async def connect_litmus(request: Request):
    # REDIRECT_URI is the exact URI you gave Litmus — see "What we need
    # from you first" — not something derived at request time. Building it
    # from request.url_for() instead can silently drift from what's
    # registered (e.g. http vs. https behind a proxy), which Litmus will
    # then reject as a mismatch.
    return await oauth.litmus.authorize_redirect(request, REDIRECT_URI, resource=LITMUS_MCP_SERVER_URL)
B
The redirect handler

Your registered redirect URI receives this request with ?code=…&state=…. authorize_access_token reads both off the query string and verifies state against what step A saved in the session.

Python — AuthlibShow code
from fastapi.responses import RedirectResponse

# The path here is REDIRECT_URI's path — this route IS your redirect URI.
@app.get("/auth/litmus/callback")
async def litmus_callback(request: Request):
    # Reads code + state from the query string and checks state against
    # what step A stashed in the session — automatically, nothing to
    # compare by hand.
    tokens = await oauth.litmus.authorize_access_token(request)
    await save_tokens_for_user(current_user_id(request), tokens)
    # Send them back into your product, not Litmus — wherever you want
    # a "Litmus connected" state to show. This URL is illustrative.
    return RedirectResponse("/settings/integrations?connected=litmus")
C
The token exchange

The exchange itself — trading the code, plus the PKCE verifier Authlib saved back in step A, for an access token — happens inside that same authorize_access_token call. Store the token dict it returns.

Python — AuthlibShow code
# The line that does it — inside the callback shown in step B:
tokens = await oauth.litmus.authorize_access_token(request)
# tokens: { access_token, token_type, expires_in, refresh_token? }
D
Expiry handling

Litmus doesn't publish a fixed access-token lifetime — treat it as short-lived and opaque. When a call to Litmus's MCP server gets a 401, either refresh with the stored refresh token, or send the user back through step A if you don't have one.

Python — AuthlibShow code
async def refresh_litmus_tokens(refresh_token: str) -> dict:
    # Not automatic — call this yourself on a 401 from the MCP server.
    return await oauth.litmus.fetch_access_token(
        grant_type="refresh_token",
        refresh_token=refresh_token,
    )

Flask and Django integrations follow the same shape (authorize_redirect / authorize_access_token) — see Authlib's framework docs if FastAPI isn't your stack.

Staying connected: offline_access

openid and offline_access are both required: a token without either is rejected. offline_access is what keeps a user connected without re-prompting login every time an access token expires.

Good to know

Why don't we get a client secret?

A secret embedded in distributable client code isn't actually secret once someone inspects the binary or the network traffic. PKCE binds each individual authorization attempt to whoever actually initiated it instead — trust is established per-flow, not via a static credential you'd otherwise have to protect and could leak.

Can we request scopes beyond openid and offline_access?

Scopes aren't how access works here: those two are the only ones, and both are required (see above). What your app can do on a user's behalf is set by the permissions they allow on the consent screen — see Consent and permissions — and you don't request those in the scope.

Why isn't whoami on the consent screen?

Identifying the connection is implied by connecting, so whoami is granted on connect. The order-request tools' permission is on the consent screen, where the user chooses Allow or Don't allow.