Endpoints

Your tenant app must implement 5 API endpoints. The hub calls these endpoints server-to-server (except the login page, which the user's browser visits). Two additional optional endpoints (GET /api/mcp/users and GET /api/mcp/roles) can be implemented to enable LockMCP to auto-populate the member and role directories.

Applies to Tenant Delegated connections only. Identity Brokering and Token Exchange connections don't require you to host any endpoints.

This is a how-to guide. The Contract Specification is the normative reference for request/response schemas and status codes.

EndpointHMAC signed?Purpose
POST /api/mcp/loginNoForm POST from login page — issues auth code
POST /api/mcp/exchangeYesExchange auth code + PKCE verifier for session token
POST /api/mcp/validateYesValidate a session token, return TTL + roles
POST /api/mcp/refreshYesRotate session token atomically
POST /api/mcp/logoutYesRevoke a session token
GET /api/mcp/usersTENANT_DELEGATED onlyOptional. Return list of tenant users
GET /api/mcp/rolesTENANT_DELEGATED onlyOptional. Return list of tenant roles

POST /api/mcp/login

Handles the form POST from the login page. Authenticates the user, issues a short-lived auth code, and redirects back to the hub's callback URL with the code.

HMAC: Not required (this is a browser form submission, not a server-to-server call).

Request: application/x-www-form-urlencoded (form data)

FieldDescription
emailUser's email address
passwordUser's password
callback_urlHub callback URL (must be in allowlist)
stateOpaque state string — pass back unchanged
code_challengePKCE S256 challenge (43-char base64url)

Success: 302 redirect to {callback_url}?state={state}&code={code}

Failure: 302 redirect to {callback_url}?state={state}&error=access_denied

javascript
// 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";

// PKCE S256 challenge is always 43 chars of base64url
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 });
  }

  // Open-redirect defense
  if (!isAllowedCallbackUrl(callbackUrl)) {
    return NextResponse.json(
      { error: "callback_url host not allowed" }, { status: 400 });
  }

  // PKCE S256 format check
  if (!CODE_CHALLENGE_RE.test(codeChallenge)) {
    return NextResponse.json(
      { error: "Invalid code_challenge: must be 43-char base64url (S256)" },
      { 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 {
    // Auth failed — redirect with error
    const errorUrl = new URL(callbackUrl);
    errorUrl.searchParams.set("state", state);
    errorUrl.searchParams.set("error", "access_denied");
    return NextResponse.redirect(errorUrl.toString(), { status: 302 });
  }

  // Issue auth code and redirect back to hub
  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 });
}
javascript
// app/api/mcp/login/route.js
import { 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) {
  const formData = await request.formData();

  const email = formData.get("email");
  const password = formData.get("password");
  const callbackUrl = formData.get("callback_url");
  const state = formData.get("state");
  const codeChallenge = formData.get("code_challenge");

  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: must be 43-char base64url (S256)" },
      { status: 400 });
  }

  let userId;
  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 });
}
java
// 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) {

        // Open-redirect defense
        if (!props.isAllowedCallbackUrl(callbackUrl)) {
            return errorRedirect(callbackUrl, state, "access_denied");
        }

        // PKCE S256 format check
        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 and redirect back to hub
        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);
    }
}
typescript
// routes/mcp-login.ts
import { Router } from "express";
import { issueAuthCode } from "../lib/mcp-contract/auth-code-store";
import { isAllowedCallbackUrl } from "../lib/mcp-contract/config";
import { authenticateUser } from "../lib/auth"; // replace with your auth logic

const router = Router();
const CODE_CHALLENGE_RE = /^[A-Za-z0-9_-]{43}$/;

router.post("/api/mcp/login",
  require("express").urlencoded({ extended: false }),
  async (req, res) => {
    const { email, password, callback_url, state, code_challenge } = req.body as {
      email: string; password: string; callback_url: string;
      state: string; code_challenge: string;
    };

    if (!email || !password || !callback_url || !state || !code_challenge) {
      return res.status(400).json({ error: "Missing required fields" });
    }

    if (!isAllowedCallbackUrl(callback_url)) {
      return res.redirect(302,
        `${callback_url}?state=${encodeURIComponent(state)}&error=access_denied`);
    }

    if (!CODE_CHALLENGE_RE.test(code_challenge)) {
      return res.redirect(302,
        `${callback_url}?state=${encodeURIComponent(state)}&error=access_denied`);
    }

    try {
      const userId = await authenticateUser(email, password);
      const code = issueAuthCode(userId, code_challenge);
      return res.redirect(302,
        `${callback_url}?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}`);
    } catch {
      return res.redirect(302,
        `${callback_url}?state=${encodeURIComponent(state)}&error=access_denied`);
    }
  }
);

export default router;
javascript
// routes/mcp-login.js
const { Router } = require("express");
const { issueAuthCode } = require("../lib/mcp-contract/auth-code-store");
const { isAllowedCallbackUrl } = require("../lib/mcp-contract/config");
const { authenticateUser } = require("../lib/auth"); // replace with your auth logic

const router = Router();
const CODE_CHALLENGE_RE = /^[A-Za-z0-9_-]{43}$/;

router.post("/api/mcp/login",
  require("express").urlencoded({ extended: false }),
  async (req, res) => {
    const { email, password, callback_url, state, code_challenge } = req.body;

    if (!email || !password || !callback_url || !state || !code_challenge) {
      return res.status(400).json({ error: "Missing required fields" });
    }

    // Open-redirect defense
    if (!isAllowedCallbackUrl(callback_url)) {
      return res.redirect(302,
        `${callback_url}?state=${encodeURIComponent(state)}&error=access_denied`);
    }

    // PKCE S256 format check
    if (!CODE_CHALLENGE_RE.test(code_challenge)) {
      return res.redirect(302,
        `${callback_url}?state=${encodeURIComponent(state)}&error=access_denied`);
    }

    try {
      const userId = await authenticateUser(email, password);
      const code = issueAuthCode(userId, code_challenge);
      return res.redirect(302,
        `${callback_url}?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}`);
    } catch {
      return res.redirect(302,
        `${callback_url}?state=${encodeURIComponent(state)}&error=access_denied`);
    }
  }
);

module.exports = router;
python
# routers/mcp_login.py
import re
from fastapi import APIRouter, Form
from fastapi.responses import RedirectResponse
from mcp_contract.config import is_allowed_callback_url
from mcp_contract.auth_code_store import issue_auth_code
from your_auth import authenticate_user  # replace with your auth logic

router = APIRouter()
CODE_CHALLENGE_RE = re.compile(r"^[A-Za-z0-9_-]{43}$")


@router.post("/api/mcp/login")
async def mcp_login(
    email: str = Form(...),
    password: str = Form(...),
    callback_url: str = Form(...),
    state: str = Form(...),
    code_challenge: str = Form(...),
):
    # Open-redirect defense
    if not is_allowed_callback_url(callback_url):
        return RedirectResponse(
            f"{callback_url}?state={state}&error=access_denied",
            status_code=302,
        )

    # PKCE S256 format check
    if not CODE_CHALLENGE_RE.match(code_challenge):
        return RedirectResponse(
            f"{callback_url}?state={state}&error=access_denied",
            status_code=302,
        )

    try:
        user_id = authenticate_user(email, password)
    except Exception:
        return RedirectResponse(
            f"{callback_url}?state={state}&error=access_denied",
            status_code=302,
        )

    # Issue auth code and redirect back to hub
    code = issue_auth_code(user_id, code_challenge)
    return RedirectResponse(
        f"{callback_url}?state={state}&code={code}", status_code=302
    )

POST /api/mcp/exchange

Exchanges an auth code (from the login flow) and a PKCE code verifier for a session token.

Request headers: HMAC signature headers (X-Hub-Timestamp, X-Hub-Nonce, X-Hub-Signature-256)

Request body:

json
{
  "code": "base64url-encoded-auth-code",
  "code_verifier": "original-PKCE-verifier-string"
}

Success response (200):

json
{
  "user_id": "user-uuid",
  "email": "user@example.com",
  "session_token": "mcp:a1b2c3d4...",
  "expires_in": 3600,
  "roles": ["admin"],
  "display_name": "Jane Doe",
  "enum_scopes": {
    "email": "user@example.com",
    "accounts": [
      {
        "accountId": 456,
        "roles": [
          { "role": "LEASING_AGENT", "propertyIds": [101, 102] }
        ]
      }
    ]
  }
}

enum_scopes is optional — include it to enable enum injection. The hub stores this payload on the session record and uses it to inject enum constraints into tool parameters marked with x-mcp-inject. The key names inside each role entry (e.g. propertyIds) must match the x-mcp-inject values in your OpenAPI spec. Omit enum_scopes entirely if your tenant doesn't use enum injection.

getUserEnumScopes (shown in the examples below) is a placeholder for your own data layer — implement it to query your database for the user's accounts and roles. See Enum Injection for the expected payload shape.

Error responses:

  • 401 — invalid/expired code, PKCE mismatch, HMAC failure
  • 400 — missing code or code_verifier
javascript
// app/api/mcp/exchange/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyHubSignature } from "@/lib/mcp-contract/hmac";
import { consumeAuthCode } from "@/lib/mcp-contract/auth-code-store";
import { createMcpSession } from "@/lib/mcp-contract/session-token";
import { getUserRolesForMcp } from "@/lib/mcp-contract/role-mapping";
import { getUserEnumScopes } from "@/lib/mcp-contract/enum-scopes"; // optional — your own implementation
import { prisma } from "@/lib/prisma";

const SESSION_TTL_SEC = parseInt(
  process.env.MCP_CONTRACT_SESSION_TTL_SEC ?? "3600", 10);

export async function POST(request: NextRequest) {
  const rawBody = await request.text();

  const hmacResult = await verifyHubSignature(
    "POST", request.url, rawBody, request.headers);
  if (!hmacResult.ok) {
    return NextResponse.json(
      { error: hmacResult.error }, { status: hmacResult.status });
  }

  let body: { code?: string; code_verifier?: string };
  try { body = JSON.parse(rawBody); }
  catch { return NextResponse.json(
    { error: "Invalid JSON" }, { status: 400 }); }

  const { code, code_verifier } = body;
  if (!code || !code_verifier) {
    return NextResponse.json(
      { error: "Missing code or code_verifier" }, { status: 400 });
  }

  const record = consumeAuthCode(code, code_verifier);
  if (!record) {
    return NextResponse.json(
      { error: "invalid_code" }, { status: 401 });
  }

  const user = await prisma.user.findUnique({
    where: { id: record.userId },
  });
  if (!user) {
    return NextResponse.json(
      { error: "user_not_found" }, { status: 401 });
  }

  const [{ token, expiresIn }, roles, enumScopes] = await Promise.all([
    createMcpSession(user.id, SESSION_TTL_SEC),
    getUserRolesForMcp(user.id),
    getUserEnumScopes(user.id), // returns null if not using enum injection
  ]);

  return NextResponse.json({
    user_id: user.id,
    email: user.email,
    session_token: token,
    expires_in: expiresIn,
    roles,
    display_name: user.name,
    ...(enumScopes && { enum_scopes: enumScopes }),
  });
}
javascript
// app/api/mcp/exchange/route.js
import { NextResponse } from "next/server";
import { verifyHubSignature } from "@/lib/mcp-contract/hmac";
import { consumeAuthCode } from "@/lib/mcp-contract/auth-code-store";
import { createMcpSession } from "@/lib/mcp-contract/session-token";
import { getUserRolesForMcp } from "@/lib/mcp-contract/role-mapping";
import { getUserEnumScopes } from "@/lib/mcp-contract/enum-scopes"; // optional — your own implementation
import { prisma } from "@/lib/prisma";
import { MCP_CONTRACT_SESSION_TTL_SEC } from "@/lib/mcp-contract/config";

export async function POST(request) {
  const rawBody = await request.text();

  const hmacResult = await verifyHubSignature(
    "POST", request.url, rawBody, request.headers);
  if (!hmacResult.ok) {
    return NextResponse.json(
      { error: hmacResult.error }, { status: hmacResult.status });
  }

  let body;
  try { body = JSON.parse(rawBody); }
  catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); }

  const { code, code_verifier } = body;
  if (!code || !code_verifier) {
    return NextResponse.json(
      { error: "Missing code or code_verifier" }, { status: 400 });
  }

  const record = consumeAuthCode(code, code_verifier);
  if (!record) {
    return NextResponse.json({ error: "invalid_code" }, { status: 401 });
  }

  const user = await prisma.user.findUnique({ where: { id: record.userId } });
  if (!user) {
    return NextResponse.json({ error: "user_not_found" }, { status: 401 });
  }

  const [{ token, expiresIn }, roles, enumScopes] = await Promise.all([
    createMcpSession(user.id, MCP_CONTRACT_SESSION_TTL_SEC),
    getUserRolesForMcp(user.id),
    getUserEnumScopes(user.id),
  ]);

  return NextResponse.json({
    user_id: user.id,
    email: user.email,
    session_token: token,
    expires_in: expiresIn,
    roles,
    display_name: user.name,
    ...(enumScopes && { enum_scopes: enumScopes }),
  });
}
java
// McpExchangeController.java
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;

@RestController
public class McpExchangeController {

    private final AuthCodeStore authCodeStore;
    private final SessionTokenService sessionService;
    private final UserRepository userRepo;
    private final McpContractProperties props;
    private final EnumScopesService enumScopesService; // optional — your own implementation; remove if not using enum injection

    public McpExchangeController(AuthCodeStore authCodeStore,
                                  SessionTokenService sessionService,
                                  UserRepository userRepo,
                                  McpContractProperties props,
                                  EnumScopesService enumScopesService) {
        this.authCodeStore = authCodeStore;
        this.sessionService = sessionService;
        this.userRepo = userRepo;
        this.props = props;
        this.enumScopesService = enumScopesService;
    }

    // HMAC verification is handled by HmacVerificationFilter

    @PostMapping("/api/mcp/exchange")
    public ResponseEntity<?> exchange(
            @RequestBody ExchangeRequest req) {

        if (req.code() == null || req.code_verifier() == null) {
            return ResponseEntity.badRequest()
                .body(Map.of("error", "Missing code or code_verifier"));
        }

        // Consume auth code + verify PKCE
        String userId = authCodeStore.consume(
            req.code(), req.code_verifier());
        if (userId == null) {
            return ResponseEntity.status(401)
                .body(Map.of("error", "invalid_code"));
        }

        var user = userRepo.findById(userId).orElse(null);
        if (user == null) {
            return ResponseEntity.status(401)
                .body(Map.of("error", "user_not_found"));
        }

        var token = sessionService.createSession(
            userId, props.sessionTtlSec());

        // optional — omit enumScopesService entirely if not using enum injection
        var enumScopes = enumScopesService.getUserEnumScopes(userId);

        var response = new java.util.LinkedHashMap<String, Object>();
        response.put("user_id", user.getId());
        response.put("email", user.getEmail());
        response.put("session_token", token.token());
        response.put("expires_in", token.expiresIn());
        response.put("roles", user.getRoles());
        response.put("display_name", user.getDisplayName());
        if (enumScopes != null) response.put("enum_scopes", enumScopes);
        return ResponseEntity.ok(response);
    }

    record ExchangeRequest(String code, String code_verifier) {}
}
typescript
// routes/mcp-exchange.ts
import { Router } from "express";
import { hmacMiddleware } from "../lib/mcp-contract/hmac";
import { consumeAuthCode } from "../lib/mcp-contract/auth-code-store";
import { createMcpSession } from "../lib/mcp-contract/session-token";
import { getUserRolesForMcp } from "../lib/mcp-contract/role-mapping";
import { getUserEnumScopes } from "../lib/mcp-contract/enum-scopes"; // optional — your own implementation
import { prisma } from "../lib/prisma";
import { MCP_CONTRACT_SESSION_TTL_SEC } from "../lib/mcp-contract/config";

const router = Router();

router.post("/api/mcp/exchange", hmacMiddleware, async (req, res) => {
  const { code, code_verifier } = req.body as {
    code: string; code_verifier: string;
  };

  if (!code || !code_verifier) {
    return res.status(400).json({ error: "Missing code or code_verifier" });
  }

  const record = consumeAuthCode(code, code_verifier);
  if (!record) return res.status(401).json({ error: "invalid_code" });

  const user = await prisma.user.findUnique({ where: { id: record.userId } });
  if (!user) return res.status(401).json({ error: "user_not_found" });

  const [{ token, expiresIn }, roles, enumScopes] = await Promise.all([
    createMcpSession(user.id, MCP_CONTRACT_SESSION_TTL_SEC),
    getUserRolesForMcp(user.id),
    getUserEnumScopes(user.id),
  ]);

  return res.json({
    user_id: user.id,
    email: user.email,
    session_token: token,
    expires_in: expiresIn,
    roles,
    display_name: user.name,
    ...(enumScopes && { enum_scopes: enumScopes }),
  });
});

export default router;
javascript
// routes/mcp-exchange.js
const { Router } = require("express");
const { hmacMiddleware } = require("../lib/mcp-contract/hmac");
const { consumeAuthCode } = require("../lib/mcp-contract/auth-code-store");
const { createMcpSession } = require("../lib/mcp-contract/session-token");
const { getUserRolesForMcp } = require("../lib/mcp-contract/role-mapping");
const { getUserEnumScopes } = require("../lib/mcp-contract/enum-scopes"); // optional — your own implementation
const { prisma } = require("../lib/prisma");
const { MCP_CONTRACT_SESSION_TTL_SEC } = require("../lib/mcp-contract/config");

const router = Router();

router.post("/api/mcp/exchange", hmacMiddleware, async (req, res) => {
  const { code, code_verifier } = req.body;

  if (!code || !code_verifier) {
    return res.status(400).json({ error: "Missing code or code_verifier" });
  }

  const record = consumeAuthCode(code, code_verifier);
  if (!record) return res.status(401).json({ error: "invalid_code" });

  const user = await prisma.user.findUnique({ where: { id: record.userId } });
  if (!user) return res.status(401).json({ error: "user_not_found" });

  const [{ token, expiresIn }, roles, enumScopes] = await Promise.all([
    createMcpSession(user.id, MCP_CONTRACT_SESSION_TTL_SEC),
    getUserRolesForMcp(user.id),
    getUserEnumScopes(user.id),
  ]);

  return res.json({
    user_id: user.id,
    email: user.email,
    session_token: token,
    expires_in: expiresIn,
    roles,
    display_name: user.name,
    ...(enumScopes && { enum_scopes: enumScopes }),
  });
});

module.exports = router;
python
# routers/mcp_exchange.py
import os
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from mcp_contract.hmac_verify import verify_hub_signature
from mcp_contract.auth_code_store import consume_auth_code
from mcp_contract.session_token import create_mcp_session
from your_db import get_db          # replace with your DB session factory
from your_users import get_user, get_user_roles  # replace with your lookups
# Optional — only needed for enum injection:
from your_roles import get_user_enum_scopes

router = APIRouter()
SESSION_TTL_SEC = int(os.environ.get("MCP_CONTRACT_SESSION_TTL_SEC", "3600"))


class ExchangeRequest(BaseModel):
    code: str
    code_verifier: str


@router.post("/api/mcp/exchange", dependencies=[Depends(verify_hub_signature)])
async def mcp_exchange(body: ExchangeRequest, db=Depends(get_db)):
    record = consume_auth_code(body.code, body.code_verifier)
    if not record:
        raise HTTPException(401, detail="invalid_code")

    user = get_user(db, record["user_id"])
    if not user:
        raise HTTPException(401, detail="user_not_found")

    session = create_mcp_session(db, user.id, SESSION_TTL_SEC)
    roles = get_user_roles(db, user.id)
    enum_scopes = get_user_enum_scopes(db, user.id)  # optional — returns None if not using enum injection

    response = {
        "user_id": user.id,
        "email": user.email,
        "session_token": session["token"],
        "expires_in": session["expires_in"],
        "roles": roles,
        "display_name": user.display_name,
    }
    if enum_scopes:
        response["enum_scopes"] = enum_scopes
    return response

POST /api/mcp/validate

Validates a session token and returns whether it's valid, the remaining TTL, and the user's roles.

Request body:

json
{ "session_token": "mcp:a1b2c3d4..." }

Response (200) — valid token:

json
{ "valid": true, "remaining_seconds": 3542, "roles": ["admin"] }

Response (200) — expired/invalid token:

json
{ "valid": false, "remaining_seconds": 0 }
javascript
// app/api/mcp/validate/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyHubSignature } from "@/lib/mcp-contract/hmac";
import { findMcpSession } from "@/lib/mcp-contract/session-token";
import { getUserRolesForMcp } from "@/lib/mcp-contract/role-mapping";
import { prisma } from "@/lib/prisma";

export async function POST(request: NextRequest) {
  const rawBody = await request.text();

  const hmacResult = await verifyHubSignature(
    "POST", request.url, rawBody, request.headers);
  if (!hmacResult.ok) {
    return NextResponse.json(
      { error: hmacResult.error }, { status: hmacResult.status });
  }

  let body: { session_token?: string };
  try { body = JSON.parse(rawBody); }
  catch { return NextResponse.json(
    { error: "Invalid JSON" }, { status: 400 }); }

  const { session_token } = body;
  if (!session_token) {
    return NextResponse.json(
      { error: "Missing session_token" }, { status: 400 });
  }

  const session = await findMcpSession(session_token);
  if (!session) {
    return NextResponse.json(
      { valid: false, remaining_seconds: 0 });
  }

  // Verify user still exists
  const user = await prisma.user.findUnique({
    where: { id: session.userId },
    select: { id: true },
  });
  if (!user) {
    return NextResponse.json(
      { valid: false, remaining_seconds: 0 });
  }

  const remainingMs = session.expiresAt.getTime() - Date.now();
  const remainingSeconds = Math.floor(remainingMs / 1000);
  const roles = await getUserRolesForMcp(session.userId);

  return NextResponse.json({
    valid: true, remaining_seconds: remainingSeconds, roles,
  });
}
javascript
// app/api/mcp/validate/route.js
import { NextResponse } from "next/server";
import { verifyHubSignature } from "@/lib/mcp-contract/hmac";
import { findMcpSession } from "@/lib/mcp-contract/session-token";
import { getUserRolesForMcp } from "@/lib/mcp-contract/role-mapping";
import { prisma } from "@/lib/prisma";

export async function POST(request) {
  const rawBody = await request.text();

  const hmacResult = await verifyHubSignature(
    "POST", request.url, rawBody, request.headers);
  if (!hmacResult.ok) {
    return NextResponse.json(
      { error: hmacResult.error }, { status: hmacResult.status });
  }

  let body;
  try { body = JSON.parse(rawBody); }
  catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); }

  const { session_token } = body;
  if (!session_token) {
    return NextResponse.json({ error: "Missing session_token" }, { status: 400 });
  }

  const session = await findMcpSession(session_token);
  if (!session) {
    return NextResponse.json({ valid: false, remaining_seconds: 0 });
  }

  const user = await prisma.user.findUnique({
    where: { id: session.userId },
    select: { id: true },
  });
  if (!user) {
    return NextResponse.json({ valid: false, remaining_seconds: 0 });
  }

  const remainingMs = session.expiresAt.getTime() - Date.now();
  const remainingSeconds = Math.floor(remainingMs / 1000);
  const roles = await getUserRolesForMcp(session.userId);

  return NextResponse.json({
    valid: true, remaining_seconds: remainingSeconds, roles,
  });
}
java
// McpValidateController.java
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.List;
import java.util.Map;

@RestController
public class McpValidateController {

    private final SessionTokenService sessionService;
    private final UserRepository userRepo;

    public McpValidateController(SessionTokenService sessionService,
                                  UserRepository userRepo) {
        this.sessionService = sessionService;
        this.userRepo = userRepo;
    }

    @PostMapping("/api/mcp/validate")
    public ResponseEntity<?> validate(
            @RequestBody Map<String, String> body) {

        String token = body.get("session_token");
        if (token == null || token.isBlank()) {
            return ResponseEntity.badRequest()
                .body(Map.of("error", "Missing session_token"));
        }

        var session = sessionService.findSession(token).orElse(null);
        if (session == null) {
            return ResponseEntity.ok(Map.of(
                "valid", false, "remaining_seconds", 0));
        }

        // Verify user still exists
        if (!userRepo.existsById(session.getUserId())) {
            return ResponseEntity.ok(Map.of(
                "valid", false, "remaining_seconds", 0));
        }

        long remaining = session.getExpiresAt().getEpochSecond()
            - Instant.now().getEpochSecond();
        List<String> roles = userRepo.findById(session.getUserId())
            .map(u -> u.getRoles())
            .orElse(List.of());

        return ResponseEntity.ok(Map.of(
            "valid", true,
            "remaining_seconds", Math.max(0, remaining),
            "roles", roles
        ));
    }
}
typescript
// routes/mcp-validate.ts
import { Router } from "express";
import { hmacMiddleware } from "../lib/mcp-contract/hmac";
import { findMcpSession } from "../lib/mcp-contract/session-token";
import { getUserRolesForMcp } from "../lib/mcp-contract/role-mapping";
import { prisma } from "../lib/prisma";

const router = Router();

router.post("/api/mcp/validate", hmacMiddleware, async (req, res) => {
  const { session_token } = req.body as { session_token: string };
  if (!session_token) {
    return res.status(400).json({ error: "Missing session_token" });
  }

  const session = await findMcpSession(session_token);
  if (!session) {
    return res.json({ valid: false, remaining_seconds: 0 });
  }

  const user = await prisma.user.findUnique({
    where: { id: session.userId },
    select: { id: true },
  });
  if (!user) {
    return res.json({ valid: false, remaining_seconds: 0 });
  }

  const remainingMs = session.expiresAt.getTime() - Date.now();
  const remainingSeconds = Math.floor(remainingMs / 1000);
  const roles = await getUserRolesForMcp(session.userId);

  return res.json({ valid: true, remaining_seconds: remainingSeconds, roles });
});

export default router;
javascript
// routes/mcp-validate.js
const { Router } = require("express");
const { hmacMiddleware } = require("../lib/mcp-contract/hmac");
const { findMcpSession } = require("../lib/mcp-contract/session-token");
const { getUserRolesForMcp } = require("../lib/mcp-contract/role-mapping");
const { prisma } = require("../lib/prisma");

const router = Router();

router.post("/api/mcp/validate", hmacMiddleware, async (req, res) => {
  const { session_token } = req.body;
  if (!session_token) {
    return res.status(400).json({ error: "Missing session_token" });
  }

  const session = await findMcpSession(session_token);
  if (!session) {
    return res.json({ valid: false, remaining_seconds: 0 });
  }

  // Verify user still exists
  const user = await prisma.user.findUnique({
    where: { id: session.userId },
    select: { id: true },
  });
  if (!user) {
    return res.json({ valid: false, remaining_seconds: 0 });
  }

  const remainingMs = session.expiresAt.getTime() - Date.now();
  const remainingSeconds = Math.floor(remainingMs / 1000);
  const roles = await getUserRolesForMcp(session.userId);

  return res.json({ valid: true, remaining_seconds: remainingSeconds, roles });
});

module.exports = router;
python
# routers/mcp_validate.py
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from mcp_contract.hmac_verify import verify_hub_signature
from mcp_contract.session_token import find_mcp_session
from your_db import get_db
from your_users import get_user_roles, user_exists

router = APIRouter()


class ValidateRequest(BaseModel):
    session_token: str


@router.post("/api/mcp/validate", dependencies=[Depends(verify_hub_signature)])
async def mcp_validate(body: ValidateRequest, db=Depends(get_db)):
    session = find_mcp_session(db, body.session_token)
    if not session or not user_exists(db, session.user_id):
        return {"valid": False, "remaining_seconds": 0}

    remaining = (
        session.expires_at - datetime.now(timezone.utc)
    ).total_seconds()
    roles = get_user_roles(db, session.user_id)

    return {
        "valid": True,
        "remaining_seconds": max(0, int(remaining)),
        "roles": roles,
    }

POST /api/mcp/refresh

Rotates a session token atomically: deletes the old token and creates a new one in a single transaction.

Request body:

json
{ "session_token": "mcp:old-token..." }

Success response (200):

json
{ "session_token": "mcp:new-token...", "expires_in": 3600 }

Error response (401):

json
{ "error": "session_expired" }
javascript
// app/api/mcp/refresh/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyHubSignature } from "@/lib/mcp-contract/hmac";
import { refreshMcpSession } from "@/lib/mcp-contract/session-token";

const SESSION_TTL_SEC = parseInt(
  process.env.MCP_CONTRACT_SESSION_TTL_SEC ?? "3600", 10);

export async function POST(request: NextRequest) {
  const rawBody = await request.text();

  const hmacResult = await verifyHubSignature(
    "POST", request.url, rawBody, request.headers);
  if (!hmacResult.ok) {
    return NextResponse.json(
      { error: hmacResult.error }, { status: hmacResult.status });
  }

  let body: { session_token?: string };
  try { body = JSON.parse(rawBody); }
  catch { return NextResponse.json(
    { error: "Invalid JSON" }, { status: 400 }); }

  const { session_token } = body;
  if (!session_token) {
    return NextResponse.json(
      { error: "Missing session_token" }, { status: 400 });
  }

  const result = await refreshMcpSession(
    session_token, SESSION_TTL_SEC);
  if (!result) {
    return NextResponse.json(
      { error: "session_expired" }, { status: 401 });
  }

  return NextResponse.json({
    session_token: result.token,
    expires_in: result.expiresIn,
  });
}
javascript
// app/api/mcp/refresh/route.js
import { NextResponse } from "next/server";
import { verifyHubSignature } from "@/lib/mcp-contract/hmac";
import { refreshMcpSession } from "@/lib/mcp-contract/session-token";
import { MCP_CONTRACT_SESSION_TTL_SEC } from "@/lib/mcp-contract/config";

export async function POST(request) {
  const rawBody = await request.text();

  const hmacResult = await verifyHubSignature(
    "POST", request.url, rawBody, request.headers);
  if (!hmacResult.ok) {
    return NextResponse.json(
      { error: hmacResult.error }, { status: hmacResult.status });
  }

  let body;
  try { body = JSON.parse(rawBody); }
  catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); }

  const { session_token } = body;
  if (!session_token) {
    return NextResponse.json({ error: "Missing session_token" }, { status: 400 });
  }

  const result = await refreshMcpSession(session_token, MCP_CONTRACT_SESSION_TTL_SEC);
  if (!result) {
    return NextResponse.json({ error: "session_expired" }, { status: 401 });
  }

  return NextResponse.json({
    session_token: result.token, expires_in: result.expiresIn,
  });
}
java
// McpRefreshController.java
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;

@RestController
public class McpRefreshController {

    private final SessionTokenService sessionService;
    private final McpContractProperties props;

    public McpRefreshController(SessionTokenService sessionService,
                                 McpContractProperties props) {
        this.sessionService = sessionService;
        this.props = props;
    }

    @PostMapping("/api/mcp/refresh")
    public ResponseEntity<?> refresh(
            @RequestBody Map<String, String> body) {

        String token = body.get("session_token");
        if (token == null || token.isBlank()) {
            return ResponseEntity.badRequest()
                .body(Map.of("error", "Missing session_token"));
        }

        var result = sessionService.refreshSession(
            token, props.sessionTtlSec());

        if (result.isEmpty()) {
            return ResponseEntity.status(401)
                .body(Map.of("error", "session_expired"));
        }

        var tr = result.get();
        return ResponseEntity.ok(Map.of(
            "session_token", tr.token(),
            "expires_in", tr.expiresIn()
        ));
    }
}
typescript
// routes/mcp-refresh.ts
import { Router } from "express";
import { hmacMiddleware } from "../lib/mcp-contract/hmac";
import { refreshMcpSession } from "../lib/mcp-contract/session-token";
import { MCP_CONTRACT_SESSION_TTL_SEC } from "../lib/mcp-contract/config";

const router = Router();

router.post("/api/mcp/refresh", hmacMiddleware, async (req, res) => {
  const { session_token } = req.body as { session_token: string };
  if (!session_token) {
    return res.status(400).json({ error: "Missing session_token" });
  }

  const result = await refreshMcpSession(session_token, MCP_CONTRACT_SESSION_TTL_SEC);
  if (!result) {
    return res.status(401).json({ error: "session_expired" });
  }

  return res.json({ session_token: result.token, expires_in: result.expiresIn });
});

export default router;
javascript
// routes/mcp-refresh.js
const { Router } = require("express");
const { hmacMiddleware } = require("../lib/mcp-contract/hmac");
const { refreshMcpSession } = require("../lib/mcp-contract/session-token");
const { MCP_CONTRACT_SESSION_TTL_SEC } = require("../lib/mcp-contract/config");

const router = Router();

router.post("/api/mcp/refresh", hmacMiddleware, async (req, res) => {
  const { session_token } = req.body;
  if (!session_token) {
    return res.status(400).json({ error: "Missing session_token" });
  }

  const result = await refreshMcpSession(session_token, MCP_CONTRACT_SESSION_TTL_SEC);
  if (!result) {
    return res.status(401).json({ error: "session_expired" });
  }

  return res.json({ session_token: result.token, expires_in: result.expiresIn });
});

module.exports = router;
python
# routers/mcp_refresh.py
import os
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from mcp_contract.hmac_verify import verify_hub_signature
from mcp_contract.session_token import refresh_mcp_session
from your_db import get_db

router = APIRouter()
SESSION_TTL_SEC = int(os.environ.get("MCP_CONTRACT_SESSION_TTL_SEC", "3600"))


class RefreshRequest(BaseModel):
    session_token: str


@router.post("/api/mcp/refresh", dependencies=[Depends(verify_hub_signature)])
async def mcp_refresh(body: RefreshRequest, db=Depends(get_db)):
    result = refresh_mcp_session(db, body.session_token, SESSION_TTL_SEC)
    if not result:
        raise HTTPException(401, detail="session_expired")

    return {"session_token": result["token"], "expires_in": result["expires_in"]}

POST /api/mcp/logout

Revokes a session token (deletes it from storage).

Request body:

json
{ "session_token": "mcp:token-to-revoke..." }

Response (200):

json
{ "ok": true }
javascript
// app/api/mcp/logout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyHubSignature } from "@/lib/mcp-contract/hmac";
import { revokeMcpSession } from "@/lib/mcp-contract/session-token";

export async function POST(request: NextRequest) {
  const rawBody = await request.text();

  const hmacResult = await verifyHubSignature(
    "POST", request.url, rawBody, request.headers);
  if (!hmacResult.ok) {
    return NextResponse.json(
      { error: hmacResult.error }, { status: hmacResult.status });
  }

  let body: { session_token?: string };
  try { body = JSON.parse(rawBody); }
  catch { return NextResponse.json(
    { error: "Invalid JSON" }, { status: 400 }); }

  const { session_token } = body;
  if (!session_token) {
    return NextResponse.json(
      { error: "Missing session_token" }, { status: 400 });
  }

  await revokeMcpSession(session_token);
  return NextResponse.json({ ok: true });
}
javascript
// app/api/mcp/logout/route.js
import { NextResponse } from "next/server";
import { verifyHubSignature } from "@/lib/mcp-contract/hmac";
import { revokeMcpSession } from "@/lib/mcp-contract/session-token";

export async function POST(request) {
  const rawBody = await request.text();

  const hmacResult = await verifyHubSignature(
    "POST", request.url, rawBody, request.headers);
  if (!hmacResult.ok) {
    return NextResponse.json(
      { error: hmacResult.error }, { status: hmacResult.status });
  }

  let body;
  try { body = JSON.parse(rawBody); }
  catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); }

  const { session_token } = body;
  if (!session_token) {
    return NextResponse.json({ error: "Missing session_token" }, { status: 400 });
  }

  await revokeMcpSession(session_token);
  return NextResponse.json({ ok: true });
}
java
// McpLogoutController.java
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;

@RestController
public class McpLogoutController {

    private final SessionTokenService sessionService;

    public McpLogoutController(SessionTokenService sessionService) {
        this.sessionService = sessionService;
    }

    @PostMapping("/api/mcp/logout")
    public ResponseEntity<?> logout(
            @RequestBody Map<String, String> body) {

        String token = body.get("session_token");
        if (token == null || token.isBlank()) {
            return ResponseEntity.badRequest()
                .body(Map.of("error", "Missing session_token"));
        }

        sessionService.revokeSession(token);
        return ResponseEntity.ok(Map.of("ok", true));
    }
}
typescript
// routes/mcp-logout.ts
import { Router } from "express";
import { hmacMiddleware } from "../lib/mcp-contract/hmac";
import { revokeMcpSession } from "../lib/mcp-contract/session-token";

const router = Router();

router.post("/api/mcp/logout", hmacMiddleware, async (req, res) => {
  const { session_token } = req.body as { session_token: string };
  if (!session_token) {
    return res.status(400).json({ error: "Missing session_token" });
  }

  await revokeMcpSession(session_token);
  return res.json({ ok: true });
});

export default router;
javascript
// routes/mcp-logout.js
const { Router } = require("express");
const { hmacMiddleware } = require("../lib/mcp-contract/hmac");
const { revokeMcpSession } = require("../lib/mcp-contract/session-token");

const router = Router();

router.post("/api/mcp/logout", hmacMiddleware, async (req, res) => {
  const { session_token } = req.body;
  if (!session_token) {
    return res.status(400).json({ error: "Missing session_token" });
  }

  await revokeMcpSession(session_token);
  return res.json({ ok: true });
});

module.exports = router;
python
# routers/mcp_logout.py
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from mcp_contract.hmac_verify import verify_hub_signature
from mcp_contract.session_token import revoke_mcp_session
from your_db import get_db

router = APIRouter()


class LogoutRequest(BaseModel):
    session_token: str


@router.post("/api/mcp/logout", dependencies=[Depends(verify_hub_signature)])
async def mcp_logout(body: LogoutRequest, db=Depends(get_db)):
    revoke_mcp_session(db, body.session_token)
    return {"ok": True}

GET /api/mcp/users

Returns the list of active users in the tenant application. LockMCP calls this endpoint when a connection with a configured userListEndpoint path is created or updated. Synced users appear in the Members tab with source badge Auto and are assigned the tenant's default role.

HMAC: Signed when identity model is TENANT_DELEGATED (same headers as other server-to-server endpoints, empty-string body hash). Unauthenticated for other identity models — protect with IP allowlisting if needed.

Response (200):

json
[
  { "email": "alice@example.com", "name": "Alice Nguyen" },
  { "email": "bob@example.com", "displayName": "Bob Smith" }
]

Top-level array or { "users": [...] } wrapper both accepted. Each entry requires an email field; name / displayName is optional.

javascript
// app/api/users/route.ts
// Configure this path as userListEndpoint on the LockMCP connection
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function GET() {
  const users = await prisma.user.findMany({
    where: { isActive: true },
    select: { email: true, name: true },
    orderBy: { name: "asc" },
  });

  return NextResponse.json(
    users.map((u) => ({ email: u.email, name: u.name ?? u.email }))
  );
}
javascript
// app/api/users/route.js
// Configure this path as userListEndpoint on the LockMCP connection
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function GET() {
  const users = await prisma.user.findMany({
    where: { isActive: true },
    select: { email: true, name: true },
    orderBy: { name: "asc" },
  });

  return NextResponse.json(
    users.map((u) => ({ email: u.email, name: u.name ?? u.email }))
  );
}
java
// McpUserListController.java
// Configure the mapping path as userListEndpoint on the LockMCP connection
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
public class McpUserListController {

    private final UserRepository userRepo;

    public McpUserListController(UserRepository userRepo) {
        this.userRepo = userRepo;
    }

    @GetMapping("/api/users")
    public ResponseEntity<List<UserSummary>> listUsers() {
        var users = userRepo.findAllByActiveTrue().stream()
            .map(u -> new UserSummary(u.getEmail(), u.getDisplayName()))
            .toList();
        return ResponseEntity.ok(users);
    }

    record UserSummary(String email, String name) {}
}
typescript
// routes/mcp-users.ts
// Configure this path as userListEndpoint on the LockMCP connection
import { Router } from "express";
import { prisma } from "../lib/prisma";

const router = Router();

router.get("/api/mcp/users", async (_req, res) => {
  const users = await prisma.user.findMany({
    where: { isActive: true },
    select: { email: true, name: true },
    orderBy: { name: "asc" },
  });

  return res.json(users.map((u) => ({ email: u.email, name: u.name ?? u.email })));
});

export default router;
javascript
// routes/mcp-users.js
// Configure this path as userListEndpoint on the LockMCP connection
const { Router } = require("express");
const { prisma } = require("../lib/prisma");

const router = Router();

router.get("/api/mcp/users", async (_req, res) => {
  const users = await prisma.user.findMany({
    where: { isActive: true },
    select: { email: true, name: true },
    orderBy: { name: "asc" },
  });

  return res.json(users.map((u) => ({ email: u.email, name: u.name ?? u.email })));
});

module.exports = router;
python
# routers/mcp_users.py
# Configure this path as userListEndpoint on the LockMCP connection
from fastapi import APIRouter, Depends
from your_db import get_db
from your_users import get_active_users  # replace with your user query

router = APIRouter()


@router.get("/api/mcp/users")
async def list_users(db=Depends(get_db)):
    users = get_active_users(db)
    return [
        {"email": u.email, "name": u.display_name or u.email}
        for u in users
    ]

GET /api/mcp/roles

Returns the list of roles defined in the tenant application. LockMCP calls this endpoint when a connection with a configured roleListEndpoint path is created or updated. Synced roles appear in the Roles tab and can be assigned to members or used for tool access control.

HMAC: Signed when identity model is TENANT_DELEGATED. Unauthenticated for other identity models — protect with IP allowlisting if needed.

Response (200):

json
[
  { "name": "admin",      "description": "Full access to all features",      "toolScopes": ["createCourse", "deleteCourse"] },
  { "name": "instructor", "description": "Can manage courses and students",  "toolScopes": ["createCourse"] },
  { "name": "student",    "description": "Can enrol and view courses",       "toolScopes": [] }
]

Top-level array or { "roles": [...] } wrapper both accepted. Each entry requires a name field; description and toolScopes are optional. toolScopes (contract 2+) lists the OpenAPI operationIds each role may invoke — see the Contract Specification.

javascript
// app/api/roles/route.ts
// Configure this path as roleListEndpoint on the LockMCP connection
import { NextResponse } from "next/server";

// Define your tenant's roles here (or load from DB)
const TENANT_ROLES = [
  { name: "admin",      description: "Full access to all features",     toolScopes: ["createCourse", "deleteCourse"] },
  { name: "instructor", description: "Can manage courses and students", toolScopes: ["createCourse"] },
  { name: "student",    description: "Can enrol and view courses",      toolScopes: [] },
];

export async function GET() {
  return NextResponse.json(TENANT_ROLES);
}
javascript
// app/api/roles/route.js
// Configure this path as roleListEndpoint on the LockMCP connection
import { NextResponse } from "next/server";

const TENANT_ROLES = [
  { name: "admin",      description: "Full access to all features",     toolScopes: ["createCourse", "deleteCourse"] },
  { name: "instructor", description: "Can manage courses and students", toolScopes: ["createCourse"] },
  { name: "student",    description: "Can enrol and view courses",      toolScopes: [] },
];

export async function GET() {
  return NextResponse.json(TENANT_ROLES);
}
java
// McpRoleListController.java
// Configure the mapping path as roleListEndpoint on the LockMCP connection
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;

@RestController
public class McpRoleListController {

    // Inject a RoleRepository if roles are stored in the DB
    @GetMapping("/api/roles")
    public ResponseEntity<List<Map<String, Object>>> listRoles() {
        return ResponseEntity.ok(List.of(
            Map.of("name", "admin",
                   "description", "Full access to all features",
                   "toolScopes", List.of("createCourse", "deleteCourse")),
            Map.of("name", "instructor",
                   "description", "Can manage courses and students",
                   "toolScopes", List.of("createCourse")),
            Map.of("name", "student",
                   "description", "Can enrol and view courses",
                   "toolScopes", List.of())
        ));
    }
}
typescript
// routes/mcp-roles.ts
// Configure this path as roleListEndpoint on the LockMCP connection
import { Router } from "express";

const router = Router();

const TENANT_ROLES = [
  { name: "admin",      description: "Full access to all features",     toolScopes: ["createCourse", "deleteCourse"] },
  { name: "instructor", description: "Can manage courses and students", toolScopes: ["createCourse"] },
  { name: "student",    description: "Can enrol and view courses",      toolScopes: [] },
];

router.get("/api/mcp/roles", (_req, res) => {
  return res.json(TENANT_ROLES);
});

export default router;
javascript
// routes/mcp-roles.js
// Configure this path as roleListEndpoint on the LockMCP connection
const { Router } = require("express");

const router = Router();

const TENANT_ROLES = [
  { name: "admin",      description: "Full access to all features",     toolScopes: ["createCourse", "deleteCourse"] },
  { name: "instructor", description: "Can manage courses and students", toolScopes: ["createCourse"] },
  { name: "student",    description: "Can enrol and view courses",      toolScopes: [] },
];

router.get("/api/mcp/roles", (_req, res) => {
  return res.json(TENANT_ROLES);
});

module.exports = router;
python
# routers/mcp_roles.py
# Configure this path as roleListEndpoint on the LockMCP connection
from fastapi import APIRouter

router = APIRouter()

# Define your tenant's roles here (or load from DB)
TENANT_ROLES = [
    {"name": "admin",      "description": "Full access to all features",     "toolScopes": ["createCourse", "deleteCourse"]},
    {"name": "instructor", "description": "Can manage courses and students", "toolScopes": ["createCourse"]},
    {"name": "student",    "description": "Can enrol and view courses",      "toolScopes": []},
]


@router.get("/api/mcp/roles")
async def list_roles():
    return TENANT_ROLES

Router registration (JavaScript)

Wire the raw-body middleware once, then mount all MCP routers:

javascript
// app.js
const express = require("express");
const app = express();

// Capture raw body as string before any JSON parsing — required for HMAC verification
app.use(express.raw({ type: "*/*" }), (req, _res, next) => {
  req.rawBody = req.body ? req.body.toString() : "";
  next();
});

// Parse JSON and form bodies for route handlers (after raw body is captured)
app.use(express.json());
app.use(express.urlencoded({ extended: false }));

app.use(require("./routes/mcp-login"));
app.use(require("./routes/mcp-exchange"));
app.use(require("./routes/mcp-validate"));
app.use(require("./routes/mcp-refresh"));
app.use(require("./routes/mcp-logout"));
app.use(require("./routes/mcp-users"));
app.use(require("./routes/mcp-roles"));

app.listen(3000);

Router registration (Python)

Register all MCP routers in your FastAPI app. The dependencies on each HMAC-protected route handle verification automatically.

python
# main.py
from fastapi import FastAPI
from routers import (
    mcp_login, mcp_exchange, mcp_validate, mcp_refresh, mcp_logout,
    mcp_users, mcp_roles,
)

app = FastAPI()
app.include_router(mcp_login.router)
app.include_router(mcp_exchange.router)
app.include_router(mcp_validate.router)
app.include_router(mcp_refresh.router)
app.include_router(mcp_logout.router)
app.include_router(mcp_users.router)
app.include_router(mcp_roles.router)

Filter registration (Java)

Register the HmacVerificationFilter for the 4 HMAC-protected endpoints:

java
// FilterConfig.java
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class FilterConfig {

    @Bean
    public FilterRegistrationBean<HmacVerificationFilter> hmacFilter() {
        var reg = new FilterRegistrationBean<>(
            new HmacVerificationFilter());
        reg.addUrlPatterns(
            "/api/mcp/exchange",
            "/api/mcp/validate",
            "/api/mcp/refresh",
            "/api/mcp/logout",
            "/api/mcp/users",
            "/api/mcp/roles"
        );
        reg.setOrder(1);
        return reg;
    }
}