From 0c00cf74e2f4733987704bad7ee946d852baea9a Mon Sep 17 00:00:00 2001 From: Hashwanth S Date: Wed, 1 Apr 2026 22:59:03 -0700 Subject: [PATCH] Add MCP hosting adapter package (microsoft-agents-hosting-mcp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a new hosting adapter that exposes an M365 Agent as an MCP server using the mcp Python SDK (FastMCP). This enables MCP clients to discover and invoke agent capabilities through the standard MCP protocol. The adapter follows the existing hosting adapter pattern: - Subclasses ChannelAdapter and implements send_activities - Translates MCP tool calls into Activity-based turns - Routes turns through the standard middleware → agent pipeline - Buffers agent responses and returns them as MCP tool results Package includes: - MCPAdapter with built-in "message" tool and custom tool support - Streamable HTTP transport via FastMCP - Middleware pipeline integration - 14 unit tests covering init, turn processing, middleware, and edge cases Addresses #321 Fixes #321 --- .../microsoft-agents-hosting-mcp/LICENSE | 21 ++ .../microsoft_agents/hosting/mcp/__init__.py | 11 + .../hosting/mcp/mcp_adapter.py | 204 ++++++++++++++++ .../pyproject.toml | 20 ++ .../microsoft-agents-hosting-mcp/readme.md | 25 ++ .../microsoft-agents-hosting-mcp/setup.py | 18 ++ tests/hosting_mcp/__init__.py | 0 tests/hosting_mcp/test_mcp_adapter.py | 218 ++++++++++++++++++ 8 files changed, 517 insertions(+) create mode 100644 libraries/microsoft-agents-hosting-mcp/LICENSE create mode 100644 libraries/microsoft-agents-hosting-mcp/microsoft_agents/hosting/mcp/__init__.py create mode 100644 libraries/microsoft-agents-hosting-mcp/microsoft_agents/hosting/mcp/mcp_adapter.py create mode 100644 libraries/microsoft-agents-hosting-mcp/pyproject.toml create mode 100644 libraries/microsoft-agents-hosting-mcp/readme.md create mode 100644 libraries/microsoft-agents-hosting-mcp/setup.py create mode 100644 tests/hosting_mcp/__init__.py create mode 100644 tests/hosting_mcp/test_mcp_adapter.py 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