Skip to content
Open
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
6 changes: 6 additions & 0 deletions sdk/agentserver/azure-ai-agentserver-ghcopilot/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 12 additions & 0 deletions sdk/agentserver/azure-ai-agentserver-ghcopilot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions sdk/agentserver/azure-ai-agentserver-ghcopilot/api.md
Original file line number Diff line number Diff line change
@@ -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 = "<inline>"
) -> 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: ...


```
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
apiMdSha256: ace25ba61ce0d54b0b838f8bfef8686bf9fb1011d506b094bb6a78a938675014
parserVersion: 0.3.28
pythonVersion: 3.11.9
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Comment on lines +330 to +339
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()
Expand All @@ -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 "")
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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(),
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading