Skip to content
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ Place it before imports with one blank line after.
- Use explicit `None` checks: `if x is not None:` not `if x:`
- Local imports should be moved to top of file
- Return defensive copies of mutable data to protect singletons
- **Async method naming**: Do NOT use `_async` suffix on async methods. The `_async` suffix is only appropriate when providing both sync and async versions of the same method. Since this SDK is async-only, use plain method names (e.g., `send_chat_history_messages` not `send_chat_history_messages_async`)

### Type Hints - NEVER Use `Any`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,87 @@ mcp_tool = MCPStreamableHTTPTool(
)
```

### 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.

#### send_chat_history_messages

The primary method for sending chat history. Converts Agent Framework `ChatMessage` objects to the `ChatHistoryMessage` format expected by the MCP platform.

```python
from agent_framework import ChatMessage, Role

service = McpToolRegistrationService()

# Create messages
messages = [
ChatMessage(role=Role.USER, text="Hello, how are you?"),
ChatMessage(role=Role.ASSISTANT, text="I'm doing well, thank you!"),
]

# Send to MCP platform for threat protection
result = await service.send_chat_history_messages(messages, turn_context)

if result.succeeded:
print("Chat history sent successfully")
else:
print(f"Failed: {result.errors}")
```

#### send_chat_history_from_store

A convenience method that extracts messages from a `ChatMessageStoreProtocol` and delegates to `send_chat_history_messages`.

```python
# Using a ChatMessageStore directly
result = await service.send_chat_history_from_store(
thread.chat_message_store,
turn_context
)
```

#### Chat History API Parameters

| Method | Parameter | Type | Description |
|--------|-----------|------|-------------|
| `send_chat_history_messages` | `chat_messages` | `Sequence[ChatMessage]` | Messages to send |
| | `turn_context` | `TurnContext` | Conversation context |
| | `tool_options` | `ToolOptions \| None` | Optional configuration |
| `send_chat_history_from_store` | `chat_message_store` | `ChatMessageStoreProtocol` | Message store |
| | `turn_context` | `TurnContext` | Conversation context |
| | `tool_options` | `ToolOptions \| None` | Optional configuration |

#### Chat History Integration Flow

```
Agent Framework ChatMessage objects
McpToolRegistrationService.send_chat_history_messages()
├── Convert ChatMessage → ChatHistoryMessage
│ ├── Extract role via .value property
│ ├── Generate UUID if message_id is None
│ ├── Filter out empty/whitespace content
│ └── Filter out None roles
McpToolServerConfigurationService.send_chat_history()
MCP Platform Real-Time Threat Protection Endpoint
```

#### Message Filtering Behavior

The conversion process filters out invalid messages:
- Messages with `None` role are skipped (logged at WARNING level)
- Messages with empty or whitespace-only content are skipped
- If all messages are filtered out, the method returns success without calling the backend

This ensures only valid, meaningful messages are sent for threat analysis.

## File Structure

```
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

from typing import Optional, List, Any, Union
import logging
import uuid
from datetime import datetime, timezone
from typing import Any, List, Optional, Sequence, Union

from agent_framework import ChatAgent, MCPStreamableHTTPTool
from agent_framework import ChatAgent, ChatMessage, ChatMessageStoreProtocol, MCPStreamableHTTPTool
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.openai import OpenAIChatClient

from microsoft_agents.hosting.core import Authorization, TurnContext

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.services.mcp_tool_server_configuration_service import (
McpToolServerConfigurationService,
)
from microsoft_agents_a365.tooling.models import ToolOptions
from microsoft_agents_a365.tooling.utils.constants import Constants

from microsoft_agents_a365.tooling.utils.utility import (
get_mcp_platform_authentication_scope,
)
Expand Down Expand Up @@ -148,6 +150,186 @@ async def add_tool_servers_to_agent(
self._logger.error(f"Failed to add tool servers to agent: {ex}")
raise

def _convert_chat_messages_to_history(
self,
chat_messages: Sequence[ChatMessage],
) -> List[ChatHistoryMessage]:
"""
Convert Agent Framework ChatMessage objects to ChatHistoryMessage format.

This internal helper method transforms Agent Framework's native ChatMessage
objects into the ChatHistoryMessage format expected by the MCP platform's
real-time threat protection endpoint.

Args:
chat_messages: Sequence of ChatMessage objects to convert.

Returns:
List of ChatHistoryMessage objects ready for the MCP platform.

Note:
- If message_id is None, a new UUID is generated
- Role is extracted via the .value property of the Role object
- Timestamp is set to current UTC time (ChatMessage has no timestamp)
- Messages with empty or whitespace-only content are filtered out and
logged at WARNING level. This is because ChatHistoryMessage requires
non-empty content for validation. The filtered messages will not be
sent to the MCP platform.
"""
history_messages: List[ChatHistoryMessage] = []
current_time = datetime.now(timezone.utc)

for msg in chat_messages:
message_id = msg.message_id if msg.message_id is not None else str(uuid.uuid4())
if msg.role is None:
self._logger.warning(
"Skipping message %s with missing role during conversion", message_id
)
continue
# Defensive handling: use .value if role is an enum, otherwise convert to string
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
content = msg.text if msg.text is not None else ""

# Skip messages with empty content as ChatHistoryMessage validates non-empty content
if not content.strip():
self._logger.warning(
"Skipping message %s with empty content during conversion", message_id
)
continue

history_message = ChatHistoryMessage(
id=message_id,
role=role,
content=content,
timestamp=current_time,
)
history_messages.append(history_message)

self._logger.debug(
"Converted message %s with role '%s' to ChatHistoryMessage", message_id, role
)

return history_messages

async def send_chat_history_messages(
self,
chat_messages: Sequence[ChatMessage],
turn_context: TurnContext,
tool_options: Optional[ToolOptions] = None,
) -> OperationResult:
"""
Send chat history messages to the MCP platform for real-time threat protection.

This is the primary implementation method that handles message conversion
and delegation to the core tooling service.

Args:
chat_messages: Sequence of Agent Framework ChatMessage objects to send.
turn_context: TurnContext from the Agents SDK containing conversation info.
tool_options: Optional configuration for the request. Defaults to
AgentFramework-specific options if not provided.

Returns:
OperationResult indicating success or failure of the operation.

Raises:
ValueError: If chat_messages or turn_context is None.

Example:
>>> service = McpToolRegistrationService()
>>> messages = [ChatMessage(role=Role.USER, text="Hello")]
>>> result = await service.send_chat_history_messages(messages, turn_context)
>>> if result.succeeded:
... print("Chat history sent successfully")
"""
# Input validation
if chat_messages is None:
raise ValueError("chat_messages cannot be None")

if turn_context is None:
raise ValueError("turn_context cannot be None")

# Handle empty messages - return success with warning
if len(chat_messages) == 0:
self._logger.warning("Empty message list provided to send_chat_history_messages")
return OperationResult.success()

self._logger.info(f"Send chat history initiated with {len(chat_messages)} messages")

# Use default options if not provided
if tool_options is None:
tool_options = ToolOptions(orchestrator_name=self._orchestrator_name)

# Convert messages to ChatHistoryMessage format
history_messages = self._convert_chat_messages_to_history(chat_messages)

# Check if all messages were filtered out during conversion
if len(history_messages) == 0:
self._logger.warning("All messages were filtered out during conversion (empty content)")
return OperationResult.success()

# Delegate to core service
result = await self._mcp_server_configuration_service.send_chat_history(
turn_context=turn_context,
chat_history_messages=history_messages,
options=tool_options,
)

if result.succeeded:
self._logger.info(
f"Chat history sent successfully with {len(history_messages)} messages"
)
else:
self._logger.error(f"Failed to send chat history: {result}")

return result

async def send_chat_history_from_store(
self,
chat_message_store: ChatMessageStoreProtocol,
turn_context: TurnContext,
tool_options: Optional[ToolOptions] = None,
) -> OperationResult:
"""
Send chat history from a ChatMessageStore to the MCP platform.

This is a convenience method that extracts messages from the store
and delegates to send_chat_history_messages().

Args:
chat_message_store: ChatMessageStore containing the conversation history.
turn_context: TurnContext from the Agents SDK containing conversation info.
tool_options: Optional configuration for the request.

Returns:
OperationResult indicating success or failure of the operation.

Raises:
ValueError: If chat_message_store or turn_context is None.

Example:
>>> service = McpToolRegistrationService()
>>> result = await service.send_chat_history_from_store(
... thread.chat_message_store, turn_context
... )
"""
# Input validation
if chat_message_store is None:
raise ValueError("chat_message_store cannot be None")

if turn_context is None:
raise ValueError("turn_context cannot be None")

# Extract messages from the store
messages = await chat_message_store.list_messages()

# Delegate to the primary implementation
return await self.send_chat_history_messages(
chat_messages=messages,
turn_context=turn_context,
tool_options=tool_options,
)

async def cleanup(self):
"""Clean up any resources used by the service."""
try:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@
"get_mcp_base_url",
"build_mcp_server_url",
]

# Enable namespace package extension for tooling-extensions-* packages
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
Original file line number Diff line number Diff line change
Expand Up @@ -599,8 +599,13 @@ async def send_chat_history(
# Validate input parameters
if turn_context is None:
raise ValueError("turn_context cannot be None")
if chat_history_messages is None or len(chat_history_messages) == 0:
raise ValueError("chat_history_messages cannot be None or empty")
if chat_history_messages is None:
raise ValueError("chat_history_messages cannot be None")

# Handle empty messages - return success with warning (consistent with extension behavior)
if len(chat_history_messages) == 0:
self._logger.warning("Empty message list provided to send_chat_history")
return OperationResult.success()

# Extract required information from turn context
if not turn_context.activity:
Expand Down
4 changes: 4 additions & 0 deletions tests/tooling/extensions/agentframework/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

"""Test package for Agent Framework tooling extensions."""
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

"""Test package for Agent Framework tooling extension services."""
Loading