diff --git a/libraries/microsoft-agents-hosting-mcp/LICENSE b/libraries/microsoft-agents-hosting-mcp/LICENSE new file mode 100644 index 00000000..9e841e7a --- /dev/null +++ b/libraries/microsoft-agents-hosting-mcp/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/libraries/microsoft-agents-hosting-mcp/microsoft_agents/hosting/mcp/__init__.py b/libraries/microsoft-agents-hosting-mcp/microsoft_agents/hosting/mcp/__init__.py new file mode 100644 index 00000000..6838fbdd --- /dev/null +++ b/libraries/microsoft-agents-hosting-mcp/microsoft_agents/hosting/mcp/__init__.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""MCP hosting adapter for the Microsoft 365 Agents SDK.""" + +from .mcp_adapter import MCPAdapter, MCP_CHANNEL_ID + +__all__ = [ + "MCPAdapter", + "MCP_CHANNEL_ID", +] diff --git a/libraries/microsoft-agents-hosting-mcp/microsoft_agents/hosting/mcp/mcp_adapter.py b/libraries/microsoft-agents-hosting-mcp/microsoft_agents/hosting/mcp/mcp_adapter.py new file mode 100644 index 00000000..2f7e215f --- /dev/null +++ b/libraries/microsoft-agents-hosting-mcp/microsoft_agents/hosting/mcp/mcp_adapter.py @@ -0,0 +1,204 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""MCP hosting adapter for the Microsoft 365 Agents SDK. + +This module provides an adapter that exposes an M365 Agent as an MCP server, +translating MCP tool calls into Activity-based turns processed by the agent +pipeline. +""" + +from __future__ import annotations + +import uuid +from typing import Any, List + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, + ConversationReference, + ResourceResponse, +) +from microsoft_agents.hosting.core.channel_adapter import ChannelAdapter +from microsoft_agents.hosting.core.turn_context import TurnContext +from microsoft_agents.hosting.core.agent import Agent + +from mcp.server.fastmcp import FastMCP + +# Channel identifier for MCP-originated activities. +MCP_CHANNEL_ID = "mcp" + + +class MCPAdapter(ChannelAdapter): + """Adapter that bridges MCP protocol requests to the Agents SDK pipeline. + + The adapter registers a ``message`` tool on the MCP server. When an MCP + client invokes that tool, the adapter constructs an + :class:`~microsoft_agents.activity.Activity`, creates a + :class:`~microsoft_agents.hosting.core.turn_context.TurnContext`, and routes + the turn through the standard middleware → agent handler pipeline. + + Responses sent by the agent via ``context.send_activity()`` are collected + and returned as MCP tool-call results. + + Args: + agent: The agent instance that handles incoming turns. + server_name: Display name for the MCP server (defaults to + ``"agents-mcp-server"``). + server_instructions: Optional instructions exposed to MCP clients + describing the server's capabilities. + tools: Optional list of additional MCP tool descriptors. Each entry + is a dict with ``name``, ``description``, and an optional + ``parameters`` JSON-schema dict. + """ + + def __init__( + self, + agent: Agent, + *, + server_name: str = "agents-mcp-server", + server_instructions: str | None = None, + tools: list[dict[str, Any]] | None = None, + ) -> None: + super().__init__() + self._agent = agent + self._mcp = FastMCP( + name=server_name, + instructions=server_instructions, + ) + # Register the built-in "message" tool. + self._register_message_tool() + + # Register any additional custom tools. + if tools: + for tool_spec in tools: + self._register_custom_tool(tool_spec) + + # ------------------------------------------------------------------ + # Public helpers + # ------------------------------------------------------------------ + + @property + def mcp_server(self) -> FastMCP: + """Return the underlying ``FastMCP`` instance. + + Callers can use this to mount the streamable-HTTP or SSE endpoint on + a web framework:: + + app.mount("/mcp", adapter.mcp_server.streamable_http_app()) + """ + return self._mcp + + def streamable_http_app(self): + """Convenience shortcut for ``self.mcp_server.streamable_http_app()``.""" + return self._mcp.streamable_http_app() + + # ------------------------------------------------------------------ + # ChannelAdapter abstract method implementations + # ------------------------------------------------------------------ + + async def send_activities( + self, context: TurnContext, activities: List[Activity] + ) -> List[ResourceResponse]: + """Buffer outgoing activities so they can be returned to the MCP client. + + Activities are stored on the turn context under the key + ``MCPAdapter.Responses`` rather than being sent over a channel. + """ + responses: list[ResourceResponse] = [] + for activity in activities: + activity_id = activity.id or str(uuid.uuid4()) + activity.id = activity_id + + # Stash the activity so _process_turn can retrieve it. + bucket: list[Activity] = context.turn_state.setdefault( + "MCPAdapter.Responses", [] + ) + bucket.append(activity) + + responses.append(ResourceResponse(id=activity_id)) + return responses + + async def update_activity(self, context: TurnContext, activity: Activity): + """Not supported for the MCP channel.""" + raise NotImplementedError("update_activity is not supported over MCP") + + async def delete_activity( + self, context: TurnContext, reference: ConversationReference + ): + """Not supported for the MCP channel.""" + raise NotImplementedError("delete_activity is not supported over MCP") + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _register_message_tool(self) -> None: + """Register the built-in ``message`` tool on the MCP server.""" + + @self._mcp.tool( + name="message", + description=( + "Send a message to the agent and receive a response. " + "Use this tool to communicate with the agent." + ), + ) + async def message_tool(text: str) -> str: + """Send *text* to the agent and return its response.""" + return await self._process_turn(text) + + def _register_custom_tool(self, tool_spec: dict[str, Any]) -> None: + """Register a custom tool that routes through the agent pipeline. + + The tool call arguments are JSON-serialised and sent as the Activity + text, with the tool name stored in ``Activity.name``. + """ + import json + + tool_name = tool_spec["name"] + tool_description = tool_spec.get("description", "") + + @self._mcp.tool(name=tool_name, description=tool_description) + async def _custom_handler(**kwargs: Any) -> str: + text = json.dumps({"tool": tool_name, "arguments": kwargs}) + return await self._process_turn(text, tool_name=tool_name) + + async def _process_turn(self, text: str, *, tool_name: str | None = None) -> str: + """Create an Activity from *text*, run it through the agent, and + return the collected response text. + """ + conversation_id = str(uuid.uuid4()) + + activity = Activity( + type=ActivityTypes.message, + id=str(uuid.uuid4()), + text=text, + channel_id=MCP_CHANNEL_ID, + from_property=ChannelAccount(id="mcp-user", name="MCP Client"), + recipient=ChannelAccount(id="agent", name="Agent"), + conversation=ConversationAccount(id=conversation_id), + service_url="urn:mcp", + ) + + if tool_name: + activity.name = tool_name + + context = TurnContext(self, activity) + + # Run the agent pipeline (middleware → agent.on_turn). + await self.run_pipeline(context, self._agent.on_turn) + + # Collect responses buffered by send_activities(). + response_activities: list[Activity] = context.turn_state.get( + "MCPAdapter.Responses", [] + ) + + # Concatenate all message-text responses. + parts: list[str] = [] + for resp in response_activities: + if resp.text: + parts.append(resp.text) + + return "\n".join(parts) if parts else "" diff --git a/libraries/microsoft-agents-hosting-mcp/pyproject.toml b/libraries/microsoft-agents-hosting-mcp/pyproject.toml new file mode 100644 index 00000000..6b038029 --- /dev/null +++ b/libraries/microsoft-agents-hosting-mcp/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "microsoft-agents-hosting-mcp" +dynamic = ["version", "dependencies"] +description = "MCP (Model Context Protocol) hosting adapter for Microsoft Agents SDK" +readme = {file = "readme.md", content-type = "text/markdown"} +authors = [{name = "Microsoft Corporation"}] +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", +] + +[project.urls] +"Homepage" = "https://github.com/microsoft/Agents" diff --git a/libraries/microsoft-agents-hosting-mcp/readme.md b/libraries/microsoft-agents-hosting-mcp/readme.md new file mode 100644 index 00000000..ac387d35 --- /dev/null +++ b/libraries/microsoft-agents-hosting-mcp/readme.md @@ -0,0 +1,25 @@ +# Microsoft Agents Hosting MCP + +MCP (Model Context Protocol) hosting adapter for the Microsoft 365 Agents SDK. + +This package enables exposing an M365 Agent as an MCP server, allowing MCP +clients (such as language models and AI assistants) to discover and invoke the +agent's capabilities through the standard MCP protocol. + +## Installation + +```bash +pip install microsoft-agents-hosting-mcp +``` + +## Usage + +```python +from microsoft_agents.hosting.mcp import MCPAdapter + +# Create adapter with your agent +adapter = MCPAdapter(agent=my_agent) + +# Mount on FastAPI +app.mount("/mcp", adapter.streamable_http_app()) +``` diff --git a/libraries/microsoft-agents-hosting-mcp/setup.py b/libraries/microsoft-agents-hosting-mcp/setup.py new file mode 100644 index 00000000..c1152f46 --- /dev/null +++ b/libraries/microsoft-agents-hosting-mcp/setup.py @@ -0,0 +1,18 @@ +from os import environ, path +from setuptools import setup + +# Try to read from VERSION.txt file first, fall back to environment variable +version_file = path.join(path.dirname(__file__), "VERSION.txt") +if path.exists(version_file): + with open(version_file, "r", encoding="utf-8") as f: + package_version = f.read().strip() +else: + package_version = environ.get("PackageVersion", "0.0.0") + +setup( + version=package_version, + install_requires=[ + f"microsoft-agents-hosting-core=={package_version}", + "mcp>=1.0.0", + ], +) diff --git a/tests/hosting_mcp/__init__.py b/tests/hosting_mcp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_mcp/test_mcp_adapter.py b/tests/hosting_mcp/test_mcp_adapter.py new file mode 100644 index 00000000..7e001ef4 --- /dev/null +++ b/tests/hosting_mcp/test_mcp_adapter.py @@ -0,0 +1,218 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for the MCP hosting adapter.""" + +import pytest + +from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.hosting.core.turn_context import TurnContext +from microsoft_agents.hosting.mcp import MCPAdapter, MCP_CHANNEL_ID + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class EchoAgent: + """Minimal agent that echoes back whatever text it receives.""" + + async def on_turn(self, context: TurnContext): + if context.activity.type == ActivityTypes.message and context.activity.text: + await context.send_activity(f"Echo: {context.activity.text}") + + +class MultiResponseAgent: + """Agent that sends multiple response activities.""" + + async def on_turn(self, context: TurnContext): + if context.activity.type == ActivityTypes.message: + await context.send_activity("First response") + await context.send_activity("Second response") + + +class SilentAgent: + """Agent that does not send any responses.""" + + async def on_turn(self, context: TurnContext): + pass + + +class ToolAwareAgent: + """Agent that reads the activity name to identify tool calls.""" + + async def on_turn(self, context: TurnContext): + if context.activity.name: + await context.send_activity( + f"Tool: {context.activity.name}, Args: {context.activity.text}" + ) + else: + await context.send_activity(f"Message: {context.activity.text}") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestMCPAdapterInit: + """Tests for MCPAdapter initialization.""" + + def test_creates_mcp_server_with_defaults(self): + adapter = MCPAdapter(agent=EchoAgent()) + assert adapter.mcp_server is not None + assert adapter.mcp_server.name == "agents-mcp-server" + + def test_creates_mcp_server_with_custom_name(self): + adapter = MCPAdapter(agent=EchoAgent(), server_name="my-agent") + assert adapter.mcp_server.name == "my-agent" + + def test_streamable_http_app_returns_asgi_app(self): + adapter = MCPAdapter(agent=EchoAgent()) + app = adapter.streamable_http_app() + assert app is not None + + +class TestMCPAdapterProcessTurn: + """Tests for the internal _process_turn method.""" + + @pytest.mark.asyncio + async def test_echo_agent_returns_response(self): + adapter = MCPAdapter(agent=EchoAgent()) + result = await adapter._process_turn("Hello") + assert result == "Echo: Hello" + + @pytest.mark.asyncio + async def test_multi_response_concatenated(self): + adapter = MCPAdapter(agent=MultiResponseAgent()) + result = await adapter._process_turn("Hi") + assert result == "First response\nSecond response" + + @pytest.mark.asyncio + async def test_silent_agent_returns_empty(self): + adapter = MCPAdapter(agent=SilentAgent()) + result = await adapter._process_turn("Hello") + assert result == "" + + @pytest.mark.asyncio + async def test_tool_name_set_on_activity(self): + adapter = MCPAdapter(agent=ToolAwareAgent()) + result = await adapter._process_turn( + '{"tool": "search", "arguments": {"q": "test"}}', + tool_name="search", + ) + assert "Tool: search" in result + + @pytest.mark.asyncio + async def test_activity_has_mcp_channel_id(self): + """Verify the activity routed through the pipeline has channel_id='mcp'.""" + captured_activities = [] + + class CapturingAgent: + async def on_turn(self, context: TurnContext): + captured_activities.append(context.activity) + await context.send_activity("ok") + + adapter = MCPAdapter(agent=CapturingAgent()) + await adapter._process_turn("test") + + assert len(captured_activities) == 1 + assert captured_activities[0].channel_id == MCP_CHANNEL_ID + assert captured_activities[0].type == ActivityTypes.message + + +class TestMCPAdapterSendActivities: + """Tests for the send_activities override.""" + + @pytest.mark.asyncio + async def test_send_activities_buffers_responses(self): + adapter = MCPAdapter(agent=EchoAgent()) + activity = Activity( + type=ActivityTypes.message, + text="test", + channel_id=MCP_CHANNEL_ID, + ) + context = TurnContext(adapter, activity) + + response_activity = Activity(type=ActivityTypes.message, text="hello") + results = await adapter.send_activities(context, [response_activity]) + + assert len(results) == 1 + assert results[0].id is not None + bucket = context.turn_state.get("MCPAdapter.Responses", []) + assert len(bucket) == 1 + assert bucket[0].text == "hello" + + +class TestMCPAdapterUnsupportedOps: + """Tests for unsupported operations.""" + + @pytest.mark.asyncio + async def test_update_activity_raises(self): + adapter = MCPAdapter(agent=EchoAgent()) + activity = Activity(type=ActivityTypes.message, text="test", channel_id="mcp") + context = TurnContext(adapter, activity) + with pytest.raises(NotImplementedError): + await adapter.update_activity(context, activity) + + @pytest.mark.asyncio + async def test_delete_activity_raises(self): + adapter = MCPAdapter(agent=EchoAgent()) + activity = Activity(type=ActivityTypes.message, text="test", channel_id="mcp") + context = TurnContext(adapter, activity) + with pytest.raises(NotImplementedError): + await adapter.delete_activity(context, None) + + +class TestMCPAdapterMiddleware: + """Tests for middleware integration.""" + + @pytest.mark.asyncio + async def test_middleware_runs_in_pipeline(self): + """Verify middleware is invoked during _process_turn.""" + middleware_called = False + + class TrackingMiddleware: + async def on_turn(self, context, logic): + nonlocal middleware_called + middleware_called = True + await logic() + + adapter = MCPAdapter(agent=EchoAgent()) + adapter.use(TrackingMiddleware()) + + result = await adapter._process_turn("Hello") + assert middleware_called + assert result == "Echo: Hello" + + @pytest.mark.asyncio + async def test_middleware_can_modify_activity(self): + """Verify middleware can intercept and modify the activity.""" + + class PrefixMiddleware: + async def on_turn(self, context, logic): + context.activity.text = "[modified] " + context.activity.text + await logic() + + adapter = MCPAdapter(agent=EchoAgent()) + adapter.use(PrefixMiddleware()) + + result = await adapter._process_turn("Hello") + assert result == "Echo: [modified] Hello" + + +class TestMCPAdapterCustomTools: + """Tests for custom tool registration.""" + + def test_custom_tools_registered(self): + adapter = MCPAdapter( + agent=EchoAgent(), + tools=[ + { + "name": "search", + "description": "Search for information", + }, + ], + ) + # The MCP server should have registered the custom tool + assert adapter.mcp_server is not None