MCP 클라이언트를 위한 OAuth2, 그리고 Identity Assertion 그랜트 | eSeGeCe 블로그

MCP 클라이언트를 위한 OAuth2, 그리고 Identity Assertion 그랜트

· 컴포넌트

원격 MCP 서버는 보호된 리소스이며, 실제로 배포되는 서버는 인가 서버 뒤에 자리합니다. 지금까지 sgcWebSockets MCP 클라이언트는 API 키나 커스텀 헤더를 보낼 수 있었고, 정적 토큰이라면 그것으로 충분했습니다. 하지만 수명이 있는 토큰이라면 모든 것이 여러분의 몫이었습니다. 직접 토큰 엔드포인트를 호출하고, 응답을 파싱하고, 시계를 지켜보다가, 토큰이 만료되기 전에 갱신해야 했습니다.

sgcWebSockets 2026.7은 그 작업을 컴포넌트 안으로 옮겼습니다. 이제 MCP 클라이언트는 OAuth2로 인증할 수 있습니다. 액세스 토큰을 가져와 캐시하고, 만료될 때까지 모든 요청에서 재사용합니다. 같은 릴리스에서 새로운 그랜트 타입인 Identity Assertion Authorization Grant도 추가되었습니다. 이 그랜트는 사용자를 두 번째 대화형 로그인으로 보내지 않고도 한 도메인의 아이덴티티를 다른 도메인으로 연결해 줍니다.

서버가 아니라 클라이언트

먼저 한 가지 짚고 넘어가겠습니다. 두 쪽을 혼동하기 쉽기 때문입니다. sgcWebSockets 2026.5는 MCP 서버의 OAuth 2.1을 제공했습니다. 디스커버리 엔드포인트, PKCE, 동적 클라이언트 등록을 갖추어 브라우저 기반 MCP 클라이언트가 여러분의 Delphi 서버에 인가를 받을 수 있게 했습니다. 그 내용은 이전에 다루었습니다. 이번 글은 그 반대편, 즉 MCP 클라이언트에 관한 것입니다. 이제 MCP 클라이언트는 자체 토큰을 획득해서 자신이 접속하는 원격 MCP 서버에 제시합니다.

이전에 MCP 클라이언트가 할 수 있었던 것

두 가지 옵션이 있었고, 둘 다 여전히 사용 가능하며 변경되지 않았습니다. Authorization: Bearer <value>로 전송되는 API 키, 그리고 테넌트나 리전 라우팅을 위한 커스텀 헤더입니다. 이전 글 MCP Authentication Delphi에서 두 가지를 자세히 다룹니다.

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';

빠져 있던 것은 그 사이의 모든 것이었습니다. 토큰 엔드포인트, 클라이언트 ID와 시크릿, 스코프, 만료 시간 말입니다. 새로운 OAuth2 객체가 바로 그것을 채워 줍니다.

MCP 클라이언트의 OAuth2

이제 인증에는 세 번째 갈래인 MCPOptions.AuthenticationOptions.OAuth2가 있으며, 타입은 TsgcWSMCPClientAuthenticationOAuth2_Options입니다. 프로퍼티는 OAuth2 클라이언트에서 예상할 만한 것들입니다. Enabled, TokenURL, ClientId, ClientSecret, Scope, GrantType, 그리고 새 그랜트가 사용하는 IdentityAssertionOptions 객체입니다. 메서드는 GetBearerToken 하나뿐이며, 보통은 직접 호출할 일이 없습니다. 모든 MCP 요청마다 클라이언트가 대신 호출해서 결과를 Authorization 헤더에 넣어 줍니다.

그랜트는 TsgcOAuth2GrantTypes 타입의 GrantType으로 선택합니다.

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

마지막 값인 auth2IdentityAssertion이 2026.7에서 새로 추가된 것입니다. 나머지는 이미 OAuth2 HTTP 클라이언트에서 지원되던 것이며, 이제 MCP 클라이언트에서도 사용할 수 있습니다.

클라이언트 크리덴셜: 가장 흔한 경우

사용자가 개입하지 않은 채 원격 MCP 서버와 통신하는 서비스라면 클라이언트 크리덴셜 그랜트입니다. 토큰 엔드포인트와 크리덴셜을 채우고, 활성화한 다음, 접속하면 됩니다.

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;

여러분의 코드에는 토큰 처리가 전혀 없습니다. 토큰이 필요한 첫 요청이 그랜트를 트리거하고, 그 이후의 모든 요청은 캐시된 토큰을 재사용합니다.

토큰 캐싱과 API 키보다 높은 우선순위

토큰은 첫 요청 시점에 지연 획득되어 만료 시각과 함께 메모리에 보관됩니다. 유효한 동안에는 클라이언트가 그것을 재사용합니다. 만료되거나, 서버가 만료 시각을 알려주지 않았고 토큰이 비어 있는 상태로 돌아오면 그랜트가 다시 실행됩니다. 또한 클라이언트는 명시된 만료 시각보다 약간 앞서 갱신하므로, 곧 만료될 토큰이 요청에 실려 나갔다가 도착할 무렵에는 거부되는 일이 없습니다.

우선순위는 단순하며 분명히 짚어 둘 만합니다. OAuth2.Enabled가 True이면 OAuth2가 우선합니다. Authorization 헤더는 그랜트에서 얻은 베어러 토큰을 실어 나르고, ApiKey는 함께 활성화되어 있더라도 무시됩니다. 덕분에 마이그레이션하는 동안 기존 API 키 설정을 그대로 둔 채 OAuth2.Enabled만 켜고 다른 것은 아무것도 바꾸지 않아도 됩니다. CustomHeader는 이와 별개이며 두 경우 모두 계속 전송됩니다.

MCP 클라이언트의 TLS 옵션

이제 MCP 클라이언트의 HTTP 전송 계층은 MCPOptions.HttpOptions.TLSOptions를 통해 자체 TLS 설정을 노출합니다. 따라서 MCP 서버와 토큰 엔드포인트로의 연결을 기본값 대신 여러분의 환경이 요구하는 설정으로 고정할 수 있습니다.

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

Identity Assertion 그랜트

이제 흥미로운 부분입니다. auth2IdentityAssertion은 도메인을 넘어 아이덴티티를 연결하기 위해 IETF가 정의한 OAuth2 그랜트이며, 토큰 교환 방식의 플로우입니다.

이 그랜트가 해결하는 상황은 이렇습니다. 여러분의 애플리케이션은 이미 도메인 A에서 사용자가 누구인지 증명하는 토큰을 보유하고 있습니다. 보통은 사용자가 로그인한 아이덴티티 공급자가 발급한 ID 토큰입니다. 그런데 이제 도메인 B의 MCP 서버를 호출해야 하는데, 그 서버는 도메인 A의 토큰을 받아들이지 않습니다. 남의 인가 서버가 아니라 자기 인가 서버를 신뢰하기 때문입니다. 사용자는 바로 그 자리에 있고 이미 인증되어 있으므로, 두 번째 대화형 로그인을 거치게 하는 것은 정확히 피하고 싶은 마찰입니다.

Identity Assertion 그랜트가 그 해법입니다. 도메인 A의 토큰을 subject token으로 제시하면, 교환 결과로 여러분이 요청한 audience 또는 resource에 스코프가 지정된 토큰, 즉 도메인 B가 받아들이는 토큰이 반환됩니다. 옵션의 구성이 그렇게 되어 있는 이유입니다. SubjectTokenSubjectTokenType은 여러분이 보유한 것을 기술하고, RequestedTokenType, Audience, Resource는 여러분이 원하는 것을 기술하며, ActorToken / ActorTokenType은 대상 서버가 요구할 때 사용자를 대신해 행위하는 서비스를 지정하게 해 줍니다.

이것은 여러 단계로 이루어진 교환이며, 클라이언트가 대신 그 과정을 밟아 줍니다. subject token은 요청 당사자의 인가 서버(RequestingPartyTokenURL, RequestingPartyClientIdRequestingPartyClientSecret과 함께)로 전달되고, 그 서버는 도메인 간 어서션을 반환합니다. 그 어서션은 다시 TokenURL의 리소스 인가 서버에 제시되고, 그 서버가 MCP 클라이언트가 실제로 전송할 액세스 토큰을 발급합니다. 이미 다른 곳에서 어서션을 확보했다면 Assertion을 설정하면 되고, 첫 번째 구간은 건너뜁니다.

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;

호출하는 코드 입장에서는 여전히 프로퍼티 하나와 접속 하나입니다. 위의 모든 일은 GetBearerToken 안에서 일어나고, 그 결과로 얻은 액세스 토큰은 다른 토큰과 마찬가지로 캐시됩니다.

교환 과정 따라가기

이 그랜트는 MCP에만 국한되지 않습니다. TsgcHTTPComponentClient_OAuth2가 직접 지원하므로, 각 단계를 확인하거나 로깅하거나 토큰을 다른 곳에서 재사용해야 할 때 이것을 쓰면 됩니다. OAuth2Options.GrantType에서 그랜트를 선택하고, IdentityAssertionOptions를 채운 다음, Start를 호출하세요. 중간 어서션은 읽기 전용 IdentityAssertion 프로퍼티에 남으며, 플로우를 명시적으로 직접 구동하고 싶다면 DoIdentityAssertionGrant가 준비되어 있습니다.

이벤트가 각 구간을 보고합니다. OnBeforeTokenExchange, OnAfterTokenExchange, OnErrorTokenExchange가 도메인 간 교환을 다루고, 기존의 OnAuthTokenOnAuthTokenError가 최종 결과를 보고합니다.

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;

어느 한 구간이 실패하면, 오류 이벤트가 인가 서버가 반환한 그대로의 OAuth2 오류 코드와 설명을 전달합니다. 따라서 잘못 설정된 audience나 받아들여지지 않는 subject token 타입이 나중에 MCP 서버의 401로 드러나는 대신 즉시 보입니다.

제공 범위

MCP 클라이언트의 OAuth2 인증, Identity Assertion 그랜트, MCP 클라이언트 TLS 옵션은 sgcWebSockets 2026.7의 Delphi 7부터 13까지와 C++Builder에서, Win32/Win64, Linux64, macOS, Android, iOS를 대상으로 제공됩니다. 기존 코드에는 아무 변화가 없습니다. Enabled를 설정하기 전까지 OAuth2는 꺼져 있고, API 키와 커스텀 헤더 인증은 이전과 똑같이 동작합니다.

활성 구독 중인 고객은 고객 영역에서 새 빌드를 내려받을 수 있습니다. 평가판 사용자는 esegece.com/products/websockets/download에서 업데이트된 설치 파일을 받을 수 있습니다.

질문이나 피드백이 있거나, 애플리케이션에 그랜트를 연결하는 데 도움이 필요하신가요? 문의해 주세요. 코드를 직접 작성한 사람들이 답변해 드립니다.