Connect MCP clients and AI connectors
Let claude.ai, ChatGPT, Cursor and other MCP clients sign your users in to your MCP server, with a consent screen and a token made out to that server alone.
An MCP client — a claude.ai or ChatGPT connector, Cursor, an IDE — connecting to your MCP server needs three things from an authorization server: a way to find it, a way to register itself, and a token addressed to your server rather than to anyone else. Base316 provides all three, per environment.
How a connection goes
- The client calls your MCP server with no token. Your server answers
401, pointing at its resource metadata. - The client reads that metadata, finds your environment's issuer, and fetches
its metadata from
/.well-known/oauth-authorization-server. - It registers itself at
/register(RFC 7591) and gets aclient_id— or, if it has a client ID metadata document, uses that document's URL as itsclient_idand skips registering. ChatGPT prefers the second. - It sends the person to sign in, naming your server with
resource=. - The person signs in on your hosted pages and sees a consent screen listing what the client asked for. Nothing is shared until they allow it.
- The client redeems the code for an access token whose
audis your server, carrying only the scopes the person's plan entitles.
Set it up
- Under Developers → Protected APIs, add your MCP server as an API. Its audience is the
server's URL, exactly —
https://mcp.example.com. Add the scopes it understands, and tie any paid ones to a feature so the plan decides who gets them. - Under Settings → Security & sign-up policy, in Password and session
policy, check Let AI connectors and other applications register
themselves. It is on by default in Sandbox and off in Production
until you turn it on: registration is an unauthenticated endpoint, and every
application it creates is a third party. From code:
base316.authPolicy.update({ allowDynamicClientRegistration: true })(nullreturns to the environment's default). - Serve the resource metadata from your MCP server, and verify tokens.
import {
createProtector,
protectedResourceMetadata,
resourceMetadataChallenge,
} from "@base316/sdk";
const resource = "https://mcp.example.com";
const issuer = "https://api.base316.com/oidc/env_1a2b3c";
const protector = createProtector({ audience: resource, issuer });
// GET /.well-known/oauth-protected-resource
app.get("/.well-known/oauth-protected-resource", (c) =>
c.json(protectedResourceMetadata({ resource, issuer, scopes: ["notes.read"] })),
);
app.use("/mcp", async (c, next) => {
const result = await protector.check(c.req.header("authorization"));
if (!result.ok) {
// The header is how a client that has never seen you finds Base316.
if (result.status === 401) {
c.header("WWW-Authenticate", resourceMetadataChallenge(resource));
}
return c.json({ error: result.failure.reason }, result.status);
}
await next();
});The resource the client sends, the API's audience in Base316 and the audience you verify
against must be the same string. A trailing slash on one of them is enough for every token to be
refused.
Turning on AI connectors
With registration on, the environment's discovery document (at
{issuer}/.well-known/openid-configuration, and the RFC 8414 forms
{issuer}/.well-known/oauth-authorization-server and
https://api.base316.com/.well-known/oauth-authorization-server/oidc/{environmentId})
advertises registration_endpoint and client_id_metadata_document_supported.
That is all claude.ai and ChatGPT need: add your MCP URL as a custom connector
(claude.ai: Settings → Connectors → Add custom connector; ChatGPT:
Settings → Apps & Connectors → Advanced → Developer mode → Create) and the
first person to connect is sent to your sign-in and consent pages.
| Client | Connects with | Sends people back to |
|---|---|---|
| claude.ai, Claude Desktop | registration | https://claude.ai/api/mcp/auth_callback, https://claude.com/api/mcp/auth_callback |
| ChatGPT | metadata document (private_key_jwt), or registration | https://chatgpt.com/connector_platform_oauth_redirect, or https://chatgpt.com/connector/oauth/{callback_id} shown in the app's settings |
| Claude Code, Cursor, VS Code, MCP Inspector | registration | http://127.0.0.1:{port}/… or http://localhost:{port}/…; Cursor also cursor://… |
Three settings beside the switch narrow it:
- Allowed redirect hosts — one per line. Empty allows any https address,
the loopback interface and native app schemes. Otherwise a redirect must be
on a listed host (
claude.ai), under a wildcard (*.example.com— its subdomains, not itself),localhostfor every loopback address, or a listed app scheme (cursor:). It is checked at registration and again at every sign-in, so tightening it also cuts off applications already registered. For claude.ai and ChatGPT only:claude.ai,claude.com,chatgpt.com. - Most self-registered applications — 500 by default. Past it, registration is refused until you delete some (OpenID Connect lists them, marked Self-registered) or unused ones are removed.
- Registration is also rate limited: 20 an hour per address and 200 an hour per environment.
Every application that connects this way:
- is a public client that must use PKCE (S256), or — from a metadata
document — a
private_key_jwtclient verified against the document's key set; OAuth 2.1 strict mode is always on; - may use only
authorization_codeandrefresh_token, neverclient_credentials; - gets a refresh token when it registered the refresh grant and asks for one of your APIs, so the connection outlives your API's short access tokens (the consent screen says "Stay connected when you are not using it");
- may ask only for the scopes your environment's APIs declare, and gets what the person approves and their plan entitles;
- gets a token only for a
resource=registered as one of your APIs. Any other resource is refused withinvalid_target.
A registration's response carries a registration_access_token and
registration_client_uri (RFC 7592), with which the client can read
(GET) or delete (DELETE) its own registration. Updating is not supported;
a client that needs different metadata registers again.
A metadata document is fetched when the client first sends someone to sign
in, through the same outbound protections as a key set, and refreshed after an
hour. It must name its own URL as client_id, carry no secret, and list
redirect URIs that pass the rules above.
What the person sees
The consent screen names the application, the host it will return them to, and each scope in plain words — the description you gave the scope, or its key. Give a scope that moves money or deletes things its own key and a description that says so: it is listed as its own line. A self-registered application is marked Unverified application: its name and home page are shown as its own claims, and the person is told to allow it only if they started connecting it just now.
The answer is remembered. The next sign-in through the same client, for the same or fewer scopes, goes straight through; asking for more asks again.
Which organization a connection acts in
A person can belong to several of your tenants (organizations). A token that acted in all of them would let an application connected "for" one company read and change another, so every grant is bound to one organization:
- Somebody in more than one organization picks one on the consent screen.
It is preselected from an
organization(ororg_id) parameter on the authorize URL when the person is a member of it — otherwise the one their previous approval named, otherwise their oldest. A hint naming somewhere the person is not a member is ignored, and the client is not told. - Somebody in exactly one is not asked: the grant is bound to it.
- Somebody in none gets an unbound grant, as before.
- A first-party client (no consent screen) is bound only when the choice is unambiguous — a valid hint, or a single membership — and is otherwise unbound.
The choice is stored on the approval, the authorization code and the refresh token, so refreshed tokens keep it. Connecting again with a different hint shows the consent screen again; the new choice replaces the old one.
The access token (and the id_token) carry it as org_id, the Base316
organization id, and createProtector reports it as
principal.organizationId (SDK 0.7.0 and later). When it is set, act only in
that organization:
const result = await protector.check(c.req.header("authorization"));
if (result.ok && result.principal.organizationId) {
// Look the tenant up by this id, and refuse anything outside it.
}Membership is re-checked whenever the grant is used. Once the person
leaves the organization (or it is deleted), the refresh grant is refused
(invalid_grant), introspection answers active: false, /userinfo answers
401 and token exchange refuses the subject token — never a silently unbound
token. An access token already issued is verified offline, so it keeps
verifying until it expires; check the membership in your own records too if
you hold them.
Unbound tokens. Tokens issued before binding existed, and grants for people
in no organization, have no org_id; principal.organizationId is null and
introspection says organization_bound: false. They keep working. Decide
whether your API accepts them — once every client has reconnected, refusing
them is the safer setting.
Auth hooks. A claim rule can already write org_id onto the id_token from
the organization source. For a bound grant that source now describes the
organization the person chose, and the consent choice is written last, so the
id_token and the access token always name the same organization. For an
unbound grant the rule behaves exactly as before (the person's first
membership). The entitlement claims and customer_id describe the bound
organization's customer.
Taking access back
A person disconnects an application from their own settings page with
base316Browser.auth.disconnectApp(sessionToken, clientId), and your support
team can do it from the user's Security dialog in the dashboard or with
base316.users.disconnectApp(userId, clientId).
Disconnecting withdraws the approval and revokes the client's refresh tokens, so it cannot mint another access token. One it already holds works until it expires — the API's token lifetime, fifteen minutes by default.
Checking a token from somewhere else
A service that would rather not verify JWTs itself can ask Base316 at
/introspect, authenticating as one of your confidential clients. The answer
reflects the account as it is now: a token for somebody disabled a minute ago
still verifies by its signature, but introspects as inactive. The answer
includes org_id for a bound token and organization_bound for any token
issued to a person.
Housekeeping
A client that registers itself and is never approved by anyone is removed after thirty days. An approved one is kept however old it is — removing it would disconnect whoever approved it.

