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
-
The client calls your MCP server without a token. You answer
401with aWWW-Authenticateheader pointing at your protected resource metadata. -
The client reads that metadata (RFC 9728). Its
authorization_serversentry is Gate's issuer,https://evonia-gate.annatarhe.com/api/auth. -
The client reads Gate's metadata at
https://evonia-gate.annatarhe.com/.well-known/oauth-authorization-server/api/authand findsclient_id_metadata_document_supported: trueandnoneamong the token endpoint auth methods. -
The client starts an authorization request at
/api/auth/oauth2/authorizewith its URL asclient_id, PKCE (S256), and your MCP server asresource. Gate fetches and validates the client's metadata document, and refuses the request unless an active project has configured that resource. -
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.
-
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.
-
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 foroffline_access. -
The client calls your MCP server with
Authorization: Bearer …and refreshes before the token expires, sendingresourceon 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 -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(httponly onlocalhost) 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
nullor 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:
{
"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/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();
});package mcpauth
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
)
const (
issuer = "https://evonia-gate.annatarhe.com/api/auth"
resource = "https://mcp.example.com/mcp" // exactly the project's MCP server URL
metadataURL = "https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
)
type Caller struct {
UserID string `json:"sub"`
ClientID string `json:"azp"`
}
type callerKey struct{}
// Build once at startup: the provider caches JWKS and refreshes on unknown kid.
func NewVerifier(ctx context.Context) (*oidc.IDTokenVerifier, error) {
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, err
}
return provider.Verifier(&oidc.Config{
ClientID: resource, // checked against `aud`
SupportedSigningAlgs: []string{oidc.ES256},
}), nil
}
// Serve at /.well-known/oauth-protected-resource/mcp.
func ProtectedResourceMetadata(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"resource": resource,
"authorization_servers": []string{issuer},
"bearer_methods_supported": []string{"header"},
"scopes_supported": []string{"openid", "profile", "email", "offline_access"},
})
}
// Wrap the MCP endpoint.
func RequireToken(v *oidc.IDTokenVerifier, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
caller, err := authenticate(r.Context(), v, r.Header.Get("Authorization"))
if err != nil {
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+metadataURL+`"`)
http.Error(w, "invalid_token", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), callerKey{}, caller)))
})
}
func authenticate(ctx context.Context, v *oidc.IDTokenVerifier, header string) (Caller, error) {
var caller Caller
raw, ok := strings.CutPrefix(header, "Bearer ")
if !ok {
return caller, errors.New("missing bearer token")
}
token, err := v.Verify(ctx, raw)
if err != nil {
return caller, err
}
return caller, token.Claims(&caller)
}What these tokens look like:
| Claim | Value |
|---|---|
iss | https://evonia-gate.annatarhe.com/api/auth |
sub | The Gate user id, the same one your product's own clients see |
aud | An array: your MCP server URL, plus Gate's userinfo endpoint when openid was granted |
azp | The MCP client's metadata document URL |
scope | A subset of openid profile email offline_access |
exp | 600 s after issue |
https://evonia-gate.annatarhe.com/resource | Your 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:
{
"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_idURL ishttps, has a path, carries no query, fragment, or credentials, and equals the document'sclient_idexactly. client_nameis present and there are 1 to 20redirect_uris. Each ishttps, a loopbackhttpURL, or a reverse-domain private scheme such ascom.example.app:/callback. A loopback redirect matches the same path on any port, since native clients bind an ephemeral one.token_endpoint_auth_methodisnoneor absent, andgrant_typesholds onlyauthorization_codeandrefresh_token. The client receives refresh tokens only when it listsrefresh_tokenand requestsoffline_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):
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/mcpThen redeem the code from the loopback redirect, naming the same resource:
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/mcpTroubleshooting
| Symptom | Cause |
|---|---|
invalid_target on authorize | resource is missing, or no active project has configured it (check the trailing slash) |
invalid_target on token | resource is missing, or differs from the one the code was authorized for. Refreshes need it too |
invalid_client | The metadata document could not be fetched or failed validation; the description says which |
unauthorized_client | A grant other than authorization_code or refresh_token |
invalid_redirect | The redirect_uri is not in the document (loopback URIs aside, which match on any port) |
Your server rejects the aud | Your verifier expects a different URL from the one stored on the project |
| Tokens stop being issued for your resource | The URL was cleared, or the project or organization archived; Gate withdraws it within 30 seconds |