Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
452 changes: 452 additions & 0 deletions docs/prd-x-ms-agentid-header.md

Large diffs are not rendered by default.

98 changes: 96 additions & 2 deletions packages/agents-a365-runtime/src/utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,53 @@
import { TurnContext } from '@microsoft/agents-hosting';
import * as jwt from 'jsonwebtoken';
import os from 'os';
import fs from 'fs';
import path from 'path';

import { LIB_VERSION } from './version';

/**
* Utility class providing helper methods for agent runtime operations.
*/
export class Utility {
// Cache for application name read from package.json
// null = checked but not found, string = found
// Eagerly initialized at module load time to avoid sync I/O during requests
private static cachedPackageName: string | null = Utility.initPackageName();

/**
* Reads the application name from package.json at module load time.
* This ensures file I/O happens during initialization, not during requests.
*
* Note: Uses process.cwd() which assumes the application is started from its root directory.
* This is a fallback mechanism - npm_package_name (checked first in getApplicationName) is
* the preferred source as it's reliably set by npm/pnpm when running package scripts.
*/
private static initPackageName(): string | null {
try {
const packageJsonPath = path.resolve(process.cwd(), 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
return packageJson.name || null;
} catch {
// TODO: Add debug-level logging once a logger implementation is available
// to help troubleshoot package.json read failures in production environments
return null;
}
}
/**
* **WARNING: NO SIGNATURE VERIFICATION** - This method uses jwt.decode() which does NOT
* verify the token signature. The token claims can be spoofed by malicious actors.
* This method is ONLY suitable for logging, analytics, and diagnostics purposes.
* Do NOT use the returned value for authorization, access control, or security decisions.
*
* Decodes the current token and retrieves the App ID (appid or azp claim).
*
* Note: Returns a default GUID ('00000000-0000-0000-0000-000000000000') for empty tokens
* for backward compatibility with callers that expect a valid-looking GUID.
* For agent identification where empty string is preferred, use {@link getAgentIdFromToken}.
*
* @param token Token to Decode
* @returns AppId
* @returns AppId, or default GUID for empty token, or empty string if decode fails
*/
public static GetAppIdFromToken(token: string): string {
if (!token || token.trim() === '') {
Expand All @@ -36,6 +72,40 @@ export class Utility {
}
}

/**
* **WARNING: NO SIGNATURE VERIFICATION** - This method uses jwt.decode() which does NOT
* verify the token signature. The token claims can be spoofed by malicious actors.
* This method is ONLY suitable for logging, analytics, and diagnostics purposes.
* Do NOT use the returned value for authorization, access control, or security decisions.
*
* Decodes the token and retrieves the best available agent identifier.
* Checks claims in priority order: xms_par_app_azp (agent blueprint ID) > appid > azp.
*
* Note: Returns empty string for empty/missing tokens (unlike {@link GetAppIdFromToken} which
* returns a default GUID). This allows callers to omit headers when no identifier is available.
*
* @param token JWT token to decode
* @returns Agent ID (GUID) or empty string if not found or token is empty
*/
public static getAgentIdFromToken(token: string): string {
if (!token || token.trim() === '') {
return '';
}

try {
const decoded = jwt.decode(token) as jwt.JwtPayload;
if (!decoded) {
return '';
}

// Priority: xms_par_app_azp (agent blueprint ID) > appid > azp
return decoded['xms_par_app_azp'] || decoded['appid'] || decoded['azp'] || '';
} catch (_error) {
// Silent error handling - return empty string on decode failure
return '';
}
}

/**
* Resolves the agent identity from the turn context or auth token.
* @param context Turn Context of the turn.
Expand All @@ -61,4 +131,28 @@ export class Utility {
const orchestratorPart = orchestrator ? `; ${orchestrator}` : '';
return `Agent365SDK/${LIB_VERSION} (${osType}; Node.js ${process.version}${orchestratorPart})`;
}
}

/**
* Gets the application name from npm_package_name environment variable or package.json.
* The package.json result is cached at module load time to avoid sync I/O during requests.
* @returns Application name or undefined if not available.
*/
public static getApplicationName(): string | undefined {
// First try npm_package_name (set automatically by npm/pnpm when running scripts)
if (process.env.npm_package_name) {
return process.env.npm_package_name;
}

// Fall back to cached package.json name (read at module load time)
return this.cachedPackageName || undefined;
}

/**
* Resets the cached application name. Used for testing purposes.
* @internal
*/
public static resetApplicationNameCache(): void {
this.cachedPackageName = Utility.initPackageName();
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
import fs from 'fs';
import path from 'path';
import axios from 'axios';
import { TurnContext } from '@microsoft/agents-hosting';
import { OperationResult, OperationError } from '@microsoft/agents-a365-runtime';
import { TurnContext, Authorization } from '@microsoft/agents-hosting';
import { OperationResult, OperationError, AgenticAuthenticationService, Utility as RuntimeUtility } from '@microsoft/agents-a365-runtime';
import { MCPServerConfig, MCPServerManifestEntry, McpClientTool, ToolOptions } from './contracts';
import { ChatHistoryMessage, ChatMessageRequest } from './models/index';
import { Utility } from './Utility';
Expand All @@ -29,25 +29,94 @@ export class McpToolServerConfigurationService {
/**
* Return MCP server definitions for the given agent. In development (NODE_ENV=Development) this reads the local ToolingManifest.json; otherwise it queries the remote tooling gateway.
*
* @deprecated Use the overload with TurnContext and Authorization parameters instead to enable x-ms-agentid header support and automatic token generation.
* @param agenticAppId The agentic app id for which to discover servers.
* @param authToken Optional bearer token used when querying the remote tooling gateway.
* @param authToken Bearer token used when querying the remote tooling gateway.
* @returns A promise resolving to an array of normalized MCP server configuration objects.
*/
async listToolServers(agenticAppId: string, authToken: string): Promise<MCPServerConfig[]>;

/**
* Return MCP server definitions for the given agent. In development (NODE_ENV=Development) this reads the local ToolingManifest.json; otherwise it queries the remote tooling gateway.
*
* @deprecated Use the overload with TurnContext and Authorization parameters instead to enable x-ms-agentid header support and automatic token generation.
* @param agenticAppId The agentic app id for which to discover servers.
* @param authToken Optional bearer token used when querying the remote tooling gateway.
* @param authToken Bearer token used when querying the remote tooling gateway.
* @param options Optional tool options when calling the gateway.
* @returns A promise resolving to an array of normalized MCP server configuration objects.
*/
async listToolServers(agenticAppId: string, authToken: string, options?: ToolOptions): Promise<MCPServerConfig[]>;

async listToolServers(agenticAppId: string, authToken: string, options?: ToolOptions): Promise<MCPServerConfig[]> {
return await (this.isDevScenario() ? this.getMCPServerConfigsFromManifest() :
this.getMCPServerConfigsFromToolingGateway(agenticAppId, authToken, options));
/**
* Return MCP server definitions for the given agent. In development (NODE_ENV=Development) this reads the local ToolingManifest.json; otherwise it queries the remote tooling gateway.
* This overload automatically resolves the agenticAppId from the TurnContext and generates the auth token if not provided.
*
* @param turnContext The TurnContext of the current request.
* @param authorization Authorization object for token exchange.
* @param authHandlerName The name of the auth handler to use for token exchange.
* @param authToken Optional bearer token. If not provided, will be auto-generated via token exchange.
* @param options Optional tool options when calling the gateway.
* @returns A promise resolving to an array of normalized MCP server configuration objects.
*/
async listToolServers(turnContext: TurnContext, authorization: Authorization, authHandlerName: string, authToken?: string, options?: ToolOptions): Promise<MCPServerConfig[]>;

async listToolServers(
agenticAppIdOrTurnContext: string | TurnContext,
authTokenOrAuthorization: string | Authorization,
optionsOrAuthHandlerName?: ToolOptions | string,
authTokenOrOptions?: string | ToolOptions,
options?: ToolOptions
): Promise<MCPServerConfig[]> {
// Detect which signature is being used based on the type of the first parameter
if (typeof agenticAppIdOrTurnContext === 'string') {
// LEGACY PATH: listToolServers(agenticAppId, authToken, options?)
const agenticAppId = agenticAppIdOrTurnContext;

// Runtime validation for legacy signature parameters
if (typeof authTokenOrAuthorization !== 'string') {
throw new Error('authToken must be a string when using the legacy listToolServers(agenticAppId, authToken) signature');
}
const authToken = authTokenOrAuthorization;
const toolOptions = optionsOrAuthHandlerName as ToolOptions | undefined;

return await (this.isDevScenario()
? this.getMCPServerConfigsFromManifest()
: this.getMCPServerConfigsFromToolingGateway(agenticAppId, authToken, undefined, toolOptions));
} else {
// NEW PATH: listToolServers(turnContext, authorization, authHandlerName, authToken?, options?)
const turnContext = agenticAppIdOrTurnContext;

// Runtime validation for new signature parameters
if (typeof authTokenOrAuthorization === 'string') {
throw new Error('authorization must be an Authorization object when using the new listToolServers(turnContext, authorization, authHandlerName) signature');
}
if (typeof optionsOrAuthHandlerName !== 'string') {
throw new Error('authHandlerName must be a string when using the new listToolServers(turnContext, authorization, authHandlerName) signature');
}

const authorization = authTokenOrAuthorization;
const authHandlerName = optionsOrAuthHandlerName;
let authToken = authTokenOrOptions as string | undefined;
const toolOptions = options;

// Auto-generate token if not provided
if (!authToken) {
authToken = await AgenticAuthenticationService.GetAgenticUserToken(authorization, authHandlerName, turnContext);
if (!authToken) {
throw new Error('Failed to obtain authentication token from token exchange');
}
}

// Note: Token validation (format/expiration) is performed inside getMCPServerConfigsFromToolingGateway()
// to avoid duplicate validation (it's also called by the legacy path)

// Resolve agenticAppId from TurnContext
const agenticAppId = RuntimeUtility.ResolveAgentIdentity(turnContext, authToken);

return await (this.isDevScenario()
? this.getMCPServerConfigsFromManifest()
: this.getMCPServerConfigsFromToolingGateway(agenticAppId, authToken, turnContext, toolOptions));
}
}

/**
Expand Down Expand Up @@ -192,10 +261,11 @@ export class McpToolServerConfigurationService {
*
* @param agenticAppId The agentic app id used by the tooling gateway to scope results.
* @param authToken Optional Bearer token to include in the Authorization header when calling the gateway.
* @param turnContext Optional TurnContext for extracting agent blueprint ID for request headers.
* @param options Optional tool options when calling the gateway.
* @throws Error when the gateway call fails or returns an unexpected payload.
*/
private async getMCPServerConfigsFromToolingGateway(agenticAppId: string, authToken: string, options?: ToolOptions): Promise<MCPServerConfig[]> {
private async getMCPServerConfigsFromToolingGateway(agenticAppId: string, authToken: string, turnContext?: TurnContext, options?: ToolOptions): Promise<MCPServerConfig[]> {
// Validate the authentication token
Utility.ValidateAuthToken(authToken);

Expand All @@ -205,7 +275,7 @@ export class McpToolServerConfigurationService {
const response = await axios.get(
configEndpoint,
{
headers: Utility.GetToolRequestHeaders(authToken, undefined, options),
headers: Utility.GetToolRequestHeaders(authToken, turnContext, options),
timeout: 10000 // 10 seconds timeout
}
);
Expand Down
42 changes: 42 additions & 0 deletions packages/agents-a365-tooling/src/Utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

import { TurnContext } from '@microsoft/agents-hosting';
import { ChannelAccount } from '@microsoft/agents-activity';
import { Utility as RuntimeUtility } from '@microsoft/agents-a365-runtime';

import { ToolOptions } from './contracts';
Expand All @@ -13,6 +14,8 @@ export class Utility {
public static readonly HEADER_CHANNEL_ID = 'x-ms-channel-id';
public static readonly HEADER_SUBCHANNEL_ID = 'x-ms-subchannel-id';
public static readonly HEADER_USER_AGENT = 'User-Agent';
/** Header name for sending the agent identifier to MCP platform for logging/analytics. */
public static readonly HEADER_AGENT_ID = 'x-ms-agentid';

/**
* Compose standard headers for MCP tooling requests.
Expand All @@ -32,6 +35,12 @@ export class Utility {

if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;

// Add x-ms-agentid header with priority fallback (only when authToken present)
const agentId = this.resolveAgentIdForHeader(authToken, turnContext);
if (agentId) {
headers[Utility.HEADER_AGENT_ID] = agentId;
}
}

const channelId = turnContext?.activity?.channelId as string | undefined;
Expand All @@ -52,6 +61,39 @@ export class Utility {
return headers;
}

/**
* Resolves the best available agent identifier for the x-ms-agentid header.
* Priority: TurnContext.agenticAppBlueprintId > token claims (xms_par_app_azp > appid > azp) > application name
*
* Note: This differs from RuntimeUtility.ResolveAgentIdentity() which resolves the agenticAppId
* for URL construction. This method resolves the identifier specifically for the x-ms-agentid header.
*
* @param authToken The authentication token to extract claims from.
* @param turnContext Optional TurnContext to extract agent blueprint ID from.
* @returns Agent ID string or undefined if not available.
*/
private static resolveAgentIdForHeader(
authToken: string,
turnContext?: TurnContext
): string | undefined {
// Priority 1: Agent Blueprint ID from TurnContext
// The 'from' property may include agenticAppBlueprintId when the request originates from an agentic app
const blueprintId = (turnContext?.activity?.from as ChannelAccount | undefined)?.agenticAppBlueprintId;
if (blueprintId) {
return blueprintId;
}

// Priority 2 & 3: Agent ID from token (xms_par_app_azp > appid > azp)
// Single decode, checks claims in priority order
const agentId = RuntimeUtility.getAgentIdFromToken(authToken);
if (agentId) {
return agentId;
}

// Priority 4: Application name from npm_package_name or package.json
return RuntimeUtility.getApplicationName();
}

/**
* Validates a JWT authentication token.
* Checks that the token is a valid JWT and is not expired.
Expand Down
Loading
Loading