Skip to content

Commit f3b76d4

Browse files
committed
Fix manifest/gateway parsing and add local dev per-server token support
- Normalize "default" audience → None and "null" scope → None in both _parse_manifest_server_config() and _parse_gateway_server_config() so Dataverse-style servers are correctly treated as V1 (shared ATG token) instead of triggering a bogus V2 token exchange - Add "default" guard to resolve_token_scope_for_server() as defense-in-depth - Fall back to mcpServerName when mcpServerUniqueName is absent from the manifest or gateway response - Add _attach_dev_tokens() — reads BEARER_TOKEN_<SERVER_UNIQUE_NAME> and BEARER_TOKEN env vars written by `a365 develop get-token` and attaches per-server Authorization headers during local dev manifest loading; no-op in production where OBO via _attach_per_audience_tokens() is used
1 parent 74451d7 commit f3b76d4

3 files changed

Lines changed: 68 additions & 5 deletions

File tree

libraries/microsoft-agents-a365-tooling/CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
- Added V1/V2 per-audience token acquisition support in `McpToolServerConfigurationService.list_tool_servers()`. When `authorization`, `auth_handler_name`, and `turn_context` are provided, each MCP server receives its own OAuth token scoped to its audience — V1 servers (no audience, or shared ATG AppId) share a single ATG-scoped token; V2 servers (unique non-ATG audience GUID or `api://` URI) each receive a token scoped to `{audience}/{scope}` (or `{audience}/.default` when scope is absent and pre-consented)
12+
- Added MCP V1/V2 per-audience token acquisition support in `McpToolServerConfigurationService.list_tool_servers()`. When `authorization`, `auth_handler_name`, and `turn_context` are provided, each MCP server receives its own OAuth token scoped to its audience — V1 servers (no audience, or shared ATG AppId) share a single ATG-scoped token; V2 servers (unique non-ATG audience GUID or `api://` URI) each receive a token scoped to `{audience}/{scope}` (or `{audience}/.default` when scope is absent and pre-consented)
1313
- Added `_attach_per_audience_tokens()` private method to `McpToolServerConfigurationService` — acquires one token per unique scope, caches within the call to avoid redundant exchanges, and attaches `Authorization: Bearer` headers to each server config
1414
- Added `resolve_token_scope_for_server()` utility function to derive the correct OAuth scope for a given `MCPServerConfig` based on its `audience` and `scope` fields
1515
- Added `audience`, `scope`, `publisher`, and `headers` fields to `MCPServerConfig`
1616
- Gateway discovery endpoint bumped to `/agents/v2/{id}/mcpServers`
1717
- `_parse_gateway_server_config()` and `_parse_manifest_server_config()` now map `audience`, `scope`, and `publisher` fields from gateway/manifest responses into `MCPServerConfig`
1818

19+
- Added `_attach_dev_tokens()` private method to `McpToolServerConfigurationService` — reads `BEARER_TOKEN_<SERVER_UNIQUE_NAME>` and `BEARER_TOKEN` environment variables written by the `a365 develop get-token` CLI and attaches per-server `Authorization: Bearer` headers during local dev manifest loading; no-op in production
20+
1921
### Changed
2022

2123
- OpenAI, Semantic Kernel, and Google ADK extensions now pass auth context to `list_tool_servers()` and merge per-server headers (`{**base_headers, **server.headers}`) instead of injecting a single shared ATG token for all servers — fully backward compatible, V1 agents continue to receive the same shared ATG token
24+
- `_extract_server_unique_name()` now falls back to `mcpServerName` when `mcpServerUniqueName` is absent from the manifest or gateway response
25+
- `_parse_manifest_server_config()` and `_parse_gateway_server_config()` now normalize `"null"` scope strings and `"default"` audience strings to `None` to prevent incorrect V2 token scope resolution
26+
- `resolve_token_scope_for_server()` now treats `"default"` audience as V1 (shared ATG token) as a defense-in-depth guard
2227

2328
### Notes
2429

2530
- **Backward compatible**: agents with V1 manifests (null audience or shared ATG AppId) work identically with the new SDK — no token exchange behaviour changes
2631
- **Migration required for V2**: agents upgraded to V2 blueprint permissions (per-audience MCP servers) require this SDK version. Running a V2 blueprint with the old SDK will result in MCP tool auth failures (401/403)
32+
- **Local dev token flow**: run `a365 develop get-token` before starting the agent locally; the CLI writes `BEARER_TOKEN` (V1 shared) and `BEARER_TOKEN_<SERVER_NAME>` (V2 per-server) to the environment, which the SDK reads automatically from the manifest path
2733

2834
- Added `send_chat_history` method to `McpToolServerConfigurationService` for sending chat conversation history to the MCP platform for real-time threat protection analysis
2935
- Added `ChatHistoryMessage` Pydantic model for representing individual messages in chat history

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

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ def _load_servers_from_manifest(self) -> List[MCPServerConfig]:
267267
if manifest_path and manifest_path.exists():
268268
self._logger.info(f"Loading MCP servers from: {manifest_path}")
269269
mcp_servers = self._parse_manifest_file(manifest_path)
270+
self._attach_dev_tokens(mcp_servers)
270271
else:
271272
self._log_manifest_search_failure()
272273

@@ -568,12 +569,18 @@ def _parse_manifest_server_config(
568569
# Determine the final URL: use custom URL if provided, otherwise construct it
569570
final_url = endpoint if endpoint else build_mcp_server_url(server_name)
570571

572+
scope_raw = server_element.get("scope")
573+
scope = None if not scope_raw or scope_raw.lower() == "null" else scope_raw
574+
575+
audience_raw = server_element.get("audience")
576+
audience = None if not audience_raw or audience_raw.lower() == "default" else audience_raw
577+
571578
return MCPServerConfig(
572579
mcp_server_name=mcp_server_name,
573580
mcp_server_unique_name=mcp_server_unique_name,
574581
url=final_url,
575-
audience=server_element.get("audience"),
576-
scope=server_element.get("scope"),
582+
audience=audience,
583+
scope=scope,
577584
publisher=server_element.get("publisher"),
578585
)
579586

@@ -608,12 +615,18 @@ def _parse_gateway_server_config(
608615
# Determine the final URL: use custom URL if provided, otherwise construct it
609616
final_url = endpoint if endpoint else build_mcp_server_url(server_name)
610617

618+
scope_raw = server_element.get("scope")
619+
scope = None if not scope_raw or scope_raw.lower() == "null" else scope_raw
620+
621+
audience_raw = server_element.get("audience")
622+
audience = None if not audience_raw or audience_raw.lower() == "default" else audience_raw
623+
611624
return MCPServerConfig(
612625
mcp_server_name=mcp_server_name,
613626
mcp_server_unique_name=mcp_server_unique_name,
614627
url=final_url,
615-
audience=server_element.get("audience"),
616-
scope=server_element.get("scope"),
628+
audience=audience,
629+
scope=scope,
617630
publisher=server_element.get("publisher"),
618631
)
619632

@@ -624,6 +637,46 @@ def _parse_gateway_server_config(
624637
# VALIDATION AND UTILITY HELPERS
625638
# --------------------------------------------------------------------------
626639

640+
def _attach_dev_tokens(self, servers: List[MCPServerConfig]) -> None:
641+
"""
642+
Attach per-server Authorization headers from environment variables (local dev only).
643+
644+
The CLI (``a365 develop get-token``) pre-acquires tokens interactively and writes
645+
them to the environment before the agent starts:
646+
647+
- ``BEARER_TOKEN_<SERVER_UNIQUE_NAME_UPPER>`` — V2 per-server token
648+
- ``BEARER_TOKEN`` — V1 shared ATG token (fallback)
649+
650+
For each server, the resolution order is:
651+
1. ``BEARER_TOKEN_<MCP_SERVER_UNIQUE_NAME.upper()>`` (per-audience V2 token)
652+
2. ``BEARER_TOKEN`` (shared V1 ATG token)
653+
654+
If neither is set, no Authorization header is injected and the server is left as-is.
655+
This method is a no-op in production (``_load_servers_from_manifest`` is never called
656+
there) and when ``authorization`` is provided (``_attach_per_audience_tokens`` takes
657+
precedence via the caller in ``list_tool_servers``).
658+
659+
Args:
660+
servers: List of MCP server configs parsed from ToolingManifest.json.
661+
"""
662+
shared_token = os.getenv("BEARER_TOKEN")
663+
664+
for server in servers:
665+
unique_name = server.mcp_server_unique_name or ""
666+
per_server_token = os.getenv(f"BEARER_TOKEN_{unique_name.upper()}")
667+
token = per_server_token or shared_token
668+
669+
if token:
670+
existing = dict(server.headers) if server.headers else {}
671+
existing[Constants.Headers.AUTHORIZATION] = (
672+
f"{Constants.Headers.BEARER_PREFIX} {token}"
673+
)
674+
server.headers = existing
675+
self._logger.debug(
676+
f"Attached {'per-server' if per_server_token else 'shared'} "
677+
f"dev token for '{server.mcp_server_unique_name}'"
678+
)
679+
627680
def _validate_input_parameters(self, agentic_app_id: str, auth_token: str) -> None:
628681
"""
629682
Validates input parameters for the main API method.
@@ -668,6 +721,9 @@ def _extract_server_unique_name(self, server_element: Dict[str, Any]) -> Optiona
668721
server_element["mcpServerUniqueName"], str
669722
):
670723
return server_element["mcpServerUniqueName"]
724+
# Fall back to mcpServerName when mcpServerUniqueName is absent
725+
if "mcpServerName" in server_element and isinstance(server_element["mcpServerName"], str):
726+
return server_element["mcpServerName"]
671727
return None
672728

673729
def _extract_server_url(self, server_element: Dict[str, Any]) -> Optional[str]:

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ def resolve_token_scope_for_server(server: MCPServerConfig) -> str:
137137
"""
138138
if (
139139
server.audience is not None
140+
and server.audience.lower() != "default"
140141
and server.audience != ATG_APP_ID
141142
and server.audience != ATG_APP_ID_URI
142143
):

0 commit comments

Comments
 (0)