From 5fe749b57657e16def29063515a54e9dcac985a6 Mon Sep 17 00:00:00 2001 From: Pranav Pandit Date: Sun, 28 Jun 2026 08:26:27 +0000 Subject: [PATCH] [agentserver] ghcopilot toolbox per-request call ID (protocol v2.0.0) Adds the ghcopilot toolbox/MCP per-request call ID forwarding that was split out of #47584 so it could land after core (2.0.0b7) and responses (1.0.0b8) were merged and published. Bumps ghcopilot to 1.0.0b3 and includes the generated api.md / api.metadata.yml. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CHANGELOG.md | 6 + .../azure-ai-agentserver-ghcopilot/README.md | 12 ++ .../azure-ai-agentserver-ghcopilot/api.md | 71 ++++++++++++ .../api.metadata.yml | 3 + .../githubcopilot/_copilot_adapter.py | 45 +++++++- .../ai/agentserver/githubcopilot/_toolbox.py | 76 +++++++++++- .../ai/agentserver/githubcopilot/_version.py | 2 +- .../pyproject.toml | 4 +- .../tests/unit_tests/test_toolbox.py | 109 ++++++++++++++++++ 9 files changed, 317 insertions(+), 11 deletions(-) create mode 100644 sdk/agentserver/azure-ai-agentserver-ghcopilot/api.md create mode 100644 sdk/agentserver/azure-ai-agentserver-ghcopilot/api.metadata.yml diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-ghcopilot/CHANGELOG.md index c5242a31a831..63b9e0b2202c 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 1.0.0b3 (Unreleased) + +### Features Added + +- Container protocol version `2.0.0` support: the toolbox MCP bridge now echoes the per-request call ID (`x-agent-foundry-call-id`) on outbound Foundry toolbox calls. `initialize` / `tools/list` (run in the request task) read it from the request context; `tools/call` (dispatched on the Copilot engine task, where the request context var is empty) resolves it out-of-band keyed by the Copilot `session_id` and echoes it as both the HTTP header and `params._meta`. Copilot sessions are bound to a single user (`x-agent-user-id`) and never reused across users. No-op under protocol version `1.0.0` or local development. + ## 1.0.0b2 (2026-04-24) ### Breaking Changes diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/README.md b/sdk/agentserver/azure-ai-agentserver-ghcopilot/README.md index c602bd6dda87..bf101db16a6b 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/README.md +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/README.md @@ -114,6 +114,18 @@ adapter = GitHubCopilotAdapter.from_project(".") adapter.run() ``` +### Multi-user sessions (per-request call ID) + +On container protocol `2.0.0` a single agent can serve **multiple users** — and the adapter handles it for you. It echoes the per-request `x-agent-foundry-call-id` on outbound **Foundry toolbox** calls so each `tools/call` resolves the correct caller and acts on their behalf. Because the Copilot engine dispatches tools on a separate task, the call ID is carried per turn keyed by the Copilot `session_id` and stamped on both the request header and the MCP `params._meta`. Copilot sessions are bound to a single user (`x-agent-user-id`) and never reused across users — so no agent code change is needed. + +```python +from azure.ai.agentserver.githubcopilot import GitHubCopilotAdapter + +# Toolbox call IDs are forwarded automatically per request; sessions never cross users. +adapter = GitHubCopilotAdapter.from_project(".") +adapter.run() +``` + ### With custom credential ```python diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/api.md b/sdk/agentserver/azure-ai-agentserver-ghcopilot/api.md new file mode 100644 index 000000000000..71c8ab83c25b --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/api.md @@ -0,0 +1,71 @@ +```py +namespace azure.ai.agentserver.githubcopilot + + class azure.ai.agentserver.githubcopilot.CopilotAdapter: + + def __init__( + self, + session_config: Optional[dict] = None, + acl: Optional[ToolAcl] = None, + credential: Optional[Any] = None + ): ... + + def run(self, port: int = None): ... + + async def run_async(self, port: int = None): ... + + + class azure.ai.agentserver.githubcopilot.GitHubCopilotAdapter(CopilotAdapter): + + def __init__( + self, + skill_directories: Optional[list[str]] = None, + tools: Optional[list] = None, + project_root: Optional[str] = None, + toolbox_endpoint: Optional[str] = None, + **kwargs + ): ... + + @classmethod + def from_project( + cls, + project_path: str = ".", + **kwargs + ) -> GitHubCopilotAdapter: ... + + def clear_default_model(self) -> None: ... + + async def connect_toolboxes(self): ... + + def get_model(self) -> Optional[str]: ... + + async def initialize(self): ... + + def run(self, port: int = None): ... + + async def run_async(self, port: int = None): ... + + + class azure.ai.agentserver.githubcopilot.ToolAcl: + + def __init__( + self, + rules: List[_Rule], + default_action: _Action = "deny", + source: str = "" + ) -> None: ... + + def __repr__(self) -> str: ... + + @classmethod + def from_env(cls, env_var: str = "TOOL_ACL_PATH") -> Optional[ToolAcl]: ... + + @classmethod + def from_file(cls, path: str | PathLike) -> ToolAcl: ... + + def evaluate(self, req: Dict[str, Any]) -> _Action: ... + + def is_allowed(self, req: Dict[str, Any]) -> bool: ... + + +``` \ No newline at end of file diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/api.metadata.yml b/sdk/agentserver/azure-ai-agentserver-ghcopilot/api.metadata.yml new file mode 100644 index 000000000000..729733ac3e55 --- /dev/null +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/api.metadata.yml @@ -0,0 +1,3 @@ +apiMdSha256: ace25ba61ce0d54b0b838f8bfef8686bf9fb1011d506b094bb6a78a938675014 +parserVersion: 0.3.28 +pythonVersion: 3.11.9 diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_copilot_adapter.py b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_copilot_adapter.py index f82e5e9ef8c6..8ae399216001 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_copilot_adapter.py +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_copilot_adapter.py @@ -30,6 +30,7 @@ from copilot.session import PermissionRequestResult, ProviderConfig from azure.ai.agentserver.core import AgentServerHost # noqa: F401 (re-exported for subclasses) +from azure.ai.agentserver.core import get_request_context # pylint: disable=no-name-in-module from azure.ai.agentserver.responses import ( ResponseContext, ResponseEventStream, @@ -44,7 +45,12 @@ ) from ._tool_acl import ToolAcl -from ._toolbox import connect_toolbox, discover_mcp_servers +from ._toolbox import ( + clear_session_call_id, + connect_toolbox, + discover_mcp_servers, + set_session_call_id, +) logger = logging.getLogger("azure.ai.agentserver.githubcopilot") @@ -251,6 +257,11 @@ def __init__( # Multi-turn: conversation_id -> live CopilotSession self._sessions: Dict[str, Any] = {} + # §8.4 identity boundary: conversation_id -> owning x-agent-user-id. + # A Copilot session (and the per-turn call id bound to it) must belong + # to exactly one user; a session is never reused across users. + self._session_owner: Dict[str, str] = {} + # Credential for BYOK token refresh and MCP server auth. _has_byok_provider = ( "provider" in self._session_config @@ -310,9 +321,25 @@ def _on_permission(req, _ctx): async def _get_or_create_session(self, conversation_id=None): """Get existing session or create new one.""" + # §8.4: a session is bound to exactly one user. Never reuse it across + # users, or one user's tool call could echo another's call id. + request_user = get_request_context().user_id + if conversation_id and conversation_id in self._sessions: - logger.info(f"Reusing session for conversation {conversation_id!r}") - return self._sessions[conversation_id] + owner = self._session_owner.get(conversation_id) + if request_user is not None and owner is not None and owner != request_user: + logger.warning( + "Session for conversation %r is owned by a different user; " + "creating a fresh session to avoid cross-user identity bleed", + conversation_id, + ) + stale = self._sessions.pop(conversation_id, None) + self._session_owner.pop(conversation_id, None) + if stale is not None: + clear_session_call_id(getattr(stale, "session_id", None)) + else: + logger.info(f"Reusing session for conversation {conversation_id!r}") + return self._sessions[conversation_id] client = await self._ensure_client() config = self._refresh_token_if_needed() @@ -335,6 +362,8 @@ async def _get_or_create_session(self, conversation_id=None): if conversation_id: self._sessions[conversation_id] = session + if request_user is not None: + self._session_owner[conversation_id] = request_user logger.info( "Created new Copilot session" + (f" for conversation {conversation_id!r}" if conversation_id else "") @@ -392,6 +421,12 @@ async def _handle_create(self, request, context, cancellation_signal): session = await self._get_or_create_session(conversation_id) + # Bind this turn's call id to the Copilot session so that tool calls + # dispatched on the engine task — where the request-scoped context var + # is empty — can echo ``x-agent-foundry-call-id`` on outbound toolbox + # calls (container protocol ``2.0.0``; see bring-your-own §8). + set_session_call_id(getattr(session, "session_id", None), get_request_context().call_id) + # Set up event queue queue: asyncio.Queue = asyncio.Queue() @@ -1088,6 +1123,10 @@ async def _get_or_create_session(self, conversation_id=None): ) await session.send_and_wait(preamble, timeout=120.0) self._sessions[conversation_id] = session + # §8.4: bind the bootstrapped session to the requesting user. + _bootstrap_user = get_request_context().user_id + if _bootstrap_user is not None: + self._session_owner[conversation_id] = _bootstrap_user logger.info("Bootstrapped session %s with %d chars of history", conversation_id, len(history)) return await super()._get_or_create_session(conversation_id) diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py index 3a49e512830b..7df9d61fc97c 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_toolbox.py @@ -26,6 +26,9 @@ import httpx from copilot.tools import Tool, ToolResult +from azure.ai.agentserver.core import get_request_context # pylint: disable=no-name-in-module +from azure.ai.agentserver.core._platform_headers import FOUNDRY_CALL_ID # pylint: disable=no-name-in-module + logger = logging.getLogger("azure.ai.agentserver.githubcopilot") # Canary — proves which version of _toolbox.py is deployed. @@ -37,6 +40,43 @@ _FOUNDRY_SCOPE = "https://ai.azure.com/.default" +# --------------------------------------------------------------------------- +# Per-turn call-id carried out-of-band, keyed by Copilot session id +# --------------------------------------------------------------------------- +# +# On container protocol version ``2.0.0`` the container must echo the inbound +# ``x-agent-foundry-call-id`` on outbound Foundry toolbox calls. The request- +# scoped context var (``get_request_context()``) is valid in the request task, +# but the Copilot engine dispatches ``tools/call`` on a separate, long-lived +# reader task where that context var is **empty**. So the per-turn call id is +# captured in the request task and stashed here keyed by the Copilot +# ``session_id`` — the only correlator that reaches the tool handler. +_call_id_by_session: Dict[str, str] = {} + + +def set_session_call_id(session_id: Optional[str], call_id: Optional[str]) -> None: + """Bind (or clear) the current turn's call id for a Copilot session. + + Called from the request task (where ``get_request_context()`` is valid). + Tool calls dispatched later on the Copilot engine task read this by + ``session_id``. + + :param session_id: The Copilot session id, or ``None`` (no-op). + :param call_id: The per-turn ``x-agent-foundry-call-id``, or ``None`` to clear. + """ + if not session_id: + return + if call_id: + _call_id_by_session[session_id] = call_id + else: + _call_id_by_session.pop(session_id, None) + + +def clear_session_call_id(session_id: Optional[str]) -> None: + """Remove any call id bound to a Copilot session (e.g. on eviction).""" + if session_id: + _call_id_by_session.pop(session_id, None) + # --------------------------------------------------------------------------- # Discovery — read mcp.json and build server config dicts @@ -180,6 +220,11 @@ def _request_headers(self) -> Dict[str, str]: logger.warning("Failed to refresh token for MCP bridge", exc_info=True) if self._session_id: headers["mcp-session-id"] = self._session_id + # Forward the per-request call ID (x-agent-foundry-call-id) to the Foundry + # toolbox service on container protocol version 2.0.0. No-op when absent + # (protocol 1.0.0 or local development). x-agent-user-id is never forwarded + # to 1P services; it is used only for container-side per-user partitioning. + headers.update(get_request_context().platform_headers()) return headers async def initialize(self) -> str: @@ -196,7 +241,7 @@ async def initialize(self) -> str: resp = await self._client.post( self._endpoint, - headers=self._headers, + headers={**self._headers, **get_request_context().platform_headers()}, json={ "jsonrpc": "2.0", "id": self._next_id(), @@ -275,22 +320,40 @@ async def list_tools(self) -> List[Dict[str, Any]]: ) return tools - async def call_tool(self, name: str, arguments: Dict[str, Any]) -> str: + async def call_tool(self, name: str, arguments: Dict[str, Any], *, session_id: Optional[str] = None) -> str: """Call ``tools/call`` and return the text result. :param name: The original MCP tool name (not sanitised). :param arguments: Tool arguments dict. + :keyword session_id: The Copilot session id for this tool call. Used to + resolve the per-turn ``x-agent-foundry-call-id`` out-of-band, because + this method runs on the Copilot engine task where the request-scoped + context var is empty (container protocol ``2.0.0``). :returns: Formatted text result. """ logger.info("MCP tools/call: %s args=%s", name, list(arguments.keys())) + headers = self._request_headers() + params: Dict[str, Any] = {"name": name, "arguments": arguments} + + # Echo the per-turn call id resolved by session id. Stamp it both as the + # HTTP header (preferred) and in ``params._meta`` (fallback) — the + # platform accepts either. The session-keyed value overrides the (empty) + # request-context value for the engine-task dispatch. + call_id = _call_id_by_session.get(session_id) if session_id else None + if call_id: + headers[FOUNDRY_CALL_ID] = call_id + meta = dict(params.get("_meta") or {}) + meta[FOUNDRY_CALL_ID] = call_id + params["_meta"] = meta + resp = await self._client.post( self._endpoint, - headers=self._request_headers(), + headers=headers, json={ "jsonrpc": "2.0", "id": self._next_id(), "method": "tools/call", - "params": {"name": name, "arguments": arguments}, + "params": params, }, ) resp.raise_for_status() @@ -398,8 +461,11 @@ async def async_handler(invocation): args = getattr(invocation, "arguments", None) or {} if not isinstance(args, dict): args = {} + # session_id is the only correlator that reaches the engine-task + # tool dispatch; use it to resolve the per-turn call id (§2.0.0). + session_id = getattr(invocation, "session_id", None) try: - result_text = await bridge.call_tool(original_name, args) + result_text = await bridge.call_tool(original_name, args, session_id=session_id) return ToolResult(text_result_for_llm=result_text) except Exception as e: logger.warning("Tool %s failed: %s", original_name, e) diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_version.py b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_version.py index 0dcf5333ec20..054e06e92c03 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_version.py +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/azure/ai/agentserver/githubcopilot/_version.py @@ -3,4 +3,4 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # --------------------------------------------------------- -VERSION = "1.0.0b2" +VERSION = "1.0.0b3" diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/pyproject.toml b/sdk/agentserver/azure-ai-agentserver-ghcopilot/pyproject.toml index 6920f0fb4d39..443863caf127 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/pyproject.toml +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/pyproject.toml @@ -20,8 +20,8 @@ classifiers = [ keywords = ["azure", "azure sdk"] dependencies = [ - "azure-ai-agentserver-core>=2.0.0a1", - "azure-ai-agentserver-responses>=1.0.0a1", + "azure-ai-agentserver-core>=2.0.0b7", + "azure-ai-agentserver-responses>=1.0.0b8", "github-copilot-sdk>=0.2.0,<0.3.0", "azure-identity", "httpx>=0.24.0", diff --git a/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py b/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py index 791972f426dd..969775f0f7c0 100644 --- a/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py +++ b/sdk/agentserver/azure-ai-agentserver-ghcopilot/tests/unit_tests/test_toolbox.py @@ -339,3 +339,112 @@ def test_default_schema_when_missing(self): mcp_tools = [{"name": "simple_tool", "description": "A tool"}] tools = _make_copilot_tools(bridge, mcp_tools) assert tools[0].parameters == {"type": "object", "properties": {}} + + +# --------------------------------------------------------------------------- +# McpBridge platform identity header forwarding (container protocol 2.0.0) +# --------------------------------------------------------------------------- + + +class TestMcpBridgePlatformHeaders: + """The MCP bridge forwards the inbound call ID / user ID on outbound calls.""" + + def test_request_headers_forward_call_id(self): + from azure.ai.agentserver.core import ( + FoundryAgentRequestContext, + reset_request_context, + set_request_context, + ) + + bridge = McpBridge("https://acct.services.ai.azure.com/toolboxes/t/mcp", {"X-Static": "1"}) + token = set_request_context(FoundryAgentRequestContext(call_id="call-123", user_id="user-abc")) + try: + headers = bridge._request_headers() + finally: + reset_request_context(token) + + # Only the call ID is forwarded to 1P; user_id is not accepted/trusted by 1P. + assert headers["x-agent-foundry-call-id"] == "call-123" + assert "x-agent-user-id" not in headers + assert headers["X-Static"] == "1" + + def test_request_headers_omit_platform_context_when_absent(self): + bridge = McpBridge("https://acct.services.ai.azure.com/toolboxes/t/mcp", {"X-Static": "1"}) + + headers = bridge._request_headers() + + assert "x-agent-foundry-call-id" not in headers + assert "x-agent-user-id" not in headers + assert headers["X-Static"] == "1" + + +# --------------------------------------------------------------------------- +# Session-keyed per-turn call-id carry on tools/call (container protocol 2.0.0, §8) +# --------------------------------------------------------------------------- + +set_session_call_id = _toolbox.set_session_call_id +clear_session_call_id = _toolbox.clear_session_call_id + + +def _fake_post_response(payload): + resp = mock.Mock() + resp.raise_for_status = mock.Mock() + resp.json = mock.Mock(return_value=payload) + resp.headers = {} + return resp + + +class TestSessionKeyedCallId: + """tools/call must echo the per-turn call id resolved by session_id, because + it runs on the Copilot engine task where the request context var is empty.""" + + @pytest.mark.asyncio + async def test_call_tool_stamps_session_call_id_header_and_meta(self): + bridge = McpBridge("https://acct.services.ai.azure.com/toolboxes/t/mcp", {}) + captured = {} + + async def _post(url, headers=None, json=None): + captured["headers"] = headers + captured["json"] = json + return _fake_post_response({"result": {"content": [{"type": "text", "text": "ok"}]}}) + + bridge._client.post = _post + set_session_call_id("session-1", "call-xyz") + try: + out = await bridge.call_tool("doc.search", {"q": "x"}, session_id="session-1") + finally: + clear_session_call_id("session-1") + + assert out == "ok" + # Echoed as header (preferred) and in params._meta (fallback). + assert captured["headers"]["x-agent-foundry-call-id"] == "call-xyz" + assert captured["json"]["params"]["_meta"]["x-agent-foundry-call-id"] == "call-xyz" + + @pytest.mark.asyncio + async def test_call_tool_no_call_id_when_session_unbound(self): + bridge = McpBridge("https://acct.services.ai.azure.com/toolboxes/t/mcp", {}) + captured = {} + + async def _post(url, headers=None, json=None): + captured["headers"] = headers + captured["json"] = json + return _fake_post_response({"result": {"content": [{"type": "text", "text": "ok"}]}}) + + bridge._client.post = _post + out = await bridge.call_tool("doc.search", {"q": "x"}, session_id="unknown-session") + + assert out == "ok" + assert "x-agent-foundry-call-id" not in captured["headers"] + assert "_meta" not in captured["json"]["params"] + + def test_set_and_clear_session_call_id(self): + set_session_call_id("s", "c") + assert _toolbox._call_id_by_session.get("s") == "c" + set_session_call_id("s", None) # clear via None + assert "s" not in _toolbox._call_id_by_session + set_session_call_id("s", "c2") + clear_session_call_id("s") + assert "s" not in _toolbox._call_id_by_session + # no-op for falsy session id + set_session_call_id(None, "c") + assert None not in _toolbox._call_id_by_session