MCP servers

An MCP server is an OAuth protected resource. MCP clients such as Claude and Cursor discover its authorization server, sign the user in, and call the server with a bearer token. Gate plays the authorization server for every Evonia product, so your MCP server never handles a password and the user who connects is the same Gate user who signs in to your product directly.

MCP clients have no prior relationship with Gate and nobody registers them. They identify themselves with an OAuth Client ID Metadata Document: the client_id is an HTTPS URL, and the JSON served there is the client's registration. Dynamic Client Registration stays off.

Your side of the integration is three things: name the MCP server on its project, publish protected resource metadata that points at Gate, and verify the tokens it receives.

How the flow fits together

  1. The client calls your MCP server without a token. You answer 401 with a WWW-Authenticate header pointing at your protected resource metadata.

  2. The client reads that metadata (RFC 9728). Its authorization_servers entry is Gate's issuer, https://evonia-gate.annatarhe.com/api/auth.

  3. The client reads Gate's metadata at https://evonia-gate.annatarhe.com/.well-known/oauth-authorization-server/api/auth and finds client_id_metadata_document_supported: true and none among the token endpoint auth methods.

  4. The client starts an authorization request at /api/auth/oauth2/authorize with its URL as client_id, PKCE (S256), and your MCP server as resource. Gate fetches and validates the client's metadata document, and refuses the request unless an active project has configured that resource.

  5. The user signs in under your project. The sign-in page offers your project's social providers, so the result is the same Gate user as signing in to your product.

  6. The user consents. The consent screen names the client, the host its document came from, and your project, and warns that the application registered itself.

  7. The client exchanges the code, again sending resource. A code only redeems for the resource it was authorized for. Gate returns an ES256 JWT access token whose audience is your MCP server, plus a refresh token when the client asked for offline_access.

  8. The client calls your MCP server with Authorization: Bearer … and refreshes before the token expires, sending resource on every refresh as well.

1. Name the MCP server on its project

Each project may name one MCP server. Set MCP server URL under the project's settings in the dashboard, or send mcpResource to the project endpoint (project:write):

curl
curl -sS -X PATCH "https://evonia-gate.annatarhe.com/api/v1/projects/$PROJECT_ID" \
  -H "x-api-key: $GATE_API_KEY" \
  -H "content-type: application/json" \
  -d '{"mcpResource":"https://mcp.example.com/mcp"}'
  • Use the exact URL clients connect to. It must be https (http only on localhost) with no credentials, query, or fragment. Gate stores it without a trailing slash.
  • A URL belongs to at most one project; claiming one another project already uses returns 409.
  • Within 30 seconds, every Gate instance accepts the URL as a token audience. Send null or an empty string to clear it; clearing it or archiving the project or organization withdraws it the same way.

2. Publish protected resource metadata

Serve RFC 9728 metadata describing your MCP server. For a resource with a path, the well-known location inserts that path after /.well-known/oauth-protected-resource, so https://mcp.example.com/mcp publishes at https://mcp.example.com/.well-known/oauth-protected-resource/mcp:

JSON
{
  "resource": "https://mcp.example.com/mcp",
  "authorization_servers": ["https://evonia-gate.annatarhe.com/api/auth"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": ["openid", "profile", "email", "offline_access"]
}

authorization_servers holds the issuer, including the /api/auth suffix, not the bare origin. Gate serves its authorization server metadata at the matching path-inserted location.

3. Challenge requests without a valid token

Answer a missing, expired, or otherwise invalid token with 401 and a WWW-Authenticate header that carries the metadata URL. That header is how a client finds its way to Gate:

HTTP
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"

4. Verify the access token

Verify locally against Gate's JWKS, exactly as Verifying tokens describes, with your MCP server URL as the audience.

import { Hono } from "hono";
import { createRemoteJWKSet, jwtVerify } from "jose";

const ISSUER = "https://evonia-gate.annatarhe.com/api/auth";
const RESOURCE = "https://mcp.example.com/mcp"; // exactly the project's MCP server URL
const METADATA_URL = "https://mcp.example.com/.well-known/oauth-protected-resource/mcp";

// Module scope: the set caches keys and refetches only when it sees an unknown `kid`.
const jwks = createRemoteJWKSet(new URL("https://evonia-gate.annatarhe.com/.well-known/jwks.json"));

const app = new Hono<{ Variables: { userId: string; clientId: string } }>();

app.get("/.well-known/oauth-protected-resource/mcp", (c) =>
  c.json({
    resource: RESOURCE,
    authorization_servers: [ISSUER],
    bearer_methods_supported: ["header"],
    scopes_supported: ["openid", "profile", "email", "offline_access"],
  }),
);

app.use("/mcp", async (c, next) => {
  const header = c.req.header("authorization") ?? "";
  try {
    if (!header.startsWith("Bearer ")) throw new Error("missing bearer token");
    const { payload } = await jwtVerify(header.slice("Bearer ".length), jwks, {
      algorithms: ["ES256"],
      audience: RESOURCE,
      clockTolerance: 5,
      issuer: ISSUER,
    });
    c.set("userId", payload.sub!);
    c.set("clientId", String(payload.azp));
  } catch {
    c.header("WWW-Authenticate", `Bearer resource_metadata="${METADATA_URL}"`);
    return c.json({ error: "invalid_token" }, 401);
  }
  await next();
});

What these tokens look like:

ClaimValue
isshttps://evonia-gate.annatarhe.com/api/auth
subThe Gate user id, the same one your product's own clients see
audAn array: your MCP server URL, plus Gate's userinfo endpoint when openid was granted
azpThe MCP client's metadata document URL
scopeA subset of openid profile email offline_access
exp600 s after issue
https://evonia-gate.annatarhe.com/resourceYour MCP server URL

Your MCP server URL is an audience for every Gate client, not only for metadata-document clients. Your product's own web or native client can request a token for it by sending the same resource.

What the MCP client sends

You do not write this part, but it helps when reading logs or testing by hand. The client hosts a document like this at its client_id URL:

JSON
{
  "client_id": "https://client.example/oauth/client-metadata.json",
  "client_name": "Example MCP Client",
  "client_uri": "https://client.example",
  "logo_uri": "https://client.example/logo.png",
  "redirect_uris": ["http://127.0.0.1/callback", "https://client.example/oauth/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}

Gate accepts it when:

  • The client_id URL is https, has a path, carries no query, fragment, or credentials, and equals the document's client_id exactly.
  • client_name is present and there are 1 to 20 redirect_uris. Each is https, a loopback http URL, or a reverse-domain private scheme such as com.example.app:/callback. A loopback redirect matches the same path on any port, since native clients bind an ephemeral one.
  • token_endpoint_auth_method is none or absent, and grant_types holds only authorization_code and refresh_token. The client receives refresh tokens only when it lists refresh_token and requests offline_access.

Gate fetches the document over HTTPS from public addresses only, follows no redirects, and gives up after 5 seconds or 10 KB. It keeps the copy for the document's Cache-Control: max-age, clamped between 5 minutes and 24 hours.

To walk through the exchange by hand, open the authorization URL in a browser (line breaks added for reading; URL-encode each value):

Text
https://evonia-gate.annatarhe.com/api/auth/oauth2/authorize
  ?response_type=code
  &client_id=https://client.example/oauth/client-metadata.json
  &redirect_uri=http://127.0.0.1:53682/callback
  &scope=openid profile email offline_access
  &state=$STATE
  &code_challenge=$CHALLENGE
  &code_challenge_method=S256
  &resource=https://mcp.example.com/mcp

Then redeem the code from the loopback redirect, naming the same resource:

curl
curl -sS -X POST https://evonia-gate.annatarhe.com/api/auth/oauth2/token \
  -d grant_type=authorization_code \
  -d client_id=https://client.example/oauth/client-metadata.json \
  -d code="$CODE" \
  -d redirect_uri=http://127.0.0.1:53682/callback \
  -d code_verifier="$VERIFIER" \
  -d resource=https://mcp.example.com/mcp

Troubleshooting

SymptomCause
invalid_target on authorizeresource is missing, or no active project has configured it (check the trailing slash)
invalid_target on tokenresource is missing, or differs from the one the code was authorized for. Refreshes need it too
invalid_clientThe metadata document could not be fetched or failed validation; the description says which
unauthorized_clientA grant other than authorization_code or refresh_token
invalid_redirectThe redirect_uri is not in the document (loopback URIs aside, which match on any port)
Your server rejects the audYour verifier expects a different URL from the one stored on the project
Tokens stop being issued for your resourceThe URL was cleared, or the project or organization archived; Gate withdraws it within 30 seconds