Skip to content

Commit 5644a68

Browse files
author
Johan Broberg
committed
Add support for resolving agent identity.
1 parent 02a08fd commit 5644a68

8 files changed

Lines changed: 253 additions & 8 deletions

File tree

libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
from .environment_utils import get_observability_authentication_scope
44
from .power_platform_api_discovery import ClusterCategory, PowerPlatformApiDiscovery
5+
from .utility import Utility
56

67
__all__ = [
78
"get_observability_authentication_scope",
89
"PowerPlatformApiDiscovery",
910
"ClusterCategory",
11+
"Utility",
1012
]
1113

1214
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
"""
4+
Utility functions for Microsoft Agent 365 runtime operations.
5+
6+
This module provides utility functions for token handling, agent identity resolution,
7+
and other common runtime operations.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import uuid
13+
from typing import Any, Optional
14+
15+
import jwt
16+
17+
18+
class Utility:
19+
"""
20+
Utility class providing common runtime operations for Agent 365.
21+
22+
This class contains static methods for token processing, agent identity resolution,
23+
and other utility functions used across the Agent 365 runtime.
24+
"""
25+
26+
@staticmethod
27+
def get_app_id_from_token(token: Optional[str]) -> str:
28+
"""
29+
Decodes the current token and retrieves the App ID (appid or azp claim).
30+
31+
Args:
32+
token: JWT token to decode. Can be None or empty.
33+
34+
Returns:
35+
str: The App ID from the token's claims, or empty GUID if token is invalid.
36+
Returns "00000000-0000-0000-0000-000000000000" if no valid App ID is found.
37+
"""
38+
if not token or not token.strip():
39+
return str(uuid.UUID(int=0))
40+
41+
try:
42+
# Decode the JWT token without verification (we only need the claims)
43+
# Note: verify=False is used because we only need to extract claims,
44+
# not verify the token's authenticity
45+
decoded_payload = jwt.decode(token, options={"verify_signature": False})
46+
47+
# Look for appid or azp claims (appid takes precedence)
48+
app_id = decoded_payload.get("appid") or decoded_payload.get("azp")
49+
return app_id if app_id else ""
50+
51+
except (jwt.DecodeError, jwt.InvalidTokenError):
52+
# Token is malformed or invalid
53+
return ""
54+
55+
@staticmethod
56+
def resolve_agent_identity(context: Any, auth_token: Optional[str]) -> str:
57+
"""
58+
Resolves the agent identity from the turn context or auth token.
59+
60+
Args:
61+
context: Turn context of the conversation turn. Expected to have an Activity
62+
with methods like is_agentic_request() and get_agentic_instance_id().
63+
auth_token: Authentication token if available.
64+
65+
Returns:
66+
str: The agent identity (App ID). Returns the agentic instance ID if the
67+
request is agentic, otherwise returns the App ID from the auth token.
68+
"""
69+
try:
70+
# App ID is required to pass to MCP server URL
71+
# Try to get agentic instance ID if this is an agentic request
72+
if context and context.activity and context.activity.is_agentic_request():
73+
agentic_id = context.activity.get_agentic_instance_id()
74+
return agentic_id if agentic_id else ""
75+
76+
except (AttributeError, TypeError, Exception):
77+
# Context/activity doesn't have the expected methods or properties
78+
# or any other error occurred while accessing context/activity
79+
pass
80+
81+
# Fallback to extracting App ID from the auth token
82+
return Utility.get_app_id_from_token(auth_token)

libraries/microsoft-agents-a365-runtime/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ classifiers = [
2525
license = {text = "MIT"}
2626
keywords = ["observability", "telemetry", "tracing", "opentelemetry", "monitoring", "ai", "agents"]
2727
dependencies = [
28+
"PyJWT >= 2.8.0",
2829
]
2930

3031
[project.urls]

libraries/microsoft-agents-a365-tooling-extensions-agentframework/microsoft_agents_a365/tooling/extensions/agentframework/services/mcp_tool_registration_service.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from microsoft_agents.hosting.core import Authorization, TurnContext
1111

12+
from microsoft_agents_a365.runtime.utility import Utility
1213
from microsoft_agents_a365.tooling.services.mcp_tool_server_configuration_service import (
1314
McpToolServerConfigurationService,
1415
)
@@ -45,7 +46,6 @@ async def add_tool_servers_to_agent(
4546
chat_client: Union[OpenAIChatClient, AzureOpenAIChatClient],
4647
agent_instructions: str,
4748
initial_tools: List[Any],
48-
agentic_app_id: str,
4949
auth: Authorization,
5050
auth_handler_name: str,
5151
turn_context: TurnContext,
@@ -58,7 +58,6 @@ async def add_tool_servers_to_agent(
5858
chat_client: The chat client instance (Union[OpenAIChatClient, AzureOpenAIChatClient])
5959
agent_instructions: Instructions for the agent behavior
6060
initial_tools: List of initial tools to add to the agent
61-
agentic_app_id: Agentic app identifier for the agent
6261
auth: Authorization context for token exchange
6362
auth_handler_name: Name of the authorization handler.
6463
turn_context: Turn context for the operation
@@ -74,6 +73,8 @@ async def add_tool_servers_to_agent(
7473
authToken = await auth.exchange_token(turn_context, scopes, auth_handler_name)
7574
auth_token = authToken.token
7675

76+
agentic_app_id = Utility.resolve_agent_identity(turn_context, auth_token)
77+
7778
self._logger.info(f"Listing MCP tool servers for agent {agentic_app_id}")
7879

7980
# Get MCP server configurations

libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/microsoft_agents_a365/tooling/extensions/azureaifoundry/services/mcp_tool_registration_service.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from azure.identity import DefaultAzureCredential
1818
from azure.ai.agents.models import McpTool, ToolResources
1919
from microsoft_agents.hosting.core import Authorization, TurnContext
20+
from microsoft_agents_a365.runtime.utility import Utility
2021
from microsoft_agents_a365.tooling.services.mcp_tool_server_configuration_service import (
2122
McpToolServerConfigurationService,
2223
)
@@ -69,7 +70,6 @@ def __init__(
6970
async def add_tool_servers_to_agent(
7071
self,
7172
project_client: "AIProjectClient",
72-
agentic_app_id: str,
7373
auth: Authorization,
7474
auth_handler_name: str,
7575
context: TurnContext,
@@ -80,7 +80,6 @@ async def add_tool_servers_to_agent(
8080
8181
Args:
8282
project_client: The Azure Foundry AIProjectClient instance.
83-
agentic_app_id: Agentic App ID for the agent.
8483
auth: Authorization handler for token exchange.
8584
auth_handler_name: Name of the authorization handler.
8685
context: Turn context for the current operation.
@@ -99,6 +98,7 @@ async def add_tool_servers_to_agent(
9998
auth_token = authToken.token
10099

101100
try:
101+
agentic_app_id = Utility.resolve_agent_identity(context, auth_token)
102102
# Get the tool definitions and resources using the async implementation
103103
tool_definitions, tool_resources = await self._get_mcp_tool_definitions_and_resources(
104104
agentic_app_id, auth_token or ""

libraries/microsoft-agents-a365-tooling-extensions-openai/microsoft_agents_a365/tooling/extensions/openai/mcp_tool_registration_service.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
MCPServerStreamableHttp,
1313
MCPServerStreamableHttpParams,
1414
)
15+
from microsoft_agents_a365.runtime.utility import Utility
1516
from microsoft_agents_a365.tooling.services.mcp_tool_server_configuration_service import (
1617
McpToolServerConfigurationService,
1718
)
@@ -50,7 +51,6 @@ def __init__(self, logger: Optional[logging.Logger] = None):
5051
async def add_tool_servers_to_agent(
5152
self,
5253
agent: Agent,
53-
agentic_app_id: str,
5454
auth: Authorization,
5555
auth_handler_name: str,
5656
context: TurnContext,
@@ -65,7 +65,6 @@ async def add_tool_servers_to_agent(
6565
6666
Args:
6767
agent: The existing agent to add servers to
68-
agentic_app_id: Agentic App ID for the agent
6968
auth: Authorization handler for token exchange.
7069
auth_handler_name: Name of the authorization handler.
7170
context: Turn context for the current operation.
@@ -84,6 +83,7 @@ async def add_tool_servers_to_agent(
8483
# mcp_server_configs = []
8584
# TODO: radevika: Update once the common project is merged.
8685

86+
agentic_app_id = Utility.resolve_agent_identity(context, auth_token)
8787
self._logger.info(f"Listing MCP tool servers for agent {agentic_app_id}")
8888
mcp_server_configs = await self.config_service.list_tool_servers(
8989
agentic_app_id=agentic_app_id,

libraries/microsoft-agents-a365-tooling-extensions-semantickernel/microsoft_agents_a365/tooling/extensions/semantickernel/services/mcp_tool_registration_service.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from semantic_kernel import kernel as sk
1717
from semantic_kernel.connectors.mcp import MCPStreamableHttpPlugin
1818
from microsoft_agents.hosting.core import Authorization, TurnContext
19+
from microsoft_agents_a365.runtime.utility import Utility
1920
from microsoft_agents_a365.tooling.services.mcp_tool_server_configuration_service import (
2021
McpToolServerConfigurationService,
2122
)
@@ -77,7 +78,6 @@ def __init__(
7778
async def add_tool_servers_to_agent(
7879
self,
7980
kernel: sk.Kernel,
80-
agentic_app_id: str,
8181
auth: Authorization,
8282
auth_handler_name: str,
8383
context: TurnContext,
@@ -88,7 +88,6 @@ async def add_tool_servers_to_agent(
8888
8989
Args:
9090
kernel: The Semantic Kernel instance to which the tools will be added.
91-
agentic_app_id: Agentic App ID for the agent.
9291
auth: Authorization handler for token exchange.
9392
auth_handler_name: Name of the authorization handler.
9493
context: Turn context for the current operation.
@@ -104,6 +103,7 @@ async def add_tool_servers_to_agent(
104103
authToken = await auth.exchange_token(context, scopes, auth_handler_name)
105104
auth_token = authToken.token
106105

106+
agentic_app_id = Utility.resolve_agent_identity(context, auth_token)
107107
self._validate_inputs(kernel, agentic_app_id, auth_token)
108108

109109
# Get and process servers

tests/runtime/test_utility.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
import unittest
4+
import uuid
5+
import jwt
6+
7+
from microsoft_agents_a365.runtime.utility import Utility
8+
9+
10+
class TestUtility(unittest.TestCase):
11+
"""Test cases for the Utility class."""
12+
13+
def setUp(self):
14+
"""Set up test fixtures."""
15+
self.test_app_id = "12345678-1234-1234-1234-123456789abc"
16+
self.test_azp_id = "87654321-4321-4321-4321-cba987654321"
17+
18+
def create_test_jwt(self, claims: dict) -> str:
19+
"""Create a test JWT token with the given claims."""
20+
# Use PyJWT to create a proper JWT token (unsigned for testing)
21+
return jwt.encode(claims, key="", algorithm="none")
22+
23+
def test_get_app_id_from_token_with_none_token(self):
24+
"""Test get_app_id_from_token with None token."""
25+
result = Utility.get_app_id_from_token(None)
26+
self.assertEqual(result, str(uuid.UUID(int=0)))
27+
28+
def test_get_app_id_from_token_with_empty_token(self):
29+
"""Test get_app_id_from_token with empty token."""
30+
result = Utility.get_app_id_from_token("")
31+
self.assertEqual(result, str(uuid.UUID(int=0)))
32+
33+
result = Utility.get_app_id_from_token(" ")
34+
self.assertEqual(result, str(uuid.UUID(int=0)))
35+
36+
def test_get_app_id_from_token_with_appid_claim(self):
37+
"""Test get_app_id_from_token with appid claim."""
38+
token = self.create_test_jwt({"appid": self.test_app_id, "other": "value"})
39+
result = Utility.get_app_id_from_token(token)
40+
self.assertEqual(result, self.test_app_id)
41+
42+
def test_get_app_id_from_token_with_azp_claim(self):
43+
"""Test get_app_id_from_token with azp claim."""
44+
token = self.create_test_jwt({"azp": self.test_azp_id, "other": "value"})
45+
result = Utility.get_app_id_from_token(token)
46+
self.assertEqual(result, self.test_azp_id)
47+
48+
def test_get_app_id_from_token_with_both_claims(self):
49+
"""Test get_app_id_from_token with both appid and azp claims (appid takes precedence)."""
50+
token = self.create_test_jwt({"appid": self.test_app_id, "azp": self.test_azp_id})
51+
result = Utility.get_app_id_from_token(token)
52+
self.assertEqual(result, self.test_app_id)
53+
54+
def test_get_app_id_from_token_without_app_claims(self):
55+
"""Test get_app_id_from_token with token containing no app claims."""
56+
token = self.create_test_jwt({"sub": "user123", "iss": "issuer"})
57+
result = Utility.get_app_id_from_token(token)
58+
self.assertEqual(result, "")
59+
60+
def test_get_app_id_from_token_with_invalid_token(self):
61+
"""Test get_app_id_from_token with invalid token formats."""
62+
# Invalid token format
63+
result = Utility.get_app_id_from_token("invalid.token")
64+
self.assertEqual(result, "")
65+
66+
# Token with only two parts
67+
result = Utility.get_app_id_from_token("header.payload")
68+
self.assertEqual(result, "")
69+
70+
# Token with invalid base64
71+
result = Utility.get_app_id_from_token("invalid.!!!invalid!!!.signature")
72+
self.assertEqual(result, "")
73+
74+
75+
class MockActivity:
76+
"""Mock activity class for testing."""
77+
78+
def __init__(self, is_agentic: bool = False, agentic_id: str = ""):
79+
self._is_agentic = is_agentic
80+
self._agentic_id = agentic_id
81+
82+
def is_agentic_request(self) -> bool:
83+
return self._is_agentic
84+
85+
def get_agentic_instance_id(self) -> str:
86+
return self._agentic_id
87+
88+
89+
class MockContext:
90+
"""Mock context class for testing."""
91+
92+
def __init__(self, activity=None):
93+
self.activity = activity
94+
95+
96+
class TestUtilityResolveAgentIdentity(unittest.TestCase):
97+
"""Test cases for the resolve_agent_identity method."""
98+
99+
def setUp(self):
100+
"""Set up test fixtures."""
101+
self.test_app_id = "token-app-id-123"
102+
self.agentic_id = "agentic-id-456"
103+
104+
# Create a test token with PyJWT
105+
claims = {"appid": self.test_app_id}
106+
self.test_token = jwt.encode(claims, key="", algorithm="none")
107+
108+
def test_resolve_agent_identity_with_agentic_request(self):
109+
"""Test resolve_agent_identity with agentic request."""
110+
activity = MockActivity(is_agentic=True, agentic_id=self.agentic_id)
111+
context = MockContext(activity)
112+
113+
result = Utility.resolve_agent_identity(context, self.test_token)
114+
self.assertEqual(result, self.agentic_id)
115+
116+
def test_resolve_agent_identity_with_non_agentic_request(self):
117+
"""Test resolve_agent_identity with non-agentic request."""
118+
activity = MockActivity(is_agentic=False)
119+
context = MockContext(activity)
120+
121+
result = Utility.resolve_agent_identity(context, self.test_token)
122+
self.assertEqual(result, self.test_app_id)
123+
124+
def test_resolve_agent_identity_with_context_without_activity(self):
125+
"""Test resolve_agent_identity with context that has no activity."""
126+
context = MockContext()
127+
128+
result = Utility.resolve_agent_identity(context, self.test_token)
129+
self.assertEqual(result, self.test_app_id)
130+
131+
def test_resolve_agent_identity_with_none_context(self):
132+
"""Test resolve_agent_identity with None context."""
133+
result = Utility.resolve_agent_identity(None, self.test_token)
134+
self.assertEqual(result, self.test_app_id)
135+
136+
def test_resolve_agent_identity_with_agentic_but_empty_id(self):
137+
"""Test resolve_agent_identity with agentic request but empty agentic ID."""
138+
activity = MockActivity(is_agentic=True, agentic_id="")
139+
context = MockContext(activity)
140+
141+
result = Utility.resolve_agent_identity(context, self.test_token)
142+
self.assertEqual(result, "")
143+
144+
def test_resolve_agent_identity_fallback_on_exception(self):
145+
"""Test resolve_agent_identity falls back to token when context access fails."""
146+
147+
# Create a context that will raise an exception when accessed
148+
class FaultyContext:
149+
@property
150+
def activity(self):
151+
raise RuntimeError("Context access failed")
152+
153+
context = FaultyContext()
154+
result = Utility.resolve_agent_identity(context, self.test_token)
155+
self.assertEqual(result, self.test_app_id)
156+
157+
158+
if __name__ == "__main__":
159+
unittest.main()

0 commit comments

Comments
 (0)