From 184b1116330edcf2fc00f50a2f8ac1f5c39fccc3 Mon Sep 17 00:00:00 2001 From: BlockVectra <5194435+gjwang@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:50:46 +0800 Subject: [PATCH] docs: add OpenAI Agents SDK documentation-first example --- AGENTS.md | 1 + README.md | 14 ++++ README.zh.md | 14 ++++ examples/openai_agents_docs.py | 62 ++++++++++++++++ examples/test_openai_agents_docs.py | 111 ++++++++++++++++++++++++++++ 5 files changed, 202 insertions(+) create mode 100644 examples/openai_agents_docs.py create mode 100644 examples/test_openai_agents_docs.py diff --git a/AGENTS.md b/AGENTS.md index 2bc6f8e..841d199 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ Choose the entry that matches the requested task before installing dependencies | Read documentation through LangChain tools | [LangChain MCP example](examples/langchain_docs.py) and the README section “Read the same page with LangChain”; Python 3.11+, uv, no model or account key. | | Read documentation through Google ADK tools | [ADK MCP example](examples/adk_docs.py) and the README section “Read documentation with Google ADK”; Python 3.11+, uv, no model, Cloud project or account key. | | Read documentation through Pydantic AI tools | [Pydantic AI MCP example](examples/pydantic_docs.py) and the README section “Read documentation with Pydantic AI”; Python 3.11+, uv, no model or account key. | +| Read documentation through the OpenAI Agents SDK | [OpenAI Agents SDK MCP example](examples/openai_agents_docs.py) and the README section “Read documentation with the OpenAI Agents SDK”; Python 3.12+, uv, no model or account key. | | Check a chain's identity with Python | [Standard-library public reader](examples/public_chain.py) and the README section “Python standard-library entry”. | | Check chain identity and read the latest block number | [Public TypeScript reader](src/public-read-cli.ts) and the README section “Start with a keyless read”. | | Inspect a saved BSC receipt and fixed-block logs offline | [Pinned BSC event sample](https://gist.github.com/gjwang/5a8f80f5e441a5701e2e01ae73f187bf/0f680ce849c78375537fce5ed1ce9e447fe2f341) and the README section “Check a saved BSC receipt and its logs”; Node.js 22+, no account or key. | diff --git a/README.md b/README.md index 5e76ef2..b5b5d24 100644 --- a/README.md +++ b/README.md @@ -441,6 +441,20 @@ The `async with toolset` block keeps the connection open for both calls and clos For a model-backed agent, configure your model separately and reuse the toolset in the agent's `toolsets`. Pydantic AI recommends its higher-level `MCP` capability for ordinary agent runs; this example uses `MCPToolset` directly so its connection and two calls are explicit. See the [official client guide](https://github.com/pydantic/pydantic-ai/blob/v2.66.0/docs/mcp/client.md). This example does not test a model-driven agent workflow. +### Read documentation with the OpenAI Agents SDK + +Use Python 3.12+ and [uv](https://docs.astral.sh/uv/) from this checkout: + +```bash +uv run examples/openai_agents_docs.py +``` + +The [example](examples/openai_agents_docs.py) pins `openai-agents==0.24.0` and `mcp==2.3.0`. It uses the SDK's `MCPServerStreamableHttp` to discover the two documentation tools, search for `quickstart`, and read that English page. JSON output includes the Markdown. No model, model API key, BlockVectra account, wallet or API key is needed, and tracing is disabled. + +The `async with server` block keeps the connection open for both calls and closes it on success or failure. Tool errors, an unexpected tool list, an empty search or an empty document stop the example. HTTP and session timeouts are 15 seconds, with a 60-second cooperative overall timeout; dependency installation happens before these timeouts. The HTTP client follows no redirects and the example adds no retry loop. It reads documentation without executing the RPC or account instructions in the page. + +This calls the SDK-managed MCP server directly; it does not run an `Agent` or `Runner`. For a model-backed agent, configure your model separately and pass the same server in the agent's `mcp_servers`. See the [official OpenAI Agents SDK guide](https://developers.openai.com/api/docs/guides/agents/integrations-observability). This example does not test a model-driven agent workflow. + ### 1. Claude Code Configuration #### Keyless Documentation MCP diff --git a/README.zh.md b/README.zh.md index 4c6ac94..7c2b6b0 100644 --- a/README.zh.md +++ b/README.zh.md @@ -441,6 +441,20 @@ uv run examples/pydantic_docs.py 需要模型参与时,另行配置模型,将同一 toolset 放入 agent 的 `toolsets`。Pydantic AI 对普通 agent 运行推荐更高层的 `MCP` capability;此例直接使用 `MCPToolset`,让连接和两次调用清晰可见。参见[官方客户端指南](https://github.com/pydantic/pydantic-ai/blob/v2.66.0/docs/mcp/client.md)。此例未验证模型驱动的 Agent 工作流。 +### 用 OpenAI Agents SDK 读取文档 + +使用 Python 3.12+,并在本仓库目录中运行 [uv](https://docs.astral.sh/uv/): + +```bash +uv run examples/openai_agents_docs.py +``` + +[示例](examples/openai_agents_docs.py)固定 `openai-agents==0.24.0` 与 `mcp==2.3.0`,使用 SDK 的 `MCPServerStreamableHttp` 发现两个文档工具,搜索 `quickstart` 后读取英文页面,将 Markdown 放在 JSON 中输出。无需模型、模型 API key、BlockVectra 账户、钱包或 API Key,且已关闭 tracing。 + +`async with server` 在两次调用期间保持连接,成功或失败都会关闭。工具错误、工具列表不符、搜索无结果或文档为空都会让示例停止。HTTP 与会话超时均为 15 秒,整体协作式超时 60 秒;依赖安装发生在这些超时之前。HTTP 客户端不跟随重定向,示例也不加重试循环。它只读取文档,不执行页面中的 RPC 或账户步骤。 + +此例直接调用由 SDK 管理的 MCP server,不运行 `Agent` 或 `Runner`。需要模型参与时,另行配置模型,并把同一个 server 放入 agent 的 `mcp_servers`。参见 [OpenAI Agents SDK 官方指南](https://developers.openai.com/api/docs/guides/agents/integrations-observability)。此例未验证模型驱动的 Agent 工作流。 + ### 1. Claude Code 配置 #### 免 Key 文档服务 diff --git a/examples/openai_agents_docs.py b/examples/openai_agents_docs.py new file mode 100644 index 0000000..5478a9f --- /dev/null +++ b/examples/openai_agents_docs.py @@ -0,0 +1,62 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = ["openai-agents==0.24.0", "mcp==2.3.0"] +# /// +"""Search and read BlockVectra docs through the OpenAI Agents SDK MCP client, without a model or key.""" + +import asyncio +import json +import os + +import httpx2 +from agents import set_tracing_disabled +from agents.mcp import MCPServerStreamableHttp + +from mcp_docs import first_path, tool_text + +ENDPOINT = "https://docs.blockvectra.com/mcp/docs" +USER_AGENT = "BlockVectraOpenAIAgents/1" +if os.environ.get("BLOCKVECTRA_INTERNAL_QA") == "1": + USER_AGENT += " BlockVectraQA/1" + + +def http_client_factory(headers=None, timeout=None, auth=None): + return httpx2.AsyncClient( + headers=headers, timeout=timeout, auth=auth, follow_redirects=False + ) + + +def make_server() -> MCPServerStreamableHttp: + return MCPServerStreamableHttp( + params={ + "url": ENDPOINT, + "headers": {"User-Agent": USER_AGENT}, + "timeout": 15, + "sse_read_timeout": 15, + "httpx_client_factory": http_client_factory, + }, + client_session_timeout_seconds=15, + max_retry_attempts=0, + max_list_pages=1, + ) + + +async def read_docs(server) -> dict: + async with server: + names = sorted(tool.name for tool in await server.list_tools()) + if names != ["read_doc", "search_docs"]: + raise RuntimeError("The endpoint did not expose exactly the two documentation tools") + search = await server.call_tool("search_docs", {"query": "quickstart", "lang": "en", "limit": 1}) + path = first_path(tool_text(search, "search_docs")) + document = await server.call_tool("read_doc", {"path": path, "lang": "en"}) + return {"tools": names, "path": path, "lang": "en", "text": tool_text(document, "read_doc")} + + +async def main() -> None: + set_tracing_disabled(True) + async with asyncio.timeout(60): + print(json.dumps(await read_docs(make_server()), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/test_openai_agents_docs.py b/examples/test_openai_agents_docs.py new file mode 100644 index 0000000..3ac6b48 --- /dev/null +++ b/examples/test_openai_agents_docs.py @@ -0,0 +1,111 @@ +"""Exercise the OpenAI Agents SDK example against a mocked MCP server, offline.""" + +import unittest +from types import SimpleNamespace +from unittest import mock + +import openai_agents_docs as example +from openai_agents_docs import read_docs + + +def result(text, is_error=False): + payload = {"content": [{"type": "text", "text": text}]} + if is_error: + payload["isError"] = True + return SimpleNamespace(model_dump=lambda mode, by_alias: payload) + + +class FakeServer: + def __init__(self, tools=("search_docs", "read_doc"), search='{"results": [{"path": "quickstart"}]}', + doc="# Quickstart\nFixture.", search_error=False): + self.tools = [SimpleNamespace(name=n) for n in tools] + self.search, self.doc, self.search_error = search, doc, search_error + self.calls, self.entered, self.exited = [], 0, 0 + + async def __aenter__(self): + self.entered += 1 + return self + + async def __aexit__(self, *exc): + self.exited += 1 + + async def list_tools(self): + return self.tools + + async def call_tool(self, name, arguments): + self.calls.append((name, arguments)) + if name == "search_docs": + return result(self.search, self.search_error) + return result(self.doc) + + +class ReadDocsTests(unittest.IsolatedAsyncioTestCase): + async def test_search_then_read_same_path_returns_raw_markdown(self): + server = FakeServer() + out = await read_docs(server) + self.assertEqual(server.calls, [ + ("search_docs", {"query": "quickstart", "lang": "en", "limit": 1}), + ("read_doc", {"path": "quickstart", "lang": "en"}), + ]) + self.assertEqual(out, {"tools": ["read_doc", "search_docs"], "path": "quickstart", + "lang": "en", "text": "# Quickstart\nFixture."}) + self.assertEqual((server.entered, server.exited), (1, 1)) + + async def test_read_uses_path_from_search(self): + server = FakeServer(search='{"results": [{"path": "rpc/bsc"}]}') + out = await read_docs(server) + self.assertEqual(server.calls[1], ("read_doc", {"path": "rpc/bsc", "lang": "en"})) + self.assertEqual(out["path"], "rpc/bsc") + + async def test_unexpected_tool_list_stops_before_calls(self): + for tools in (("search_docs",), ("search_docs", "read_doc", "create_account")): + server = FakeServer(tools=tools) + with self.assertRaisesRegex(RuntimeError, "exactly the two"): + await read_docs(server) + self.assertEqual(server.calls, []) + self.assertEqual(server.exited, 1) + + async def test_empty_search_stops_before_read(self): + server = FakeServer(search='{"results": []}') + with self.assertRaisesRegex(RuntimeError, "no matching"): + await read_docs(server) + self.assertEqual([c[0] for c in server.calls], ["search_docs"]) + self.assertEqual(server.exited, 1) + + async def test_tool_error_stops_before_read(self): + server = FakeServer(search_error=True) + with self.assertRaisesRegex(RuntimeError, "tool error"): + await read_docs(server) + self.assertEqual([c[0] for c in server.calls], ["search_docs"]) + self.assertEqual(server.exited, 1) + + async def test_empty_document_is_not_success(self): + server = FakeServer(doc=" ") + with self.assertRaisesRegex(RuntimeError, "no documentation text"): + await read_docs(server) + self.assertEqual(server.exited, 1) + + +class ServerConfigTests(unittest.TestCase): + def test_server_arguments(self): + with mock.patch.object(example, "MCPServerStreamableHttp") as cls: + example.make_server() + kwargs = cls.call_args.kwargs + params = kwargs["params"] + self.assertEqual(params["url"], "https://docs.blockvectra.com/mcp/docs") + self.assertEqual(params["timeout"], 15) + self.assertNotIn("Authorization", params["headers"]) + self.assertTrue(params["headers"]["User-Agent"].startswith("BlockVectraOpenAIAgents/1")) + self.assertIs(params["httpx_client_factory"], example.http_client_factory) + self.assertEqual(kwargs["client_session_timeout_seconds"], 15) + self.assertEqual(kwargs["max_retry_attempts"], 0) + self.assertEqual(kwargs["max_list_pages"], 1) + + def test_client_factory_does_not_follow_redirects(self): + client = example.http_client_factory(headers={"User-Agent": "x"}, timeout=15, auth=None) + self.assertFalse(client.follow_redirects) + self.assertEqual(client.headers["User-Agent"], "x") + + +if __name__ == "__main__": + unittest.main()