Skip to content

Commit 090b36b

Browse files
author
Johan Broberg
committed
feat: Add x-ms-agentid header for MCP platform calls
Port from Node.js SDK PR #183 - adds x-ms-agentid header to all outbound HTTP requests to the MCP platform for agent identification. Header priority: 1. Agent Blueprint ID from TurnContext (agenticAppBlueprintId) 2. Agent Blueprint ID from token (xms_par_app_azp claim) 3. Entra Application ID from token (appid or azp claim) 4. Application name from entry assembly Changes: - Add GetAgentIdFromToken() and GetApplicationName() to Runtime Utility - Add AgentIdHeader constant to Tooling Constants - Update HttpContextHeadersHandler to add x-ms-agentid header - Pass authToken to HttpContextHeadersHandler for header resolution - Add InternalsVisibleTo for test access - Add comprehensive unit tests for new methods - Add PRD documentation
1 parent d264811 commit 090b36b

7 files changed

Lines changed: 603 additions & 6 deletions

File tree

src/Runtime/Core/Utility.cs

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,18 @@ public static HttpClient GetDefaultHttpClient(IHttpClientFactory httpClientFacto
5454
}
5555

5656
/// <summary>
57-
/// Decodes the current token and retrieves the App ID (appid or azp claim).
57+
/// <para><b>WARNING: NO SIGNATURE VERIFICATION</b> - This method uses JwtSecurityTokenHandler.ReadJwtToken()
58+
/// which does NOT verify the token signature. The token claims can be spoofed by malicious actors.</para>
59+
/// <para>This method is ONLY suitable for logging, analytics, and diagnostics purposes.
60+
/// Do NOT use the returned value for authorization, access control, or security decisions.</para>
61+
/// <para>Decodes the current token and retrieves the App ID (appid or azp claim).</para>
62+
/// <para>Note: Returns a default GUID ('00000000-0000-0000-0000-000000000000') for empty tokens
63+
/// for backward compatibility with callers that expect a valid-looking GUID.
64+
/// For agent identification where empty string is preferred, use <see cref="GetAgentIdFromToken"/>.</para>
5865
/// </summary>
5966
/// <param name="token">Token to Decode</param>
60-
/// <returns>AppId</returns>
67+
/// <returns>AppId, or default GUID for empty token</returns>
68+
/// <exception cref="ArgumentException">Thrown when token format is invalid</exception>
6169
public static string GetAppIdFromToken(string token)
6270
{
6371
if (string.IsNullOrWhiteSpace(token))
@@ -70,6 +78,62 @@ public static string GetAppIdFromToken(string token)
7078
return appIdClaim?.Value ?? string.Empty;
7179
}
7280

81+
/// <summary>
82+
/// <para><b>WARNING: NO SIGNATURE VERIFICATION</b> - This method uses JwtSecurityTokenHandler.ReadJwtToken()
83+
/// which does NOT verify the token signature. The token claims can be spoofed by malicious actors.</para>
84+
/// <para>This method is ONLY suitable for logging, analytics, and diagnostics purposes.
85+
/// Do NOT use the returned value for authorization, access control, or security decisions.</para>
86+
/// <para>Decodes the token and retrieves the best available agent identifier.
87+
/// Checks claims in priority order: xms_par_app_azp (agent blueprint ID) > appid > azp.</para>
88+
/// <para>Note: Returns empty string for empty/missing tokens (unlike <see cref="GetAppIdFromToken"/> which
89+
/// returns a default GUID). This allows callers to omit headers when no identifier is available.</para>
90+
/// </summary>
91+
/// <param name="token">JWT token to decode</param>
92+
/// <returns>Agent ID (GUID) or empty string if not found or token is empty</returns>
93+
public static string GetAgentIdFromToken(string token)
94+
{
95+
if (string.IsNullOrWhiteSpace(token))
96+
{
97+
return string.Empty;
98+
}
99+
100+
try
101+
{
102+
var handler = new JwtSecurityTokenHandler();
103+
var jwtToken = handler.ReadJwtToken(token);
104+
105+
// Priority: xms_par_app_azp (agent blueprint ID) > appid > azp
106+
var blueprintClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "xms_par_app_azp");
107+
if (!string.IsNullOrEmpty(blueprintClaim?.Value))
108+
{
109+
return blueprintClaim.Value;
110+
}
111+
112+
var appIdClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "appid");
113+
if (!string.IsNullOrEmpty(appIdClaim?.Value))
114+
{
115+
return appIdClaim.Value;
116+
}
117+
118+
var azpClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "azp");
119+
return azpClaim?.Value ?? string.Empty;
120+
}
121+
catch
122+
{
123+
// Silent error handling - return empty string on decode failure
124+
return string.Empty;
125+
}
126+
}
127+
128+
/// <summary>
129+
/// Gets the application name from the entry assembly.
130+
/// </summary>
131+
/// <returns>Application name or null if not available.</returns>
132+
public static string? GetApplicationName()
133+
{
134+
return System.Reflection.Assembly.GetEntryAssembly()?.GetName()?.Name;
135+
}
136+
73137
/// <summary>
74138
/// Resolves the agent identity from the turn context or auth token.
75139
/// </summary>

src/Tests/Runtime.Tests/UtilityTests.cs

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
using Microsoft.Extensions.Configuration;
55
using Microsoft.Agents.A365.Runtime.Utils;
66
using Moq;
7+
using System.IdentityModel.Tokens.Jwt;
8+
using System.Security.Claims;
9+
using System.Text;
710

811
namespace Microsoft.Agents.A365.Runtime.Tests
912
{
@@ -84,5 +87,189 @@ public void GetCurrentEnvironment_ReturnsDevelopment_WhenConfigMissing()
8487
// Assert
8588
Assert.Equal("Development", result);
8689
}
90+
91+
#region GetAgentIdFromToken Tests
92+
93+
[Fact]
94+
public void GetAgentIdFromToken_ReturnsEmptyString_WhenTokenIsEmpty()
95+
{
96+
// Act
97+
var result = Utility.GetAgentIdFromToken("");
98+
99+
// Assert
100+
Assert.Equal(string.Empty, result);
101+
}
102+
103+
[Fact]
104+
public void GetAgentIdFromToken_ReturnsEmptyString_WhenTokenIsWhitespace()
105+
{
106+
// Act
107+
var result = Utility.GetAgentIdFromToken(" ");
108+
109+
// Assert
110+
Assert.Equal(string.Empty, result);
111+
}
112+
113+
[Fact]
114+
public void GetAgentIdFromToken_ReturnsEmptyString_WhenTokenIsNull()
115+
{
116+
// Act
117+
var result = Utility.GetAgentIdFromToken(null!);
118+
119+
// Assert
120+
Assert.Equal(string.Empty, result);
121+
}
122+
123+
[Fact]
124+
public void GetAgentIdFromToken_ReturnsBlueprintId_WhenXmsParAppAzpPresent()
125+
{
126+
// Arrange
127+
var token = CreateTestJwtToken(new Claim("xms_par_app_azp", "blueprint-id-123"),
128+
new Claim("appid", "app-id-456"),
129+
new Claim("azp", "azp-id-789"));
130+
131+
// Act
132+
var result = Utility.GetAgentIdFromToken(token);
133+
134+
// Assert
135+
Assert.Equal("blueprint-id-123", result);
136+
}
137+
138+
[Fact]
139+
public void GetAgentIdFromToken_ReturnsAppId_WhenXmsParAppAzpNotPresent()
140+
{
141+
// Arrange
142+
var token = CreateTestJwtToken(new Claim("appid", "app-id-456"),
143+
new Claim("azp", "azp-id-789"));
144+
145+
// Act
146+
var result = Utility.GetAgentIdFromToken(token);
147+
148+
// Assert
149+
Assert.Equal("app-id-456", result);
150+
}
151+
152+
[Fact]
153+
public void GetAgentIdFromToken_ReturnsAzp_WhenOnlyAzpPresent()
154+
{
155+
// Arrange
156+
var token = CreateTestJwtToken(new Claim("azp", "azp-id-789"));
157+
158+
// Act
159+
var result = Utility.GetAgentIdFromToken(token);
160+
161+
// Assert
162+
Assert.Equal("azp-id-789", result);
163+
}
164+
165+
[Fact]
166+
public void GetAgentIdFromToken_ReturnsEmptyString_WhenNoRelevantClaimsPresent()
167+
{
168+
// Arrange
169+
var token = CreateTestJwtToken(new Claim("sub", "some-subject"),
170+
new Claim("iss", "some-issuer"));
171+
172+
// Act
173+
var result = Utility.GetAgentIdFromToken(token);
174+
175+
// Assert
176+
Assert.Equal(string.Empty, result);
177+
}
178+
179+
[Fact]
180+
public void GetAgentIdFromToken_ReturnsEmptyString_WhenTokenIsMalformed()
181+
{
182+
// Act
183+
var result = Utility.GetAgentIdFromToken("not-a-valid-jwt-token");
184+
185+
// Assert
186+
Assert.Equal(string.Empty, result);
187+
}
188+
189+
[Fact]
190+
public void GetAgentIdFromToken_PrefersXmsParAppAzp_OverAppId()
191+
{
192+
// Arrange - both present, xms_par_app_azp should win
193+
var token = CreateTestJwtToken(new Claim("appid", "app-id-first"),
194+
new Claim("xms_par_app_azp", "blueprint-id-second"));
195+
196+
// Act
197+
var result = Utility.GetAgentIdFromToken(token);
198+
199+
// Assert
200+
Assert.Equal("blueprint-id-second", result);
201+
}
202+
203+
[Fact]
204+
public void GetAgentIdFromToken_FallsBackToAppId_WhenXmsParAppAzpIsEmpty()
205+
{
206+
// Arrange
207+
var token = CreateTestJwtToken(new Claim("xms_par_app_azp", ""),
208+
new Claim("appid", "app-id-456"));
209+
210+
// Act
211+
var result = Utility.GetAgentIdFromToken(token);
212+
213+
// Assert
214+
Assert.Equal("app-id-456", result);
215+
}
216+
217+
[Fact]
218+
public void GetAgentIdFromToken_FallsBackToAzp_WhenBothXmsParAppAzpAndAppIdAreEmpty()
219+
{
220+
// Arrange
221+
var token = CreateTestJwtToken(new Claim("xms_par_app_azp", ""),
222+
new Claim("appid", ""),
223+
new Claim("azp", "azp-id-789"));
224+
225+
// Act
226+
var result = Utility.GetAgentIdFromToken(token);
227+
228+
// Assert
229+
Assert.Equal("azp-id-789", result);
230+
}
231+
232+
#endregion
233+
234+
#region GetApplicationName Tests
235+
236+
[Fact]
237+
public void GetApplicationName_ReturnsAssemblyName()
238+
{
239+
// Act
240+
var result = Utility.GetApplicationName();
241+
242+
// Assert
243+
// In a test context, the entry assembly should exist and have a name
244+
// The exact name depends on the test runner, so we just verify it's not null
245+
Assert.NotNull(result);
246+
Assert.NotEmpty(result);
247+
}
248+
249+
#endregion
250+
251+
#region Helper Methods
252+
253+
/// <summary>
254+
/// Creates a test JWT token with the specified claims.
255+
/// Note: This creates an unsigned token suitable for testing claim extraction only.
256+
/// </summary>
257+
private static string CreateTestJwtToken(params Claim[] claims)
258+
{
259+
var header = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"alg\":\"none\",\"typ\":\"JWT\"}"))
260+
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
261+
262+
var payloadDict = claims
263+
.GroupBy(c => c.Type)
264+
.ToDictionary(g => g.Key, g => g.First().Value);
265+
266+
var payloadJson = System.Text.Json.JsonSerializer.Serialize(payloadDict);
267+
var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(payloadJson))
268+
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
269+
270+
return $"{header}.{payload}.";
271+
}
272+
273+
#endregion
87274
}
88275
}

src/Tooling/Core/Handlers/HttpContextHeadersHandler.cs

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ namespace Microsoft.Agents.A365.Tooling.Handlers
66
using Microsoft.Extensions.Logging;
77
using Microsoft.Agents.A365.Runtime;
88
using Microsoft.Agents.A365.Tooling.Models;
9+
using Microsoft.Agents.A365.Tooling.Utils;
910
using System;
1011
using System.Globalization;
1112
using System.Net.Http;
1213
using System.Text;
1314
using System.Text.RegularExpressions;
1415
using System.Threading;
1516
using System.Threading.Tasks;
17+
using RuntimeUtility = Microsoft.Agents.A365.Runtime.Utils.Utility;
1618

1719
internal class HttpContextHeadersHandler : DelegatingHandler
1820
{
@@ -32,12 +34,14 @@ internal class HttpContextHeadersHandler : DelegatingHandler
3234
private readonly ITurnContext turnContext;
3335
private readonly ILogger logger;
3436
private readonly ToolOptions toolOptions;
37+
private readonly string? authToken;
3538

36-
public HttpContextHeadersHandler(ITurnContext turnContext, ILogger logger, ToolOptions toolOptions)
39+
public HttpContextHeadersHandler(ITurnContext turnContext, ILogger logger, ToolOptions toolOptions, string? authToken = null)
3740
{
3841
this.turnContext = turnContext;
3942
this.logger = logger;
4043
this.toolOptions = toolOptions;
44+
this.authToken = authToken;
4145
}
4246

4347
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
@@ -86,9 +90,72 @@ protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage reques
8690
request.Headers.Add(UserAgentHeader, UserAgentHelper.BuildUserAgent(this.toolOptions.UserAgentConfiguration));
8791
}
8892

93+
// Add x-ms-agentid header if auth token is available
94+
if (!string.IsNullOrEmpty(authToken))
95+
{
96+
var agentId = ResolveAgentIdForHeader();
97+
if (!string.IsNullOrEmpty(agentId))
98+
{
99+
request.Headers.Add(Constants.Headers.AgentIdHeader, agentId);
100+
}
101+
}
102+
89103
return base.SendAsync(request, cancellationToken);
90104
}
91105

106+
/// <summary>
107+
/// Resolves the best available agent identifier for the x-ms-agentid header.
108+
/// Priority: TurnContext.agenticAppBlueprintId > token claims (xms_par_app_azp > appid > azp) > application name
109+
/// </summary>
110+
/// <returns>Agent ID string or null if not available.</returns>
111+
private string? ResolveAgentIdForHeader()
112+
{
113+
// Priority 1: Agent Blueprint ID from TurnContext
114+
// The 'From' property may include agenticAppBlueprintId when the request originates from an agentic app
115+
var blueprintId = GetAgenticAppBlueprintIdFromContext();
116+
if (!string.IsNullOrEmpty(blueprintId))
117+
{
118+
return blueprintId;
119+
}
120+
121+
// Priority 2 & 3: Agent ID from token (xms_par_app_azp > appid > azp)
122+
// Single decode, checks claims in priority order
123+
if (!string.IsNullOrEmpty(authToken))
124+
{
125+
var agentId = RuntimeUtility.GetAgentIdFromToken(authToken);
126+
if (!string.IsNullOrEmpty(agentId))
127+
{
128+
return agentId;
129+
}
130+
}
131+
132+
// Priority 4: Application name from assembly
133+
return RuntimeUtility.GetApplicationName();
134+
}
135+
136+
/// <summary>
137+
/// Gets the agentic app blueprint ID from the turn context if available.
138+
/// </summary>
139+
/// <returns>The blueprint ID or null if not available.</returns>
140+
private string? GetAgenticAppBlueprintIdFromContext()
141+
{
142+
if (turnContext?.Activity?.From?.Properties == null)
143+
{
144+
return null;
145+
}
146+
147+
if (turnContext.Activity.From.Properties.TryGetValue("agenticAppBlueprintId", out var blueprintIdElement))
148+
{
149+
var blueprintId = blueprintIdElement.ToString();
150+
if (!string.IsNullOrEmpty(blueprintId))
151+
{
152+
return blueprintId;
153+
}
154+
}
155+
156+
return null;
157+
}
158+
92159
public static string SanitizeTextForHeader(string input, ILogger logger)
93160
{
94161
try

src/Tooling/Core/Microsoft.Agents.A365.Tooling.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
<Product>Microsoft Agent 365 Tooling SDK</Product>
1313
<PackageTags>Microsoft;Agent365;A365;Tooling;MCP;AI;Agents</PackageTags>
1414
</PropertyGroup>
15+
<ItemGroup>
16+
<InternalsVisibleTo Include="Microsoft.Agents.A365.Tooling.Tests" />
17+
</ItemGroup>
1518
<ItemGroup>
1619
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
1720
<PackageReference Include="Microsoft.SemanticKernel.Agents.Core" />

0 commit comments

Comments
 (0)