OAuth2 for the MCP Client, and the Identity Assertion Grant | eSeGeCe Blog

OAuth2 for the MCP Client, and the Identity Assertion Grant

· Components
OAuth2 for the MCP Client, and the Identity Assertion Grant

Remote MCP servers are protected resources, and the servers people actually deploy sit behind an authorization server. Until now the sgcWebSockets MCP client could send an API key or a custom header, which was fine for a static token but meant that anything with a lifetime was your problem: you called the token endpoint yourself, parsed the response, watched the clock, and refreshed before the token expired.

sgcWebSockets 2026.7 moves that work into the component. The MCP client can now authenticate with OAuth2, so it fetches the access token, caches it, and reuses it on every request until it expires. The same release adds a new grant type, the Identity Assertion Authorization Grant, which chains an identity from one domain into another without sending the user through a second interactive login.

Client, not server

One clarification first, because the two sides are easy to mix up. sgcWebSockets 2026.5 shipped OAuth 2.1 on the MCP Server: discovery endpoints, PKCE and dynamic client registration, so a browser-based MCP client could authorize against your Delphi server. That was covered previously. This post is about the other end of the wire, the MCP Client, which now obtains a token of its own and presents it to the remote MCP server it connects to.

What the MCP client could do before

Two options, both still available and both unchanged. An API key, sent as Authorization: Bearer <value>, and a custom header for tenant or region routing. The earlier post MCP Authentication Delphi covers both in detail.

MCPClient.MCPOptions.AuthenticationOptions.ApiKey.Enabled := True;
MCPClient.MCPOptions.AuthenticationOptions.ApiKey.Value := 'YOUR_API_KEY';

MCPClient.MCPOptions.AuthenticationOptions.CustomHeader.Enabled := True;
MCPClient.MCPOptions.AuthenticationOptions.CustomHeader.Header := 'X-Tenant';
MCPClient.MCPOptions.AuthenticationOptions.CustomHeader.Value := 'Retail';

What was missing is everything in between: a token endpoint, a client id and secret, a scope, an expiry. That is what the new OAuth2 object adds.

OAuth2 on the MCP client

Authentication now has a third branch, MCPOptions.AuthenticationOptions.OAuth2, of type TsgcWSMCPClientAuthenticationOAuth2_Options. Its properties are the ones you would expect from an OAuth2 client: Enabled, TokenURL, ClientId, ClientSecret, Scope, a GrantType, and an IdentityAssertionOptions object used by the new grant. There is one method, GetBearerToken, and normally you never call it: the client calls it for you on every MCP request and puts the result in the Authorization header.

The grant is selected with GrantType, of type TsgcOAuth2GrantTypes:

TsgcOAuth2GrantTypes = (auth2Code, auth2CodePKCE, auth2ClientCredentials,
  auth2ResourceOwnerPassword, auth2DeviceCode, auth2IdentityAssertion);

The last value, auth2IdentityAssertion, is new in 2026.7. The others were already supported by the OAuth2 HTTP client and are now reachable from the MCP client too.

Client credentials: the common case

A service that talks to a remote MCP server, with no user in the loop, is the client credentials grant. Fill in the token endpoint and the credentials, enable it, and connect:

uses
  sgcAI, sgcAI_MCP_Client, sgcHTTP_OAuth_Types;

var
  MCPClient: TsgcWSAPIClient_MCP;
begin
  MCPClient := TsgcWSAPIClient_MCP.Create(nil);

  MCPClient.MCPOptions.HttpOptions.URL := 'https://mcp.example.com/mcp';

  MCPClient.MCPOptions.AuthenticationOptions.OAuth2.Enabled := True;
  MCPClient.MCPOptions.AuthenticationOptions.OAuth2.GrantType := auth2ClientCredentials;
  MCPClient.MCPOptions.AuthenticationOptions.OAuth2.TokenURL := 'https://auth.example.com/oauth2/token';
  MCPClient.MCPOptions.AuthenticationOptions.OAuth2.ClientId := 'YOUR_CLIENT_ID';
  MCPClient.MCPOptions.AuthenticationOptions.OAuth2.ClientSecret := 'YOUR_CLIENT_SECRET';
  MCPClient.MCPOptions.AuthenticationOptions.OAuth2.Scope := 'mcp.read mcp.tools';

  MCPClient.Initialize;
  MCPClient.ListTools;
end;

No token handling in your code. The first request that needs a token triggers the grant, and every request after that reuses what is cached.

Token caching and priority over the API key

The token is acquired lazily, on the first request, and kept in memory with its expiry. While it is still valid the client reuses it. When it expires, or when the server never told you an expiry and the token comes back empty, the grant runs again. The client also refreshes slightly ahead of the stated expiry, so a token that is about to lapse is not sent on a request that would be rejected by the time it arrives.

Priority is simple and worth stating clearly: when OAuth2.Enabled is True, OAuth2 wins. The Authorization header carries the bearer token from the grant, and ApiKey is ignored even if it is also enabled. That lets you leave an existing API key configured while you migrate, then flip OAuth2.Enabled and change nothing else. CustomHeader is orthogonal and is still sent in both cases.

TLS options on the MCP client

The MCP client HTTP transport now exposes its own TLS configuration through MCPOptions.HttpOptions.TLSOptions, so the connection to the MCP server and to the token endpoint can be pinned to the settings your environment requires instead of the defaults.

MCPClient.MCPOptions.HttpOptions.TLSOptions.Version := tls1_3;
MCPClient.MCPOptions.HttpOptions.TLSOptions.VerifyCertificate := True;
MCPClient.MCPOptions.HttpOptions.TLSOptions.RootCertFile := 'C:\certs\ca-bundle.pem';
MCPClient.MCPOptions.HttpOptions.TLSOptions.IOHandler := iohOpenSSL;   // or iohSChannel on Windows

The Identity Assertion grant

Now the interesting part. auth2IdentityAssertion is an IETF-defined OAuth2 grant for chaining an identity across domains, and it is a token-exchange style flow.

The situation it solves looks like this. Your application already holds a token that proves who the user is in domain A, typically an id token from the identity provider the user signed in with. You now need to call an MCP server in domain B, which will not accept the domain A token: it trusts its own authorization server, not someone else's. The user is right there and already authenticated, so sending them through a second interactive login is exactly the friction you want to avoid.

The Identity Assertion grant is the way out. You present the domain A token as the subject token, and the exchange returns a token scoped to the audience or resource you asked for, one that domain B accepts. That is why the options are shaped the way they are: SubjectToken and SubjectTokenType describe what you hold, RequestedTokenType, Audience and Resource describe what you want, and ActorToken / ActorTokenType let you name the service acting on the user's behalf when the target requires it.

It is a multi-step exchange, and the client walks it for you. The subject token goes to the requesting party's authorization server (RequestingPartyTokenURL, with RequestingPartyClientId and RequestingPartyClientSecret), which returns a cross-domain assertion. That assertion is then presented to the resource authorization server at TokenURL, which issues the access token the MCP client actually sends. If you already have the assertion from somewhere else, set Assertion and the first leg is skipped.

with MCPClient.MCPOptions.AuthenticationOptions.OAuth2 do
begin
  Enabled := True;
  GrantType := auth2IdentityAssertion;

  // resource authorization server (domain B): issues the token the MCP server accepts
  TokenURL := 'https://auth.domain-b.com/oauth2/token';
  ClientId := 'YOUR_CLIENT_ID';
  ClientSecret := 'YOUR_CLIENT_SECRET';
  Scope := 'mcp.tools';

  // the identity you already hold in domain A
  IdentityAssertionOptions.SubjectToken := vIdTokenFromDomainA;
  IdentityAssertionOptions.SubjectTokenType := 'urn:ietf:params:oauth:token-type:id_token';
  IdentityAssertionOptions.RequestedTokenType := 'urn:ietf:params:oauth:token-type:id-jag';

  // where the exchange happens, and who you are exchanging as
  IdentityAssertionOptions.RequestingPartyTokenURL := 'https://auth.domain-a.com/oauth2/token';
  IdentityAssertionOptions.RequestingPartyClientId := 'DOMAIN_A_CLIENT_ID';
  IdentityAssertionOptions.RequestingPartyClientSecret := 'DOMAIN_A_CLIENT_SECRET';

  // what the resulting token is for
  IdentityAssertionOptions.Audience := 'https://mcp.domain-b.com';
  IdentityAssertionOptions.Resource := 'https://mcp.domain-b.com/mcp';
end;

MCPClient.Initialize;

From the calling code it is still one property and one connect. Everything above happens inside GetBearerToken, and the resulting access token is cached like any other.

Following the exchange

The grant is not limited to MCP. TsgcHTTPComponentClient_OAuth2 supports it directly, which is what you want when you need to see each step, log it, or reuse the token elsewhere. Select the grant on OAuth2Options.GrantType, fill in IdentityAssertionOptions, and call Start. The intermediate assertion is left in the read-only IdentityAssertion property, and DoIdentityAssertionGrant is there if you want to drive the flow explicitly.

The events report each leg. OnBeforeTokenExchange, OnAfterTokenExchange and OnErrorTokenExchange cover the cross-domain exchange, and the existing OnAuthToken and OnAuthTokenError report the final result.

uses
  sgcHTTP_OAuth2_Client, sgcHTTP_OAuth_Types;

procedure TForm1.OnAfterTokenExchange(Sender: TObject; const Access_Token,
  Issued_Token_Type, Token_Type, Expires_In, Scope, RawParams: String;
  var Handled: Boolean);
begin
  // ... leg 1 done: we now hold the cross-domain assertion
  Log('assertion issued, type: ' + Issued_Token_Type);
end;

procedure TForm1.OnAuthToken(Sender: TObject; const TokenType, Token,
  Data: String);
begin
  // ... leg 2 done: this is the token domain B accepts
  Log('access token: ' + TokenType + ' ' + Token);
end;

procedure TForm1.Button1Click(Sender: TObject);
var
  oOAuth2: TsgcHTTPComponentClient_OAuth2;
begin
  oOAuth2 := TsgcHTTPComponentClient_OAuth2.Create(nil);
  try
    oOAuth2.OAuth2Options.GrantType := auth2IdentityAssertion;
    oOAuth2.OAuth2Options.ClientId := 'YOUR_CLIENT_ID';
    oOAuth2.OAuth2Options.ClientSecret := 'YOUR_CLIENT_SECRET';
    oOAuth2.AuthorizationServerOptions.TokenURL := 'https://auth.domain-b.com/oauth2/token';

    oOAuth2.IdentityAssertionOptions.SubjectToken := vIdTokenFromDomainA;
    oOAuth2.IdentityAssertionOptions.SubjectTokenType := 'urn:ietf:params:oauth:token-type:id_token';
    oOAuth2.IdentityAssertionOptions.RequestedTokenType := 'urn:ietf:params:oauth:token-type:id-jag';
    oOAuth2.IdentityAssertionOptions.RequestingPartyTokenURL := 'https://auth.domain-a.com/oauth2/token';
    oOAuth2.IdentityAssertionOptions.Audience := 'https://mcp.domain-b.com';

    oOAuth2.OnAfterTokenExchange := OnAfterTokenExchange;
    oOAuth2.OnErrorTokenExchange := OnErrorTokenExchange;
    oOAuth2.OnAuthToken := OnAuthToken;
    oOAuth2.OnAuthTokenError := OnAuthTokenError;

    oOAuth2.Start;
  finally
    oOAuth2.Free;
  end;
end;

If a leg fails, the error event carries the OAuth2 error code and description as the authorization server returned them, so a misconfigured audience or an unaccepted subject token type is visible immediately instead of surfacing later as a 401 from the MCP server.

Availability

OAuth2 authentication on the MCP client, the Identity Assertion grant and the MCP client TLS options are available in sgcWebSockets 2026.7 for Delphi 7 through 13 and C++Builder, on Win32/Win64, Linux64, macOS, Android and iOS. Nothing changes for existing code: OAuth2 is off until you set Enabled, and API key and custom header authentication behave exactly as before.

Customers with an active subscription can download the new build from the customer area. Trial users can grab the updated installer at esegece.com/products/websockets/download.

Questions, feedback or help wiring a grant into your app? Get in touch, you will get a reply from the people who wrote the code.