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_01…Identifies your app to Litmus's OAuth server. Not secret — safe to embed in distributed client code.
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.
https://platform.litmus.science/mcpBase 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.
Clicks “Connect to Litmus” in your product.
Their browser leaves your app.
Logs into Litmus — not your app.
Their real Litmus account, Litmus's own login screen. If they're already signed in, this is skipped.
Sees a Litmus consent screen.
“{your app} would like access to your account.” One click: Allow.
Lands back in your product, connected.
Your redirect URI receives the result — nothing further for them to do.
What you build
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 — Authlib
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", # add "offline_access" too, if wanted — 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)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 — Authlib
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")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 — Authlib
# 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? }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 — Authlib
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 is always required, and every token you're issued carries it. Add offline_access to the scope in step A above if you'd rather not re-prompt login every time an access token expires.
offline_access alongside openid and the token response from step C includes a refresh_token too. Litmus issues a new refresh token on every use and expects the previous one to stop working shortly after — don't hold onto or reuse an old refresh token once you've exchanged it, and design for one refresh token in flight at a time.Calling the MCP server
You've already done the OAuth work in steps A–D — your backend holds a real access token for this user. The snippets below just attach it as a bearer header — no auth machinery needed here.
Python — fastmcp
from fastmcp import Client
from fastmcp.client.auth import BearerAuth
async with Client(
"https://platform.litmus.science/mcp",
auth=BearerAuth(access_token),
) as client:
result = await client.call_tool("whoami", {})
print(result)TypeScript — @modelcontextprotocol/sdk
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport }
from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const transport = new StreamableHTTPClientTransport(
new URL("https://platform.litmus.science/mcp"),
{ requestInit: { headers: { Authorization: `Bearer ${accessToken}` } } },
);
const client = new Client({ name: "your-app", version: "1.0.0" });
await client.connect(transport);
const result = await client.callTool({ name: "whoami", arguments: {} });
console.log(result);We recommend fastmcp over the lower-level official mcp Python package — it wraps the same protocol with a much smaller surface (BearerAuth above is one line; the raw SDK wants you to wire up a streaming transport by hand). On the TypeScript side, @modelcontextprotocol/ sdk is the one to use — there isn't an equivalent higher-level wrapper with the same ecosystem weight behind it.
A 401 from either snippet means the access token expired — that's step D above: refresh it (if you requested offline_access) or send the user back through step A, then reconnect with the new token. Neither snippet retries that for you.
Available tools
Litmus's MCP server exposes one tool today. It's the identity primitive everything else will build on — check with your Litmus contact for the current roster before assuming a larger set.
Identifies both the OAuth client calling the tool (your integration) and the Litmus user who authorized it. Granted automatically as part of the standard consent screen in the flow above — there's no separate permission step for you to build.
{
"integrator_id": "…",
"integrator_name": "…",
"client_id": "…",
"scopes": ["openid"],
"user_id": "…",
"user_email": "…",
"user_name": "…" | null
}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 more than identity (openid)?
You can add offline_access for a refresh token (see above) — that's the only other scope Litmus's MCP server issues today. Talk to your Litmus contact if your integration needs something beyond identity.
Where's the checkbox for the whoami permission?
There isn't a separate one. It rides along with the "Allow" step the user already takes to complete the connection — not a second decision point you need to design for.