Skip to content

Commit c52f367

Browse files
biswapmclaude
andcommitted
Support V1/V2 per-audience token acquisition for MCP servers
V2 MCP servers require individual OAuth tokens scoped to their own audience GUID rather than the shared ATG token used by V1 servers. - Add audience, scope, publisher, headers fields to MCPServerConfig - Add resolve_token_scope_for_server() to determine OAuth scope: V2 servers (GUID audience) get <audience>/.default, V1 servers fall back to the shared ATG scope - Add _attach_per_audience_tokens() to McpToolServerConfigurationService: acquires one token per unique audience (cached), attaches Authorization header to each server config after discovery - Extend list_tool_servers() to accept optional authorization context; calls _attach_per_audience_tokens() when provided - Preserve audience/scope/publisher fields in manifest and gateway parsers - Update McpToolRegistrationService to pass auth context to list_tool_servers() and use per-server headers instead of a single shared token - Update tests to reflect the new header flow All V1 agents continue working unchanged (audience defaults to None, falls back to ATG scope). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e74116a commit c52f367

5 files changed

Lines changed: 179 additions & 20 deletions

File tree

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

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,15 @@ async def add_tool_servers_to_agent(
9494

9595
options = ToolOptions(orchestrator_name=self._orchestrator_name)
9696

97-
# Get MCP server configurations
97+
# Get MCP server configurations — pass auth context so each server receives
98+
# its own per-audience Authorization token (V1 = shared ATG, V2 = per-GUID).
9899
server_configs = await self._mcp_server_configuration_service.list_tool_servers(
99100
agentic_app_id=agentic_app_id,
100101
auth_token=auth_token,
101102
options=options,
103+
authorization=auth,
104+
auth_handler_name=auth_handler_name,
105+
turn_context=turn_context,
102106
)
103107

104108
self._logger.info(f"Loaded {len(server_configs)} MCP server configurations")
@@ -112,16 +116,15 @@ async def add_tool_servers_to_agent(
112116
server_name = config.mcp_server_name or config.mcp_server_unique_name
113117

114118
try:
115-
# Prepare auth headers
116-
headers = {}
117-
if auth_token:
118-
headers[Constants.Headers.AUTHORIZATION] = (
119-
f"{Constants.Headers.BEARER_PREFIX} {auth_token}"
119+
# Merge base (non-auth) headers with per-server headers from list_tool_servers.
120+
# server.headers already contains the correct per-audience Authorization token.
121+
base_headers = {
122+
Constants.Headers.USER_AGENT: Utility.get_user_agent_header(
123+
self._orchestrator_name
120124
)
121-
122-
headers[Constants.Headers.USER_AGENT] = Utility.get_user_agent_header(
123-
self._orchestrator_name
124-
)
125+
}
126+
server_headers = dict(config.headers) if config.headers else {}
127+
headers = {**base_headers, **server_headers} # server auth takes precedence
125128

126129
# Create httpx client with auth headers configured
127130
http_client = httpx.AsyncClient(

libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/models/mcp_server_config.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"""
77

88
from dataclasses import dataclass
9-
from typing import Optional
9+
from typing import Dict, Optional
1010

1111

1212
@dataclass
@@ -25,6 +25,18 @@ class MCPServerConfig:
2525
#: instead of constructing the URL from the base URL and unique name.
2626
url: Optional[str] = None
2727

28+
#: Per-server HTTP headers (includes the Authorization header set by attach_per_audience_tokens).
29+
headers: Optional[Dict[str, str]] = None
30+
31+
#: Per-server AppId (V2) or shared ATG AppId (V1). None means treat as V1.
32+
audience: Optional[str] = None
33+
34+
#: OAuth scope, e.g. "Tools.ListInvoke.All" (V2) or "McpServers.Mail.All" (V1).
35+
scope: Optional[str] = None
36+
37+
#: Publisher identifier for the MCP server.
38+
publisher: Optional[str] = None
39+
2840
def __post_init__(self):
2941
"""Validate the configuration after initialization."""
3042
if not self.mcp_server_name:

libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/services/mcp_tool_server_configuration_service.py

Lines changed: 105 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,16 @@
2929

3030
# Third-party imports
3131
import aiohttp
32-
from microsoft_agents.hosting.core import TurnContext
32+
from microsoft_agents.hosting.core import Authorization, TurnContext
3333

3434
# Local imports
3535
from ..models import ChatHistoryMessage, ChatMessageRequest, MCPServerConfig, ToolOptions
3636
from ..utils import Constants
3737
from ..utils.utility import (
38-
get_tooling_gateway_for_digital_worker,
3938
build_mcp_server_url,
4039
get_chat_history_endpoint,
40+
get_tooling_gateway_for_digital_worker,
41+
resolve_token_scope_for_server,
4142
)
4243

4344
# Runtime Imports
@@ -89,22 +90,38 @@ def __init__(self, logger: Optional[logging.Logger] = None):
8990
# --------------------------------------------------------------------------
9091

9192
async def list_tool_servers(
92-
self, agentic_app_id: str, auth_token: str, options: Optional[ToolOptions] = None
93+
self,
94+
agentic_app_id: str,
95+
auth_token: str,
96+
options: Optional[ToolOptions] = None,
97+
authorization: Optional[Authorization] = None,
98+
auth_handler_name: Optional[str] = None,
99+
turn_context: Optional[TurnContext] = None,
93100
) -> List[MCPServerConfig]:
94101
"""
95102
Gets the list of MCP Servers that are configured for the agent.
96103
104+
When ``authorization``, ``auth_handler_name``, and ``turn_context`` are all provided,
105+
per-audience OAuth tokens are acquired for each server after discovery:
106+
- V1 servers (no ``audience`` field) share the shared ATG token (one exchange).
107+
- V2 servers each receive a token scoped to their own audience GUID.
108+
97109
Args:
98110
agentic_app_id: Agentic App ID for the agent.
99-
auth_token: Authentication token to access the MCP servers.
111+
auth_token: Authentication token used for gateway discovery.
100112
options: Optional ToolOptions instance containing optional parameters.
113+
authorization: Optional Authorization context for per-audience token exchange.
114+
auth_handler_name: Optional auth handler name used with ``authorization``.
115+
turn_context: Optional TurnContext used with ``authorization``.
101116
102117
Returns:
103-
List[MCPServerConfig]: Returns the list of MCP Servers that are configured.
118+
List[MCPServerConfig]: Returns the list of MCP Servers that are configured,
119+
each with an ``Authorization`` header attached when auth context is provided.
104120
105121
Raises:
106122
ValueError: If required parameters are invalid or empty.
107-
Exception: If there's an error communicating with the tooling gateway.
123+
Exception: If there's an error communicating with the tooling gateway or
124+
a per-audience token exchange fails.
108125
"""
109126
# Validate input parameters
110127
self._validate_input_parameters(agentic_app_id, auth_token)
@@ -117,9 +134,17 @@ async def list_tool_servers(
117134

118135
# Determine configuration source based on environment
119136
if self._is_development_scenario():
120-
return self._load_servers_from_manifest()
137+
servers = self._load_servers_from_manifest()
121138
else:
122-
return await self._load_servers_from_gateway(agentic_app_id, auth_token, options)
139+
servers = await self._load_servers_from_gateway(agentic_app_id, auth_token, options)
140+
141+
# Acquire per-audience tokens and attach Authorization headers when auth context provided
142+
if authorization is not None and auth_handler_name is not None and turn_context is not None:
143+
servers = await self._attach_per_audience_tokens(
144+
servers, authorization, auth_handler_name, turn_context
145+
)
146+
147+
return servers
123148

124149
# --------------------------------------------------------------------------
125150
# ENVIRONMENT DETECTION
@@ -138,6 +163,72 @@ def _is_development_scenario(self) -> bool:
138163
self._logger.debug(f"Environment: {environment}, Development scenario: {is_dev}")
139164
return is_dev
140165

166+
async def _attach_per_audience_tokens(
167+
self,
168+
servers: List[MCPServerConfig],
169+
authorization: Authorization,
170+
auth_handler_name: str,
171+
turn_context: TurnContext,
172+
) -> List[MCPServerConfig]:
173+
"""
174+
Acquire one OAuth token per unique audience and attach an ``Authorization: Bearer``
175+
header to each server's headers.
176+
177+
V1 servers (no ``audience`` field, or audience matching the shared ATG AppId) all
178+
share the same ATG-scoped token (one exchange). V2 servers each receive a token
179+
scoped to their own audience GUID.
180+
181+
Args:
182+
servers: List of MCP server configs returned from discovery.
183+
authorization: Authorization context for token exchange.
184+
auth_handler_name: Auth handler name to pass to the token exchange.
185+
turn_context: TurnContext to pass to the token exchange.
186+
187+
Returns:
188+
List[MCPServerConfig]: New list of server configs with ``Authorization`` headers set.
189+
190+
Raises:
191+
Exception: If a token exchange fails for any server.
192+
"""
193+
token_cache: Dict[str, str] = {} # scope → bearer token
194+
result: List[MCPServerConfig] = []
195+
196+
for server in servers:
197+
scope = resolve_token_scope_for_server(server)
198+
199+
if scope not in token_cache:
200+
self._logger.debug(
201+
f"Acquiring token for MCP server '{server.mcp_server_name}' (scope: {scope})"
202+
)
203+
token_result = await authorization.exchange_token(
204+
turn_context, [scope], auth_handler_name
205+
)
206+
if token_result is None or not token_result.token:
207+
raise Exception(
208+
f"Failed to obtain token for MCP server '{server.mcp_server_name}'"
209+
f" (scope: {scope})"
210+
)
211+
token_cache[scope] = token_result.token
212+
213+
merged_headers: Dict[str, str] = dict(server.headers) if server.headers else {}
214+
merged_headers[Constants.Headers.AUTHORIZATION] = (
215+
f"{Constants.Headers.BEARER_PREFIX} {token_cache[scope]}"
216+
)
217+
218+
result.append(
219+
MCPServerConfig(
220+
mcp_server_name=server.mcp_server_name,
221+
mcp_server_unique_name=server.mcp_server_unique_name,
222+
url=server.url,
223+
headers=merged_headers,
224+
audience=server.audience,
225+
scope=server.scope,
226+
publisher=server.publisher,
227+
)
228+
)
229+
230+
return result
231+
141232
# --------------------------------------------------------------------------
142233
# DEVELOPMENT: MANIFEST-BASED CONFIGURATION
143234
# --------------------------------------------------------------------------
@@ -481,6 +572,9 @@ def _parse_manifest_server_config(
481572
mcp_server_name=mcp_server_name,
482573
mcp_server_unique_name=mcp_server_unique_name,
483574
url=final_url,
575+
audience=server_element.get("audience"),
576+
scope=server_element.get("scope"),
577+
publisher=server_element.get("publisher"),
484578
)
485579

486580
except Exception:
@@ -518,6 +612,9 @@ def _parse_gateway_server_config(
518612
mcp_server_name=mcp_server_name,
519613
mcp_server_unique_name=mcp_server_unique_name,
520614
url=final_url,
615+
audience=server_element.get("audience"),
616+
scope=server_element.get("scope"),
617+
publisher=server_element.get("publisher"),
521618
)
522619

523620
except Exception:

libraries/microsoft-agents-a365-tooling/microsoft_agents_a365/tooling/utils/utility.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55
Provides utility functions for the Tooling components.
66
"""
77

8+
from __future__ import annotations
9+
810
import os
11+
from typing import TYPE_CHECKING
12+
13+
if TYPE_CHECKING:
14+
from ..models.mcp_server_config import MCPServerConfig
915

1016

1117
# Constants for base URLs
@@ -17,6 +23,9 @@
1723
PPAPI_TOKEN_SCOPE = "https://api.powerplatform.com"
1824
PROD_MCP_PLATFORM_AUTHENTICATION_SCOPE = "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default"
1925

26+
# Shared ATG AppId — V1 servers (no audience field) use this scope
27+
ATG_APP_ID = "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1"
28+
2029

2130
def get_tooling_gateway_for_digital_worker(agentic_app_id: str) -> str:
2231
"""
@@ -104,3 +113,26 @@ def get_chat_history_endpoint() -> str:
104113
str: The chat history endpoint URL.
105114
"""
106115
return f"{_get_mcp_platform_base_url()}{CHAT_HISTORY_ENDPOINT_PATH}"
116+
117+
118+
def resolve_token_scope_for_server(server: MCPServerConfig) -> str:
119+
"""
120+
Resolve the OAuth scope to request for a given MCP server.
121+
122+
V2 servers carry their own audience GUID in the ``audience`` field and receive
123+
a token scoped to that GUID. V1 servers (no audience, audience equals the shared
124+
ATG AppId, or audience starting with ``api://``) fall back to the shared ATG scope.
125+
126+
Args:
127+
server: The MCP server configuration to resolve the scope for.
128+
129+
Returns:
130+
str: The OAuth scope string (e.g. ``"<guid>/.default"``).
131+
"""
132+
if (
133+
server.audience is not None
134+
and server.audience != ATG_APP_ID
135+
and not server.audience.startswith("api://")
136+
):
137+
return f"{server.audience}/.default"
138+
return f"{ATG_APP_ID}/.default"

tests/tooling/extensions/agentframework/services/test_mcp_tool_registration_service.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def mock_mcp_server_config(self):
6262
config.mcp_server_name = "test-mcp-server"
6363
config.mcp_server_unique_name = "test-mcp-server-unique"
6464
config.url = "https://test-mcp-server.example.com/api"
65+
config.headers = None # per-audience headers attached by list_tool_servers
6566
return config
6667

6768
@pytest.fixture
@@ -79,8 +80,17 @@ async def test_httpx_client_has_authorization_header(
7980
mock_chat_client,
8081
mock_mcp_server_config,
8182
):
82-
"""Test that httpx.AsyncClient is created with Authorization header."""
83+
"""Test that httpx.AsyncClient is created with Authorization header.
84+
85+
In the V1/V2 model, list_tool_servers (via _attach_per_audience_tokens) attaches
86+
the per-audience Authorization header to config.headers before returning. The
87+
mock server config must reflect this to verify the header reaches the httpx client.
88+
"""
8389
auth_token = "test-bearer-token-xyz"
90+
# Simulate the Authorization header that _attach_per_audience_tokens would attach
91+
mock_mcp_server_config.headers = {
92+
Constants.Headers.AUTHORIZATION: (f"{Constants.Headers.BEARER_PREFIX} {auth_token}")
93+
}
8494

8595
with (
8696
patch.object(
@@ -481,6 +491,7 @@ async def test_full_client_lifecycle_single_server(
481491
mock_server_config.mcp_server_name = "test-server"
482492
mock_server_config.mcp_server_unique_name = "test-server-unique"
483493
mock_server_config.url = "https://test.example.com/api"
494+
mock_server_config.headers = None # per-audience headers attached by list_tool_servers
484495

485496
mock_http_client_instance = MagicMock()
486497

@@ -553,16 +564,19 @@ async def test_full_client_lifecycle_multiple_servers(
553564
mock_server_config1.mcp_server_name = "server-1"
554565
mock_server_config1.mcp_server_unique_name = "server-1-unique"
555566
mock_server_config1.url = "https://server1.example.com/api"
567+
mock_server_config1.headers = None # per-audience headers attached by list_tool_servers
556568

557569
mock_server_config2 = Mock()
558570
mock_server_config2.mcp_server_name = "server-2"
559571
mock_server_config2.mcp_server_unique_name = "server-2-unique"
560572
mock_server_config2.url = "https://server2.example.com/api"
573+
mock_server_config2.headers = None
561574

562575
mock_server_config3 = Mock()
563576
mock_server_config3.mcp_server_name = "server-3"
564577
mock_server_config3.mcp_server_unique_name = "server-3-unique"
565578
mock_server_config3.url = "https://server3.example.com/api"
579+
mock_server_config3.headers = None
566580

567581
# Create unique mock clients for each server
568582
mock_clients = [MagicMock() for _ in range(3)]
@@ -661,6 +675,7 @@ async def test_cleanup_called_twice_after_creating_clients(
661675
mock_server_config.mcp_server_name = "test-server"
662676
mock_server_config.mcp_server_unique_name = "test-server-unique"
663677
mock_server_config.url = "https://test.example.com/api"
678+
mock_server_config.headers = None # per-audience headers attached by list_tool_servers
664679

665680
mock_http_client_instance = MagicMock()
666681

0 commit comments

Comments
 (0)