Skip to content

MCP Browser Authorization ​

Basic

This guide explains how an MCP client connects to Cabloy Basic's authenticated shared /mcp endpoint. The authorization server uses RFC 7591 Dynamic Client Registration (DCR), existing Cabloy login, explicit consent and authorization code exchange with PKCE. Static configuration clients are not supported.

Read MCP Integration Guide for endpoint ownership and admission, and MCP Controller Tools for Action mapping and business examples. The independent local test launcher is anonymous and rejects credentials; do not send OAuth tokens to it.

Connected journey ​

text
MCP client → /mcp/register → /mcp/auth → existing SSR Site → consent page
           → existing login, if needed → explicit approve or deny
           → registered client callback → /mcp/token → Bearer-authenticated /mcp

Backend mcp-auth owns client registrations, authorization state, personal scopes, decisions, codes and tokens. Backend cabloy-mcp owns the normal shared endpoint and its transport lifecycle. The consent page and personal scope management belong to Zova's cabloy-mcp module within existing SSR Sites; they are not a separate management console or SSR Site.

Supported profile ​

ItemBoundary
GrantAuthorization code with PKCE S256; refresh when explicitly registered
Client registrationRFC 7591 DCR only, available with the existing AS/MCP ingress
Public client authenticationnone
Confidential client authenticationclient_secret_basic
ScopesRegistered mcp:all/mcp:read ceilings; consent may narrow to a personal mcp:custom:<name>
Pending authorization lifetime10 minutes by default
Authorization code lifetime60 seconds by default; one use
Access token lifetime7200 seconds (2 hours) by default; MCP-specific JWT
Refresh window7 days from issuance or each successful refresh by default; no absolute cap
Refresh rotationEach successful use rotates the pair; older-generation replay revokes the family
ConsentExplicit approve/deny on each new authorization; no remembered consent

Ordinary Cabloy login, refresh, temporary and OAuth credentials are not MCP tokens, and MCP credentials cannot become ordinary login credentials. Configured TTL values and OAuth expires_in use seconds; an expiresAt timestamp uses epoch milliseconds.

Shared/role/nested scopes, a generalized scope policy engine, Client ID Metadata Documents (CIMD), remembered consent, general management UI, public registration update/delete endpoints and offline_access are not supported.

Endpoints and authority ​

PurposePath
Begin browser authorization/mcp/auth
Exchange a code or refresh token/mcp/token
Dynamic client registration/mcp/register
Authorization-server metadata/.well-known/oauth-authorization-server/mcp
MCP protected-resource metadata/.well-known/oauth-protected-resource/mcp
Shared MCP resource/mcp
Consent page inside the selected SSR Site/cabloy/mcp/consent

Both issuer and protected resource are the exact ${origin}/mcp identifier, including the /mcp path. origin comes from the root backend server.serve.protocol and server.serve.host, not config.modules['mcp-auth'], request Host or proxy headers.

Production requires the explicit trusted public serve pair. Nonproduction may use the existing requestless AppUtil fallback http://localhost:<SERVER_LISTEN_PORT> when no public serve pair is configured. Incoming request values must not establish authority. The ingress validates Host against the canonical authority and reuses existing automatic instance routing in a fresh selector-free context. Authorization-server endpoints reject instance selectors. Shared /mcp permits recognized singleton selectors only when they equal the server-derived instance, then strips them before dispatch; mismatches are rejected. Selectors cannot establish or change instance authority. There is no separate MCP resource/instance registry or cross-origin AS/resource federation.

Recognized dev/test/prod modes use the same authenticated, permission-checked boundary. Unknown modes, not-ready, closing, disabled state and invalid required authority configuration fail closed. The resource integration is enabled by default; only literal CABLOY_MCP_ENABLED=false disables its environment-derived default, while ordinary application overrides remain supported. This resource gate does not itself disable mcp-auth registration, authorization, token exchange or authorization-server discovery; those endpoints enforce their own readiness, shutdown and authority/configuration checks.

Register a client ​

Send a direct JSON request:

http
POST /mcp/register
Content-Type: application/json

{
  "client_name": "Example MCP client",
  "redirect_uris": ["https://client.example.com/oauth/callback"],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code"],
  "response_types": ["code"]
}

The direct JSON response uses HTTP 201 and contains a server-issued opaque client_id, exact redirect URIs, canonical registered scope, and supported grant/response/authentication metadata. A confidential registration with client_secret_basic returns its secret only once; the server stores only its digest. Keep that secret private.

Redirect URIs must be exact HTTPS values, or HTTP for exactly localhost, 127.0.0.1 or [::1] while allowLoopbackHttp is true (the module default). An explicit false disables that exception. Lookalikes, other loopback addresses and arbitrary HTTP hosts are not allowed.

Unknown metadata, duplicate members, forwarded headers, instance selectors, query strings, oversized/malformed JSON and unsupported metadata are rejected. The server fixes the resource to the canonical ${origin}/mcp. Registration creates a client identity, not user authentication, a local role, tenant access, an authorization code or a token. Explicit consent and PKCE remain required.

DCR and its metadata advertisement are available with the existing AS/MCP ingress; there is no registration.enabled field or separate activation step. Body/redirect limits and registration rate limiting remain in effect.

Scope ceiling and client configuration ​

  • New registration: omitted scope defaults to mcp:all. Explicit mcp:all and mcp:read are supported. Either ordering of the space-separated list containing both canonicalizes to mcp:all.
  • Authorization: omitted scope requests the registered ceiling, not an unconditional all default. Consent chooses one allowed all/read/custom scope within both the original request and client ceiling; it never combines options to widen access.
  • Existing read grants: existing mcp:read registrations, grants and tokens are not promoted. Broader access requires a new mcp:all registration and fresh explicit authorization, not rewriting persisted bindings.
  • Local authority: neither scope grants business permissions or cross-instance access. mcp:read lists/calls only tools classified read-only. mcp:all permits both classifications only within the admitted catalog and current business authority.

No client scope configuration is needed for the registration default. To explicitly narrow a Codex connection, use top-level scopes in the server's MCP configuration table, not HTTP headers:

toml
[mcp_servers.cabloy]
url = "https://mcp.example.com/mcp"
scopes = ["mcp:read"]

To enable refresh, explicitly register grant_types: ["authorization_code", "refresh_token"] (either request order is accepted). The server persists and returns that canonical pair; code exchange then returns both tokens. Omitted grant_types or explicit ["authorization_code"] remain code-only. Refresh-only, unrelated or duplicate grants and unsupported scopes remain invalid. Existing code-only registrations, pending grants and legacy opaque tokens are not upgraded: obtain a new pair-capable registration and fresh authorization.

Native client compatibility ​

DCR accepts an omitted application_type or exactly application_type: "native" as a request-only compatibility hint. It is validated and discarded, not persisted or echoed. Other values, including web, null, wrong types, case and whitespace variants, remain invalid. This does not introduce OIDC application-type semantics or unrestricted RFC 7591 metadata support. Exact redirects, client authentication, scopes and PKCE remain required.

Personal scopes from account security ​

  1. Sign in and open Account → Security → Personal MCP scopes. This opens the current Site's /cabloy/mcp/scopes page through normal protected navigation, not a new Site or OAuth callback.
  2. Create a preset with a name, description and exact selected tools. Review each tool's read/write classification. The trimmed name must contain 1–100 ASCII characters from A–Z, a–z, 0–9, _ and -; case is preserved. Entering test produces exactly mcp:custom:test, without a random suffix. The trimmed name is unique within your user and active instance; other users may use the same name safely.
  3. During fresh authorization, choose one allowed all/read/personal-custom option. A read request cannot approve a preset containing a write. An option with unavailable tools or outside the original request or registered ceiling cannot be approved.

A preset stores exact tool identities/names, not a wildcard, category or expanding query. Newly exposed tools do not join it automatically. Both tools/list and direct known-name tools/call enforce the same selection. Selected tools still require current business permissions, ownership and instance scope; selection does not promise access to every record.

Code and refresh token responses report the actual effective scope, possibly mcp:custom:<name>, rather than necessarily the original all/read request. Clients must honor it. Refresh can retain or narrow the grant, never widen it or pick up newly added preset tools.

Editing has authorization consequences: rename changes the scope identifier. Rename, selected-tool changes or deletion revoke codes, access tokens and refresh tokens linked through either the old effective scope or old requested scope, including grants narrowed from a custom request to another effective scope. Affected clients need fresh browser authorization; refresh cannot recover the old grant. Description-only edits preserve grants.

Before login, custom validation checks syntax only. Authenticated resolution checks ownership and active instance and requires the stored identifier to match its name-derived value. Old random/noncanonical identifiers are rejected, not accepted as aliases. There is no automatic identifier migration or database reset. DCR and public discovery expose only built-in all/read ceilings, not personal presets.

Before using the flow, ensure:

  • The trusted root serve pair or nonproduction requestless fallback establishes authority. Do not author mcp-auth.issuer or mcp-auth.resources.
  • Automatic instance routing resolves an enabled instance after Host validation, without client selectors.
  • An enabled SSR Site's built flavor contains the shared consent page and ordinary role admission permits the intended user.
  • Registration limits and an operational policy for AS-owned client records are appropriate.
  • Callback URIs are registered exactly, and confidential client secrets are kept out of browser state, URLs, logs and metadata.

Illustrative configuration, not a provisioned or verified deployment:

ts
config.server.serve.protocol = 'https';
config.server.serve.host = 'mcp.example.com';
// Issuer and resource: https://mcp.example.com/mcp.
config.modules['mcp-auth'] = {
  registration: {
    maxBodyBytes: 16384,
    maxRedirectUris: 16,
    rateLimit: 10,
    rateLimitWindow: 600,
  },
  pendingTtl: 600,
  codeTtl: 60,
  tokenTtl: 7200,
  refreshTokenTtl: 7 * 24 * 60 * 60,
  allowLoopbackHttp: true,
};

Set MCP_AUTH_SSR_SITE or override config.modules['mcp-auth'].ssrSite to select a registered consent Site, for example basic-siteweb:web. A nonempty selector must match an enabled Site for the current instance/host; otherwise authorization returns temporarily_unavailable (503) without selecting a different Site. If unset or empty, the default is the enabled Site with the shortest publicPath, preserving existing ordering for ties. Selection does not probe page availability, fall back by route capability or bypass role restrictions. Include the shared page in every selectable flavor. The normal page-path helper adds the selected Site's public-path prefix.

Existing databases and authority changes ​

mcp-auth currently keeps vonaModule.fileVersion: 1. Its current version-1 schema includes personal scopes, requested/effective scope bindings and refresh grant fields. Editing that path does not replay an already-applied version 1: an existing v1 database needs a separately approved upgrade or disposable-data strategy. Fresh isolated tests initialize the current schema; they do not upgrade a development database. npm run db:reset is destructive and requires explicit approval, never an automatic troubleshooting step.

Old origin-only issuer values differ from the exact ${origin}/mcp issuer. Grants, codes and access tokens carrying the old issuer fail exact comparison and require fresh authorization. Do not rewrite them, relax matching or reset a database to preserve them.

Client and browser journey ​

1. Start a fresh authorization request ​

After DCR, create a fresh PKCE verifier and S256 challenge, then open /mcp/auth. Parameters identify response_type=code, the issued client_id, exact registered redirect_uri, canonical resource, optional scope, code_challenge and code_challenge_method=S256. Omit scope to inherit the client ceiling, or request mcp:read to narrow an all registration. Keep and verify client-generated OAuth state for callback correlation.

The server validates client, redirect, resource, scope, instance and PKCE before redirecting toward a client callback, including error callbacks. Malformed/duplicate parameters and mismatched bindings are rejected.

2. Reuse the existing login ​

The selected Site presents /cabloy/mcp/consent?request=…. Its protected-page guard sends an anonymous user through normal Cabloy login and returns to that same internal pending-consent URL. The OAuth callback is not the login returnTo.

Existing SSR/browser Passport cookie support and the normal authenticated API client own identity. No new MCP cookie, holder secret, identity bridge, session system or CSRF-bootstrap step is needed. The request value locates pending state; possession is not authentication or approval.

The consent page uses the session SSR profile. Its model loads private summary data after the required hydration/admission boundary, keeping server HTML and the client's initial render equivalent. Authorization responses must not enter SSR state or browser query persistence.

3. Approve or deny explicitly ​

Review the application, resource and original requested scope, then choose one allowed option as described in Personal scopes. The original request remains visible separately from the effective choice. Every request requires explicit approval or denial, even when signed in; the server rechecks that the selection cannot widen access.

Approve/deny is an authenticated POST, not a GET link or page-load side effect. It uses the existing backend origin check and a single-winner pending transition. This is not a claim that stock CSRF-token enforcement is enabled: the helper retains development/loopback exceptions. Do not weaken Site admission, API-origin configuration or CORS/Origin policy.

Approval returns a server-built registered callback with a one-use code and client state; denial returns an error callback without a code. The browser immediately navigates there. Do not cache, persist, log or serialize the credential-bearing response. An expired, decided or invalid request must not issue a replacement code.

4. Exchange once, then call MCP ​

Validate callback state, then exchange at /mcp/token using grant_type=authorization_code, the code, original code_verifier, canonical resource and the exact authorization redirect_uri. Public clients use client_id without a secret; confidential clients use HTTP Basic under client_secret_basic, never a URL secret.

A successful exchange consumes the code and activates generation 1 of the MCP JWT grant family in one committed conditional database update. A refresh-enabled grant returns both credentials; a code-only grant returns only access. Codes cannot be replayed, and a lost response does not permit credential recovery or automatic issuance retry.

Send the access token only as one Authorization: Bearer header on protected /mcp requests, with required MCP framing/version/method headers. Do not put credentials in query parameters, cookies, proxy headers or ordinary login-token shortcuts. This resource rule does not remove normal cookies from the SSR consent page.

Each request checks current expiry, revocation, registration and account validity, then builds a fresh local Passport projection in the trusted instance. Missing/invalid bearer receives 401 with the applicable challenge; insufficient scope receives 403. Configuration/lookup failure denies before dispatch. Business guards, ownership and data scope remain authoritative.

5. Refresh without a new browser authorization ​

A refresh-capable client posts grant_type=refresh_token, its current refresh token and canonical resource to /mcp/token, using the same client authentication as code exchange. Do not send code, redirect or PKCE fields. Optional scope may retain or narrow the grant, never widen it or exceed the current client ceiling. Refresh may succeed after access expiry while the family, account and client bindings remain valid.

Each successful refresh atomically replaces nonce digest and generation, advances the deadline and returns a new pair. The old access token becomes invalid immediately, even before JWT expiry. The default refresh window is 7 days from issuance and resets to 7 days after each successful refresh, with no absolute family lifetime cap. Ordinary access and failed refresh do not extend it. Cleanup retains refreshable grants through that deadline; revocation invalidates both credentials.

Replay of a correctly signed older refresh generation after authentication/binding checks revokes the family, including a concurrent winner's new pair. Serialize refresh and replace both credentials together. Do not automatically retry code or refresh issuance after an uncertain/lost response. Credentials are not recoverable, and replay can revoke the family. Expired/revoked families require fresh authorization.

Existing exact-format opaque access tokens remain usable only through bounded legacy lookup until original expiry; they cannot refresh or upgrade. JWT verification failure never falls back to legacy authentication.

Server-side refresh evidence ​

Two framework logger DEBUG events identify rotation and subsequent use, provided logger/transport filters admit DEBUG (the default INFO threshold does not):

  • mcp.oauth.refresh_succeeded: rotation, pair signing and Passport attachment completed.
  • mcp.oauth.refreshed_access_accepted: a later MCP request authenticated using a refreshed generation, not necessarily a successful tool call.

Example application-authored fields:

json
{"event":"mcp.oauth.refresh_succeeded","family":"<same SHA-256 family correlator>","generation":2}
{"event":"mcp.oauth.refreshed_access_accepted","family":"<same SHA-256 family correlator>","generation":2}

The correlator hashes a domain-separated grant-binding tuple, not a token or nonce. These fields exclude raw user/client/grant IDs, credentials, codes, headers, cookies and tool arguments/results. The normal logger may add timestamp, level, bean, method/path and telemetry metadata. This is pseudonymous operational correlation, not anonymization or protected audit.

To observe refresh without changing lifetimes, make a read-only interaction, wait beyond access expiry (2 hours by default), then interact without manually reauthorizing. Matching family and generation across both events establishes server-side refresh followed by use, not client persistence or tool success. Continued usability alone is insufficient. Missing logs are inconclusive when filtered/suppressed; do not bypass logging policy, collect credentials, reset databases or replay refresh tokens to obtain evidence.

Business reads ​

Tool discovery applies the OAuth ceiling, personal selection if any, and fresh current Action guards. Calls independently reauthorize through executor.performAction with innerAccess: false; a listed tool is not a reusable grant.

With optional training active, Student select/group/aggregate require select permission, and view/summary require view permission. Neither implies the other. Create/update/delete additionally require a write-capable scope and their independent permissions. Student examples are not a shared catalog ceiling; without training, other eligible tools may remain, never fixture fallback.

For a read-only check:

  1. Authorize a fresh connection, list tools, and inspect schemas. Use an existing Student the account may already view, not a disposable test identity or new seed data.
  2. If separately select-authorized, call training-student_student_select with { "name": "Student", "pageNo": 1, "pageSize": 10 } and choose a matching row's returned ID. LIKE wildcards % and _ retain their usual meaning.
  3. With view authority, call training-student_student_view and training-student_student_summary using { "id": "<returned student ID>" }, or an already known authorized ID when select is unavailable. View retains full serialized detail; summary contains exactly id, name, level, levelTitle, descriptionLength, summaryText, without mobile/full notes. Structured content and JSON text must agree.
  4. With a separately authorized read scope, confirm writes are absent. Do not invoke mutations merely to test discovery. Missing, excluded or unsupported Actions must not become available by knowing their names.

See MCP Controller Tools for complete contracts, output limits, safe errors and mutation behavior. Images are existing URL presentation, not MCP image blocks or guaranteed client rendering.

Shared Passport implementation boundary ​

MCP reuses Passport's auth-token lifecycle through static JWT client selection. Both mcpAccess and mcpRefresh select authTokenAdapter: 'mcp-auth:authTokenAdapter'; trusted server code passes those names to createAuthToken, checkAuthToken and refreshAuthToken. No auth provider, signin event or duplicate identity is introduced.

The adapter reloads current user, auth ownership/provider and roles. Its fixed options: { stateless: false, strategy: 'reissue' } retains stateful grant validation regardless of ordinary session/provider overrides. Other clients retain existing auth/session behavior.

Verification is read-only: access requires the current generation, while refresh can pass an otherwise valid older generation into reissue for replay-family revocation. Generation 0 creation activates the grant; later generations rotate it atomically with validated resource/scope operation input. The existing authorization row is the family, storing only the current nonce digest rather than plaintext credentials or a second identity/history store.

The selected passportAuthTokenClient survives the MCP Passport handoff and clears with current state. Ordinary signin, refresh, temporary-token and OAuth helpers reject conversion from attached MCP authority. See Auth Guide and JWT Guide.

Troubleshooting without weakening the boundary ​

  • Registration unavailable: check trusted root authority, Host, automatic routing and ingress readiness. No static client, authored issuer/resource registry or registration switch is required.
  • Consent route missing or Site admission denied: verify the selected Site's paired SSR/REST artifacts contain the page and ordinary role admission is appropriate. The server does not probe or choose a more permissive Site. Follow the contract-loop workflow when rebuilding changed frontend artifacts.
  • Login does not return to consent: preserve the internal pending URL, not the OAuth callback; do not add another cookie or identity bridge.
  • Consent API origin mismatch: check explicit Site/module overrides first. Vona integrated SSR normally supplies runtime API origins; see Runtime API origins. Do not change environment values merely to match an isolated test listener.
  • Decision rejected: check current identity, lifetime and trusted origin. Do not replace authenticated POST with GET or weaken origin checks.
  • Code/refresh rejected: check exact bindings, verifier or current credential pair, expiry, revocation and scope. Reauthorize when needed; do not replay, recover or convert ordinary login credentials.
  • MCP denied: distinguish OAuth failure from independent business authorization. Tokens do not create local roles or cross-instance access.
  • 503 or missing tools: check recognized mode, enabled/ready state, shutdown, required authority, Controller/route registration, opt-in/exclusion, supported contract, scope and actual Action guards. Inspect server-side diagnostics for unsupported opted-in contracts. The standalone launcher always composes exactly three fixtures, while shared discovery can also include their opted-in Actions when mcp-test is active. Shared /mcp remains authenticated and does not use a fixed three-tool catalog.

Operational limits ​

The profile initially targets SQLite on Vona's primary datasource and ordinary wall-clock deadlines under a maintained host clock. It does not establish cross-dialect, multi-worker, cross-host, nonrollback-clock or synchronous browser-logout/token-revocation guarantees. Expiry/current revocation must not depend on background cleanup.

Per-worker transport bounds, actual-work settlement and shutdown drain remain active. Dedicated protected audit, audit-outage availability gating and deployment-wide fine-grained quotas are not supplied for the Student examples. Ordinary logs are not protected audit; production-mode support is not qualified deployment, compliance or release evidence. Mutation limits apply independently of browser approval.

Never retain tokens, codes, secrets, Authorization/cookie values or complete credential-bearing callback URLs in logs, screenshots, traces or shared evidence. Do not reset development databases, alter shared ports or expose the standalone fixture to troubleshoot authorization. See Repo Scripts for managed verification and resource boundaries.

Released under the MIT License.