diff --git a/docs/mcp.md b/docs/mcp.md index eef61a047..11a895ef2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -167,6 +167,42 @@ agent = Agent( ) ``` +## Resources + +Resources are a core primitive in the Model Context Protocol (MCP) that allow servers to expose data and content that can be read by clients and used as context for LLM interactions. + +### Using Resources + +MCP servers that support resources provide three main methods: + +- `list_resources()`: Lists all available resources on the server +- `list_resource_templates()`: Lists all available resources templates on the server +- `read_resource()`: Read data from a specific resource given its URI + +```python +# List available resources +resources_result = await mcp_server.list_resources() +for resource in resources_result.resources: + print(f"name: {resource.name}, description: {resource.description}") + +# List available resources templates +resources_templates_result = await mcp_server.list_resource_templates() +for resource in resources_templates_result.resourceTemplates: + print(f"name: {resource.name}, description: {resource.description}") + +# Read from a specific resource +resource = await mcp_server.read_resource("docs://api/reference") +print(resource.contents[0].text) + +# Use the prompt-generated instructions with an Agent +agent = Agent( + name="Company Information Maintainer", + instructions="How to access to API service?", + mcp_servers=[server] +) +``` + + ## Caching Every time an Agent runs, it calls `list_tools()` on the MCP server. This can be a latency hit, especially if the server is a remote server. To automatically cache the list of tools, you can pass `cache_tools_list=True` to [`MCPServerStdio`][agents.mcp.server.MCPServerStdio], [`MCPServerSse`][agents.mcp.server.MCPServerSse], and [`MCPServerStreamableHttp`][agents.mcp.server.MCPServerStreamableHttp]. You should only do this if you're certain the tool list will not change. diff --git a/examples/mcp/resources_server/README.md b/examples/mcp/resources_server/README.md new file mode 100644 index 000000000..5d3423ef5 --- /dev/null +++ b/examples/mcp/resources_server/README.md @@ -0,0 +1,19 @@ +# MCP Resources Server Example +This example shows the absolute basics of working with an MCP resources server by discovering what resources exist and reading one of them. + +The local MCP Resources Server is defined in [server.py](server.py). + +Run the example via: + +``` +uv run python examples/mcp/resources_server/main.py +``` + +## What the code does + +The example uses the `MCPServerStreamableHttp` class from `agents.mcp`. The server runs in a sub-process at `http://localhost:8000/mcp` and provides resources that can be exposed to agents. +The example demonstrates three main functions: + +1. **`list_resources`** - Lists all available resources in the MCP server. +2. **`list_resource_templates`** - Lists all available resources templates in the MCP server. +3. **`read_resource`** - Read a specific resource from the MCP server. diff --git a/examples/mcp/resources_server/main.py b/examples/mcp/resources_server/main.py new file mode 100644 index 000000000..3dc66386e --- /dev/null +++ b/examples/mcp/resources_server/main.py @@ -0,0 +1,81 @@ +import asyncio +import os +import shutil +import subprocess +import time +from typing import Any + +from mcp.types import ListResourcesResult, ListResourceTemplatesResult, ReadResourceResult +from pydantic import AnyUrl + +from agents import Agent, Runner, gen_trace_id, trace +from agents.mcp import MCPServer, MCPServerStreamableHttp + + +async def list_resources(mcp_server: MCPServer): + """List available resources""" + resources_result: ListResourcesResult = await mcp_server.list_resources() + print("\n### Resources ###") + for resource in resources_result.resources: + print(f"name: {resource.name}, description: {resource.description}") + +async def list_resource_templates(mcp_server: MCPServer): + """List available resources templates""" + resources_templates_result: ListResourceTemplatesResult = await mcp_server.list_resource_templates() + print("\n### Resource Templates ###") + for resource in resources_templates_result.resourceTemplates: + print(f"name: {resource.name}, description: {resource.description}") + +async def read_resource(mcp_server: MCPServer, uri: AnyUrl): + resource: ReadResourceResult = await mcp_server.read_resource(uri) + print("\n### Resource Content ###") + print(resource.contents[0]) + +async def main(): + async with MCPServerStreamableHttp( + name="Simple Prompt Server", + params={"url": "http://localhost:8000/mcp"}, + ) as server: + trace_id = gen_trace_id() + with trace(workflow_name="Simple Prompt Demo", trace_id=trace_id): + print(f"Trace: https://platform.openai.com/traces/trace?trace_id={trace_id}\n") + + await list_resources(server) + await list_resource_templates(server) + await read_resource(server, AnyUrl("docs://api/reference")) + + agent = Agent( + name="Assistant", + instructions="Answer users queries using the available resources", + mcp_servers=[server], + ) + + message = "What's the process to access the APIs? What are the available endpoints?" + print("\n" + "-" * 40) + print(f"Running: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + +if __name__ == "__main__": + if not shutil.which("uv"): + raise RuntimeError("uv is not installed") + + process: subprocess.Popen[Any] | None = None + try: + this_dir = os.path.dirname(os.path.abspath(__file__)) + server_file = os.path.join(this_dir, "server.py") + + print("Starting Simple Resources Server...") + process = subprocess.Popen(["uv", "run", server_file]) + time.sleep(3) + print("Server started\n") + except Exception as e: + print(f"Error starting server: {e}") + exit(1) + + try: + asyncio.run(main()) + finally: + if process: + process.terminate() + print("Server terminated.") diff --git a/examples/mcp/resources_server/server.py b/examples/mcp/resources_server/server.py new file mode 100644 index 000000000..f8fdad691 --- /dev/null +++ b/examples/mcp/resources_server/server.py @@ -0,0 +1,101 @@ +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("Resources Server") + +API_REFERENCE_MD = """ +# Company API Reference + +## Authentication +Use the `Authorization: Bearer ` header. + +### Endpoints +| Method | Path | Description | +|--------|--------------------|--------------------| +| GET | /users | List users | +| POST | /users | Create a new user | +| GET | /users/{{id}} | Retrieve a user | + +""" + +GETTING_STARTED_MD = """ +# Getting Started Guide + +Welcome! Follow these steps to get productive quickly. + +1. Sign up for an account. +2. Generate an API token. +3. Call `GET /users` to verify your setup. + +""" + +CHANGELOG_MD = """ +# Latest Changelog + +## v2.1.0 — 2025-07-01 +* Added OAuth 2.1 support +* Reduced request latency by 25 % +* Fixed edge-case bug in /reports endpoint +""" + +# ────────────────────────────────────────────────────────────────────── +# 1. Static resources +# ────────────────────────────────────────────────────────────────────── +@mcp.resource( + "docs://api/reference", + name="Company API Reference", + description=( + "Static Markdown reference covering authentication, every endpoint’s " + "method and path, request/response schema, and example payloads." + ), +) +def api_reference() -> str: + return API_REFERENCE_MD + +@mcp.resource( + "docs://guides/getting-started", + name="Getting Started Guide", + description=( + "Introductory walkthrough for new developers: account creation, token " + "generation, first API call, and common troubleshooting tips." + ), +) +def getting_started() -> str: + return GETTING_STARTED_MD + +# ────────────────────────────────────────────────────────────────────── +# 2. Dynamic (async) resource +# ────────────────────────────────────────────────────────────────────── +@mcp.resource( + "docs://changelog/latest", + name="Latest Changelog", + description=( + "Async resource that delivers the most recent release notes at read-time. " + "Useful for surfacing new features and bug fixes to the LLM." + ), +) +async def latest_changelog() -> str: + return CHANGELOG_MD + +# ────────────────────────────────────────────────────────────────────── +# 3. Template resource +# ────────────────────────────────────────────────────────────────────── +@mcp.resource( + "docs://{section}/search", + name="Docs Search", + description=( + "Template resource enabling full-text search within a chosen docs section " + "(e.g., api, guides, changelog). The URI parameter {section} must match " + "the function argument." + ), +) +def docs_search(section: str) -> str: + database = { + "api": API_REFERENCE_MD, + "guides": GETTING_STARTED_MD, + "changelog": CHANGELOG_MD, + } + return database.get(section, "Section not found.") + +if __name__ == "__main__": + # Initialize and run the server + mcp.run(transport="streamable-http") diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index 91a9274fc..9305c4c9b 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -13,7 +13,16 @@ from mcp.client.sse import sse_client from mcp.client.streamable_http import GetSessionIdCallback, streamablehttp_client from mcp.shared.message import SessionMessage -from mcp.types import CallToolResult, GetPromptResult, InitializeResult, ListPromptsResult +from mcp.types import ( + CallToolResult, + GetPromptResult, + InitializeResult, + ListPromptsResult, + ListResourcesResult, + ListResourceTemplatesResult, + ReadResourceResult, +) +from pydantic import AnyUrl from typing_extensions import NotRequired, TypedDict from ..exceptions import UserError @@ -77,6 +86,23 @@ async def get_prompt( """Get a specific prompt from the server.""" pass + @abc.abstractmethod + async def list_resources(self, cursor: str | None = None) -> ListResourcesResult: + """List the resources available on the server.""" + pass + + @abc.abstractmethod + async def list_resource_templates( + self, cursor: str | None = None + ) -> ListResourceTemplatesResult: + """List the resources templates available on the server.""" + pass + + @abc.abstractmethod + async def read_resource(self, uri: AnyUrl) -> ReadResourceResult: + """Read a specific resource given its uri.""" + pass + class _MCPServerWithClientSession(MCPServer, abc.ABC): """Base class for MCP servers that use a `ClientSession` to communicate with the server.""" @@ -293,6 +319,29 @@ async def get_prompt( return await self.session.get_prompt(name, arguments) + async def list_resources(self, cursor: str | None = None) -> ListResourcesResult: + """List the resources available on the server.""" + if not self.session: + raise UserError("Server not initialized. Make sure you call `connect()` first.") + + return await self.session.list_resources(cursor) + + async def list_resource_templates( + self, cursor: str | None = None + ) -> ListResourceTemplatesResult: + """List the resources templates available on the server.""" + if not self.session: + raise UserError("Server not initialized. Make sure you call `connect()` first.") + + return await self.session.list_resource_templates(cursor) + + async def read_resource(self, uri: AnyUrl) -> ReadResourceResult: + """Read a specific resource given its uri.""" + if not self.session: + raise UserError("Server not initialized. Make sure you call `connect()` first.") + + return await self.session.read_resource(uri) + async def cleanup(self): """Cleanup the server.""" async with self._cleanup_lock: diff --git a/tests/mcp/helpers.py b/tests/mcp/helpers.py index 31d43c228..a5adc8b21 100644 --- a/tests/mcp/helpers.py +++ b/tests/mcp/helpers.py @@ -4,7 +4,19 @@ from typing import Any from mcp import Tool as MCPTool -from mcp.types import CallToolResult, GetPromptResult, ListPromptsResult, PromptMessage, TextContent +from mcp.types import ( + CallToolResult, + GetPromptResult, + ListPromptsResult, + ListResourcesResult, + ListResourceTemplatesResult, + PromptMessage, + ReadResourceResult, + Resource, + ResourceTemplate, + TextContent, +) +from pydantic import AnyUrl from agents.mcp import MCPServer from agents.mcp.server import _MCPServerWithClientSession @@ -57,10 +69,14 @@ def name(self) -> str: class FakeMCPServer(MCPServer): def __init__( self, + resources: list[Resource] | None = None, + resources_templates: list[ResourceTemplate] | None = None, tools: list[MCPTool] | None = None, tool_filter: ToolFilter = None, server_name: str = "fake_mcp_server", ): + self.resources = resources or [] + self.resources_templates = resources_templates or [] self.tools: list[MCPTool] = tools or [] self.tool_calls: list[str] = [] self.tool_results: list[str] = [] @@ -106,6 +122,33 @@ async def get_prompt( message = PromptMessage(role="user", content=TextContent(type="text", text=content)) return GetPromptResult(description=f"Fake prompt: {name}", messages=[message]) + async def list_resources(self, run_context=None, agent=None) -> ListResourcesResult: + """Return empty list of resources for the fake server""" + return ListResourcesResult(resources=self.resources) + + async def list_resource_templates(self, run_context=None, agent=None) \ + -> ListResourceTemplatesResult: + """Return empty list of resources templates for the fake server""" + return ListResourceTemplatesResult(resourceTemplates=self.resources_templates) + + async def read_resource(self, uri: AnyUrl) -> ReadResourceResult: + """Return a fake resource read for the fake server""" + for resource in self.resources: + if resource.uri == uri: + return ReadResourceResult(**resource.model_dump(), contents=[]) + + raise KeyError(f"Resource {uri} not found") + + def add_resource(self, uri: AnyUrl, name: str, description: str | None = None): + """Add a resource to the fake server""" + self.resources.append(Resource(uri=uri, description=description, name=name)) + + def add_resource_template(self, uri: str, name: str, description: str | None = None): + """Add a resource template to the fake server""" + self.resources_templates.append( + ResourceTemplate(uriTemplate=uri, description=description, name=name) + ) + @property def name(self) -> str: return self._server_name diff --git a/tests/mcp/test_prompt_server.py b/tests/mcp/test_prompt_server.py index 15afe28e4..1f508f54b 100644 --- a/tests/mcp/test_prompt_server.py +++ b/tests/mcp/test_prompt_server.py @@ -1,6 +1,8 @@ from typing import Any import pytest +from mcp.types import ListResourcesResult, ListResourceTemplatesResult, ReadResourceResult +from pydantic import AnyUrl from agents import Agent, Runner from agents.mcp import MCPServer @@ -66,6 +68,19 @@ async def list_tools(self, run_context=None, agent=None): async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = None): raise NotImplementedError("This fake server doesn't support tools") + async def list_resources(self, run_context=None, agent=None) -> ListResourcesResult: + """Return empty list of resources for fake server""" + return ListResourcesResult(resources=[]) + + async def list_resource_templates(self, run_context=None, agent=None) \ + -> ListResourceTemplatesResult: + """Return empty list of resources templates for fake server""" + return ListResourceTemplatesResult(resourceTemplates=[]) + + async def read_resource(self, uri: AnyUrl) -> ReadResourceResult: + """Return a fake resource read for fake server""" + return ReadResourceResult(contents=[]) + @property def name(self) -> str: return self._server_name diff --git a/tests/mcp/test_resources_server.py b/tests/mcp/test_resources_server.py new file mode 100644 index 000000000..5d49d6528 --- /dev/null +++ b/tests/mcp/test_resources_server.py @@ -0,0 +1,45 @@ +import pytest +from pydantic import AnyUrl + +from .helpers import FakeMCPServer + + +@pytest.mark.asyncio +async def test_list_resources(): + """Test listing available resources""" + server = FakeMCPServer() + server.add_resource(uri=AnyUrl("docs://api/reference"), name="reference") + + result = await server.list_resources() + assert len(result.resources) == 1 + assert result.resources[0].uri == AnyUrl("docs://api/reference") + assert result.resources[0].name == "reference" + +@pytest.mark.asyncio +async def test_list_resource_templates(): + """Test listing available resource templates""" + server = FakeMCPServer() + server.add_resource_template(uri="docs://{section}/search", name="Docs Search") + server.add_resource_template(uri="api://{router}/get", name="APIs Search") + + result = await server.list_resource_templates() + assert len(result.resourceTemplates) == 2 + assert result.resourceTemplates[0].uriTemplate == "docs://{section}/search" + assert result.resourceTemplates[0].name == "Docs Search" + +@pytest.mark.asyncio +async def test_read_resource(): + """Test getting a resource""" + server = FakeMCPServer() + server.add_resource(AnyUrl("docs://api/reference"), name="Docs Search") + + await server.read_resource(AnyUrl("docs://api/reference")) + +@pytest.mark.asyncio +async def test_read_resource_not_found(): + """Test getting a resource that doesn't exist""" + server = FakeMCPServer() + + uri = "docs://api/reference" + with pytest.raises(KeyError, match=f"Resource {uri} not found"): + await server.read_resource(AnyUrl(uri)) diff --git a/tests/test_handoff_tool.py b/tests/test_handoff_tool.py index 291f0a4f5..dbceb68b1 100644 --- a/tests/test_handoff_tool.py +++ b/tests/test_handoff_tool.py @@ -16,6 +16,10 @@ UserError, handoff, ) +from agents.extensions.handoff_prompt import ( + RECOMMENDED_PROMPT_PREFIX, + prompt_with_handoff_instructions, +) from agents.run import AgentRunner @@ -375,3 +379,10 @@ async def test_handoff_is_enabled_filtering_integration(): agent_names = {h.agent_name for h in filtered_handoffs} assert agent_names == {"agent_1", "agent_3"} assert "agent_2" not in agent_names + +@pytest.mark.asyncio +async def test_handoff_prompt_is_correct(): + n = len(RECOMMENDED_PROMPT_PREFIX) + assert (RECOMMENDED_PROMPT_PREFIX == + prompt_with_handoff_instructions("Respond to user queries") + [:n])