From 064a76987e6e15e1d62753f50dd11857d2f520f7 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 20 Jul 2026 13:53:56 +0200 Subject: [PATCH 1/3] Python: Add MCP hosting helpers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5d6987cd-1b67-4ba1-8b54-3c50da6e7607 --- .../workflows/python-integration-tests.yml | 1 + .github/workflows/python-merge-tests.yml | 2 + docs/specs/002-python-hosting-channels.md | 73 +++++++ python/AGENTS.md | 1 + python/PACKAGE_STATUS.md | 1 + python/packages/hosting-mcp/AGENTS.md | 53 +++++ python/packages/hosting-mcp/LICENSE | 21 ++ python/packages/hosting-mcp/README.md | 174 +++++++++++++++++ .../agent_framework_hosting_mcp/__init__.py | 22 +++ .../_agent_tool.py | 181 ++++++++++++++++++ .../_conversion.py | 155 +++++++++++++++ .../_workflow_tool.py | 143 ++++++++++++++ .../agent_framework_hosting_mcp/py.typed | 0 python/packages/hosting-mcp/pyproject.toml | 81 ++++++++ .../tests/hosting_mcp/test_agent_tool.py | 155 +++++++++++++++ .../tests/hosting_mcp/test_conversion.py | 144 ++++++++++++++ .../tests/hosting_mcp/test_integration.py | 118 ++++++++++++ .../tests/hosting_mcp/test_workflow_tool.py | 131 +++++++++++++ python/pyproject.toml | 1 + python/samples/04-hosting/mcp/README.md | 148 ++++++++++++++ python/samples/04-hosting/mcp/agent_app.py | 107 +++++++++++ python/samples/04-hosting/mcp/fastmcp_app.py | 97 ++++++++++ python/samples/04-hosting/mcp/manual_app.py | 122 ++++++++++++ python/samples/04-hosting/mcp/pyproject.toml | 22 +++ python/samples/04-hosting/mcp/session_app.py | 132 +++++++++++++ python/samples/04-hosting/mcp/workflow_app.py | 107 +++++++++++ python/uv.lock | 20 ++ 27 files changed, 2212 insertions(+) create mode 100644 python/packages/hosting-mcp/AGENTS.md create mode 100644 python/packages/hosting-mcp/LICENSE create mode 100644 python/packages/hosting-mcp/README.md create mode 100644 python/packages/hosting-mcp/agent_framework_hosting_mcp/__init__.py create mode 100644 python/packages/hosting-mcp/agent_framework_hosting_mcp/_agent_tool.py create mode 100644 python/packages/hosting-mcp/agent_framework_hosting_mcp/_conversion.py create mode 100644 python/packages/hosting-mcp/agent_framework_hosting_mcp/_workflow_tool.py create mode 100644 python/packages/hosting-mcp/agent_framework_hosting_mcp/py.typed create mode 100644 python/packages/hosting-mcp/pyproject.toml create mode 100644 python/packages/hosting-mcp/tests/hosting_mcp/test_agent_tool.py create mode 100644 python/packages/hosting-mcp/tests/hosting_mcp/test_conversion.py create mode 100644 python/packages/hosting-mcp/tests/hosting_mcp/test_integration.py create mode 100644 python/packages/hosting-mcp/tests/hosting_mcp/test_workflow_tool.py create mode 100644 python/samples/04-hosting/mcp/README.md create mode 100644 python/samples/04-hosting/mcp/agent_app.py create mode 100644 python/samples/04-hosting/mcp/fastmcp_app.py create mode 100644 python/samples/04-hosting/mcp/manual_app.py create mode 100644 python/samples/04-hosting/mcp/pyproject.toml create mode 100644 python/samples/04-hosting/mcp/session_app.py create mode 100644 python/samples/04-hosting/mcp/workflow_app.py diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index fdc587aa59d..756e0686409 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -241,6 +241,7 @@ jobs: packages/hyperlight/tests packages/ollama/tests packages/core/tests/core/test_mcp.py + packages/hosting-mcp/tests -m integration -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index db391fa1acb..9f6e1034f97 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -71,6 +71,7 @@ jobs: - 'python/packages/ollama/**' - 'python/packages/core/agent_framework/_mcp.py' - 'python/packages/core/tests/core/test_mcp.py' + - 'python/packages/hosting-mcp/**' - 'python/scripts/local_mcp_streamable_http_server.py' - '.github/actions/setup-local-mcp-server/**' - '.github/workflows/python-merge-tests.yml' @@ -345,6 +346,7 @@ jobs: packages/hyperlight/tests packages/ollama/tests packages/core/tests/core/test_mcp.py + packages/hosting-mcp/tests -m integration -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread diff --git a/docs/specs/002-python-hosting-channels.md b/docs/specs/002-python-hosting-channels.md index 83c993414fa..337dcef1f92 100644 --- a/docs/specs/002-python-hosting-channels.md +++ b/docs/specs/002-python-hosting-channels.md @@ -67,6 +67,7 @@ must be aligned with the helper-first model before implementation. Old vocabular |---|---|---| | `agent-framework-hosting` | `agent_framework_hosting` | `AgentState`, `WorkflowState`, `SessionStore`, and run-argument `TypedDict`s. | | `agent-framework-hosting-a2a` | `agent_framework_hosting_a2a` | A2A `Message` to run conversion and Agent Framework output to A2A `Part` conversion. | +| `agent-framework-hosting-mcp` | `agent_framework_hosting_mcp` | Agent and workflow MCP tool adapters, MCP tool arguments to run conversion, and Agent Framework output to MCP `ContentBlock` conversion. | | `agent-framework-hosting-responses` | `agent_framework_hosting_responses` | Responses helpers: request parsing, session id extraction, response id creation, response rendering, streaming rendering. | | `agent-framework-hosting-telegram` | `agent_framework_hosting_telegram` | Telegram Bot API helpers: update parsing, chat/session/command/media extraction, final rendering, and streaming edit rendering. | | Future protocol packages | e.g. `agent_framework_hosting_activity_protocol` | Protocol-specific helpers such as `activity_to_run(...)`, `activity_from_run(...)`, `activity_session_id(...)`, and command/media helpers when useful. | @@ -272,6 +273,78 @@ artifact-id policy, or session-key policy. Application code composes the two helpers with those native A2A SDK constructs and may use any server framework supported by the SDK. +## `agent-framework-hosting-mcp` + +The MCP package provides only the conversion seam between native MCP SDK values +and Agent Framework: + +- `MCPAgentTool(target, ...)` +- `MCPWorkflowTool(target, ...)` +- `mcp_to_run(arguments, *, argument_name="task", chat_option_arguments=()) -> AgentRunArgs` +- `mcp_from_run(result) -> list[mcp.types.ContentBlock]` + +`MCPAgentTool` represents one Agent Framework agent as one native MCP tool. It +derives the default tool name and description from the agent, accepts +overrides for those values and the main text parameter, includes app-owned +additional parameter schemas, and explicitly maps selected parameter schemas +to ChatOptions. Its asynchronous `list_tools()` returns the native `Tool` list, +and `call_tool(...)` performs conversion, agent execution, and final result +conversion. + +The adapter accepts either an agent or an existing `AgentState`. With a +configured `session_id_parameter`, it loads and stores the corresponding +`AgentSession`. The application remains responsible for deriving and +authorizing the session id and preventing concurrent updates to the same +session. + +`MCPWorkflowTool` represents one Agent Framework workflow as one native MCP +tool. It derives the tool name and description from the workflow and derives +the input schema from the start executor's single declared input type. +Object-shaped dataclass and Pydantic inputs become top-level MCP arguments; +primitive inputs are wrapped in one configurable argument. The adapter +validates the arguments against that type, runs the workflow, and converts +terminal outputs to MCP content blocks. + +Workflow instances preserve state and reject concurrent runs. Applications +that need independent calls should provide a `WorkflowState` factory with +`cache_target=False`. Checkpoint restoration, human-in-the-loop responses, and +continuation identifiers remain application-owned contracts. If a workflow +stops to request external input, the adapter raises rather than returning an +empty successful tool result. + +`mcp_to_run(...)` accepts the argument mapping from a native MCP `call_tool` +handler. The application owns the tool schema and may select which required +string argument contains the user request. The application should define that +argument name once and use the same value in the native tool schema and the +`argument_name` parameter so those two sides of the contract remain aligned. +Applications may also expose selected ChatOptions fields in their native tool +schema and pass those names through `chat_option_arguments`. Only explicitly +selected names are copied to run options; the helper does not forward all MCP +arguments or own their JSON Schema validation. + +MCP `tools/call` arguments are JSON-only and do not have a native multimodal +content-block union. The package does not impose a non-standard JSON +representation for multimodal tool arguments. + +`mcp_from_run(...)` accepts an `AgentResponse` or `Message`. It converts text, +URI, image data, audio data, and other binary data into native MCP content +blocks. + +Its output is specifically the content union accepted by `CallToolResult`. +Sampling-only values such as `ToolUseContent` belong to the separate MCP +sampling response path and are not emitted by this hosting helper. + +MCP `tools/call` returns one final `CallToolResult`. Streamable HTTP can carry +multiple MCP messages and progress notifications can report operation status, +but the protocol does not define partial tool-result content chunks. +Experimental MCP tasks defer retrieval of the same final result. Therefore the +conversion helpers do not expose Agent Framework streaming updates. + +The package does not provide an MCP `Server`, handler registration, transport, route, +session policy, authentication, authorization, or deployment wrapper. +Application code composes the adapters and conversion helpers with native MCP SDK constructs and +may use stdio, streamable HTTP, or another transport supported by the SDK. + ## `agent-framework-hosting-telegram` The Telegram package provides side-effect-free helpers around Telegram Bot API diff --git a/python/AGENTS.md b/python/AGENTS.md index 5b00ec89e1a..b99f0ec7dad 100644 --- a/python/AGENTS.md +++ b/python/AGENTS.md @@ -95,6 +95,7 @@ python/ ### Protocols & UI - [a2a](packages/a2a/AGENTS.md) - Agent-to-Agent protocol - [hosting-a2a](packages/hosting-a2a/AGENTS.md) - A2A hosting conversion helpers +- [hosting-mcp](packages/hosting-mcp/AGENTS.md) - MCP hosting conversion helpers - [ag-ui](packages/ag-ui/AGENTS.md) - AG-UI protocol - [chatkit](packages/chatkit/AGENTS.md) - OpenAI ChatKit integration - [devui](packages/devui/AGENTS.md) - Developer UI for testing diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index 182b445cd5d..cd99ec36448 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -36,6 +36,7 @@ Status is grouped into these buckets: | `agent-framework-github-copilot` | `python/packages/github_copilot` | `rc` | | `agent-framework-hosting` | `python/packages/hosting` | `alpha` | | `agent-framework-hosting-a2a` | `python/packages/hosting-a2a` | `alpha` | +| `agent-framework-hosting-mcp` | `python/packages/hosting-mcp` | `alpha` | | `agent-framework-hosting-responses` | `python/packages/hosting-responses` | `alpha` | | `agent-framework-hosting-telegram` | `python/packages/hosting-telegram` | `alpha` | | `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` | diff --git a/python/packages/hosting-mcp/AGENTS.md b/python/packages/hosting-mcp/AGENTS.md new file mode 100644 index 00000000000..6f19756e603 --- /dev/null +++ b/python/packages/hosting-mcp/AGENTS.md @@ -0,0 +1,53 @@ +# MCP Hosting Helpers (`agent-framework-hosting-mcp`) + +Side-effect-free adapters and conversion helpers for hosting Agent Framework +agents and workflows through the native MCP SDK. + +## Public API + +- `MCPAgentTool(target, ...)` generates one native MCP `Tool` from an agent, + converts and executes calls, and optionally persists sessions through an + existing `AgentState`. +- `MCPWorkflowTool(target, ...)` derives one native MCP `Tool` from a workflow's + single start-executor input type and converts completed workflow outputs. +- `mcp_to_run(arguments, *, argument_name="task", + chat_option_arguments=())` converts MCP tool arguments to `AgentRunArgs` and + copies only explicitly selected arguments into chat options. +- `mcp_from_run(result)` converts an Agent Framework response or message to MCP + `ContentBlock` values. + +## Boundary + +This package does not provide a server, routes, transport lifecycle, +authentication, authorization, session-key policy, concurrency policy, or +outbound delivery. Applications compose the adapter and conversion helpers +with native MCP SDK constructs. + +`MCPAgentTool` owns only the schema for its single generated agent tool. It +does not register that schema with a server. Applications call +`await adapter.list_tools()` and `await adapter.call_tool(...)` from native MCP +handlers. + +`MCPWorkflowTool` owns only the schema derived from the start executor. It +requires exactly one input type. Workflow factories and continuation policy +remain application-owned. Pending external-input requests raise because the +adapter does not own a human-in-the-loop continuation contract. + +When configured with `AgentState`, the adapter performs session get/run/set. +Applications still derive and authorize the session identifier and serialize +concurrent calls for the same session. + +MCP tool arguments are JSON-only and have no native multimodal content-block +union. Do not add a package-owned JSON convention for image or audio input. + +`mcp_from_run(...)` intentionally returns a flat content block list. It +preserves content-level metadata, while applications own MCP result-level +metadata and structured content. + +The helper targets `CallToolResult.content`. Do not add sampling-only +`ToolUseContent` to its output; MCP sampling has a separate response content +union. + +`CallToolResult` is a single final result. Streamable HTTP transports MCP +messages rather than partial result content; progress notifications and +experimental tasks remain application-owned protocol concerns. diff --git a/python/packages/hosting-mcp/LICENSE b/python/packages/hosting-mcp/LICENSE new file mode 100644 index 00000000000..9e841e7a26e --- /dev/null +++ b/python/packages/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/python/packages/hosting-mcp/README.md b/python/packages/hosting-mcp/README.md new file mode 100644 index 00000000000..ff14a29860c --- /dev/null +++ b/python/packages/hosting-mcp/README.md @@ -0,0 +1,174 @@ +# agent-framework-hosting-mcp + +MCP conversion helpers for app-owned Agent Framework hosting. + +The package deliberately does not choose a web framework or wrap the MCP SDK +server lifecycle. It provides two conversion functions and small adapters: + +- `mcp_to_run(...)` converts native MCP tool arguments into Agent Framework run + arguments. +- `mcp_from_run(...)` converts an `AgentResponse` or `Message` into native MCP + `ContentBlock` values. +- `MCPAgentTool(...)` generates the native `Tool` definition from an agent and + keeps listing, parsing, execution, result conversion, and optional + `AgentState` session persistence aligned. +- `MCPWorkflowTool(...)` generates the native `Tool` definition from a + workflow's start-executor input type and converts completed workflow outputs. + +Application code keeps ownership of the MCP SDK's `Server`, handler +registration, request context, transport, session-key policy, authentication, +authorization, and deployment. + +For direct conversion, the argument name is part of the app-owned MCP tool +contract. Define it once and use the same value in both the native tool schema +and `mcp_to_run(...)`: + +```python +agent_input_argument = "task" +chat_option_arguments = { + "reasoning_effort": { + "type": "string", + "enum": ["low", "medium", "high"], + }, +} + +tool = Tool( + name="run_agent", + inputSchema={ + "type": "object", + "properties": { + agent_input_argument: {"type": "string"}, + **chat_option_arguments, + }, + "required": [agent_input_argument], + }, +) +run = mcp_to_run( + arguments, + argument_name=agent_input_argument, + chat_option_arguments=chat_option_arguments, +) +``` + +Only names listed in `chat_option_arguments` are copied to `run["options"]`; +other MCP arguments remain available in the message's raw representation but +are not forwarded to the model client. The native MCP schema remains +responsible for validating exposed option types and ranges. + +For an agent exposed as one MCP tool, use the adapter so the schema and +conversion cannot drift: + +```python +agent_tool = MCPAgentTool( + agent, + name="run_agent", + argument_description="The request for the hosted agent.", + parameters={"audience": {"type": "string"}}, + chat_option_parameters={ + "reasoning_effort": { + "type": "string", + "enum": ["low", "medium", "high"], + } + }, +) + +@server.list_tools() +async def list_tools(): + return await agent_tool.list_tools() + +@server.call_tool() +async def call_tool(name, arguments): + return await agent_tool.call_tool(name, arguments) +``` + +`MCPAgentTool` uses the agent's name and description unless overridden. +`parameters` adds app-owned JSON Schema properties that remain available in the +raw MCP arguments. `chat_option_parameters` adds properties and explicitly +copies their values into Agent Framework chat options. + +For a workflow exposed as one MCP tool, use `MCPWorkflowTool`: + +```python +workflow_tool = MCPWorkflowTool( + WorkflowState(create_workflow, cache_target=False), + name="run_workflow", +) +``` + +The start executor must declare exactly one input type. Dataclass, Pydantic, and +other object-shaped inputs become the MCP tool's top-level arguments. Primitive +inputs are wrapped in the configurable `argument_name` property. The adapter +validates MCP arguments against that derived type before calling +`workflow.run(...)`. + +Workflow instances preserve execution state, so applications that need +independent calls should supply a `WorkflowState` factory with +`cache_target=False`, as above. Checkpoint restoration, human-in-the-loop +responses, and continuation identifiers remain application-owned contracts. +If a workflow requests external input, the adapter raises instead of returning +an empty successful tool result. + +Pass an existing `AgentState` plus `session_id_parameter` to persist an +`AgentSession`: + +```python +state = AgentState(agent) +agent_tool = MCPAgentTool( + state, + parameters={"session_id": {"type": "string", "minLength": 1}}, + required_parameters={"session_id"}, + session_id_parameter="session_id", +) +``` + +The application must authenticate or authorize that session identifier and +serialize concurrent calls for the same session. The adapter only performs the +`AgentState` session-store get/run/set sequence. A configured session parameter +is always marked required in the generated MCP schema. + +The session identifier is an opaque, application-defined key. Neither MCP nor +Agent Framework prescribes its format. `MCPAgentTool` treats it as the key for +one mutable conversation: each call loads that session and stores the updated +session under the same key. It does not implement +`previous_response_id`-style branching. Branching requires an app-owned +contract with separate source and destination identifiers so the application +can authorize both, copy the source session, and store the result under the +destination key. + +MCP `tools/call` inputs are JSON objects defined by the app's `inputSchema`; +the protocol does not define image, audio, or resource content blocks for tool +arguments. This helper therefore converts one selected string argument and +does not impose a non-standard multimodal JSON convention. + +For non-image/audio binary output, `mcp_from_run(...)` uses an app-provided +`content.additional_properties["uri"]` when present and otherwise uses the +short fallback `af://binary`; the payload itself is stored only in the MCP +resource's `blob` field. + +`mcp_from_run(...)` targets `CallToolResult.content`, whose MCP content union +does not include sampling-only `ToolUseContent`. Agent Framework +`function_call` content is therefore omitted from tool results. MCP sampling +callbacks use a separate response contract and may convert function calls to +`ToolUseContent`. + +MCP tool calls return one final `CallToolResult`; they do not stream partial +content blocks. Streamable HTTP may carry multiple MCP messages, and apps may +send progress notifications while work runs, but neither mechanism turns +Agent Framework response updates into incremental tool results. Experimental +MCP tasks defer retrieval of the same final result. + +```python +run = mcp_to_run(arguments) +result = await agent.run( + run["messages"], + options=run["options"], +) +content = mcp_from_run(result) + +# Native MCP SDK application code returns `content` from its call_tool handler. +``` + +The surrounding MCP application still owns the low-level `Server`, handler +registration, Starlette/FastAPI composition, stdio or streamable HTTP +transport, request authentication, session-key trust, concurrency, and +deployment. diff --git a/python/packages/hosting-mcp/agent_framework_hosting_mcp/__init__.py b/python/packages/hosting-mcp/agent_framework_hosting_mcp/__init__.py new file mode 100644 index 00000000000..573f67390f4 --- /dev/null +++ b/python/packages/hosting-mcp/agent_framework_hosting_mcp/__init__.py @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Agent and workflow MCP tool adapters for app-owned hosting.""" + +import importlib.metadata + +from ._agent_tool import MCPAgentTool +from ._conversion import mcp_from_run, mcp_to_run +from ._workflow_tool import MCPWorkflowTool + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" + +__all__ = [ + "MCPAgentTool", + "MCPWorkflowTool", + "__version__", + "mcp_from_run", + "mcp_to_run", +] diff --git a/python/packages/hosting-mcp/agent_framework_hosting_mcp/_agent_tool.py b/python/packages/hosting-mcp/agent_framework_hosting_mcp/_agent_tool.py new file mode 100644 index 00000000000..d8d88757e6f --- /dev/null +++ b/python/packages/hosting-mcp/agent_framework_hosting_mcp/_agent_tool.py @@ -0,0 +1,181 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Agent-backed MCP tool adapter for app-owned servers.""" + +from __future__ import annotations + +import re +from collections.abc import Collection, Mapping +from typing import Any, Generic, TypeVar, cast + +from agent_framework import AgentResponse, Message, SupportsAgentRun +from agent_framework_hosting import AgentRunArgs, AgentState +from mcp import types + +from ._conversion import mcp_from_run, mcp_to_run + +AgentT = TypeVar("AgentT", bound=SupportsAgentRun) + + +class MCPAgentTool(Generic[AgentT]): + """Expose one Agent Framework agent through an app-owned MCP server. + + The adapter generates the native MCP tool definition and keeps its argument + parsing aligned with agent execution. It does not create an MCP server, + register handlers, derive trusted session identifiers, or choose a + transport. + """ + + def __init__( + self, + target: AgentT | AgentState[AgentT], + *, + name: str | None = None, + description: str | None = None, + argument_name: str = "task", + argument_description: str | None = None, + parameters: Mapping[str, Mapping[str, Any]] | None = None, + required_parameters: Collection[str] = (), + chat_option_parameters: Mapping[str, Mapping[str, Any]] | None = None, + session_id_parameter: str | None = None, + ) -> None: + """Create an agent-backed MCP tool adapter. + + Args: + target: Agent target or existing ``AgentState``. + + Keyword Args: + name: MCP tool name override. Defaults to a sanitized agent name. + description: MCP tool description override. Defaults to the agent description. + argument_name: Name of the main text argument. + argument_description: Description of the main text argument. + parameters: Additional app-owned MCP parameter schemas. + required_parameters: Additional parameter names that are required. + chat_option_parameters: MCP parameter schemas whose values are copied to chat options. + session_id_parameter: Additional string parameter used as the ``AgentState`` session key. + + Raises: + ValueError: If parameter names overlap or required/session parameters are not defined. + """ + self.state = target if isinstance(target, AgentState) else AgentState(target) + self._name = name + self._description = description + self.argument_name = argument_name + self.argument_description = argument_description + self.parameters = {key: dict(value) for key, value in (parameters or {}).items()} + self.chat_option_parameters = {key: dict(value) for key, value in (chat_option_parameters or {}).items()} + self.session_id_parameter = session_id_parameter + + parameter_names = set(self.parameters) + chat_option_names = set(self.chat_option_parameters) + if self.argument_name in parameter_names | chat_option_names: + raise ValueError(f"Main argument '{self.argument_name}' must not be repeated in additional parameters.") + if parameter_names & chat_option_names: + raise ValueError("Additional parameters and chat option parameters must have distinct names.") + required_names = set(required_parameters) + if session_id_parameter is not None: + required_names.add(session_id_parameter) + if self.session_id_parameter is not None and self.session_id_parameter not in parameter_names: + raise ValueError("session_id_parameter must name an additional parameter.") + undefined_required = required_names - parameter_names - chat_option_names + if undefined_required: + raise ValueError(f"Required parameters are not defined: {sorted(undefined_required)}") + self.required_parameters = tuple( + name for name in (*self.parameters, *self.chat_option_parameters) if name in required_names + ) + + async def list_tools(self) -> list[types.Tool]: + """Return the native MCP tool definition for the target agent.""" + target = await self.state.get_target() + return [self._tool_for_target(target)] + + def _tool_for_target(self, target: AgentT) -> types.Tool: + """Create the native MCP tool definition for a resolved target.""" + tool_name = self._name + if tool_name is None: + if target.name is None: + raise ValueError("MCP tool name requires either an override or an agent name.") + tool_name = re.sub(r"[^a-zA-Z0-9_.-]+", "_", target.name).strip("_") or "agent" + + properties: dict[str, Any] = { + self.argument_name: { + "type": "string", + "description": self.argument_description or f"Task for {tool_name}", + }, + **self.parameters, + **self.chat_option_parameters, + } + return types.Tool( + name=tool_name, + description=self._description if self._description is not None else target.description or "", + inputSchema={ + "type": "object", + "properties": properties, + "required": [self.argument_name, *self.required_parameters], + "additionalProperties": False, + }, + ) + + def mcp_to_run(self, arguments: Mapping[str, Any] | None) -> AgentRunArgs: + """Convert this tool's MCP arguments into Agent Framework run arguments.""" + return mcp_to_run( + arguments, + argument_name=self.argument_name, + chat_option_arguments=self.chat_option_parameters, + ) + + def mcp_from_run(self, result: AgentResponse[Any] | Message) -> list[types.ContentBlock]: + """Convert Agent Framework output into this tool's MCP result content.""" + return mcp_from_run(result) + + async def call_tool( + self, + name: str, + arguments: Mapping[str, Any] | None, + ) -> list[types.ContentBlock]: + """Run the target agent for a native MCP ``call_tool`` handler. + + Args: + name: MCP tool name selected by the client. + arguments: Native MCP tool arguments. + + Returns: + Native MCP content blocks for the completed tool result. + + Raises: + ValueError: If the tool name or configured session id is invalid. + """ + target = await self.state.get_target() + tool = self._tool_for_target(target) + if name != tool.name: + raise ValueError(f"Unknown MCP tool: {name}") + + run = self.mcp_to_run(arguments) + if self.session_id_parameter is None: + result = cast( + "AgentResponse[Any]", + await target.run( # pyright: ignore[reportCallIssue] + run["messages"], + options=run["options"], + stream=False, + ), + ) + return self.mcp_from_run(result) + + session_id = arguments.get(self.session_id_parameter) if arguments else None + if not isinstance(session_id, str) or not session_id: + raise ValueError(f"MCP tool argument '{self.session_id_parameter}' must be a non-empty string.") + session = await self.state.session_store.get(session_id) + if session is None: + session = target.create_session(session_id=session_id) + result = cast( + "AgentResponse[Any]", + await target.run( # pyright: ignore[reportCallIssue] + run["messages"], + options=run["options"], + session=session, + stream=False, + ), + ) + await self.state.set_session(session_id, session) + return self.mcp_from_run(result) diff --git a/python/packages/hosting-mcp/agent_framework_hosting_mcp/_conversion.py b/python/packages/hosting-mcp/agent_framework_hosting_mcp/_conversion.py new file mode 100644 index 00000000000..b91d30570b7 --- /dev/null +++ b/python/packages/hosting-mcp/agent_framework_hosting_mcp/_conversion.py @@ -0,0 +1,155 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Conversion between native MCP values and Agent Framework run values.""" + +from __future__ import annotations + +import base64 +import logging +from collections.abc import Collection, Mapping, Sequence +from typing import Any, cast + +from agent_framework import AgentResponse, ChatOptions, Content, Message +from agent_framework_hosting import AgentRunArgs +from mcp import types + +logger = logging.getLogger("agent_framework.hosting.mcp") + + +def mcp_to_run( + arguments: Mapping[str, Any] | None, + *, + argument_name: str = "task", + chat_option_arguments: Collection[str] = (), +) -> AgentRunArgs: + """Convert native MCP tool arguments into Agent Framework run arguments. + + The application owns the MCP tool schema and chooses which string argument + contains the user request. This helper does not create sessions, inspect + request context, or interact with an MCP server. + + Args: + arguments: Arguments supplied to an MCP ``call_tool`` handler. + + Keyword Args: + argument_name: Name of the required string argument containing the user request. + chat_option_arguments: MCP argument names to copy into Agent Framework chat options when present. + + Returns: + Arguments corresponding to ``Agent.run(...)``. + + Raises: + ValueError: If the selected argument is missing or is not a string. + """ + if arguments is None or argument_name not in arguments: + raise ValueError(f"MCP tool arguments must include a '{argument_name}' string.") + + message_value = arguments[argument_name] + if not isinstance(message_value, str): + raise ValueError(f"MCP tool argument '{argument_name}' must be a string.") + + options = {name: arguments[name] for name in chat_option_arguments if name in arguments} + return AgentRunArgs( + messages=[ + Message( + "user", + [Content.from_text(message_value)], + raw_representation=dict(arguments), + ) + ], + options=cast("ChatOptions[Any]", options), + stream=False, + ) + + +def mcp_from_run( + result: AgentResponse[Any] | Message, +) -> list[types.ContentBlock]: + """Convert Agent Framework output into native MCP content blocks. + + ``AgentResponse`` values are flattened in message order. User-role + messages are omitted. Text, external URI, and inline data content become + the corresponding native MCP content block types. Content-level metadata + is preserved in each block's ``_meta`` field. + + Args: + result: A completed response or response message. + + Returns: + Native MCP content blocks ready for a ``CallToolResult``. + + Raises: + ValueError: If Agent Framework data content contains an invalid data URI. + """ + items: Sequence[Message] = result.messages if isinstance(result, AgentResponse) else [result] + + blocks: list[types.ContentBlock] = [] + for item in items: + if item.role == "user": + continue + for content in item.contents: + metadata = content.additional_properties or None + match content.type: + case "text" if content.text is not None: + blocks.append(types.TextContent(type="text", text=content.text, _meta=metadata)) + case "uri" if content.uri is not None: + name = content.uri.rsplit("/", maxsplit=1)[-1] or content.uri + blocks.append( + types.ResourceLink( + type="resource_link", + name=name, + uri=content.uri, # pyright: ignore[reportArgumentType] + mimeType=content.media_type, + _meta=metadata, + ) + ) + case "data" if content.uri is not None: + prefix, separator, encoded = content.uri.partition(",") + if not separator or not prefix.startswith("data:") or ";base64" not in prefix: + raise ValueError("Agent Framework data content must contain a base64 data URI.") + try: + base64.b64decode(encoded, validate=True) + except ValueError as exc: + raise ValueError("Agent Framework data content contains invalid base64 data.") from exc + + if content.media_type and content.media_type.startswith("image/"): + blocks.append( + types.ImageContent( + type="image", + data=encoded, + mimeType=content.media_type, + _meta=metadata, + ) + ) + elif content.media_type and content.media_type.startswith("audio/"): + blocks.append( + types.AudioContent( + type="audio", + data=encoded, + mimeType=content.media_type, + _meta=metadata, + ) + ) + else: + resource_uri = ( + content.additional_properties.get("uri") if content.additional_properties else None + ) + if not isinstance(resource_uri, str): + resource_uri = "af://binary" + blocks.append( + types.EmbeddedResource( + type="resource", + resource=types.BlobResourceContents( + uri=resource_uri, # pyright: ignore[reportArgumentType] + blob=encoded, + mimeType=content.media_type, + ), + _meta=metadata, + ) + ) + case _: + logger.warning( + "Agent Framework content type %s is not supported in MCP tool results and was omitted.", + content.type, + ) + return blocks diff --git a/python/packages/hosting-mcp/agent_framework_hosting_mcp/_workflow_tool.py b/python/packages/hosting-mcp/agent_framework_hosting_mcp/_workflow_tool.py new file mode 100644 index 00000000000..6fe3cbf3a93 --- /dev/null +++ b/python/packages/hosting-mcp/agent_framework_hosting_mcp/_workflow_tool.py @@ -0,0 +1,143 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Workflow-backed MCP tool adapter for app-owned servers.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import Any, Generic, TypeVar, cast + +from agent_framework import AgentResponse, Message, Workflow, WorkflowRunResult +from agent_framework_hosting import WorkflowState +from mcp import types +from pydantic import TypeAdapter + +from ._conversion import mcp_from_run + +WorkflowT = TypeVar("WorkflowT", bound=Workflow) + + +class MCPWorkflowTool(Generic[WorkflowT]): + """Expose one Agent Framework workflow through an app-owned MCP server. + + The adapter derives the native MCP input schema from the workflow's start + executor. It does not create an MCP server, register handlers, choose a + transport, or manage workflow checkpoints and human-in-the-loop responses. + """ + + def __init__( + self, + target: WorkflowT | WorkflowState[WorkflowT], + *, + name: str | None = None, + description: str | None = None, + argument_name: str = "input", + ) -> None: + """Create a workflow-backed MCP tool adapter. + + Args: + target: Workflow target or existing ``WorkflowState``. Use a + ``WorkflowState`` factory with ``cache_target=False`` when + concurrent calls need independent workflow instances. + + Keyword Args: + name: MCP tool name override. Defaults to a sanitized workflow name. + description: MCP tool description override. Defaults to the workflow description. + argument_name: MCP property used when the workflow input is not an object. + """ + self.state = target if isinstance(target, WorkflowState) else WorkflowState(target) + self._name = name + self._description = description + self.argument_name = argument_name + + async def list_tools(self) -> list[types.Tool]: + """Return the native MCP tool definition for the target workflow.""" + workflow = await self.state.get_target() + return [self._tool_for_workflow(workflow)] + + def _tool_for_workflow(self, workflow: WorkflowT) -> types.Tool: + tool_name = self._name + if tool_name is None: + tool_name = re.sub(r"[^a-zA-Z0-9_.-]+", "_", workflow.name).strip("_") or "workflow" + + input_adapter = self._input_adapter(workflow) + input_schema = input_adapter.json_schema() + if input_schema.get("type") != "object": + input_schema = { + "type": "object", + "properties": {self.argument_name: input_schema}, + "required": [self.argument_name], + "additionalProperties": False, + } + + return types.Tool( + name=tool_name, + description=self._description if self._description is not None else workflow.description or "", + inputSchema=input_schema, + ) + + def _input_adapter(self, workflow: WorkflowT) -> TypeAdapter[Any]: + input_types = workflow.get_start_executor().input_types + if len(input_types) != 1: + raise ValueError( + f"MCP workflow tools require exactly one start-executor input type; found {len(input_types)}." + ) + return TypeAdapter(input_types[0]) + + def _workflow_input(self, workflow: WorkflowT, arguments: Mapping[str, Any] | None) -> Any: + input_adapter = self._input_adapter(workflow) + input_schema = input_adapter.json_schema() + if input_schema.get("type") == "object": + return input_adapter.validate_python(dict(arguments or {})) + if arguments is None or self.argument_name not in arguments: + raise ValueError(f"MCP tool arguments must include '{self.argument_name}'.") + return input_adapter.validate_python(arguments[self.argument_name]) + + def mcp_from_run(self, result: WorkflowRunResult) -> list[types.ContentBlock]: + """Convert completed workflow outputs into native MCP content blocks.""" + if result.get_request_info_events(): + raise ValueError( + "The workflow requires external input. MCPWorkflowTool does not manage " + "human-in-the-loop continuation; handle it in the application contract." + ) + + blocks: list[types.ContentBlock] = [] + for output in result.get_outputs(): + if isinstance(output, (AgentResponse, Message)): + blocks.extend(mcp_from_run(cast("AgentResponse[Any] | Message", output))) + elif isinstance(output, str): + blocks.append(types.TextContent(type="text", text=output)) + else: + blocks.append( + types.TextContent( + type="text", + text=TypeAdapter[Any](Any).dump_json(output, serialize_as_any=True).decode(), + ) + ) + return blocks + + async def call_tool( + self, + name: str, + arguments: Mapping[str, Any] | None, + ) -> list[types.ContentBlock]: + """Run the target workflow for a native MCP ``call_tool`` handler. + + Args: + name: MCP tool name selected by the client. + arguments: Native MCP tool arguments. + + Returns: + Native MCP content blocks for the completed workflow result. + + Raises: + ValueError: If the tool name or workflow input contract is invalid. + """ + workflow = await self.state.get_target() + tool = self._tool_for_workflow(workflow) + if name != tool.name: + raise ValueError(f"Unknown MCP tool: {name}") + + result = await workflow.run(self._workflow_input(workflow, arguments), stream=False) + return self.mcp_from_run(result) diff --git a/python/packages/hosting-mcp/agent_framework_hosting_mcp/py.typed b/python/packages/hosting-mcp/agent_framework_hosting_mcp/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/hosting-mcp/pyproject.toml b/python/packages/hosting-mcp/pyproject.toml new file mode 100644 index 00000000000..bea0efb7b65 --- /dev/null +++ b/python/packages/hosting-mcp/pyproject.toml @@ -0,0 +1,81 @@ +[project] +name = "agent-framework-hosting-mcp" +description = "Agent and workflow MCP tool adapters for app-owned hosting." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0a260720" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.11.0,<2", + "agent-framework-hosting==1.0.0a260709", + "mcp>=1.11.0,<2", + "pydantic>=2,<3", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_hosting_mcp"] +exclude = ['tests'] + +[tool.bandit] +targets = ["agent_framework_hosting_mcp"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_mcp --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/hosting-mcp/tests/hosting_mcp/test_agent_tool.py b/python/packages/hosting-mcp/tests/hosting_mcp/test_agent_tool.py new file mode 100644 index 00000000000..ffb74b28ca3 --- /dev/null +++ b/python/packages/hosting-mcp/tests/hosting_mcp/test_agent_tool.py @@ -0,0 +1,155 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from collections.abc import Awaitable, Mapping, Sequence +from typing import Any + +from agent_framework import ( + Agent, + BaseChatClient, + ChatOptions, + ChatResponse, + ChatResponseUpdate, + InMemoryHistoryProvider, + Message, + ResponseStream, +) +from agent_framework_hosting import AgentState +from mcp import types +from pytest import raises + +from agent_framework_hosting_mcp import MCPAgentTool + + +class RecordingClient(BaseChatClient[ChatOptions[None]]): + """Record messages and return their latest text.""" + + def __init__(self) -> None: + super().__init__() + self.calls: list[list[str]] = [] + + def _inner_get_response( + self, + *, + messages: Sequence[Message], + stream: bool = False, + options: Mapping[str, Any], + **kwargs: Any, + ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: + async def get_response() -> ChatResponse: + self.calls.append([message.text for message in messages]) + return ChatResponse(messages=Message("assistant", [f"response: {messages[-1].text}"])) + + return get_response() + + +async def test_agent_tool_generates_schema_from_agent_with_overrides() -> None: + agent = Agent( + client=RecordingClient(), + name="Research Agent", + description="Agent description", + ) + tool: MCPAgentTool[Any] = MCPAgentTool( + agent, + name="research", + description="Tool description", + argument_name="prompt", + argument_description="Research request", + parameters={"audience": {"type": "string"}}, + required_parameters={"audience"}, + chat_option_parameters={ + "reasoning_effort": { + "type": "string", + "enum": ["low", "medium", "high"], + } + }, + ) + + definitions = await tool.list_tools() + + assert len(definitions) == 1 + definition = definitions[0] + assert definition.name == "research" + assert definition.description == "Tool description" + assert definition.inputSchema == { + "type": "object", + "properties": { + "prompt": {"type": "string", "description": "Research request"}, + "audience": {"type": "string"}, + "reasoning_effort": { + "type": "string", + "enum": ["low", "medium", "high"], + }, + }, + "required": ["prompt", "audience"], + "additionalProperties": False, + } + + run = tool.mcp_to_run({ + "prompt": "Investigate MCP", + "audience": "developers", + "reasoning_effort": "high", + }) + messages = run["messages"] + assert isinstance(messages, list) + assert isinstance(messages[0], Message) + assert messages[0].text == "Investigate MCP" + assert run["options"] == {"reasoning_effort": "high"} + + +async def test_agent_tool_uses_agent_metadata_by_default() -> None: + agent = Agent(client=RecordingClient(), name="Research Agent", description="Agent description") + tool: MCPAgentTool[Any] = MCPAgentTool(agent) + + definition = (await tool.list_tools())[0] + + assert definition.name == "Research_Agent" + assert definition.description == "Agent description" + + +async def test_agent_tool_runs_with_agent_state_session() -> None: + client = RecordingClient() + agent = Agent( + client=client, + name="session-agent", + context_providers=[InMemoryHistoryProvider()], + ) + state = AgentState(agent) + tool: MCPAgentTool[Any] = MCPAgentTool( + state, + parameters={"session_id": {"type": "string"}}, + required_parameters={"session_id"}, + session_id_parameter="session_id", + ) + + first = await tool.call_tool("session-agent", {"task": "first", "session_id": "session-1"}) + second = await tool.call_tool("session-agent", {"task": "second", "session_id": "session-1"}) + + assert isinstance(first[0], types.TextContent) + assert first[0].text == "response: first" + assert isinstance(second[0], types.TextContent) + assert second[0].text == "response: second" + assert client.calls[0] == ["first"] + assert client.calls[1] == ["first", "response: first", "second"] + assert await state.session_store.get("session-1") is not None + + +def test_agent_tool_rejects_undefined_session_parameter() -> None: + agent = Agent(client=RecordingClient(), name="agent") + + with raises(ValueError, match="session_id_parameter"): + MCPAgentTool(agent, session_id_parameter="session_id") + + +async def test_agent_tool_always_requires_session_parameter() -> None: + agent = Agent(client=RecordingClient(), name="agent") + tool: MCPAgentTool[Any] = MCPAgentTool( + agent, + parameters={"session_id": {"type": "string"}}, + session_id_parameter="session_id", + ) + + definition = (await tool.list_tools())[0] + + assert definition.inputSchema["required"] == ["task", "session_id"] diff --git a/python/packages/hosting-mcp/tests/hosting_mcp/test_conversion.py b/python/packages/hosting-mcp/tests/hosting_mcp/test_conversion.py new file mode 100644 index 00000000000..0cfcf1f88a6 --- /dev/null +++ b/python/packages/hosting-mcp/tests/hosting_mcp/test_conversion.py @@ -0,0 +1,144 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework import AgentResponse, Content, Message +from mcp import types +from pytest import raises + +from agent_framework_hosting_mcp import mcp_from_run, mcp_to_run + + +def test_mcp_to_run_converts_selected_argument() -> None: + arguments = { + "prompt": "hello", + "temperature": 0.2, + "max_tokens": 100, + "tenant": "example", + } + + run = mcp_to_run( + arguments, + argument_name="prompt", + chat_option_arguments=("temperature", "max_tokens", "top_p"), + ) + + messages = run["messages"] + assert isinstance(messages, list) + converted = messages[0] + assert isinstance(converted, Message) + assert converted.text == "hello" + assert converted.raw_representation == arguments + assert run["stream"] is False + assert run["options"] == {"temperature": 0.2, "max_tokens": 100} + + +def test_mcp_to_run_rejects_missing_argument() -> None: + with raises(ValueError, match="'task' string"): + mcp_to_run({}) + + +def test_mcp_to_run_rejects_non_string_argument() -> None: + with raises(ValueError, match="'task' must be a string"): + mcp_to_run({"task": 42}) + + +def test_mcp_from_run_converts_final_response() -> None: + response = AgentResponse( + messages=[ + Message("user", ["omit me"]), + Message( + "assistant", + [ + Content.from_text("hello", additional_properties={"source": "agent"}), + Content.from_uri("https://example.com/image.png", media_type="image/png"), + Content.from_data(b"image", "image/png", additional_properties={"source": "image"}), + Content.from_data(b"audio", "audio/wav"), + Content.from_data(b"bytes", "application/octet-stream"), + ], + ), + ] + ) + + blocks = mcp_from_run(response) + + assert len(blocks) == 5 + assert isinstance(blocks[0], types.TextContent) + assert blocks[0].text == "hello" + assert blocks[0].meta == {"source": "agent"} + assert isinstance(blocks[1], types.ResourceLink) + assert str(blocks[1].uri) == "https://example.com/image.png" + assert blocks[1].name == "image.png" + assert isinstance(blocks[2], types.ImageContent) + assert blocks[2].data == "aW1hZ2U=" + assert blocks[2].mimeType == "image/png" + assert blocks[2].meta == {"source": "image"} + assert isinstance(blocks[3], types.AudioContent) + assert blocks[3].data == "YXVkaW8=" + assert blocks[3].mimeType == "audio/wav" + assert isinstance(blocks[4], types.EmbeddedResource) + assert isinstance(blocks[4].resource, types.BlobResourceContents) + assert str(blocks[4].resource.uri) == "af://binary" + assert blocks[4].resource.blob == "Ynl0ZXM=" + + +def test_mcp_from_run_uses_app_owned_binary_resource_uri() -> None: + blocks = mcp_from_run( + Message( + "assistant", + [ + Content.from_data( + b"document", + "application/pdf", + additional_properties={"uri": "af://documents/report"}, + ) + ], + ) + ) + + assert len(blocks) == 1 + assert isinstance(blocks[0], types.EmbeddedResource) + assert str(blocks[0].resource.uri) == "af://documents/report" + + +def test_mcp_from_run_preserves_empty_text() -> None: + blocks = mcp_from_run(Message("assistant", [Content.from_text("")])) + + assert len(blocks) == 1 + assert isinstance(blocks[0], types.TextContent) + assert blocks[0].text == "" + + +def test_mcp_from_run_omits_content_not_supported_in_tool_results() -> None: + blocks = mcp_from_run( + Message( + "assistant", + [ + Content.from_function_call(call_id="call-1", name="get_weather", arguments="{}"), + Content.from_text("hello"), + ], + ) + ) + + assert len(blocks) == 1 + assert isinstance(blocks[0], types.TextContent) + + +def test_mcp_from_run_omits_user_messages() -> None: + assert mcp_from_run(AgentResponse(messages=[Message("user", ["omit me"])])) == [] + + +def test_mcp_from_run_rejects_invalid_data_uri() -> None: + content = Content("data", uri="not-a-data-uri", media_type="application/octet-stream") + + with raises(ValueError, match="base64 data URI"): + mcp_from_run(Message("assistant", [content])) + + +def test_mcp_from_run_rejects_invalid_base64_data() -> None: + content = Content( + "data", + uri="data:application/octet-stream;base64,not valid base64", + media_type="application/octet-stream", + ) + + with raises(ValueError, match="invalid base64"): + mcp_from_run(Message("assistant", [content])) diff --git a/python/packages/hosting-mcp/tests/hosting_mcp/test_integration.py b/python/packages/hosting-mcp/tests/hosting_mcp/test_integration.py new file mode 100644 index 00000000000..773071552e9 --- /dev/null +++ b/python/packages/hosting-mcp/tests/hosting_mcp/test_integration.py @@ -0,0 +1,118 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import asyncio +import socket +from collections.abc import AsyncIterator, Awaitable, Mapping, Sequence +from contextlib import asynccontextmanager +from typing import Any + +import pytest +import uvicorn +from agent_framework import ( + Agent, + BaseChatClient, + ChatOptions, + ChatResponse, + ChatResponseUpdate, + MCPStreamableHTTPTool, + Message, + ResponseStream, +) +from mcp import types +from mcp.server.lowlevel import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from starlette.applications import Starlette +from starlette.routing import Mount + +from agent_framework_hosting_mcp import MCPAgentTool + + +class HostedAgentClient(BaseChatClient[ChatOptions[None]]): + """Return a deterministic response from the hosted agent.""" + + def __init__(self) -> None: + super().__init__() + self.received_options: Mapping[str, Any] | None = None + + def _inner_get_response( + self, + *, + messages: Sequence[Message], + stream: bool = False, + options: Mapping[str, Any], + **kwargs: Any, + ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: + async def get_response() -> ChatResponse: + self.received_options = options + return ChatResponse(messages=Message("assistant", [f"Hosted agent received: {messages[-1].text}"])) + + return get_response() + + +@pytest.mark.flaky +@pytest.mark.integration +async def test_mcp_tool_calls_locally_hosted_agent() -> None: + """Host an agent, connect an MCP tool, and invoke it through real HTTP.""" + hosted_client = HostedAgentClient() + hosted_agent = Agent(client=hosted_client, name="HostedAgent", description="Hosted test agent.") + agent_tool: MCPAgentTool[Any] = MCPAgentTool( + hosted_agent, + name="run_agent", + chat_option_parameters={"reasoning_effort": {"type": "string"}}, + ) + mcp_server = Server("hosting-mcp-integration") + + @mcp_server.list_tools() + async def list_tools() -> list[types.Tool]: + return await agent_tool.list_tools() + + @mcp_server.call_tool() + async def call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.ContentBlock]: + return await agent_tool.call_tool(name, arguments) + + session_manager = StreamableHTTPSessionManager( + app=mcp_server, + event_store=None, + json_response=True, + stateless=True, + ) + + @asynccontextmanager + async def lifespan(_app: Starlette) -> AsyncIterator[None]: + async with session_manager.run(): + yield + + app = Starlette(routes=[Mount("/", app=session_manager.handle_request)], lifespan=lifespan) + with socket.socket() as port_socket: + port_socket.bind(("127.0.0.1", 0)) + port = port_socket.getsockname()[1] + + uvicorn_server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error", lifespan="on")) + server_task = asyncio.create_task(uvicorn_server.serve()) + try: + for _ in range(100): + if uvicorn_server.started: + break + await asyncio.sleep(0.01) + assert uvicorn_server.started + + mcp_tool = MCPStreamableHTTPTool( + name="hosted_agent", + url=f"http://127.0.0.1:{port}/mcp", + approval_mode="never_require", + ) + async with mcp_tool: + assert len(mcp_tool.functions) == 1 + result = await mcp_tool.functions[0].invoke(task="hello through MCP", reasoning_effort="low") + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].type == "text" + assert result[0].text == "Hosted agent received: hello through MCP" + assert hosted_client.received_options is not None + assert hosted_client.received_options["reasoning_effort"] == "low" + finally: + uvicorn_server.should_exit = True + await server_task diff --git a/python/packages/hosting-mcp/tests/hosting_mcp/test_workflow_tool.py b/python/packages/hosting-mcp/tests/hosting_mcp/test_workflow_tool.py new file mode 100644 index 00000000000..04831dd3fd4 --- /dev/null +++ b/python/packages/hosting-mcp/tests/hosting_mcp/test_workflow_tool.py @@ -0,0 +1,131 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from agent_framework import ( + Executor, + WorkflowBuilder, + WorkflowContext, + WorkflowEvent, + WorkflowRunResult, + executor, + handler, +) +from agent_framework_hosting import WorkflowState +from mcp import types +from pytest import raises + +from agent_framework_hosting_mcp import MCPWorkflowTool + + +@dataclass +class WorkflowInput: + """Input exposed as MCP tool arguments.""" + + text: str + repeat: int + + +def create_workflow(): + """Create a workflow with an object-shaped input.""" + + @executor(id="repeat") + async def repeat_text(value: WorkflowInput, ctx: WorkflowContext[object, str]) -> None: + await ctx.yield_output(value.text * value.repeat) + + return WorkflowBuilder( + start_executor=repeat_text, + name="Repeat Workflow", + description="Repeat text a requested number of times.", + output_from=[repeat_text], + ).build() + + +async def test_workflow_tool_derives_object_schema_and_runs_workflow() -> None: + tool: MCPWorkflowTool[Any] = MCPWorkflowTool( + WorkflowState(create_workflow, cache_target=False), + name="repeat_text", + ) + + definition = (await tool.list_tools())[0] + result = await tool.call_tool("repeat_text", {"text": "go", "repeat": 2}) + + assert definition.description == "Repeat text a requested number of times." + assert definition.inputSchema["type"] == "object" + assert definition.inputSchema["properties"]["text"]["type"] == "string" + assert definition.inputSchema["properties"]["repeat"]["type"] == "integer" + assert set(definition.inputSchema["required"]) == {"text", "repeat"} + assert result == [types.TextContent(type="text", text="gogo")] + + +async def test_workflow_tool_wraps_primitive_input() -> None: + @executor(id="uppercase") + async def uppercase(value: str, ctx: WorkflowContext[object, str]) -> None: + await ctx.yield_output(value.upper()) + + workflow = WorkflowBuilder(start_executor=uppercase, name="uppercase", output_from=[uppercase]).build() + tool: MCPWorkflowTool[Any] = MCPWorkflowTool(workflow, argument_name="text") + + definition = (await tool.list_tools())[0] + result = await tool.call_tool("uppercase", {"text": "hello"}) + + assert definition.inputSchema == { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + "additionalProperties": False, + } + assert result == [types.TextContent(type="text", text="HELLO")] + + +async def test_workflow_tool_serializes_structured_output_as_json_text() -> None: + @executor(id="structured") + async def structured(value: str, ctx: WorkflowContext[object, dict[str, str]]) -> None: + await ctx.yield_output({"value": value}) + + workflow = WorkflowBuilder(start_executor=structured, name="structured", output_from=[structured]).build() + tool: MCPWorkflowTool[Any] = MCPWorkflowTool(workflow) + + result = await tool.call_tool("structured", {"input": "hello"}) + + assert result == [types.TextContent(type="text", text='{"value":"hello"}')] + + +def test_workflow_tool_rejects_unhandled_external_input_requests() -> None: + workflow = create_workflow() + tool: MCPWorkflowTool[Any] = MCPWorkflowTool(workflow) + result = WorkflowRunResult([ + WorkflowEvent.request_info( + request_id="approval", + source_executor_id="review", + request_data={"question": "Approve?"}, + response_type=bool, + ) + ]) + + with raises(ValueError, match="requires external input"): + tool.mcp_from_run(result) + + +def test_workflow_tool_rejects_multiple_start_input_types() -> None: + class MultipleInputs(Executor): + @handler + async def handle_text(self, value: str, ctx: WorkflowContext[object, str]) -> None: + await ctx.yield_output(value) + + @handler + async def handle_number(self, value: int, ctx: WorkflowContext[object, str]) -> None: + await ctx.yield_output(str(value)) + + workflow = WorkflowBuilder( + start_executor=MultipleInputs(id="multiple"), + name="multiple", + output_from="all", + ).build() + tool: MCPWorkflowTool[Any] = MCPWorkflowTool(workflow) + + with raises(ValueError, match="exactly one"): + tool._tool_for_workflow(workflow) # pyright: ignore[reportPrivateUsage] diff --git a/python/pyproject.toml b/python/pyproject.toml index d827b608463..7b979301121 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -93,6 +93,7 @@ agent-framework-gemini = { workspace = true } agent-framework-github-copilot = { workspace = true } agent-framework-hosting = { workspace = true } agent-framework-hosting-a2a = { workspace = true } +agent-framework-hosting-mcp = { workspace = true } agent-framework-hosting-responses = { workspace = true } agent-framework-hosting-telegram = { workspace = true } agent-framework-hyperlight = { workspace = true } diff --git a/python/samples/04-hosting/mcp/README.md b/python/samples/04-hosting/mcp/README.md new file mode 100644 index 00000000000..9d9363487c2 --- /dev/null +++ b/python/samples/04-hosting/mcp/README.md @@ -0,0 +1,148 @@ +# MCP hosting with native SDK constructs + +An MCP server that exposes tools must handle `tools/list` and `tools/call`. +There are two common ways to build that: + +1. Implement the list and call handlers directly. The application defines each + native tool schema, validates or parses its arguments, routes the selected + tool name, and returns its result. +2. Declare callable tools and let a higher-level server generate the list and + call handlers from those declarations. FastMCP follows this model by deriving + tool schemas and argument parsing from Python function signatures. + +Choose between them based on the other MCP features you want to expose and how +much control you need over the server, schema, validation, lifecycle, and +transport. Hosting one Agent Framework agent or workflow usually exposes only +one MCP tool, so either model remains small. A directly implemented +`call_tool` handler needs little routing logic when it has only one supported +tool name. + +Both approaches use the same two protocol-boundary functions from +`agent-framework-hosting-mcp`: + +- `mcp_to_run(...)` converts validated MCP tool arguments into Agent Framework + messages and selected chat options. +- `mcp_from_run(...)` converts a completed Agent Framework response into native + MCP result content blocks. + +These functions are the smallest Agent Framework integration boundary. The +later samples add optional helpers that derive tool schemas, execute agents or +workflows, and manage Agent Framework conversation state. + +## Samples + +### 1. Manual low-level server + +[`manual_app.py`](manual_app.py) shows the complete boundary directly. It +defines the native MCP `Tool`, registers low-level `list_tools` and `call_tool` +handlers, calls `mcp_to_run(...)`, runs the agent, and calls +`mcp_from_run(...)`. + +Use this when the application needs full control over a custom MCP contract. + +```bash +uv run manual_app.py +``` + +### 2. FastMCP server + +[`fastmcp_app.py`](fastmcp_app.py) keeps the same two conversion functions but +replaces the low-level server setup with FastMCP. FastMCP derives and validates +the tool schema from the decorated `run_agent(...)` function and owns the +streamable HTTP server. + +Use this when a normal Python function signature fully describes the MCP tool. +FastMCP keeps its generated schema and argument parsing aligned. + +```bash +uv run fastmcp_app.py +``` + +### 3. Agent-derived tool + +[`agent_app.py`](agent_app.py) adds `MCPAgentTool` to the low-level server. The adapter +derives the native tool name and description from the agent, owns the configured +argument schema, runs the agent, and applies the same conversion boundary. + +Use this when one Agent Framework agent should be represented as one generated +MCP tool. Unlike the FastMCP sample, this adapter derives the contract from the +agent and its adapter configuration rather than a decorated function signature. + +```bash +uv run agent_app.py +``` + +### 4. Session-aware agent + +[`session_app.py`](session_app.py) builds on `MCPAgentTool` with `AgentState`. +It loads and stores an `AgentSession` using an opaque, application-defined +`session_id` and serializes calls per ID in-process. + +Reusing the same ID continues and updates one conversation. This is not +`previous_response_id`-style branching. An application that needs forks should +define separate source and destination IDs, copy the source session, and store +the completed turn under the destination ID. + +```bash +uv run session_app.py +``` + +### 5. Workflow-derived tool + +[`workflow_app.py`](workflow_app.py) uses `MCPWorkflowTool` to derive the tool +schema from the workflow start executor's single input type. Dataclass or +Pydantic fields become top-level MCP arguments; primitive inputs are wrapped in +one configurable argument. + +The sample uses a `WorkflowState` factory with `cache_target=False` so each MCP +call receives a fresh workflow instance. Checkpoint and human-in-the-loop +continuation remain application-owned contracts. + +```bash +uv run workflow_app.py +``` + +## Concept summary + +| Concept | Responsibility | Used by | +|---|---|---| +| `mcp_to_run(...)` | Converts validated MCP arguments into Agent Framework messages and selected chat options. | `manual_app.py`, `fastmcp_app.py` | +| `mcp_from_run(...)` | Converts a completed agent response into MCP result content blocks. | `manual_app.py`, `fastmcp_app.py` | +| `MCPAgentTool` | Derives one native MCP tool from an agent and keeps schema, execution, and conversion aligned. | `agent_app.py`, `session_app.py` | +| `MCPWorkflowTool` | Derives one native MCP tool from a workflow start executor and converts workflow outputs. | `workflow_app.py` | + +## Run + +The agent samples require Microsoft Foundry configuration: + +```bash +export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com +export FOUNDRY_MODEL=gpt-5-nano +az login +``` + +Run one sample, connect an MCP client to `http://127.0.0.1:8000/mcp`, and invoke +its tool. The agent tools accept a `task` string and optionally +`reasoning_effort` with `low`, `medium`, or `high`. + +Each entry point declares its complete Agent Framework and third-party +dependency set using PEP 723 inline script metadata. + +## Common behavior + +- **No framework choice:** the package does not select FastMCP, Starlette, + Uvicorn, stdio, or streamable HTTP. +- **Chat options:** only explicitly selected MCP arguments are passed to the + model client. The samples expose `reasoning_effort` as a example, but any option + valid for the agent can be exposed. +- **Input shape:** MCP tool arguments are JSON objects and do not define native + multimodal input content blocks. The samples do not present an + application-specific image schema as protocol behavior. +- **Streaming:** streamable HTTP can carry multiple MCP messages, but a tool + call still produces one final `CallToolResult`. Progress notifications and + experimental deferred tasks remain application-owned protocol features. +- **Authentication:** the local endpoints are intentionally unauthenticated. + MCP transport session identifiers are not user authorization. Production + servers must authenticate and authorize before loading tenant or user state. +- **Errors:** conversion, model, workflow, and protocol errors propagate to the + MCP SDK rather than becoming success-shaped tool output. diff --git a/python/samples/04-hosting/mcp/agent_app.py b/python/samples/04-hosting/mcp/agent_app.py new file mode 100644 index 00000000000..9aa07988f1e --- /dev/null +++ b/python/samples/04-hosting/mcp/agent_app.py @@ -0,0 +1,107 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-foundry", +# "agent-framework-hosting-mcp", +# "azure-identity", +# "mcp>=1.27.0,<2", +# "starlette>=0.40", +# "uvicorn>=0.30", +# ] +# /// +# Run with: uv run agent_app.py + +# Copyright (c) Microsoft. All rights reserved. + +"""Host an Agent Framework agent with the native MCP streamable HTTP server. + +The hosting helper package only converts values at the protocol boundary. This +application owns the MCP tool schema, server, transport, authentication policy, +and Agent Framework session policy. + +This compact local sample is intentionally unauthenticated and stateless: +every tool call starts a fresh Agent Framework conversation. Add authentication +in the outer ASGI server before deriving a trusted user or tenant session key. + +Required environment variables: ``FOUNDRY_PROJECT_ENDPOINT`` and +``FOUNDRY_MODEL``. +""" + +from __future__ import annotations + +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import uvicorn +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_hosting_mcp import MCPAgentTool +from azure.identity.aio import DefaultAzureCredential +from mcp import types +from mcp.server.lowlevel import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from starlette.applications import Starlette +from starlette.routing import Mount + +server = Server("agent-framework-hosting-mcp-sample") +credential = DefaultAzureCredential() +agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ), + name="MCPHostedAgent", + description="Answer a request with the hosted Agent Framework agent.", + instructions="Answer the user's request clearly and concisely.", +) +agent_tool = MCPAgentTool( + agent, + name="run_agent", + argument_description="The request for the hosted agent.", + chat_option_parameters={ + "reasoning_effort": { + "type": "string", + "enum": ["low", "medium", "high"], + "description": "Optional reasoning effort for models that support it.", + } + }, +) + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + """Describe the app-owned MCP tool schema.""" + return await agent_tool.list_tools() + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, object] | None) -> list[types.ContentBlock]: + """Run the app-owned tool with native MCP and Agent Framework values.""" + return await agent_tool.call_tool(name, arguments) + + +session_manager = StreamableHTTPSessionManager( + app=server, + event_store=None, + json_response=True, + stateless=True, +) + + +@asynccontextmanager +async def lifespan(_app: Starlette) -> AsyncIterator[None]: + """Start and stop native MCP and model-client resources.""" + async with session_manager.run(), credential: + yield + + +app = Starlette( + routes=[Mount("/", app=session_manager.handle_request)], + lifespan=lifespan, +) + + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/python/samples/04-hosting/mcp/fastmcp_app.py b/python/samples/04-hosting/mcp/fastmcp_app.py new file mode 100644 index 00000000000..fb0901804e3 --- /dev/null +++ b/python/samples/04-hosting/mcp/fastmcp_app.py @@ -0,0 +1,97 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-foundry", +# "agent-framework-hosting-mcp", +# "azure-identity", +# "mcp>=1.27.0,<2", +# ] +# /// +# Run with: uv run fastmcp_app.py + +# Copyright (c) Microsoft. All rights reserved. + +"""Host an Agent Framework agent with FastMCP and the conversion helpers. + +FastMCP derives the native MCP tool schema from the decorated function +signature. The Agent Framework hosting package only converts the validated +arguments and completed agent response at the protocol boundary. + +This compact local sample is intentionally unauthenticated and stateless: +every tool call starts a fresh Agent Framework conversation. + +Required environment variables: ``FOUNDRY_PROJECT_ENDPOINT`` and +``FOUNDRY_MODEL``. +""" + +from __future__ import annotations + +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Literal + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_hosting_mcp import mcp_from_run, mcp_to_run +from azure.identity.aio import DefaultAzureCredential +from mcp import types +from mcp.server.fastmcp import FastMCP + +credential = DefaultAzureCredential() +agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ), + name="MCPHostedAgent", + description="Answer a request with the hosted Agent Framework agent.", + instructions="Answer the user's request clearly and concisely.", +) + + +@asynccontextmanager +async def lifespan(_server: FastMCP[None]) -> AsyncIterator[None]: + """Close the model credential when the FastMCP server stops.""" + async with credential: + yield + + +server = FastMCP( + name="agent-framework-hosting-fastmcp-sample", + instructions="Expose an Agent Framework agent as an MCP tool.", + host="127.0.0.1", + port=8000, + streamable_http_path="/mcp", + json_response=True, + stateless_http=True, + lifespan=lifespan, +) + + +@server.tool( + name="run_agent", + description="Run the hosted Agent Framework agent.", + structured_output=False, +) +async def run_agent( + task: str, + reasoning_effort: Literal["low", "medium", "high"] | None = None, +) -> list[types.ContentBlock]: + """Run the agent with FastMCP-validated arguments.""" + arguments: dict[str, object] = {"task": task} + if reasoning_effort is not None: + arguments["reasoning_effort"] = reasoning_effort + + run = mcp_to_run(arguments, chat_option_arguments={"reasoning_effort"}) + result = await agent.run( + run["messages"], + options=run["options"], + stream=False, + ) + return mcp_from_run(result) + + +if __name__ == "__main__": + server.run(transport="streamable-http") diff --git a/python/samples/04-hosting/mcp/manual_app.py b/python/samples/04-hosting/mcp/manual_app.py new file mode 100644 index 00000000000..9e000781508 --- /dev/null +++ b/python/samples/04-hosting/mcp/manual_app.py @@ -0,0 +1,122 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-foundry", +# "agent-framework-hosting-mcp", +# "azure-identity", +# "mcp>=1.27.0,<2", +# "starlette>=0.40", +# "uvicorn>=0.30", +# ] +# /// +# Run with: uv run manual_app.py + +# Copyright (c) Microsoft. All rights reserved. + +"""Host an Agent Framework agent using the conversion functions directly. + +This version is useful when an application's MCP tool contract does not fit the +single-agent ``MCPAgentTool`` adapter. The native tool schema and handler stay +fully visible while ``mcp_to_run`` and ``mcp_from_run`` bridge AF values. +""" + +from __future__ import annotations + +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import uvicorn +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_hosting_mcp import mcp_from_run, mcp_to_run +from azure.identity.aio import DefaultAzureCredential +from mcp import types +from mcp.server.lowlevel import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from starlette.applications import Starlette +from starlette.routing import Mount + +TASK_ARGUMENT = "task" +CHAT_OPTION_ARGUMENTS = { + "reasoning_effort": { + "type": "string", + "enum": ["low", "medium", "high"], + "description": "Optional reasoning effort for models that support it.", + } +} + +server = Server("agent-framework-hosting-mcp-manual-sample") +credential = DefaultAzureCredential() +agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ), + name="ManualMCPAgent", + description="Answer requests through a manually defined MCP tool.", + instructions="Answer the user's request clearly and concisely.", +) + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + """Return the app-owned native MCP tool definition.""" + return [ + types.Tool( + name="run_agent_manually", + description=agent.description or "", + inputSchema={ + "type": "object", + "properties": { + TASK_ARGUMENT: { + "type": "string", + "description": "The request for the hosted agent.", + }, + **CHAT_OPTION_ARGUMENTS, + }, + "required": [TASK_ARGUMENT], + "additionalProperties": False, + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, object] | None) -> list[types.ContentBlock]: + """Convert, run, and render without the agent-backed adapter.""" + if name != "run_agent_manually": + raise ValueError(f"Unknown MCP tool: {name}") + run = mcp_to_run( + arguments, + argument_name=TASK_ARGUMENT, + chat_option_arguments=CHAT_OPTION_ARGUMENTS, + ) + result = await agent.run(run["messages"], options=run["options"]) + return mcp_from_run(result) + + +session_manager = StreamableHTTPSessionManager( + app=server, + event_store=None, + json_response=True, + stateless=True, +) + + +@asynccontextmanager +async def lifespan(_app: Starlette) -> AsyncIterator[None]: + """Start and stop native MCP and model-client resources.""" + async with session_manager.run(), credential: + yield + + +app = Starlette( + routes=[Mount("/", app=session_manager.handle_request)], + lifespan=lifespan, +) + + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/python/samples/04-hosting/mcp/pyproject.toml b/python/samples/04-hosting/mcp/pyproject.toml new file mode 100644 index 00000000000..2d1bb331dcb --- /dev/null +++ b/python/samples/04-hosting/mcp/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "agent-framework-hosting-sample-mcp" +version = "0.0.1" +description = "Streamable HTTP MCP server using Agent Framework hosting helpers." +requires-python = ">=3.10" +dependencies = [ + "agent-framework-core", + "agent-framework-foundry", + "agent-framework-hosting", + "agent-framework-hosting-mcp", + "azure-identity", + "mcp>=1.27.0,<2", + "starlette>=0.40", + "uvicorn>=0.30", +] + +[tool.uv] +package = false + +[tool.uv.sources] +agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting" } +agent-framework-hosting-mcp = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting-mcp" } diff --git a/python/samples/04-hosting/mcp/session_app.py b/python/samples/04-hosting/mcp/session_app.py new file mode 100644 index 00000000000..ae5bcf5fa7b --- /dev/null +++ b/python/samples/04-hosting/mcp/session_app.py @@ -0,0 +1,132 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-foundry", +# "agent-framework-hosting-mcp", +# "azure-identity", +# "mcp>=1.27.0,<2", +# "starlette>=0.40", +# "uvicorn>=0.30", +# ] +# /// +# Run with: uv run session_app.py + +# Copyright (c) Microsoft. All rights reserved. + +"""Host a session-aware Agent Framework agent through native MCP constructs. + +The MCP tool accepts an opaque, app-defined ``session_id`` string. Its format is +not prescribed by MCP or Agent Framework; an application might use a UUID, a +database key, or a key derived from authenticated tenant and conversation IDs. +Reusing an ID continues and updates that one conversation. + +This is deliberately different from ``previous_response_id``-style branching, +where an earlier point can be used to create a new conversation branch. An app +that needs branching should accept separate source and destination IDs, load a +copy from the source, and store the updated session under the destination. + +This local sample uses the caller-provided ID directly for clarity. Production +servers must derive or authorize the session key from authenticated outer-server +context rather than trusting an arbitrary caller-provided identifier. +""" + +from __future__ import annotations + +import asyncio +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import uvicorn +from agent_framework import Agent, InMemoryHistoryProvider +from agent_framework.foundry import FoundryChatClient +from agent_framework_hosting import AgentState +from agent_framework_hosting_mcp import MCPAgentTool +from azure.identity.aio import DefaultAzureCredential +from mcp import types +from mcp.server.lowlevel import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from starlette.applications import Starlette +from starlette.routing import Mount + +server = Server("agent-framework-hosting-mcp-session-sample") +credential = DefaultAzureCredential() +agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ), + name="SessionAwareAgent", + description="Answer requests while preserving app-owned conversation state.", + instructions="Answer clearly and use prior conversation context when relevant.", + context_providers=[InMemoryHistoryProvider()], + default_options={"store": False}, +) +state = AgentState(agent) +agent_tool = MCPAgentTool( + state, + name="run_agent", + argument_description="The request for the hosted agent.", + parameters={ + "session_id": { + "type": "string", + "minLength": 1, + "description": ( + "Opaque, app-defined conversation key. Reuse the same value to continue and update one conversation." + ), + } + }, + required_parameters={"session_id"}, + chat_option_parameters={ + "reasoning_effort": { + "type": "string", + "enum": ["low", "medium", "high"], + "description": "Optional reasoning effort for models that support it.", + } + }, + session_id_parameter="session_id", +) +session_locks: dict[str, asyncio.Lock] = {} + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + """Return the agent-derived MCP tool definition.""" + return await agent_tool.list_tools() + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, object] | None) -> list[types.ContentBlock]: + """Serialize calls per app-owned session before using ``AgentState``.""" + session_id = arguments.get("session_id") if arguments else None + if not isinstance(session_id, str) or not session_id: + raise ValueError("MCP tool argument 'session_id' must be a non-empty string.") + lock = session_locks.setdefault(session_id, asyncio.Lock()) + async with lock: + return await agent_tool.call_tool(name, arguments) + + +session_manager = StreamableHTTPSessionManager( + app=server, + event_store=None, + json_response=True, + stateless=True, +) + + +@asynccontextmanager +async def lifespan(_app: Starlette) -> AsyncIterator[None]: + """Start and stop native MCP and model-client resources.""" + async with session_manager.run(), credential: + yield + + +app = Starlette( + routes=[Mount("/", app=session_manager.handle_request)], + lifespan=lifespan, +) + + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/python/samples/04-hosting/mcp/workflow_app.py b/python/samples/04-hosting/mcp/workflow_app.py new file mode 100644 index 00000000000..dd0ccee6413 --- /dev/null +++ b/python/samples/04-hosting/mcp/workflow_app.py @@ -0,0 +1,107 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-hosting-mcp", +# "mcp>=1.27.0,<2", +# "starlette>=0.40", +# "uvicorn>=0.30", +# ] +# /// +# Run with: uv run workflow_app.py + +# Copyright (c) Microsoft. All rights reserved. + +"""Host a typed Agent Framework workflow through native MCP constructs. + +``MCPWorkflowTool`` derives the MCP arguments from the start executor's input +type. This sample uses a dataclass, so its fields become the tool's top-level +arguments. The application still owns the MCP server and transport. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +import uvicorn +from agent_framework import WorkflowBuilder, WorkflowContext, executor +from agent_framework_hosting import WorkflowState +from agent_framework_hosting_mcp import MCPWorkflowTool +from mcp import types +from mcp.server.lowlevel import Server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from starlette.applications import Starlette +from starlette.routing import Mount + + +@dataclass +class DraftRequest: + """Input contract exposed by the MCP tool.""" + + topic: str + audience: str + paragraph_count: int = 2 + + +def create_workflow(): + """Create an independent workflow instance for one MCP call.""" + + @executor(id="draft") + async def draft(request: DraftRequest, ctx: WorkflowContext[object, str]) -> None: + paragraphs = "\n\n".join( + f"Paragraph {index + 1}: {request.topic} for {request.audience}." + for index in range(request.paragraph_count) + ) + await ctx.yield_output(paragraphs) + + return WorkflowBuilder( + start_executor=draft, + name="Draft Workflow", + description="Draft short content for a specified topic and audience.", + output_from=[draft], + ).build() + + +server = Server("agent-framework-hosting-mcp-workflow-sample") +workflow_tool = MCPWorkflowTool( + WorkflowState(create_workflow, cache_target=False), + name="draft_content", +) + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + """Return the workflow-derived MCP tool definition.""" + return await workflow_tool.list_tools() + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, object] | None) -> list[types.ContentBlock]: + """Run a fresh workflow instance with validated MCP arguments.""" + return await workflow_tool.call_tool(name, arguments) + + +session_manager = StreamableHTTPSessionManager( + app=server, + event_store=None, + json_response=True, + stateless=True, +) + + +@asynccontextmanager +async def lifespan(_app: Starlette) -> AsyncIterator[None]: + """Start and stop the native MCP transport.""" + async with session_manager.run(): + yield + + +app = Starlette( + routes=[Mount("/", app=session_manager.handle_request)], + lifespan=lifespan, +) + + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/python/uv.lock b/python/uv.lock index 79779dd42ba..71585f66f7b 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -52,6 +52,7 @@ members = [ "agent-framework-github-copilot", "agent-framework-hosting", "agent-framework-hosting-a2a", + "agent-framework-hosting-mcp", "agent-framework-hosting-responses", "agent-framework-hosting-telegram", "agent-framework-hyperlight", @@ -673,6 +674,25 @@ requires-dist = [ { name = "agent-framework-hosting", editable = "packages/hosting" }, ] +[[package]] +name = "agent-framework-hosting-mcp" +version = "1.0.0a260720" +source = { editable = "packages/hosting-mcp" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "agent-framework-hosting", editable = "packages/hosting" }, + { name = "mcp", specifier = ">=1.11.0,<2" }, + { name = "pydantic", specifier = ">=2,<3" }, +] + [[package]] name = "agent-framework-hosting-responses" version = "1.0.0a260709" From c8127747801f9dbebe81235dac91d7d8f06dcfa0 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 20 Jul 2026 14:16:18 +0200 Subject: [PATCH 2/3] Python: Address MCP hosting review comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5d6987cd-1b67-4ba1-8b54-3c50da6e7607 --- .../agent_framework_hosting_mcp/_agent_tool.py | 4 +--- .../hosting-mcp/tests/hosting_mcp/test_integration.py | 8 ++++---- python/samples/04-hosting/mcp/README.md | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/python/packages/hosting-mcp/agent_framework_hosting_mcp/_agent_tool.py b/python/packages/hosting-mcp/agent_framework_hosting_mcp/_agent_tool.py index d8d88757e6f..c451a07e9e0 100644 --- a/python/packages/hosting-mcp/agent_framework_hosting_mcp/_agent_tool.py +++ b/python/packages/hosting-mcp/agent_framework_hosting_mcp/_agent_tool.py @@ -165,9 +165,7 @@ async def call_tool( session_id = arguments.get(self.session_id_parameter) if arguments else None if not isinstance(session_id, str) or not session_id: raise ValueError(f"MCP tool argument '{self.session_id_parameter}' must be a non-empty string.") - session = await self.state.session_store.get(session_id) - if session is None: - session = target.create_session(session_id=session_id) + session = await self.state.get_or_create_session(session_id) result = cast( "AgentResponse[Any]", await target.run( # pyright: ignore[reportCallIssue] diff --git a/python/packages/hosting-mcp/tests/hosting_mcp/test_integration.py b/python/packages/hosting-mcp/tests/hosting_mcp/test_integration.py index 773071552e9..f4cdb24d671 100644 --- a/python/packages/hosting-mcp/tests/hosting_mcp/test_integration.py +++ b/python/packages/hosting-mcp/tests/hosting_mcp/test_integration.py @@ -4,6 +4,7 @@ import asyncio import socket +import time from collections.abc import AsyncIterator, Awaitable, Mapping, Sequence from contextlib import asynccontextmanager from typing import Any @@ -92,10 +93,9 @@ async def lifespan(_app: Starlette) -> AsyncIterator[None]: uvicorn_server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error", lifespan="on")) server_task = asyncio.create_task(uvicorn_server.serve()) try: - for _ in range(100): - if uvicorn_server.started: - break - await asyncio.sleep(0.01) + startup_deadline = time.monotonic() + 10 + while not uvicorn_server.started and time.monotonic() < startup_deadline: # noqa: ASYNC110 + await asyncio.sleep(0.05) assert uvicorn_server.started mcp_tool = MCPStreamableHTTPTool( diff --git a/python/samples/04-hosting/mcp/README.md b/python/samples/04-hosting/mcp/README.md index 9d9363487c2..2f55c199594 100644 --- a/python/samples/04-hosting/mcp/README.md +++ b/python/samples/04-hosting/mcp/README.md @@ -133,7 +133,7 @@ dependency set using PEP 723 inline script metadata. - **No framework choice:** the package does not select FastMCP, Starlette, Uvicorn, stdio, or streamable HTTP. - **Chat options:** only explicitly selected MCP arguments are passed to the - model client. The samples expose `reasoning_effort` as a example, but any option + model client. The samples expose `reasoning_effort` as an example, but any option valid for the agent can be exposed. - **Input shape:** MCP tool arguments are JSON objects and do not define native multimodal input content blocks. The samples do not present an From 4105bcef9882ac70d76a6046c7cbd07f7be6e118 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 21 Jul 2026 16:39:25 +0200 Subject: [PATCH 3/3] renamed to AgentMCPTool --- python/packages/hosting-mcp/AGENTS.md | 8 ++++---- python/packages/hosting-mcp/README.md | 16 ++++++++-------- .../agent_framework_hosting_mcp/__init__.py | 8 ++++---- .../agent_framework_hosting_mcp/_agent_tool.py | 2 +- .../_workflow_tool.py | 4 ++-- .../tests/hosting_mcp/test_agent_tool.py | 12 ++++++------ .../tests/hosting_mcp/test_integration.py | 4 ++-- .../tests/hosting_mcp/test_workflow_tool.py | 12 ++++++------ python/samples/04-hosting/mcp/README.md | 10 +++++----- python/samples/04-hosting/mcp/agent_app.py | 4 ++-- python/samples/04-hosting/mcp/manual_app.py | 2 +- python/samples/04-hosting/mcp/session_app.py | 4 ++-- python/samples/04-hosting/mcp/workflow_app.py | 6 +++--- 13 files changed, 46 insertions(+), 46 deletions(-) diff --git a/python/packages/hosting-mcp/AGENTS.md b/python/packages/hosting-mcp/AGENTS.md index 6f19756e603..b4b403280e0 100644 --- a/python/packages/hosting-mcp/AGENTS.md +++ b/python/packages/hosting-mcp/AGENTS.md @@ -5,10 +5,10 @@ agents and workflows through the native MCP SDK. ## Public API -- `MCPAgentTool(target, ...)` generates one native MCP `Tool` from an agent, +- `AgentMCPTool(target, ...)` generates one native MCP `Tool` from an agent, converts and executes calls, and optionally persists sessions through an existing `AgentState`. -- `MCPWorkflowTool(target, ...)` derives one native MCP `Tool` from a workflow's +- `WorkflowMCPTool(target, ...)` derives one native MCP `Tool` from a workflow's single start-executor input type and converts completed workflow outputs. - `mcp_to_run(arguments, *, argument_name="task", chat_option_arguments=())` converts MCP tool arguments to `AgentRunArgs` and @@ -23,12 +23,12 @@ authentication, authorization, session-key policy, concurrency policy, or outbound delivery. Applications compose the adapter and conversion helpers with native MCP SDK constructs. -`MCPAgentTool` owns only the schema for its single generated agent tool. It +`AgentMCPTool` owns only the schema for its single generated agent tool. It does not register that schema with a server. Applications call `await adapter.list_tools()` and `await adapter.call_tool(...)` from native MCP handlers. -`MCPWorkflowTool` owns only the schema derived from the start executor. It +`WorkflowMCPTool` owns only the schema derived from the start executor. It requires exactly one input type. Workflow factories and continuation policy remain application-owned. Pending external-input requests raise because the adapter does not own a human-in-the-loop continuation contract. diff --git a/python/packages/hosting-mcp/README.md b/python/packages/hosting-mcp/README.md index ff14a29860c..8b077efb99e 100644 --- a/python/packages/hosting-mcp/README.md +++ b/python/packages/hosting-mcp/README.md @@ -9,10 +9,10 @@ server lifecycle. It provides two conversion functions and small adapters: arguments. - `mcp_from_run(...)` converts an `AgentResponse` or `Message` into native MCP `ContentBlock` values. -- `MCPAgentTool(...)` generates the native `Tool` definition from an agent and +- `AgentMCPTool(...)` generates the native `Tool` definition from an agent and keeps listing, parsing, execution, result conversion, and optional `AgentState` session persistence aligned. -- `MCPWorkflowTool(...)` generates the native `Tool` definition from a +- `WorkflowMCPTool(...)` generates the native `Tool` definition from a workflow's start-executor input type and converts completed workflow outputs. Application code keeps ownership of the MCP SDK's `Server`, handler @@ -59,7 +59,7 @@ For an agent exposed as one MCP tool, use the adapter so the schema and conversion cannot drift: ```python -agent_tool = MCPAgentTool( +agent_tool = AgentMCPTool( agent, name="run_agent", argument_description="The request for the hosted agent.", @@ -81,15 +81,15 @@ async def call_tool(name, arguments): return await agent_tool.call_tool(name, arguments) ``` -`MCPAgentTool` uses the agent's name and description unless overridden. +`AgentMCPTool` uses the agent's name and description unless overridden. `parameters` adds app-owned JSON Schema properties that remain available in the raw MCP arguments. `chat_option_parameters` adds properties and explicitly copies their values into Agent Framework chat options. -For a workflow exposed as one MCP tool, use `MCPWorkflowTool`: +For a workflow exposed as one MCP tool, use `WorkflowMCPTool`: ```python -workflow_tool = MCPWorkflowTool( +workflow_tool = WorkflowMCPTool( WorkflowState(create_workflow, cache_target=False), name="run_workflow", ) @@ -113,7 +113,7 @@ Pass an existing `AgentState` plus `session_id_parameter` to persist an ```python state = AgentState(agent) -agent_tool = MCPAgentTool( +agent_tool = AgentMCPTool( state, parameters={"session_id": {"type": "string", "minLength": 1}}, required_parameters={"session_id"}, @@ -127,7 +127,7 @@ serialize concurrent calls for the same session. The adapter only performs the is always marked required in the generated MCP schema. The session identifier is an opaque, application-defined key. Neither MCP nor -Agent Framework prescribes its format. `MCPAgentTool` treats it as the key for +Agent Framework prescribes its format. `AgentMCPTool` treats it as the key for one mutable conversation: each call loads that session and stores the updated session under the same key. It does not implement `previous_response_id`-style branching. Branching requires an app-owned diff --git a/python/packages/hosting-mcp/agent_framework_hosting_mcp/__init__.py b/python/packages/hosting-mcp/agent_framework_hosting_mcp/__init__.py index 573f67390f4..6a4e3083276 100644 --- a/python/packages/hosting-mcp/agent_framework_hosting_mcp/__init__.py +++ b/python/packages/hosting-mcp/agent_framework_hosting_mcp/__init__.py @@ -4,9 +4,9 @@ import importlib.metadata -from ._agent_tool import MCPAgentTool +from ._agent_tool import AgentMCPTool from ._conversion import mcp_from_run, mcp_to_run -from ._workflow_tool import MCPWorkflowTool +from ._workflow_tool import WorkflowMCPTool try: __version__ = importlib.metadata.version(__name__) @@ -14,8 +14,8 @@ __version__ = "0.0.0" __all__ = [ - "MCPAgentTool", - "MCPWorkflowTool", + "AgentMCPTool", + "WorkflowMCPTool", "__version__", "mcp_from_run", "mcp_to_run", diff --git a/python/packages/hosting-mcp/agent_framework_hosting_mcp/_agent_tool.py b/python/packages/hosting-mcp/agent_framework_hosting_mcp/_agent_tool.py index c451a07e9e0..ac177c0fbda 100644 --- a/python/packages/hosting-mcp/agent_framework_hosting_mcp/_agent_tool.py +++ b/python/packages/hosting-mcp/agent_framework_hosting_mcp/_agent_tool.py @@ -17,7 +17,7 @@ AgentT = TypeVar("AgentT", bound=SupportsAgentRun) -class MCPAgentTool(Generic[AgentT]): +class AgentMCPTool(Generic[AgentT]): """Expose one Agent Framework agent through an app-owned MCP server. The adapter generates the native MCP tool definition and keeps its argument diff --git a/python/packages/hosting-mcp/agent_framework_hosting_mcp/_workflow_tool.py b/python/packages/hosting-mcp/agent_framework_hosting_mcp/_workflow_tool.py index 6fe3cbf3a93..6467382e297 100644 --- a/python/packages/hosting-mcp/agent_framework_hosting_mcp/_workflow_tool.py +++ b/python/packages/hosting-mcp/agent_framework_hosting_mcp/_workflow_tool.py @@ -18,7 +18,7 @@ WorkflowT = TypeVar("WorkflowT", bound=Workflow) -class MCPWorkflowTool(Generic[WorkflowT]): +class WorkflowMCPTool(Generic[WorkflowT]): """Expose one Agent Framework workflow through an app-owned MCP server. The adapter derives the native MCP input schema from the workflow's start @@ -98,7 +98,7 @@ def mcp_from_run(self, result: WorkflowRunResult) -> list[types.ContentBlock]: """Convert completed workflow outputs into native MCP content blocks.""" if result.get_request_info_events(): raise ValueError( - "The workflow requires external input. MCPWorkflowTool does not manage " + "The workflow requires external input. WorkflowMCPTool does not manage " "human-in-the-loop continuation; handle it in the application contract." ) diff --git a/python/packages/hosting-mcp/tests/hosting_mcp/test_agent_tool.py b/python/packages/hosting-mcp/tests/hosting_mcp/test_agent_tool.py index ffb74b28ca3..f3aec8a0126 100644 --- a/python/packages/hosting-mcp/tests/hosting_mcp/test_agent_tool.py +++ b/python/packages/hosting-mcp/tests/hosting_mcp/test_agent_tool.py @@ -19,7 +19,7 @@ from mcp import types from pytest import raises -from agent_framework_hosting_mcp import MCPAgentTool +from agent_framework_hosting_mcp import AgentMCPTool class RecordingClient(BaseChatClient[ChatOptions[None]]): @@ -50,7 +50,7 @@ async def test_agent_tool_generates_schema_from_agent_with_overrides() -> None: name="Research Agent", description="Agent description", ) - tool: MCPAgentTool[Any] = MCPAgentTool( + tool: AgentMCPTool[Any] = AgentMCPTool( agent, name="research", description="Tool description", @@ -100,7 +100,7 @@ async def test_agent_tool_generates_schema_from_agent_with_overrides() -> None: async def test_agent_tool_uses_agent_metadata_by_default() -> None: agent = Agent(client=RecordingClient(), name="Research Agent", description="Agent description") - tool: MCPAgentTool[Any] = MCPAgentTool(agent) + tool: AgentMCPTool[Any] = AgentMCPTool(agent) definition = (await tool.list_tools())[0] @@ -116,7 +116,7 @@ async def test_agent_tool_runs_with_agent_state_session() -> None: context_providers=[InMemoryHistoryProvider()], ) state = AgentState(agent) - tool: MCPAgentTool[Any] = MCPAgentTool( + tool: AgentMCPTool[Any] = AgentMCPTool( state, parameters={"session_id": {"type": "string"}}, required_parameters={"session_id"}, @@ -139,12 +139,12 @@ def test_agent_tool_rejects_undefined_session_parameter() -> None: agent = Agent(client=RecordingClient(), name="agent") with raises(ValueError, match="session_id_parameter"): - MCPAgentTool(agent, session_id_parameter="session_id") + AgentMCPTool(agent, session_id_parameter="session_id") async def test_agent_tool_always_requires_session_parameter() -> None: agent = Agent(client=RecordingClient(), name="agent") - tool: MCPAgentTool[Any] = MCPAgentTool( + tool: AgentMCPTool[Any] = AgentMCPTool( agent, parameters={"session_id": {"type": "string"}}, session_id_parameter="session_id", diff --git a/python/packages/hosting-mcp/tests/hosting_mcp/test_integration.py b/python/packages/hosting-mcp/tests/hosting_mcp/test_integration.py index f4cdb24d671..428a40548c3 100644 --- a/python/packages/hosting-mcp/tests/hosting_mcp/test_integration.py +++ b/python/packages/hosting-mcp/tests/hosting_mcp/test_integration.py @@ -27,7 +27,7 @@ from starlette.applications import Starlette from starlette.routing import Mount -from agent_framework_hosting_mcp import MCPAgentTool +from agent_framework_hosting_mcp import AgentMCPTool class HostedAgentClient(BaseChatClient[ChatOptions[None]]): @@ -58,7 +58,7 @@ async def test_mcp_tool_calls_locally_hosted_agent() -> None: """Host an agent, connect an MCP tool, and invoke it through real HTTP.""" hosted_client = HostedAgentClient() hosted_agent = Agent(client=hosted_client, name="HostedAgent", description="Hosted test agent.") - agent_tool: MCPAgentTool[Any] = MCPAgentTool( + agent_tool: AgentMCPTool[Any] = AgentMCPTool( hosted_agent, name="run_agent", chat_option_parameters={"reasoning_effort": {"type": "string"}}, diff --git a/python/packages/hosting-mcp/tests/hosting_mcp/test_workflow_tool.py b/python/packages/hosting-mcp/tests/hosting_mcp/test_workflow_tool.py index 04831dd3fd4..20705f6677c 100644 --- a/python/packages/hosting-mcp/tests/hosting_mcp/test_workflow_tool.py +++ b/python/packages/hosting-mcp/tests/hosting_mcp/test_workflow_tool.py @@ -18,7 +18,7 @@ from mcp import types from pytest import raises -from agent_framework_hosting_mcp import MCPWorkflowTool +from agent_framework_hosting_mcp import WorkflowMCPTool @dataclass @@ -45,7 +45,7 @@ async def repeat_text(value: WorkflowInput, ctx: WorkflowContext[object, str]) - async def test_workflow_tool_derives_object_schema_and_runs_workflow() -> None: - tool: MCPWorkflowTool[Any] = MCPWorkflowTool( + tool: WorkflowMCPTool[Any] = WorkflowMCPTool( WorkflowState(create_workflow, cache_target=False), name="repeat_text", ) @@ -67,7 +67,7 @@ async def uppercase(value: str, ctx: WorkflowContext[object, str]) -> None: await ctx.yield_output(value.upper()) workflow = WorkflowBuilder(start_executor=uppercase, name="uppercase", output_from=[uppercase]).build() - tool: MCPWorkflowTool[Any] = MCPWorkflowTool(workflow, argument_name="text") + tool: WorkflowMCPTool[Any] = WorkflowMCPTool(workflow, argument_name="text") definition = (await tool.list_tools())[0] result = await tool.call_tool("uppercase", {"text": "hello"}) @@ -87,7 +87,7 @@ async def structured(value: str, ctx: WorkflowContext[object, dict[str, str]]) - await ctx.yield_output({"value": value}) workflow = WorkflowBuilder(start_executor=structured, name="structured", output_from=[structured]).build() - tool: MCPWorkflowTool[Any] = MCPWorkflowTool(workflow) + tool: WorkflowMCPTool[Any] = WorkflowMCPTool(workflow) result = await tool.call_tool("structured", {"input": "hello"}) @@ -96,7 +96,7 @@ async def structured(value: str, ctx: WorkflowContext[object, dict[str, str]]) - def test_workflow_tool_rejects_unhandled_external_input_requests() -> None: workflow = create_workflow() - tool: MCPWorkflowTool[Any] = MCPWorkflowTool(workflow) + tool: WorkflowMCPTool[Any] = WorkflowMCPTool(workflow) result = WorkflowRunResult([ WorkflowEvent.request_info( request_id="approval", @@ -125,7 +125,7 @@ async def handle_number(self, value: int, ctx: WorkflowContext[object, str]) -> name="multiple", output_from="all", ).build() - tool: MCPWorkflowTool[Any] = MCPWorkflowTool(workflow) + tool: WorkflowMCPTool[Any] = WorkflowMCPTool(workflow) with raises(ValueError, match="exactly one"): tool._tool_for_workflow(workflow) # pyright: ignore[reportPrivateUsage] diff --git a/python/samples/04-hosting/mcp/README.md b/python/samples/04-hosting/mcp/README.md index 2f55c199594..ee9bbad97df 100644 --- a/python/samples/04-hosting/mcp/README.md +++ b/python/samples/04-hosting/mcp/README.md @@ -60,7 +60,7 @@ uv run fastmcp_app.py ### 3. Agent-derived tool -[`agent_app.py`](agent_app.py) adds `MCPAgentTool` to the low-level server. The adapter +[`agent_app.py`](agent_app.py) adds `AgentMCPTool` to the low-level server. The adapter derives the native tool name and description from the agent, owns the configured argument schema, runs the agent, and applies the same conversion boundary. @@ -74,7 +74,7 @@ uv run agent_app.py ### 4. Session-aware agent -[`session_app.py`](session_app.py) builds on `MCPAgentTool` with `AgentState`. +[`session_app.py`](session_app.py) builds on `AgentMCPTool` with `AgentState`. It loads and stores an `AgentSession` using an opaque, application-defined `session_id` and serializes calls per ID in-process. @@ -89,7 +89,7 @@ uv run session_app.py ### 5. Workflow-derived tool -[`workflow_app.py`](workflow_app.py) uses `MCPWorkflowTool` to derive the tool +[`workflow_app.py`](workflow_app.py) uses `WorkflowMCPTool` to derive the tool schema from the workflow start executor's single input type. Dataclass or Pydantic fields become top-level MCP arguments; primitive inputs are wrapped in one configurable argument. @@ -108,8 +108,8 @@ uv run workflow_app.py |---|---|---| | `mcp_to_run(...)` | Converts validated MCP arguments into Agent Framework messages and selected chat options. | `manual_app.py`, `fastmcp_app.py` | | `mcp_from_run(...)` | Converts a completed agent response into MCP result content blocks. | `manual_app.py`, `fastmcp_app.py` | -| `MCPAgentTool` | Derives one native MCP tool from an agent and keeps schema, execution, and conversion aligned. | `agent_app.py`, `session_app.py` | -| `MCPWorkflowTool` | Derives one native MCP tool from a workflow start executor and converts workflow outputs. | `workflow_app.py` | +| `AgentMCPTool` | Derives one native MCP tool from an agent and keeps schema, execution, and conversion aligned. | `agent_app.py`, `session_app.py` | +| `WorkflowMCPTool` | Derives one native MCP tool from a workflow start executor and converts workflow outputs. | `workflow_app.py` | ## Run diff --git a/python/samples/04-hosting/mcp/agent_app.py b/python/samples/04-hosting/mcp/agent_app.py index 9aa07988f1e..c53bf9ec44c 100644 --- a/python/samples/04-hosting/mcp/agent_app.py +++ b/python/samples/04-hosting/mcp/agent_app.py @@ -36,7 +36,7 @@ import uvicorn from agent_framework import Agent from agent_framework.foundry import FoundryChatClient -from agent_framework_hosting_mcp import MCPAgentTool +from agent_framework_hosting_mcp import AgentMCPTool from azure.identity.aio import DefaultAzureCredential from mcp import types from mcp.server.lowlevel import Server @@ -56,7 +56,7 @@ description="Answer a request with the hosted Agent Framework agent.", instructions="Answer the user's request clearly and concisely.", ) -agent_tool = MCPAgentTool( +agent_tool = AgentMCPTool( agent, name="run_agent", argument_description="The request for the hosted agent.", diff --git a/python/samples/04-hosting/mcp/manual_app.py b/python/samples/04-hosting/mcp/manual_app.py index 9e000781508..1faf24bc5a6 100644 --- a/python/samples/04-hosting/mcp/manual_app.py +++ b/python/samples/04-hosting/mcp/manual_app.py @@ -16,7 +16,7 @@ """Host an Agent Framework agent using the conversion functions directly. This version is useful when an application's MCP tool contract does not fit the -single-agent ``MCPAgentTool`` adapter. The native tool schema and handler stay +single-agent ``AgentMCPTool`` adapter. The native tool schema and handler stay fully visible while ``mcp_to_run`` and ``mcp_from_run`` bridge AF values. """ diff --git a/python/samples/04-hosting/mcp/session_app.py b/python/samples/04-hosting/mcp/session_app.py index ae5bcf5fa7b..c185c0ef130 100644 --- a/python/samples/04-hosting/mcp/session_app.py +++ b/python/samples/04-hosting/mcp/session_app.py @@ -41,7 +41,7 @@ from agent_framework import Agent, InMemoryHistoryProvider from agent_framework.foundry import FoundryChatClient from agent_framework_hosting import AgentState -from agent_framework_hosting_mcp import MCPAgentTool +from agent_framework_hosting_mcp import AgentMCPTool from azure.identity.aio import DefaultAzureCredential from mcp import types from mcp.server.lowlevel import Server @@ -64,7 +64,7 @@ default_options={"store": False}, ) state = AgentState(agent) -agent_tool = MCPAgentTool( +agent_tool = AgentMCPTool( state, name="run_agent", argument_description="The request for the hosted agent.", diff --git a/python/samples/04-hosting/mcp/workflow_app.py b/python/samples/04-hosting/mcp/workflow_app.py index dd0ccee6413..8bc715a53ed 100644 --- a/python/samples/04-hosting/mcp/workflow_app.py +++ b/python/samples/04-hosting/mcp/workflow_app.py @@ -13,7 +13,7 @@ """Host a typed Agent Framework workflow through native MCP constructs. -``MCPWorkflowTool`` derives the MCP arguments from the start executor's input +``WorkflowMCPTool`` derives the MCP arguments from the start executor's input type. This sample uses a dataclass, so its fields become the tool's top-level arguments. The application still owns the MCP server and transport. """ @@ -27,7 +27,7 @@ import uvicorn from agent_framework import WorkflowBuilder, WorkflowContext, executor from agent_framework_hosting import WorkflowState -from agent_framework_hosting_mcp import MCPWorkflowTool +from agent_framework_hosting_mcp import WorkflowMCPTool from mcp import types from mcp.server.lowlevel import Server from mcp.server.streamable_http_manager import StreamableHTTPSessionManager @@ -64,7 +64,7 @@ async def draft(request: DraftRequest, ctx: WorkflowContext[object, str]) -> Non server = Server("agent-framework-hosting-mcp-workflow-sample") -workflow_tool = MCPWorkflowTool( +workflow_tool = WorkflowMCPTool( WorkflowState(create_workflow, cache_target=False), name="draft_content", )