Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ McpToolRegistrationService.add_tool_servers_to_agent()
├── Resolve agent identity
├── Exchange token for MCP scope
├── Create MCPStreamableHTTPTool for each server
├── Gate (per server, non-blocking): Ready → MCPStreamableHTTPTool; Pending → placeholder tool
└── Create ChatAgent with all tools
Expand Down Expand Up @@ -76,6 +76,66 @@ mcp_tool = MCPStreamableHTTPTool(
)
```

### Connection-readiness gating (non-blocking, per server)

MCP server discovery runs every turn. The gateway reports each server's `connectivityStatus`
(`"Ready"` or `"Pending"`) along with a `missingConnectionsUrl` the user can visit to set up the
connection(s) that server needs. Gating is **per server and non-blocking**:

- A **Ready** server (or a legacy source with no `connectivityStatus`) is wired as a live
`MCPStreamableHTTPTool`, exactly as before.
- A **Pending** server is wired as a single **placeholder tool** named after the server. The agent
is still built and the Ready servers remain fully usable for the turn. Only if the model invokes
the placeholder — because the user's request actually needs that server — does it return a static
message (including `missingConnectionsUrl`) for the model to relay to the user. If the turn never
needs the Pending server, the user is never bothered. A later turn re-runs discovery and wires the
server for real once its connections are in place.

The placeholder **returns** the message rather than raising it. Agent Framework's tool-call loop
catches exceptions raised inside a `FunctionTool` and reflects them to the model as an opaque error
string (`"Error: Function failed."` by default), and it converts `UserInputRequiredException` into
tool-result content rather than propagating it — so returning the text is the only way to surface
the setup URL through the model. Because surfacing flows through the model, exact verbatim delivery
is best-effort; the placeholder's description instructs the model to relay the message and URL
verbatim, and `max_invocations=1` stops the model from looping on it within a turn.

> **Note:** A Pending server is represented by one placeholder named after the server (its real
> sub-tools are invisible until it connects). The model routes the user's intent to it by server
> name plus description — best-effort, not guaranteed.

```python
from microsoft_agents_a365.tooling.extensions.agentframework import (
McpToolRegistrationService,
)

service = McpToolRegistrationService()

# Non-blocking: Pending servers become placeholders, so no special handling is needed in the
# turn handler. The agent is always built and Ready tools always run.
agent = await service.add_tool_servers_to_agent(
chat_client=chat_client,
agent_instructions="You are a helpful assistant.",
initial_tools=[],
auth=auth_context,
auth_handler_name="graph",
turn_context=turn_context,
)
```

For developers who instead want **blocking** behavior (abort the whole turn until every server is
connected), the extension still exports `McpConnectionsRequiredError`. Inspect the discovered
servers yourself and raise it before building the agent:

```python
from microsoft_agents_a365.tooling.extensions.agentframework import McpConnectionsRequiredError
```

`McpConnectionsRequiredError` exposes `missing_connections_url`, `connectivity_status`, and
`server_names`, and its message is built from the same `format_mcp_connections_required_message`
helper the placeholder uses. It is owned and exported by this extension
(`microsoft_agents_a365.tooling` core only parses the per-server connection metadata; it never
gates or raises).

### Chat History API

The service provides methods to send chat history to the MCP platform for real-time threat protection analysis. This enables security scanning of conversation content.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@
__version__ = "1.0.0"

# Import services from the services module
from .exceptions import McpConnectionsRequiredError
from .services import McpToolRegistrationService

__all__ = [
"McpToolRegistrationService",
"McpConnectionsRequiredError",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

"""Exceptions and message helpers for the Agent Framework MCP tooling extension."""

from typing import List, Optional


def format_mcp_connections_required_message(
*,
server_names: List[str],
connectivity_status: Optional[str],
missing_connections_url: Optional[str],
) -> str:
"""Build the static, user-facing message shown when an MCP server needs connection setup.

This single helper is the source of truth for the wording so that the message a Pending
server's placeholder tool returns to the model is identical to the message carried by
``McpConnectionsRequiredError``.

Args:
server_names: Names of the MCP server(s) whose downstream connections are not set up.
connectivity_status: The gateway-reported ``connectivityStatus`` (typically ``"Pending"``).
missing_connections_url: URL the user opens to set up the missing connection(s). May be
``None`` when the gateway did not supply one.

Returns:
A human-readable message suitable for relaying verbatim to the end user.
"""
servers_text = ", ".join(server_names) if server_names else "(unknown)"
message = (
f"The tool(s) from MCP server(s) [{servers_text}] can't be used yet because the "
f"required data connection(s) aren't set up (connectivityStatus={connectivity_status})."
)
if missing_connections_url:
message += f" Set up the missing connection(s) here: {missing_connections_url}"
else:
message += " Ask your administrator to set up the required connection(s)."
return message


class McpConnectionsRequiredError(Exception):
"""Raised when an MCP server the user invoked is not yet connection-ready.

The tooling gateway reports a per-server ``connectivityStatus`` of ``"Pending"`` when an MCP
server has downstream connections the user has not yet established. Discovery runs every turn.

The agentframework extension gates **per server, non-blocking**: Ready servers are wired as
real tools and a Pending server is registered as a placeholder tool. Only when the model
actually invokes that placeholder (because the user's request needs the server) is the static
setup message — including ``missing_connections_url`` — surfaced; the rest of the turn runs
normally with the Ready tools.

This exception is **not** raised by the non-blocking gate (Agent Framework swallows exceptions
raised inside a tool, and converts ``UserInputRequiredException`` into tool-result content, so
neither reaches the developer's turn handler). It is exported for developers who want to
implement their own *blocking* gating — e.g. inspect the discovered servers and raise this
before building the agent to abort the whole turn — and as the structured carrier of the same
message the placeholder returns.
"""

def __init__(
self,
missing_connections_url: Optional[str],
connectivity_status: Optional[str],
server_names: List[str],
) -> None:
self.missing_connections_url = missing_connections_url
self.connectivity_status = connectivity_status
self.server_names = server_names
super().__init__(
format_mcp_connections_required_message(
server_names=server_names,
connectivity_status=connectivity_status,
missing_connections_url=missing_connections_url,
)
)
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
from datetime import datetime, timezone
from typing import TYPE_CHECKING, List, Optional, Sequence

from agent_framework import RawAgent, Message, HistoryProvider, MCPStreamableHTTPTool
from agent_framework import (
RawAgent,
Message,
HistoryProvider,
MCPStreamableHTTPTool,
FunctionTool,
)
import httpx

if TYPE_CHECKING:
Expand All @@ -18,7 +24,7 @@

from microsoft_agents_a365.runtime import OperationResult
from microsoft_agents_a365.runtime.utility import Utility
from microsoft_agents_a365.tooling.models import ChatHistoryMessage, ToolOptions
from microsoft_agents_a365.tooling.models import ChatHistoryMessage, MCPServerConfig, ToolOptions
from microsoft_agents_a365.tooling.services.mcp_tool_server_configuration_service import (
McpToolServerConfigurationService,
)
Expand All @@ -28,10 +34,21 @@
is_development_environment,
)

from ..exceptions import format_mcp_connections_required_message


# Default timeout for MCP server HTTP requests (in seconds)
MCP_HTTP_CLIENT_TIMEOUT_SECONDS = 90.0

# Sentinel per-server status that means a server's downstream connections are
# already in place. Anything else (typically "Pending") means the server is not
# connection-ready: instead of wiring it as a real tool, the extension registers
# a placeholder tool that returns a static "set up your connections" message when
# the model invokes it. This gates per server without blocking the rest of the
# turn — Ready servers stay usable. None means the source predates the field
# (dev manifest / legacy gateway) and is treated as Ready.
_CONNECTIVITY_READY = "Ready"


class McpToolRegistrationService:
"""
Expand Down Expand Up @@ -114,15 +131,47 @@ async def add_tool_servers_to_agent(
)

self._logger.info(f"Loaded {len(server_configs)} MCP server configurations")
for c in server_configs:
_name = c.mcp_server_name or c.mcp_server_unique_name
self._logger.info(
" per-server gateway state: name=%s connectivity_status=%r missing_url=%s",
_name,
c.connectivity_status,
c.missing_connections_url,
)

# Create the agent with all tools (initial + MCP tools)
all_tools = list(initial_tools)

# Add servers as MCPStreamableHTTPTool instances
# Connection-readiness gate (non-blocking, per server). Discovery runs
# every turn; the gateway flags each server's downstream connections via
# ``connectivityStatus``. A "Ready" (or legacy ``None``) server is wired
# as a real ``MCPStreamableHTTPTool``. A "Pending" server is instead
# registered as a placeholder tool that, when the model invokes it,
# returns a static "set up your connections" message (including the
# server's ``missing_connections_url``). This keeps the turn running with
# the Ready tools and only prompts the user about connection setup if the
# model actually needs the Pending server. A later turn re-runs discovery
# and wires the server for real once its connections are in place.
for config in server_configs:
# Use mcp_server_name if available (not None or empty), otherwise fall back to mcp_server_unique_name
server_name = config.mcp_server_name or config.mcp_server_unique_name

if (
config.connectivity_status is not None
and config.connectivity_status != _CONNECTIVITY_READY
):
placeholder = self._build_pending_placeholder_tool(config)
all_tools.append(placeholder)
self._logger.info(
"MCP server '%s' is %s; registered connection-setup placeholder "
"instead of live tools (setup URL=%s)",
server_name,
config.connectivity_status,
config.missing_connections_url or config.all_connections_url,
)
continue

try:
# Merge base (non-auth) headers with per-server headers from list_tool_servers.
# server.headers already contains the correct per-audience Authorization token.
Expand Down Expand Up @@ -187,6 +236,50 @@ async def add_tool_servers_to_agent(
self._logger.error(f"Failed to add tool servers to agent: {ex}")
raise

def _build_pending_placeholder_tool(self, config: MCPServerConfig) -> FunctionTool:
"""Build the placeholder tool registered in place of a not-yet-connected MCP server.

The gateway reported this server's ``connectivityStatus`` as something other than
``"Ready"`` (typically ``"Pending"``), meaning the user has not finished setting up the
downstream data connection(s) the server needs. The server's real sub-tools are not
available until those connections exist, so instead of wiring it as a live
``MCPStreamableHTTPTool`` we expose a single stand-in tool named after the server. When
the model invokes it — because the user's request needs that server — the tool returns a
static message (including ``missing_connections_url``) that the model relays to the user.

The message is **returned**, not raised: Agent Framework swallows exceptions raised inside
a tool (and converts ``UserInputRequiredException`` into tool-result content), so returning
the text is the only way to surface the URL through the model. ``max_invocations=1`` keeps
the model from calling the placeholder repeatedly within a turn.

Args:
config: The discovered configuration for the Pending MCP server.

Returns:
A ``FunctionTool`` standing in for the not-yet-connected server.
"""
server_name = config.mcp_server_name or config.mcp_server_unique_name
message = format_mcp_connections_required_message(
server_names=[server_name],
connectivity_status=config.connectivity_status,
missing_connections_url=config.missing_connections_url or config.all_connections_url,
)

def _connections_required() -> str:
return message

return FunctionTool(
name=server_name,
description=(
f"Tools provided by the '{server_name}' MCP server. The required data "
"connection(s) for this server are not set up yet, so its tools cannot run. "
"Call this when the user's request needs this server, then relay the returned "
"message — including the setup URL — to the user verbatim."
),
func=_connections_required,
max_invocations=1,
)

def _convert_chat_messages_to_history(
self,
chat_messages: Sequence[Message],
Expand Down
2 changes: 2 additions & 0 deletions libraries/microsoft-agents-a365-tooling/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `ChatHistoryMessage` Pydantic model for representing individual messages in chat history
- Added `ChatMessageRequest` Pydantic model for the chat history API request payload
- Added `py.typed` marker for PEP 561 compliance, enabling type checker support
- Added `id`, `all_connections_url`, `missing_connections_url`, and `connectivity_status` fields to `MCPServerConfig`, parsed from the per-server gateway payload (`allConnectionsUrl`, `missingConnectionsUrl`, `connectivityStatus`). The core service parses this connection metadata onto each server but does not gate on it — connection-readiness gating is owned by the framework-specific tooling extensions
- `_parse_gateway_response()` and `_load_servers_from_gateway()` return `List[MCPServerConfig]`; the wrapped `{"mcpServers": [...]}` and legacy raw-array gateway response shapes are both supported, and response-level (aggregate) connection fields are ignored
10 changes: 10 additions & 0 deletions libraries/microsoft-agents-a365-tooling/docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,16 @@ User-Agent: Agent365SDK/0.1.0 (...)

The gateway returns the same JSON structure, but `mcpServerUniqueName` contains the full endpoint URL.

### Connection metadata (per-server)

The core service parses each server's connection metadata — `connectivityStatus`,
`allConnectionsUrl`, and `missingConnectionsUrl` — onto the returned `MCPServerConfig`
objects, but it does **not** gate on them. Connection-readiness gating is the
responsibility of the framework-specific tooling extensions (e.g.
`microsoft-agents-a365-tooling-extensions-agentframework`), which decide how to surface a
`Pending` server and raise/handle the appropriate error. See the relevant extension's
design document for details.

### MCPServerConfig ([models/mcp_server_config.py](../microsoft_agents_a365/tooling/models/mcp_server_config.py))

Data class representing an MCP server configuration:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ class MCPServerConfig:
#: Publisher identifier for the MCP server.
publisher: Optional[str] = None

#: Unique identifier (GUID) of the MCP server from the gateway, if provided.
id: Optional[str] = None

#: Per-server URL to view/manage all connections for this server's connector.
all_connections_url: Optional[str] = None

#: Per-server URL to set up the connections this server is missing.
missing_connections_url: Optional[str] = None

#: Per-server connectivity status reported by the gateway ("Ready" or "Pending").
#: None when the source predates the field (dev manifest / legacy raw-array gateway).
connectivity_status: Optional[str] = None

def __post_init__(self):
"""Validate the configuration after initialization."""
if not self.mcp_server_name:
Expand Down
Loading
Loading