MCP OAuth for Integrator Apps β
When your tenant serves MCP clients (Cursor, Claude, Codex, etc.) and you run a custom frontend behind an integrator (@qelos/integrator-express, @qelos/integrator-next, β¦), you own the browser pages that Qelos redirects to during MCP OAuth. The SDK and integrators expose helpers for every step β URL construction, login resume, consent display, callback parsing, and token exchange β so you do not reimplement auth-service logic in your app.
Not building a custom frontend? The Qelos admin panel already ships
/loginand/mcp/authorize. Enable MCP in admin and add permitted callback URLs β no integrator work required. See MCP client setup.
When you need this β
Use this guide when all of the following are true:
- Your app is hosted on a custom domain that proxies
/api/**to Qelos (BFF / integrator pattern). - That same domain is the MCP OAuth issuer (what clients discover at
/.well-known/oauth-authorization-server). - You want your own login and consent UI instead of the built-in admin pages.
The MCP client (Cursor, Claude, β¦) still receives the authorization code on its callback URL (cursor://β¦, https://claude.ai/β¦). Your app only hosts the human-facing login and consent steps.
End-to-end flow β
βββββββββββββββ ββββββββββββββββββββββββββββββββ ββββββββββββββββ
β MCP client β β Your app (integrator + UI) β β Qelos auth β
β Cursor, β¦ β β https://app.example.com β β (proxied) β
ββββββββ¬βββββββ ββββββββββββββββ¬ββββββββββββββββ ββββββββ¬ββββββββ
β β β
β 1. Open authorize URL β β
β ββββββββββββββββββββββββββΊβ GET /api/auth/mcp/authorizeβ
β β ββββββββββββββββββββββββββββΊ β
β β β
β β 2. 302 β /login?redirect=β¦ β
β β ββββββββββββββββββββββββββββ β
β β β
β β 3. User signs in (your UI) β
β β ββββββββββββββββββββββββββββΊ β
β β POST /api/signin (proxy)β
β β β
β β 4. Resume authorize β
β β ββββββββββββββββββββββββββββΊ β
β β 302 β /mcp/authorize? β
β β mcp_state=β¦ β
β β ββββββββββββββββββββββββββββ β
β β β
β β 5. Consent (your UI) β
β β POST /api/auth/mcp/consent β
β β ββββββββββββββββββββββββββββΊ β
β β β
β 6. 302 β client redirect β β
β ?code=β¦&state=β¦ β ββββββββββββββββββββββββββββ β
β βββββββββββββββββββββββββββ β
β β β
β 7. PKCE token exchange β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββΊβ
β POST /api/auth/mcp/token β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββSteps 1β5 run in the browser on your domain. Step 6 redirects back to the MCP client's registered callback URL. Step 7 is performed by the MCP client itself (Cursor, Claude, etc.) β not your integrator app.
Prerequisites β
Admin configuration β
In Admin β MCP Configuration:
- Enable MCP OAuth for the tenant.
- Add every MCP client callback URL you support under Permitted callback URLs (e.g.
cursor://anysphere.cursor-mcp/oauth/callback,https://claude.ai/api/mcp/auth_callback). See MCP configuration.
Optional: enable Admin only if only workspace admins may connect MCP clients.
Integrator proxy β
Your integrator must proxy /api/** to Qelos so these endpoints work on your domain:
| Path | Purpose |
|---|---|
/.well-known/oauth-authorization-server | OAuth discovery for MCP clients |
/api/auth/mcp/authorize | Starts the flow; redirects to your login or consent page |
/api/auth/mcp/consent | Accept/deny consent (requires authenticated cookie session) |
/api/auth/mcp/token | PKCE code exchange (called by the MCP client) |
/api/signin (or social auth) | Your login page uses the proxied sign-in API |
Express and Next.js integrators enable this via the catch-all /api/** proxy. See Express integrator or Next.js integrator.
Routes you must add β
Qelos auth hardcodes two frontend paths on the tenant host:
| Path | Required | Purpose |
|---|---|---|
/login | yes | Shown when the user is not signed in during MCP OAuth |
/mcp/authorize | yes | Consent screen after sign-in |
If your app uses different paths, pass custom paths to the SDK helpers (see below) and configure your reverse proxy or auth overrides accordingly. The default Qelos auth service emits /login and /mcp/authorize.
SDK helpers β
Import from @qelos/sdk (or re-exported from @qelos/integrator-express / @qelos/integrator-next):
| Helper | Use |
|---|---|
buildMcpAuthorizePath(options) | Relative /api/auth/mcp/authorize?β¦ path |
sdk.authentication.getMcpAuthorizeUrl(options) | Absolute authorize URL |
buildMcpLoginRedirectUrl(authorizePath) | /login?redirect=β¦ (what auth returns when anonymous) |
buildMcpConsentPageUrl(mcpState) | /mcp/authorize?mcp_state=β¦ (post-login redirect target) |
resolveMcpLoginRedirect(query) | Normalize login-page query into the post-login destination |
buildMcpAuthorizePathFromQuery(query) | Build authorize path from raw OAuth query params |
decodeMcpOAuthStatePayload(mcpState) | Decode consent JWT for UI labels (not verified) |
appendMcpAccessDeniedRedirect(redirectUri, state?) | Client callback URL when user denies consent |
parseMcpCallbackParams(input) | Parse ?code=β¦ / ?error=β¦ on a callback URL |
sdk.authentication.exchangeMcpAuthorizationCode({β¦}) | PKCE token exchange (when your app owns the callback) |
Integrator-only:
| Helper | Use |
|---|---|
completeMcpAuthorizationCallback(sdk, input, { redirectUri, codeVerifier }) | Parse callback + exchange in one call |
Implementing /login β
When MCP OAuth starts and the user has no session, Qelos redirects to:
/login?redirect=%2Fapi%2Fauth%2Fmcp%2Fauthorize%3Fredirect_uri%3Dβ¦After a successful sign-in, send the browser to the decoded redirect query parameter. Use resolveMcpLoginRedirect() to normalize edge cases (direct OAuth query params or mcp_state on the login URL):
import { resolveMcpLoginRedirect } from '@qelos/sdk';
// Express β after sdk.authentication.signin()
app.post('/login', express.json(), async (req, res) => {
await req.qelos!.sdk.authentication.signin(req.body);
const next = resolveMcpLoginRedirect(req.query as Record<string, string | string[] | undefined>);
res.redirect(next || '/');
});
// Next.js App Router β login form action
import { resolveMcpLoginRedirect } from '@qelos/sdk';
import { redirect } from 'next/navigation';
export async function loginAction(formData: FormData, searchParams: Record<string, string>) {
// β¦ sign in via proxied /api/signin β¦
const next = resolveMcpLoginRedirect(searchParams);
redirect(next || '/');
}On first load, if the login page receives MCP OAuth query params without a redirect param, persist them by redirecting once:
import { resolveMcpLoginRedirect } from '@qelos/sdk';
const resume = resolveMcpLoginRedirect(searchParams);
if (resume && !searchParams.redirect) {
redirect(`/login?redirect=${encodeURIComponent(resume)}`);
}Implementing /mcp/authorize β
After sign-in, GET /api/auth/mcp/authorize redirects to:
/mcp/authorize?mcp_state=<signed-jwt>Your consent page should:
- Require an authenticated user (integrator middleware sets
req.qelos.user/getQelosContext().user). If missing, redirect to/login?redirect=β¦. - Decode
mcp_statefor display withdecodeMcpOAuthStatePayload()β showredirectUri, optionalclientId, and scopes if you collect them. - On Accept,
POSTto/api/auth/mcp/consentwith form fieldsmcp_stateandaction=accept. The browser follows the redirect back to the MCP client with?code=β¦. - On Deny, either submit
action=denyto the same endpoint or redirect withappendMcpAccessDeniedRedirect(redirectUri, oauthState).
Minimal consent form (works with the proxied auth API):
<form method="POST" action="/api/auth/mcp/consent">
<input type="hidden" name="mcp_state" value="{{ mcpState }}" />
<button type="submit" name="action" value="accept">Allow</button>
<button type="submit" name="action" value="deny">Deny</button>
</form>Express example with SDK helpers:
import {
appendMcpAccessDeniedRedirect,
buildMcpAuthorizePathFromQuery,
decodeMcpOAuthStatePayload,
} from '@qelos/integrator-express';
app.get('/mcp/authorize', (req, res) => {
if (!req.qelos?.user) {
const resume =
typeof req.query.mcp_state === 'string'
? `/mcp/authorize?mcp_state=${encodeURIComponent(req.query.mcp_state)}`
: buildMcpAuthorizePathFromQuery(req.query as Record<string, string | string[] | undefined>);
if (resume) {
return res.redirect(`/login?redirect=${encodeURIComponent(resume)}`);
}
return res.status(400).send('Missing MCP OAuth state');
}
const mcpState = String(req.query.mcp_state || '');
const payload = decodeMcpOAuthStatePayload(mcpState);
if (!payload?.redirectUri) {
return res.status(400).send('Invalid MCP OAuth state');
}
res.render('mcp-consent', { mcpState, payload });
});
app.post('/mcp/authorize/deny', (req, res) => {
const payload = decodeMcpOAuthStatePayload(String(req.body.mcp_state || ''));
if (!payload?.redirectUri) {
return res.status(400).send('Invalid state');
}
res.redirect(appendMcpAccessDeniedRedirect(payload.redirectUri, payload.state));
});Prefer posting directly to /api/auth/mcp/consent for accept so Qelos issues the authorization code server-side.
When your app owns the MCP callback URL β
Most MCP clients (Cursor, Claude remote) handle step 7 themselves. If you build a local MCP client or loopback server whose callback hits your app (e.g. http://127.0.0.1:3334/mcp/oauth/callback), store the PKCE verifier, then:
import { completeMcpAuthorizationCallback } from '@qelos/integrator-express';
const result = await completeMcpAuthorizationCallback(req.qelos.sdk, req.url, {
redirectUri: 'http://127.0.0.1:3334/mcp/oauth/callback',
codeVerifier, // same value used to build code_challenge at authorize time
});
// result.accessToken, result.refreshToken, result.userChecklist β what to update β
| Area | Action |
|---|---|
| Admin β MCP | Enable MCP; add permitted callback URLs for each client |
| Integrator | Proxy /api/**; set QELOS_APP_URL / appUrl |
| Your app | Add /login with resolveMcpLoginRedirect resume |
| Your app | Add /mcp/authorize consent UI + POST to /api/auth/mcp/consent |
| Optional | Custom paths via buildMcpLoginRedirectUrl(path, { loginPath }) and buildMcpConsentPageUrl(state, { consentPath }) if you cannot use /login and /mcp/authorize |
| Local MCP client | Register loopback callback URL in admin; use completeMcpAuthorizationCallback |
Related docs β
- MCP overview β tenant MCP server and tool exposure
- MCP client setup β connecting Cursor, Claude, Codex, β¦
- MCP configuration β permitted callbacks and admin-only mode
- Social authentication β same BFF pattern for login pages
- Cookie token lifecycle β how integrator sessions work
- Authentication API β raw HTTP reference for MCP OAuth endpoints
