Login Page Your tenant app must serve a login page at /mcp-login. The hub redirects users here during the OAuth flow. After the user authenticates, your app issues an auth code and redirects back to the hub.
Route GET /mcp-login — must return an HTML page with a login form.
Query parameters The hub appends these query parameters when redirecting:
Parameter Required Description callback_urlYes The hub URL to redirect back to after login stateYes Opaque string — must be passed back unchanged code_challengeYes PKCE S256 challenge (43-character base64url string) code_challenge_methodYes Always S256 tenant_slugNo Tenant identifier for multi-tenant UI customization login_hintNo Email address to pre-fill the login form (OAuth2 standard)
Security: callback URL validation You must validate the callback_url against your allowlist before rendering the form or redirecting. This prevents open-redirect attacks. If the callback host is not in your MCP_CONTRACT_ALLOWED_CALLBACK_HOSTS, show an error page.
Auth code issuance After successful authentication:
Generate a random auth code (32 bytes, base64url-encoded) Store it with the code_challenge and user ID, with a short TTL (60 seconds) Redirect to callback_url with ?state={state}&code={code} On authentication failure, redirect with ?state={state}&error=access_denied.
PKCE S256 format The code_challenge is always exactly 43 characters of base64url (unpadded SHA-256 of the verifier). Validate the format before storing.
Optional parameters The hub may also pass optional parameters to enhance the login experience:
Multi-tenant support (tenant_slug) When the tenant_slug parameter is provided, you can display tenant context in your login UI to help users understand which organization they're signing into.
Example:
GET /mcp-login?callback_url=...&state=...&code_challenge=...&code_challenge_method=S256&tenant_slug=acme-corpImplementation suggestion:
{tenant_slug && (
<p className="text-sm text-gray-600">
Signing in to: {tenant_slug}
</p>
)}Login hint (login_hint) The login_hint parameter follows the OAuth2 standard and provides an email address to pre-fill the login form. This improves UX for SSO flows and reduces friction when the user's identity is already known.
Example:
GET /mcp-login?callback_url=...&state=...&code_challenge=...&code_challenge_method=S256&login_hint=user@acme.comImplementation: Pre-fill the email field with the provided value (shown in code examples below).
Implementation Next.js (TypeScript) Next.js (JavaScript) Java Node.js (TypeScript) Node.js (JavaScript) Python
Login page (GET) // app/mcp-login/page.tsx
import { isAllowedCallbackUrl } from "@/lib/mcp-contract/config";
export default async function McpLoginPage({ searchParams }) {
const params = await searchParams;
const {
callback_url = "",
state = "",
code_challenge = "",
code_challenge_method = "",
tenant_slug = "",
login_hint = "",
} = params;
// Open-redirect defense
if (!callback_url || !isAllowedCallbackUrl(callback_url)) {
return (
<div>
<h1>Invalid callback URL</h1>
<p>The callback_url is not allowed for this tenant.</p>
</div>
);
}
if (code_challenge_method && code_challenge_method !== "S256") {
return (
<div>
<h1>Unsupported Method</h1>
<p>Only code_challenge_method=S256 is supported.</p>
</div>
);
}
return (
<form method="POST" action="/api/mcp/login">
<input type="hidden" name="callback_url" value={callback_url} />
<input type="hidden" name="state" value={state} />
<input type="hidden" name="code_challenge" value={code_challenge} />
<input type="hidden" name="code_challenge_method"
value={code_challenge_method} />
<input type="hidden" name="tenant_slug" value={tenant_slug} />
<label>Email</label>
<input name="email" type="email" required defaultValue={login_hint} />
<label>Password</label>
<input name="password" type="password" required />
<button type="submit">Sign In</button>
</form>
);
}Login handler (POST) // app/api/mcp/login/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { issueAuthCode } from "@/lib/mcp-contract/auth-code-store";
import { isAllowedCallbackUrl } from "@/lib/mcp-contract/config";
const CODE_CHALLENGE_RE = /^[A-Za-z0-9_-]{43}$/;
export async function POST(request: NextRequest) {
const formData = await request.formData();
const email = formData.get("email") as string | null;
const password = formData.get("password") as string | null;
const callbackUrl = formData.get("callback_url") as string | null;
const state = formData.get("state") as string | null;
const codeChallenge = formData.get("code_challenge") as string | null;
if (!email || !password || !callbackUrl || !state || !codeChallenge) {
return NextResponse.json(
{ error: "Missing required fields" }, { status: 400 });
}
if (!isAllowedCallbackUrl(callbackUrl)) {
return NextResponse.json(
{ error: "callback_url host not allowed" }, { status: 400 });
}
if (!CODE_CHALLENGE_RE.test(codeChallenge)) {
return NextResponse.json(
{ error: "Invalid code_challenge" }, { status: 400 });
}
let userId: string;
try {
const result = await auth.api.signInEmail({
body: { email, password },
});
if (!result?.user) throw new Error("Auth returned no user");
userId = result.user.id;
} catch {
const errorUrl = new URL(callbackUrl);
errorUrl.searchParams.set("state", state);
errorUrl.searchParams.set("error", "access_denied");
return NextResponse.redirect(errorUrl.toString(), { status: 302 });
}
const code = issueAuthCode(userId, codeChallenge);
const successUrl = new URL(callbackUrl);
successUrl.searchParams.set("state", state);
successUrl.searchParams.set("code", code);
return NextResponse.redirect(successUrl.toString(), { status: 302 });
}Auth code store // lib/mcp-contract/auth-code-store.ts
import { randomBytes, createHash } from "crypto";
const MCP_CONTRACT_AUTH_CODE_TTL_SEC = parseInt(
process.env.MCP_CONTRACT_AUTH_CODE_TTL_SEC ?? "60", 10
);
interface CodeRecord {
userId: string;
codeChallenge: string;
expiresAt: number;
}
const codes = new Map<string, CodeRecord>();
function sweep() {
const now = Date.now();
for (const [code, record] of codes) {
if (record.expiresAt <= now) codes.delete(code);
}
}
export function issueAuthCode(
userId: string, codeChallenge: string
): string {
sweep();
const code = randomBytes(32).toString("base64url");
codes.set(code, {
userId,
codeChallenge,
expiresAt: Date.now() + MCP_CONTRACT_AUTH_CODE_TTL_SEC * 1000,
});
return code;
}
export function consumeAuthCode(
code: string, codeVerifier: string
): { userId: string } | null {
sweep();
const record = codes.get(code);
if (!record || record.expiresAt <= Date.now()) {
codes.delete(code!);
return null;
}
// PKCE S256: BASE64URL(SHA256(verifier)) must equal stored challenge
const computed = createHash("sha256")
.update(codeVerifier, "ascii")
.digest("base64url");
if (computed !== record.codeChallenge) return null;
codes.delete(code); // Single-use
return { userId: record.userId };
}Login page (GET) // app/mcp-login/page.js
import { isAllowedCallbackUrl } from "@/lib/mcp-contract/config";
export default async function McpLoginPage({ searchParams }) {
const params = await searchParams;
const {
callback_url = "",
state = "",
code_challenge = "",
code_challenge_method = "",
tenant_slug = "",
login_hint = "",
} = params;
if (!callback_url || !isAllowedCallbackUrl(callback_url)) {
return (
<div>
<h1>Invalid callback URL</h1>
<p>The callback_url is not allowed for this tenant.</p>
</div>
);
}
if (code_challenge_method && code_challenge_method !== "S256") {
return (
<div>
<h1>Unsupported Method</h1>
<p>Only code_challenge_method=S256 is supported.</p>
</div>
);
}
return (
<form method="POST" action="/api/mcp/login">
<input type="hidden" name="callback_url" value={callback_url} />
<input type="hidden" name="state" value={state} />
<input type="hidden" name="code_challenge" value={code_challenge} />
<input type="hidden" name="code_challenge_method"
value={code_challenge_method} />
<input type="hidden" name="tenant_slug" value={tenant_slug} />
<label>Email</label>
<input name="email" type="email" required defaultValue={login_hint} />
<label>Password</label>
<input name="password" type="password" required />
<button type="submit">Sign In</button>
</form>
);
}Login handler (POST) See POST /api/mcp/login in the Endpoints guide.
Auth code store // lib/mcp-contract/auth-code-store.js (Next.js)
import { randomBytes, createHash } from "crypto";
const MCP_CONTRACT_AUTH_CODE_TTL_SEC = parseInt(
process.env.MCP_CONTRACT_AUTH_CODE_TTL_SEC ?? "60", 10
);
const codes = new Map();
function sweep() {
const now = Date.now();
for (const [code, record] of codes) {
if (record.expiresAt <= now) codes.delete(code);
}
}
export function issueAuthCode(userId, codeChallenge) {
sweep();
const code = randomBytes(32).toString("base64url");
codes.set(code, {
userId,
codeChallenge,
expiresAt: Date.now() + MCP_CONTRACT_AUTH_CODE_TTL_SEC * 1000,
});
return code;
}
export function consumeAuthCode(code, codeVerifier) {
sweep();
const record = codes.get(code);
if (!record || record.expiresAt <= Date.now()) {
codes.delete(code);
return null;
}
// PKCE S256: BASE64URL(SHA256(verifier)) must equal stored challenge
const computed = createHash("sha256")
.update(codeVerifier, "ascii")
.digest("base64url");
if (computed !== record.codeChallenge) return null;
codes.delete(code); // Single-use
return { userId: record.userId };
}Login page (Thymeleaf template) <!-- src/main/resources/templates/mcp-login.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><title>Sign In</title></head>
<body>
<h1>Sign In</h1>
<div th:if="${error}" style="color:red" th:text="${error}"></div>
<form method="POST" action="/api/mcp/login">
<input type="hidden" name="callback_url"
th:value="${callback_url}" />
<input type="hidden" name="state" th:value="${state}" />
<input type="hidden" name="code_challenge"
th:value="${code_challenge}" />
<input type="hidden" name="code_challenge_method"
th:value="${code_challenge_method}" />
<input type="hidden" name="tenant_slug"
th:value="${tenant_slug}" />
<label>Email</label>
<input name="email" type="email" required
th:value="${login_hint}" />
<label>Password</label>
<input name="password" type="password" required />
<button type="submit">Sign In</button>
</form>
</body>
</html>Login page controller (GET) // McpLoginPageController.java
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class McpLoginPageController {
private final McpContractProperties props;
public McpLoginPageController(McpContractProperties props) {
this.props = props;
}
@GetMapping("/mcp-login")
public String loginPage(
@RequestParam("callback_url") String callbackUrl,
@RequestParam("state") String state,
@RequestParam("code_challenge") String codeChallenge,
@RequestParam(value = "code_challenge_method",
defaultValue = "S256") String method,
@RequestParam(value = "tenant_slug",
required = false) String tenantSlug,
@RequestParam(value = "login_hint",
required = false) String loginHint,
Model model) {
if (!props.isAllowedCallbackUrl(callbackUrl)) {
model.addAttribute("error",
"Invalid callback URL — not in allowlist.");
return "mcp-login";
}
if (!"S256".equals(method)) {
model.addAttribute("error",
"Only code_challenge_method=S256 is supported.");
return "mcp-login";
}
model.addAttribute("callback_url", callbackUrl);
model.addAttribute("state", state);
model.addAttribute("code_challenge", codeChallenge);
model.addAttribute("code_challenge_method", method);
model.addAttribute("tenant_slug", tenantSlug);
model.addAttribute("login_hint", loginHint);
return "mcp-login";
}
}Login handler (POST) // McpLoginController.java
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.view.RedirectView;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.regex.Pattern;
@RestController
public class McpLoginController {
private static final Pattern CODE_CHALLENGE_RE =
Pattern.compile("^[A-Za-z0-9_-]{43}$");
private final McpContractProperties props;
private final AuthService authService;
private final AuthCodeStore authCodeStore;
public McpLoginController(McpContractProperties props,
AuthService authService,
AuthCodeStore authCodeStore) {
this.props = props;
this.authService = authService;
this.authCodeStore = authCodeStore;
}
@PostMapping("/api/mcp/login")
public RedirectView login(
@RequestParam("email") String email,
@RequestParam("password") String password,
@RequestParam("callback_url") String callbackUrl,
@RequestParam("state") String state,
@RequestParam("code_challenge") String codeChallenge) {
if (!props.isAllowedCallbackUrl(callbackUrl)) {
return errorRedirect(callbackUrl, state, "access_denied");
}
if (!CODE_CHALLENGE_RE.matcher(codeChallenge).matches()) {
return errorRedirect(callbackUrl, state, "access_denied");
}
// Authenticate user against your user directory
String userId;
try {
userId = authService.authenticate(email, password);
} catch (Exception e) {
return errorRedirect(callbackUrl, state, "access_denied");
}
// Issue auth code
String code = authCodeStore.issue(userId, codeChallenge);
var redirect = new RedirectView(
callbackUrl + "?state=" + enc(state) + "&code=" + enc(code));
redirect.setStatusCode(HttpStatus.FOUND);
return redirect;
}
private RedirectView errorRedirect(String callbackUrl,
String state, String error) {
var redirect = new RedirectView(
callbackUrl + "?state=" + enc(state) +
"&error=" + enc(error));
redirect.setStatusCode(HttpStatus.FOUND);
return redirect;
}
private static String enc(String s) {
return URLEncoder.encode(s, StandardCharsets.UTF_8);
}
}Auth code store // AuthCodeStore.java
import org.springframework.stereotype.Component;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class AuthCodeStore {
private static final SecureRandom RANDOM = new SecureRandom();
private final int ttlMs;
// In production, use Redis or a database instead
private final Map<String, CodeRecord> codes =
new ConcurrentHashMap<>();
public AuthCodeStore(McpContractProperties props) {
this.ttlMs = props.authCodeTtlSec() * 1000;
}
public String issue(String userId, String codeChallenge) {
sweep();
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
String code = Base64.getUrlEncoder()
.withoutPadding().encodeToString(bytes);
codes.put(code, new CodeRecord(
userId, codeChallenge, System.currentTimeMillis() + ttlMs));
return code;
}
public String consume(String code, String codeVerifier) {
sweep();
var record = codes.remove(code);
if (record == null ||
record.expiresAt <= System.currentTimeMillis()) {
return null;
}
// PKCE S256 verification
try {
var digest = MessageDigest.getInstance("SHA-256");
String computed = Base64.getUrlEncoder().withoutPadding()
.encodeToString(
digest.digest(
codeVerifier.getBytes(java.nio.charset.StandardCharsets.US_ASCII)));
if (!computed.equals(record.codeChallenge)) return null;
} catch (Exception e) {
return null;
}
return record.userId;
}
private void sweep() {
long now = System.currentTimeMillis();
codes.entrySet()
.removeIf(e -> e.getValue().expiresAt <= now);
}
private record CodeRecord(
String userId, String codeChallenge, long expiresAt) {}
}Login page (GET) // routes/mcp-login-page.ts
import { Router, Request, Response } from "express";
import { isAllowedCallbackUrl } from "../lib/mcp-contract/config";
const router = Router();
router.get("/mcp-login", (req: Request, res: Response) => {
const {
callback_url = "",
state = "",
code_challenge = "",
code_challenge_method = "S256",
tenant_slug = "",
login_hint = "",
} = req.query as Record<string, string>;
if (!callback_url || !isAllowedCallbackUrl(callback_url)) {
return res.render("mcp-login", {
error: "Invalid callback URL — not in allowlist.",
callback_url: "", state: "", code_challenge: "",
code_challenge_method: "", tenant_slug: "", login_hint: "",
});
}
if (code_challenge_method !== "S256") {
return res.render("mcp-login", {
error: "Only code_challenge_method=S256 is supported.",
callback_url, state, code_challenge, code_challenge_method,
tenant_slug, login_hint,
});
}
return res.render("mcp-login", {
error: null,
callback_url, state, code_challenge, code_challenge_method,
tenant_slug, login_hint,
});
});
export default router;Login handler (POST) See POST /api/mcp/login in the Endpoints guide.
Auth code store // lib/mcp-contract/auth-code-store.ts (Node.js / Express)
import { randomBytes, createHash } from "crypto";
import { MCP_CONTRACT_AUTH_CODE_TTL_SEC } from "./config";
interface CodeRecord {
userId: string;
codeChallenge: string;
expiresAt: number;
}
const codes = new Map<string, CodeRecord>();
function sweep(): void {
const now = Date.now();
for (const [code, record] of codes) {
if (record.expiresAt <= now) codes.delete(code);
}
}
export function issueAuthCode(userId: string, codeChallenge: string): string {
sweep();
const code = randomBytes(32).toString("base64url");
codes.set(code, {
userId,
codeChallenge,
expiresAt: Date.now() + MCP_CONTRACT_AUTH_CODE_TTL_SEC * 1000,
});
return code;
}
export function consumeAuthCode(
code: string, codeVerifier: string
): { userId: string } | null {
sweep();
const record = codes.get(code);
if (!record || record.expiresAt <= Date.now()) {
codes.delete(code);
return null;
}
// PKCE S256: BASE64URL(SHA256(verifier)) must equal stored challenge
const computed = createHash("sha256")
.update(codeVerifier, "ascii")
.digest("base64url");
if (computed !== record.codeChallenge) return null;
codes.delete(code); // Single-use
return { userId: record.userId };
}Login page (GET) // routes/mcp-login-page.js
const { Router } = require("express");
const { isAllowedCallbackUrl } = require("../lib/mcp-contract/config");
const router = Router();
router.get("/mcp-login", (req, res) => {
const {
callback_url = "",
state = "",
code_challenge = "",
code_challenge_method = "S256",
tenant_slug = "",
login_hint = "",
} = req.query;
// Open-redirect defense
if (!callback_url || !isAllowedCallbackUrl(callback_url)) {
return res.render("mcp-login", {
error: "Invalid callback URL — not in allowlist.",
callback_url: "", state: "", code_challenge: "",
code_challenge_method: "", tenant_slug: "", login_hint: "",
});
}
if (code_challenge_method && code_challenge_method !== "S256") {
return res.render("mcp-login", {
error: "Only code_challenge_method=S256 is supported.",
callback_url, state, code_challenge, code_challenge_method,
tenant_slug, login_hint,
});
}
return res.render("mcp-login", {
error: null,
callback_url, state, code_challenge, code_challenge_method,
tenant_slug, login_hint,
});
});
module.exports = router;<!-- views/mcp-login.ejs -->
<!DOCTYPE html>
<html>
<head><title>Sign In</title></head>
<body>
<h1>Sign In</h1>
<% if (error) { %><p style="color:red"><%= error %></p><% } %>
<form method="POST" action="/api/mcp/login">
<input type="hidden" name="callback_url" value="<%= callback_url %>">
<input type="hidden" name="state" value="<%= state %>">
<input type="hidden" name="code_challenge" value="<%= code_challenge %>">
<input type="hidden" name="code_challenge_method" value="<%= code_challenge_method %>">
<input type="hidden" name="tenant_slug" value="<%= tenant_slug %>">
<label>Email</label>
<input name="email" type="email" required value="<%= login_hint %>">
<label>Password</label>
<input name="password" type="password" required>
<button type="submit">Sign In</button>
</form>
</body>
</html>Login handler (POST) See POST /api/mcp/login in the Endpoints guide.
Auth code store // lib/mcp-contract/auth-code-store.js
const { randomBytes, createHash } = require("crypto");
const { MCP_CONTRACT_AUTH_CODE_TTL_SEC } = require("./config");
const codes = new Map();
function sweep() {
const now = Date.now();
for (const [code, record] of codes) {
if (record.expiresAt <= now) codes.delete(code);
}
}
function issueAuthCode(userId, codeChallenge) {
sweep();
const code = randomBytes(32).toString("base64url");
codes.set(code, {
userId,
codeChallenge,
expiresAt: Date.now() + MCP_CONTRACT_AUTH_CODE_TTL_SEC * 1000,
});
return code;
}
function consumeAuthCode(code, codeVerifier) {
sweep();
const record = codes.get(code);
if (!record || record.expiresAt <= Date.now()) {
codes.delete(code);
return null;
}
// PKCE S256: BASE64URL(SHA256(verifier)) must equal stored challenge
const computed = createHash("sha256")
.update(codeVerifier, "ascii")
.digest("base64url");
if (computed !== record.codeChallenge) return null;
codes.delete(code); // Single-use
return { userId: record.userId };
}
module.exports = { issueAuthCode, consumeAuthCode };Login page (GET) # routers/mcp_login_page.py
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from mcp_contract.config import is_allowed_callback_url
router = APIRouter()
templates = Jinja2Templates(directory="templates")
@router.get("/mcp-login", response_class=HTMLResponse)
async def mcp_login_page(
request: Request,
callback_url: str = "",
state: str = "",
code_challenge: str = "",
code_challenge_method: str = "S256",
tenant_slug: str = "",
login_hint: str = "",
):
# Open-redirect defense
if not callback_url or not is_allowed_callback_url(callback_url):
return templates.TemplateResponse(
"mcp_login.html",
{"request": request, "error": "Invalid callback URL — not in allowlist."},
)
if code_challenge_method != "S256":
return templates.TemplateResponse(
"mcp_login.html",
{"request": request,
"error": "Only code_challenge_method=S256 is supported."},
)
return templates.TemplateResponse(
"mcp_login.html",
{
"request": request,
"callback_url": callback_url,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"tenant_slug": tenant_slug,
"login_hint": login_hint,
"error": None,
},
){# templates/mcp_login.html #}
<!DOCTYPE html>
<html>
<head><title>Sign In</title></head>
<body>
<h1>Sign In</h1>
{% if error %}<p style="color:red">{{ error }}</p>{% endif %}
<form method="POST" action="/api/mcp/login">
<input type="hidden" name="callback_url" value="{{ callback_url }}">
<input type="hidden" name="state" value="{{ state }}">
<input type="hidden" name="code_challenge" value="{{ code_challenge }}">
<input type="hidden" name="code_challenge_method" value="{{ code_challenge_method }}">
<input type="hidden" name="tenant_slug" value="{{ tenant_slug }}">
<label>Email</label>
<input name="email" type="email" required value="{{ login_hint }}">
<label>Password</label>
<input name="password" type="password" required>
<button type="submit">Sign In</button>
</form>
</body>
</html>