Skip to main content

MCP Authorization Server

The MCP Authorization Server is how an agent authenticates to Mandaitor. The agent proves itself with the key it registered in the Agent Identity Registry and receives a short-lived session. The MCP server and the execution API accept that session, and through them the agent asks Mandaitor to carry out a business operation. The authorization server issues sessions and decides nothing.

Retired in October 2026

POST /mcp/authorize, which decided a tool call and left the acting to the agent, and /mcp/policy, which mapped tool names onto mandates for it, have been removed. A request to either is refused at the gateway. An agent now asks the MCP server or POST /executions, and Mandaitor decides, makes the call and answers with a signed receipt. An integrator whose own system acts asks for a request-bound decision instead.

How It Works​

The MCP Authorization Server implements the OAuth 2.0 client_credentials grant with private_key_jwt authentication. An agent authenticates using its DID and key material from the Agent Identity Registry, receives a session token, and presents it to the MCP server or the execution API.

Server Metadata Discovery​

The authorization server's issuer is https://api.mandaitor.io/v1. Its metadata (RFC 8414) is at the well-known location for that issuer, where MCP clients look for it, and at the API's own path as before:

GET https://api.mandaitor.io/.well-known/oauth-authorization-server/v1
GET https://api.mandaitor.io/v1/.well-known/oauth-authorization-server

It names the token endpoint, the client_credentials grant, private_key_jwt with the algorithms it verifies (ES256, ES384), and the algorithms a DPoP proof may use (dpop_signing_alg_values_supported, the same two). It also names an authorization endpoint, because MCP clients require one. That endpoint answers that no browser flow exists: response_types_supported is empty, and only registered agents get sessions.

Token Exchange​

Agents authenticate with a signed JWT as the client_assertion. The parameters may be sent form-encoded, as OAuth clients send them, or as JSON:

const tokenResponse = await fetch("https://api.mandaitor.io/v1/mcp/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: "did:web:agents.mandaitor.io:agt_01HXYZ",
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: signedJwt,
scope: "mandate:mdt_abc123",
}),
});

The client_assertion is a JWT signed with the agent's active registry key, which its header may name in kid. It must carry these claims:

ClaimRequirement
iss, subThe agent's DID. client_id may be left out, since iss names the agent; when it is sent, it must be the same DID.
audThe authorization server: its issuer (https://api.mandaitor.io/v1), as MCP clients send it, or the token_endpoint.
jtiA unique identifier of 1 to 256 characters.
expNo more than five minutes ahead. A minute of clock skew is tolerated.

Each assertion is accepted once. Presenting the same assertion again is refused with invalid_client, so sign a new one for every token request instead of caching it.

resource (RFC 8707) names what the token is for. https://api.mandaitor.io/v1/mcp gives a session only the MCP server accepts. The API's base URL, or no resource, gives one that only the API's own routes accept, /executions and /mcp/sessions/{id} among them. Anything else is refused with invalid_target. Errors carry error and error_description.

The session lists the agent's bound mandates that are delegated to it. A bound mandate whose delegate is someone else is left out of the session; if none is left, the request is refused with invalid_scope. The list grants nothing of its own: what an execution may do is decided on every active mandate naming the agent (see Executions).

The token is valid for expires_in seconds: 900 (15 minutes) for a bearer session, 3600 (an hour) for a session bound to a key. It ends sooner when the agent is suspended or revoked. From that moment the MCP server and the execution API refuse it with invalid_token, and it does not come back if the agent is reactivated: the agent requests a new token. There are no refresh tokens: an agent whose session ends asks /mcp/token again.

Sessions bound to a key (DPoP)​

A session can be bound to a key that the agent's client holds, with DPoP (RFC 9449). The token request carries a DPoP header holding a proof signed by that key. The session is then issued with token_type: DPoP and lasts an hour. Every request with it must carry Authorization: DPoP mcp_… and a fresh proof signed by the same key. So a session token taken from the agent is no use without the key, which never leaves the client.

The execution API accepts only a session bound to a key. The MCP server and /mcp/sessions/{id} accept either kind, because standard MCP clients do not send DPoP proofs. A session issued without a proof is a bearer session: token_type: Bearer, for 15 minutes.

The DPoP key is not the agent's registry key. The registry key authenticates the agent to /mcp/token, in the client assertion, and the DPoP key holds the session. Make one per client and keep its private half there. It needs no registration: the session records its thumbprint (cnf.jkt, RFC 7638).

A proof is a JWT:

PartRequirement
typ (header)dpop+jwt
alg (header)ES256 with a P-256 key, or ES384 with a P-384 key
jwk (header)The public key that signs the proof, never its private half
jtiUnique, 1 to 256 characters. Each proof is accepted once.
htmThe request's method, such as POST
htuThe request's URL without query or fragment, such as https://api.mandaitor.io/v1/executions
iatThe time it was made, in seconds. A proof is accepted for five minutes, and up to a minute ahead of Mandaitor's clock.
athThe base64url SHA-256 of the session token. Every request carries it except the token request, which has no session yet.

With Web Crypto, in Node.js. A session's client runs server-side: the API does not accept the DPoP header from a browser page on another origin.

// Made once per client. Its private half cannot be exported.
const dpopKey = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" },
false,
["sign"],
);
const { kty, crv, x, y } = await crypto.subtle.exportKey("jwk", dpopKey.publicKey);

const encoder = new TextEncoder();
const base64url = (bytes: Uint8Array) =>
btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const segment = (value: object) => base64url(encoder.encode(JSON.stringify(value)));

/** A proof for one request: its method and URL, and the session token it comes with. */
async function dpopProof(method: string, url: string, sessionToken?: string): Promise<string> {
const claims: Record<string, unknown> = {
jti: crypto.randomUUID(),
htm: method,
htu: url,
iat: Math.floor(Date.now() / 1000),
};
if (sessionToken) {
const hash = await crypto.subtle.digest("SHA-256", encoder.encode(sessionToken));
claims.ath = base64url(new Uint8Array(hash));
}
const header = { typ: "dpop+jwt", alg: "ES256", jwk: { kty, crv, x, y } };
const input = `${segment(header)}.${segment(claims)}`;
// Web Crypto's ECDSA signature is r || s, as a JWS carries it.
const signature = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
dpopKey.privateKey,
encoder.encode(input),
);
return `${input}.${base64url(new Uint8Array(signature))}`;
}

// The token request: the same body as above, with a proof.
const tokenUrl = "https://api.mandaitor.io/v1/mcp/token";
const response = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/json", DPoP: await dpopProof("POST", tokenUrl) },
body: JSON.stringify({
grant_type: "client_credentials",
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: signedJwt,
}),
});
const session = await response.json(); // token_type: "DPoP", expires_in: 3600

Sign a new proof for every request, a retry included: a proof sent twice is refused.

  • At /mcp/token, a proof that does not hold is refused with 400 and error: "invalid_dpop_proof", and error_description says why. So is a request with two proofs.
  • On a request with a bound session, a refusal is 401 with WWW-Authenticate: DPoP algs="ES256 ES384", error="…", error_description="…":
    • invalid_dpop_proof: there is no proof, or two, or the proof is not for this request and this token, is not signed by the session's key, is outside its window, or was sent before;
    • invalid_token: the session was sent as Bearer, or a session bound to no key was sent as DPoP.

Session Management​

Each token exchange creates a session, which can be inspected via:

GET /mcp/sessions/{id}

Send the session's own token, with a proof when the session is bound to a key. The answer is the session's metadata: the agent, its environment, the mandates it was issued with less any unbound since, and its creation and expiry times, and for a bound session cnf.jkt, its key's thumbprint. call_count counted the calls /mcp/authorize decided, so a session issued since its retirement reads 0.

info

The MCP Authorization Server is currently in Beta. Sessions, their binding to a key (DPoP) and the token exchange are stable in shape; the browser sign-in flow for people using an MCP client does not exist yet.