From 3fd635ccb638651eff6b50dc1e887d41a0480c7d Mon Sep 17 00:00:00 2001 From: Danny Zhang Date: Sat, 16 May 2026 15:16:26 -0700 Subject: [PATCH 1/3] Add connector package prototype --- src/agents/__init__.py | 4 + src/agents/agent.py | 41 +- src/agents/connectors.py | 535 +++++++++++++++++++++++ tests/test_connectors.py | 191 ++++++++ tests/test_source_compat_constructors.py | 28 ++ 5 files changed, 795 insertions(+), 4 deletions(-) create mode 100644 src/agents/connectors.py create mode 100644 tests/test_connectors.py diff --git a/src/agents/__init__.py b/src/agents/__init__.py index ce2f8fbca8..e5a756d272 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -16,6 +16,7 @@ from .agent_output import AgentOutputSchema, AgentOutputSchemaBase from .apply_diff import apply_diff from .computer import AsyncComputer, Button, Computer, Environment +from .connectors import Connector, ConnectorComponents, ConnectorPolicyLabel from .editor import ApplyPatchEditor, ApplyPatchOperation, ApplyPatchResult from .exceptions import ( AgentsException, @@ -359,6 +360,9 @@ def enable_verbose_stdout_logging(): "AsyncComputer", "Environment", "Button", + "Connector", + "ConnectorComponents", + "ConnectorPolicyLabel", "AgentsException", "InputGuardrailTripwireTriggered", "OutputGuardrailTripwireTriggered", diff --git a/src/agents/agent.py b/src/agents/agent.py index 602d84066c..fd72c097f8 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -60,6 +60,7 @@ if TYPE_CHECKING: from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall + from .connectors import Connector from .items import ToolApprovalItem from .lifecycle import AgentHooks, RunHooks from .mcp import MCPServer @@ -199,10 +200,23 @@ class AgentBase(Generic[TContext]): mcp_config: MCPConfig = field(default_factory=lambda: MCPConfig()) """Configuration for MCP servers.""" + def _get_connector_tools(self) -> list[Tool]: + connector_tools: list[Tool] = [] + for connector in getattr(self, "connectors", ()): + connector_tools.extend(connector.tools) + return connector_tools + + def _get_connector_mcp_servers(self) -> list[MCPServer]: + connector_mcp_servers: list[MCPServer] = [] + for connector in getattr(self, "connectors", ()): + connector_mcp_servers.extend(connector.mcp_servers) + return connector_mcp_servers + async def _get_mcp_tool_reserved_names( self, run_context: RunContextWrapper[TContext] ) -> set[str]: - reserved_tool_names = {tool.name for tool in self.tools if isinstance(tool, FunctionTool)} + direct_tools = [*self.tools, *self._get_connector_tools()] + reserved_tool_names = {tool.name for tool in direct_tools if isinstance(tool, FunctionTool)} async def _check_handoff_enabled(handoff_obj: Handoff[Any, Any]) -> bool: attr = handoff_obj.is_enabled @@ -233,8 +247,9 @@ async def get_mcp_tools(self, run_context: RunContextWrapper[TContext]) -> list[ if include_server_in_tool_names else None ) + mcp_servers = [*self.mcp_servers, *self._get_connector_mcp_servers()] return await MCPUtil.get_all_function_tools( - self.mcp_servers, + mcp_servers, convert_schemas_to_strict, run_context, self, @@ -259,8 +274,9 @@ async def _check_tool_enabled(tool: Tool) -> bool: return bool(await res) return bool(res) - results = await asyncio.gather(*(_check_tool_enabled(t) for t in self.tools)) - enabled: list[Tool] = [t for t, ok in zip(self.tools, results, strict=False) if ok] + direct_tools = [*self.tools, *self._get_connector_tools()] + results = await asyncio.gather(*(_check_tool_enabled(t) for t in direct_tools)) + enabled: list[Tool] = [t for t, ok in zip(direct_tools, results, strict=False) if ok] all_tools: list[Tool] = prune_orphaned_tool_search_tools([*mcp_tools, *enabled]) _validate_codex_tool_name_collisions(all_tools) return all_tools @@ -368,6 +384,9 @@ class Agent(AgentBase, Generic[TContext]): """Whether to reset the tool choice to the default value after a tool has been called. Defaults to True. This ensures that the agent doesn't enter an infinite loop of tool usage.""" + connectors: list[Connector] = field(default_factory=list) + """Connector packages that provide reusable tools and MCP servers for this agent.""" + def __post_init__(self): from typing import get_origin @@ -484,6 +503,20 @@ def __post_init__(self): f"got {type(self.reset_tool_choice).__name__}" ) + if not isinstance(self.connectors, list): + raise TypeError( + f"Agent connectors must be a list, got {type(self.connectors).__name__}" + ) + if self.connectors: + from .connectors import Connector + + for connector in self.connectors: + if not isinstance(connector, Connector): + raise TypeError( + "Agent connectors must contain Connector instances, " + f"got {type(connector).__name__}" + ) + def clone(self, **kwargs: Any) -> Agent[TContext]: """Make a copy of the agent, with the given arguments changed. Notes: diff --git a/src/agents/connectors.py b/src/agents/connectors.py new file mode 100644 index 0000000000..f08209aaae --- /dev/null +++ b/src/agents/connectors.py @@ -0,0 +1,535 @@ +from __future__ import annotations + +import json +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal, cast + +from openai.types.responses.tool_param import Mcp + +from .exceptions import UserError +from .mcp import MCPServer, MCPServerSse, MCPServerStdio, MCPServerStreamableHttp +from .mcp.server import RequireApprovalSetting +from .tool import HostedMCPTool, MCPToolApprovalFunction, Tool + +ConnectorPolicyLabel = Literal[ + "read_only", + "write", + "destructive", + "external_send", + "network", + "secret_access", + "local_execution", + "sandbox_required", +] +"""Coarse policy labels callers can use to route connector approval and sandbox decisions.""" + + +HostedConnectorAuthorization = ( + str | Mapping[str, str] | Callable[[str, str, Mapping[str, Any]], str | None] +) +"""Authorization source for hosted connectors loaded from a package app manifest.""" + + +@dataclass(frozen=True) +class ConnectorComponents: + """Resolved runtime surfaces exposed by a connector package.""" + + tools: tuple[Tool, ...] = () + mcp_servers: tuple[MCPServer, ...] = () + metadata: Mapping[str, Any] = field(default_factory=dict) + policy_labels: tuple[ConnectorPolicyLabel, ...] = () + + +@dataclass +class Connector: + """A package-level connector surface for Agents SDK. + + Connectors intentionally compose existing SDK primitives instead of introducing a new runtime: + local and hosted tools continue to flow through `Tool`, while local MCP servers continue to flow + through `MCPServer`. + """ + + name: str + description: str | None = None + tools: list[Tool] = field(default_factory=list) + mcp_servers: list[MCPServer] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + policy_labels: set[ConnectorPolicyLabel] = field(default_factory=set) + + def __post_init__(self) -> None: + if not isinstance(self.name, str): + raise TypeError(f"Connector name must be a string, got {type(self.name).__name__}") + if self.description is not None and not isinstance(self.description, str): + raise TypeError( + "Connector description must be a string or None, " + f"got {type(self.description).__name__}" + ) + if not isinstance(self.tools, list): + raise TypeError(f"Connector tools must be a list, got {type(self.tools).__name__}") + if not isinstance(self.mcp_servers, list): + raise TypeError( + f"Connector mcp_servers must be a list, got {type(self.mcp_servers).__name__}" + ) + if not isinstance(self.metadata, dict): + raise TypeError( + f"Connector metadata must be a dict, got {type(self.metadata).__name__}" + ) + if not isinstance(self.policy_labels, set): + raise TypeError( + f"Connector policy_labels must be a set, got {type(self.policy_labels).__name__}" + ) + + def components(self) -> ConnectorComponents: + """Return immutable runtime surfaces for callers that want explicit composition.""" + return ConnectorComponents( + tools=tuple(self.tools), + mcp_servers=tuple(self.mcp_servers), + metadata=self.metadata, + policy_labels=tuple(sorted(self.policy_labels)), + ) + + @classmethod + def from_tools( + cls, + name: str, + tools: Iterable[Tool], + *, + description: str | None = None, + metadata: Mapping[str, Any] | None = None, + policy_labels: Iterable[ConnectorPolicyLabel] | None = None, + ) -> Connector: + """Create a connector from SDK tools.""" + return cls( + name=name, + description=description, + tools=list(tools), + metadata=dict(metadata or {}), + policy_labels=set(policy_labels or ()), + ) + + @classmethod + def from_mcp_server( + cls, + name: str, + server: MCPServer, + *, + description: str | None = None, + metadata: Mapping[str, Any] | None = None, + policy_labels: Iterable[ConnectorPolicyLabel] | None = None, + ) -> Connector: + """Create a connector from a local MCP server instance.""" + return cls.from_mcp_servers( + name, + [server], + description=description, + metadata=metadata, + policy_labels=policy_labels, + ) + + @classmethod + def from_mcp_servers( + cls, + name: str, + servers: Iterable[MCPServer], + *, + description: str | None = None, + metadata: Mapping[str, Any] | None = None, + policy_labels: Iterable[ConnectorPolicyLabel] | None = None, + ) -> Connector: + """Create a connector from local MCP server instances.""" + return cls( + name=name, + description=description, + mcp_servers=list(servers), + metadata=dict(metadata or {}), + policy_labels=set(policy_labels or ()), + ) + + @classmethod + def from_hosted_connector( + cls, + name: str, + *, + connector_id: str, + authorization: str, + server_label: str | None = None, + allowed_tools: list[str] | None = None, + require_approval: RequireApprovalSetting = None, + defer_loading: bool = False, + on_approval_request: MCPToolApprovalFunction | None = None, + tool_config: Mapping[str, Any] | None = None, + description: str | None = None, + metadata: Mapping[str, Any] | None = None, + policy_labels: Iterable[ConnectorPolicyLabel] | None = None, + ) -> Connector: + """Create a connector for an OpenAI-hosted connector exposed through hosted MCP.""" + config = _build_hosted_mcp_tool_config( + server_label=server_label or name, + connector_id=connector_id, + authorization=authorization, + allowed_tools=allowed_tools, + require_approval=require_approval, + defer_loading=defer_loading, + extra_config=tool_config, + ) + return cls.from_tools( + name, + [HostedMCPTool(tool_config=config, on_approval_request=on_approval_request)], + description=description, + metadata={ + **dict(metadata or {}), + "hosted_connector": { + "connector_id": connector_id, + "server_label": server_label or name, + }, + }, + policy_labels=set(policy_labels or ()) | {"network"}, + ) + + @classmethod + def from_hosted_mcp( + cls, + name: str, + *, + server_url: str, + server_label: str | None = None, + allowed_tools: list[str] | None = None, + require_approval: RequireApprovalSetting = None, + defer_loading: bool = False, + on_approval_request: MCPToolApprovalFunction | None = None, + tool_config: Mapping[str, Any] | None = None, + description: str | None = None, + metadata: Mapping[str, Any] | None = None, + policy_labels: Iterable[ConnectorPolicyLabel] | None = None, + ) -> Connector: + """Create a connector for a remote MCP server executed by the hosted Responses tool.""" + config = _build_hosted_mcp_tool_config( + server_label=server_label or name, + server_url=server_url, + allowed_tools=allowed_tools, + require_approval=require_approval, + defer_loading=defer_loading, + extra_config=tool_config, + ) + return cls.from_tools( + name, + [HostedMCPTool(tool_config=config, on_approval_request=on_approval_request)], + description=description, + metadata={ + **dict(metadata or {}), + "hosted_mcp": { + "server_url": server_url, + "server_label": server_label or name, + }, + }, + policy_labels=set(policy_labels or ()) | {"network"}, + ) + + @classmethod + def from_package( + cls, + path: str | Path, + *, + authorization: HostedConnectorAuthorization | None = None, + hosted_mcp_require_approval: RequireApprovalSetting = None, + ) -> Connector: + """Load a connector from a shared Codex plugin package layout. + + The initial package bridge supports `.codex-plugin/plugin.json`, `.mcp.json`, and optional + `.app.json` hosted connector IDs. App entries become hosted MCP tools only when an + authorization source is supplied. + """ + package_root = Path(path).expanduser().resolve() + manifest_path = package_root / ".codex-plugin" / "plugin.json" + if not manifest_path.exists(): + raise UserError(f"Connector package manifest not found: {manifest_path}") + + manifest = _read_json_object(manifest_path) + name = _expect_str(manifest.get("name"), "Connector package name") + description = _optional_str(manifest.get("description"), "Connector package description") + metadata: dict[str, Any] = { + key: value + for key, value in manifest.items() + if key + not in { + "description", + "mcpServers", + "mcp_servers", + "apps", + } + } + + mcp_servers, policy_labels = _load_manifest_mcp_servers(package_root, manifest) + tools = _load_manifest_app_tools( + package_root, + manifest, + authorization=authorization, + require_approval=hosted_mcp_require_approval, + ) + if tools: + policy_labels.add("network") + + return cls( + name=name, + description=description, + tools=tools, + mcp_servers=mcp_servers, + metadata=metadata, + policy_labels=policy_labels, + ) + + +def _build_hosted_mcp_tool_config( + *, + server_label: str, + server_url: str | None = None, + connector_id: str | None = None, + authorization: str | None = None, + allowed_tools: list[str] | None = None, + require_approval: RequireApprovalSetting = None, + defer_loading: bool = False, + extra_config: Mapping[str, Any] | None = None, +) -> Mcp: + config: dict[str, Any] = {"type": "mcp", "server_label": server_label} + if server_url is not None: + config["server_url"] = server_url + if connector_id is not None: + config["connector_id"] = connector_id + if authorization is not None: + config["authorization"] = authorization + if allowed_tools is not None: + config["allowed_tools"] = allowed_tools + if require_approval is not None: + config["require_approval"] = require_approval + if defer_loading: + config["defer_loading"] = True + if extra_config: + config.update(extra_config) + return cast(Mcp, config) + + +def _load_manifest_mcp_servers( + package_root: Path, manifest: Mapping[str, Any] +) -> tuple[list[MCPServer], set[ConnectorPolicyLabel]]: + mcp_manifest_value = manifest.get("mcpServers") or manifest.get("mcp_servers") + if mcp_manifest_value is None: + return [], set() + + mcp_manifest_path = _resolve_package_path(package_root, mcp_manifest_value, "mcpServers") + mcp_manifest = _read_json_object(mcp_manifest_path) + server_configs = mcp_manifest.get("mcpServers") or mcp_manifest.get("mcp_servers") + if not isinstance(server_configs, Mapping): + raise UserError(f"MCP manifest must contain an object of servers: {mcp_manifest_path}") + + servers: list[MCPServer] = [] + policy_labels: set[ConnectorPolicyLabel] = set() + for server_name, raw_config in server_configs.items(): + if not isinstance(server_name, str): + raise UserError("MCP server names must be strings") + if not isinstance(raw_config, Mapping): + raise UserError(f"MCP server config for {server_name!r} must be an object") + if raw_config.get("enabled") is False: + continue + server, server_policy_labels = _build_mcp_server(package_root, server_name, raw_config) + servers.append(server) + policy_labels.update(server_policy_labels) + + return servers, policy_labels + + +def _build_mcp_server( + package_root: Path, server_name: str, config: Mapping[str, Any] +) -> tuple[MCPServer, set[ConnectorPolicyLabel]]: + cache_tools_list = bool(config.get("cache_tools_list", False)) + client_session_timeout_seconds = _optional_float( + config.get("client_session_timeout_seconds"), "client_session_timeout_seconds" + ) + use_structured_content = bool(config.get("use_structured_content", False)) + max_retry_attempts = int(config.get("max_retry_attempts", 0)) + retry_backoff_seconds_base = float(config.get("retry_backoff_seconds_base", 1.0)) + require_approval = cast(RequireApprovalSetting, config.get("require_approval")) + + if "command" in config: + params: dict[str, Any] = {"command": _expect_str(config["command"], "MCP command")} + if "args" in config: + params["args"] = _expect_str_list(config["args"], "MCP args") + if "env" in config: + params["env"] = _expect_str_map(config["env"], "MCP env") + if "cwd" in config: + params["cwd"] = _resolve_package_path(package_root, config["cwd"], "MCP cwd") + for key in ("encoding", "encoding_error_handler"): + if key in config: + params[key] = _expect_str(config[key], f"MCP {key}") + return ( + MCPServerStdio( + cast(Any, params), + cache_tools_list=cache_tools_list, + name=server_name, + client_session_timeout_seconds=client_session_timeout_seconds, + use_structured_content=use_structured_content, + max_retry_attempts=max_retry_attempts, + retry_backoff_seconds_base=retry_backoff_seconds_base, + require_approval=require_approval, + ), + {"local_execution"}, + ) + + if "url" in config: + params = {"url": _expect_str(config["url"], "MCP url")} + for key in ("headers", "timeout", "sse_read_timeout"): + if key in config: + params[key] = config[key] + transport = str(config.get("transport") or config.get("type") or "streamable_http") + if transport == "sse": + return ( + MCPServerSse( + cast(Any, params), + cache_tools_list=cache_tools_list, + name=server_name, + client_session_timeout_seconds=client_session_timeout_seconds, + use_structured_content=use_structured_content, + max_retry_attempts=max_retry_attempts, + retry_backoff_seconds_base=retry_backoff_seconds_base, + require_approval=require_approval, + ), + {"network"}, + ) + return ( + MCPServerStreamableHttp( + cast(Any, params), + cache_tools_list=cache_tools_list, + name=server_name, + client_session_timeout_seconds=client_session_timeout_seconds, + use_structured_content=use_structured_content, + max_retry_attempts=max_retry_attempts, + retry_backoff_seconds_base=retry_backoff_seconds_base, + require_approval=require_approval, + ), + {"network"}, + ) + + raise UserError(f"MCP server config for {server_name!r} must include either 'command' or 'url'") + + +def _load_manifest_app_tools( + package_root: Path, + manifest: Mapping[str, Any], + *, + authorization: HostedConnectorAuthorization | None, + require_approval: RequireApprovalSetting, +) -> list[Tool]: + apps_manifest_value = manifest.get("apps") + if apps_manifest_value is None: + return [] + + app_manifest_path = _resolve_package_path(package_root, apps_manifest_value, "apps") + app_manifest = _read_json_object(app_manifest_path) + apps = app_manifest.get("apps") + if not isinstance(apps, Mapping): + raise UserError(f"App manifest must contain an 'apps' object: {app_manifest_path}") + + tools: list[Tool] = [] + for app_name, raw_config in apps.items(): + if not isinstance(app_name, str): + raise UserError("App names must be strings") + if not isinstance(raw_config, Mapping): + raise UserError(f"App config for {app_name!r} must be an object") + connector_id = _expect_str(raw_config.get("id"), f"App id for {app_name!r}") + resolved_authorization = _resolve_authorization( + authorization, app_name, connector_id, raw_config + ) + if resolved_authorization is None: + continue + connector = Connector.from_hosted_connector( + app_name, + connector_id=connector_id, + authorization=resolved_authorization, + server_label=app_name, + require_approval=require_approval, + ) + tools.extend(connector.tools) + + return tools + + +def _resolve_authorization( + authorization: HostedConnectorAuthorization | None, + app_name: str, + connector_id: str, + app_config: Mapping[str, Any], +) -> str | None: + if authorization is None: + return None + if isinstance(authorization, str): + return authorization + if isinstance(authorization, Mapping): + return authorization.get(app_name) or authorization.get(connector_id) + return authorization(app_name, connector_id, app_config) + + +def _read_json_object(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text()) + except OSError as exc: + raise UserError(f"Unable to read connector package file: {path}") from exc + except json.JSONDecodeError as exc: + raise UserError(f"Invalid connector package JSON: {path}") from exc + if not isinstance(value, dict): + raise UserError(f"Connector package JSON must be an object: {path}") + return value + + +def _resolve_package_path(package_root: Path, value: Any, field_name: str) -> Path: + path_value = _expect_str(value, f"{field_name} path") + path = Path(path_value) + if path.is_absolute(): + candidate = path.resolve() + else: + candidate = (package_root / path).resolve() + if not _is_relative_to(candidate, package_root): + raise UserError(f"{field_name} path must stay inside the connector package: {value}") + return candidate + + +def _is_relative_to(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + except ValueError: + return False + return True + + +def _expect_str(value: Any, field_name: str) -> str: + if not isinstance(value, str) or not value: + raise UserError(f"{field_name} must be a non-empty string") + return value + + +def _optional_str(value: Any, field_name: str) -> str | None: + if value is None: + return None + return _expect_str(value, field_name) + + +def _expect_str_list(value: Any, field_name: str) -> list[str]: + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise UserError(f"{field_name} must be a list of strings") + return value + + +def _expect_str_map(value: Any, field_name: str) -> dict[str, str]: + if not isinstance(value, Mapping) or not all( + isinstance(key, str) and isinstance(map_value, str) for key, map_value in value.items() + ): + raise UserError(f"{field_name} must be an object of string values") + return dict(value) + + +def _optional_float(value: Any, field_name: str) -> float | None: + if value is None: + return None + if not isinstance(value, int | float): + raise UserError(f"{field_name} must be a number") + return float(value) diff --git a/tests/test_connectors.py b/tests/test_connectors.py new file mode 100644 index 0000000000..1fb9dc8f00 --- /dev/null +++ b/tests/test_connectors.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import json +from typing import Any, cast + +import pytest + +from agents import ( + Agent, + Connector, + HostedMCPTool, + RunContextWrapper, + ToolSearchTool, + UserError, + function_tool, +) +from agents.mcp import MCPServerStdio, MCPServerStreamableHttp +from tests.mcp.helpers import FakeMCPServer + + +@pytest.mark.asyncio +async def test_agent_get_all_tools_includes_connector_tools() -> None: + @function_tool + def direct_lookup() -> str: + return "direct" + + @function_tool + def connector_lookup() -> str: + return "connector" + + connector = Connector.from_tools("crm", [connector_lookup]) + agent = Agent(name="assistant", tools=[direct_lookup], connectors=[connector]) + + tools = await agent.get_all_tools(RunContextWrapper(context=None)) + + assert tools == [direct_lookup, connector_lookup] + assert agent.tools == [direct_lookup] + + +@pytest.mark.asyncio +async def test_connector_hosted_connector_can_defer_loading_with_tool_search() -> None: + connector = Connector.from_hosted_connector( + "slack", + connector_id="asdk_app_123", + authorization="conn_456", + server_label="slack", + defer_loading=True, + ) + agent = Agent(name="assistant", tools=[ToolSearchTool()], connectors=[connector]) + + tools = await agent.get_all_tools(RunContextWrapper(context=None)) + + hosted_tool = next(tool for tool in tools if isinstance(tool, HostedMCPTool)) + assert isinstance(hosted_tool, HostedMCPTool) + hosted_tool_config = cast(dict[str, Any], hosted_tool.tool_config) + assert hosted_tool_config["type"] == "mcp" + assert hosted_tool_config["server_label"] == "slack" + assert hosted_tool_config["connector_id"] == "asdk_app_123" + assert hosted_tool_config["authorization"] == "conn_456" + assert hosted_tool_config["defer_loading"] is True + assert any(isinstance(tool, ToolSearchTool) for tool in tools) + + +@pytest.mark.asyncio +async def test_connector_mcp_servers_are_used_by_agent_mcp_tools() -> None: + server = FakeMCPServer(server_name="calendar") + server.add_tool("search", {}) + connector = Connector.from_mcp_server("calendar", server) + agent = Agent( + name="assistant", + connectors=[connector], + mcp_config={"include_server_in_tool_names": True}, + ) + + tools = await agent.get_mcp_tools(RunContextWrapper(context=None)) + + assert len(tools) == 1 + assert tools[0].name == "mcp_calendar__search" + + +@pytest.mark.asyncio +async def test_connector_tools_reserve_mcp_tool_names_when_prefixing() -> None: + @function_tool(name_override="mcp_calendar__search") + def connector_lookup() -> str: + return "connector" + + server = FakeMCPServer(server_name="calendar") + server.add_tool("search", {}) + connector = Connector.from_tools("crm", [connector_lookup]) + agent = Agent( + name="assistant", + mcp_servers=[server], + connectors=[connector], + mcp_config={"include_server_in_tool_names": True}, + ) + + tools = await agent.get_mcp_tools(RunContextWrapper(context=None)) + + assert len(tools) == 1 + assert tools[0].name != "mcp_calendar__search" + assert tools[0].name.startswith("mcp_calendar__search_") + + +def test_connector_from_package_loads_codex_plugin_mcp_servers(tmp_path) -> None: + plugin_dir = tmp_path / "computer-use" + plugin_config_dir = plugin_dir / ".codex-plugin" + plugin_config_dir.mkdir(parents=True) + (plugin_config_dir / "plugin.json").write_text( + json.dumps( + { + "name": "computer-use", + "version": "1.2.3", + "description": "Control desktop apps.", + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "Computer Use", + "capabilities": ["Interactive", "Write"], + }, + } + ) + ) + (plugin_dir / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "computer-use": { + "command": "./ComputerUse", + "args": ["mcp"], + "cwd": ".", + }, + "docs": { + "url": "https://example.com/mcp", + "headers": {"Authorization": "Bearer token"}, + }, + } + } + ) + ) + + connector = Connector.from_package(plugin_dir) + + assert connector.name == "computer-use" + assert connector.description == "Control desktop apps." + assert connector.metadata["version"] == "1.2.3" + assert connector.metadata["interface"]["displayName"] == "Computer Use" + assert connector.policy_labels == {"local_execution", "network"} + assert len(connector.mcp_servers) == 2 + + stdio_server = connector.mcp_servers[0] + assert isinstance(stdio_server, MCPServerStdio) + assert stdio_server.name == "computer-use" + assert stdio_server.params.command == "./ComputerUse" + assert stdio_server.params.args == ["mcp"] + assert str(stdio_server.params.cwd) == str(plugin_dir) + + http_server = connector.mcp_servers[1] + assert isinstance(http_server, MCPServerStreamableHttp) + assert http_server.name == "docs" + assert http_server.params["url"] == "https://example.com/mcp" + assert http_server.params["headers"] == {"Authorization": "Bearer token"} + + +def test_connector_from_package_rejects_paths_outside_package(tmp_path) -> None: + plugin_dir = tmp_path / "bad-plugin" + plugin_config_dir = plugin_dir / ".codex-plugin" + plugin_config_dir.mkdir(parents=True) + (plugin_config_dir / "plugin.json").write_text( + json.dumps({"name": "bad-plugin", "mcpServers": "../outside.json"}) + ) + + with pytest.raises(UserError, match="must stay inside the connector package"): + Connector.from_package(plugin_dir) + + +def test_connector_from_hosted_connector_accepts_extra_tool_config() -> None: + connector = Connector.from_hosted_connector( + "github", + connector_id="asdk_app_789", + authorization="conn_012", + server_label="github", + allowed_tools=["search_issues"], + require_approval="always", + tool_config=cast(dict[str, Any], {"custom": "value"}), + ) + + tool = connector.tools[0] + assert isinstance(tool, HostedMCPTool) + tool_config = cast(dict[str, Any], tool.tool_config) + assert tool_config["allowed_tools"] == ["search_issues"] + assert tool_config["require_approval"] == "always" + assert tool_config["custom"] == "value" diff --git a/tests/test_source_compat_constructors.py b/tests/test_source_compat_constructors.py index 8b276613df..8eb26c1302 100644 --- a/tests/test_source_compat_constructors.py +++ b/tests/test_source_compat_constructors.py @@ -197,6 +197,34 @@ def allow_output(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput: assert tool.timeout_error_function is None +def test_agent_connectors_append_preserves_reset_tool_choice_position() -> None: + model_settings = ModelSettings() + agent = Agent( + "agent", + None, + [], + [], + {}, + "instructions", + None, + [], + None, + model_settings, + [], + [], + None, + None, + "stop_on_first_tool", + False, + ) + + assert agent.instructions == "instructions" + assert agent.model_settings is model_settings + assert agent.tool_use_behavior == "stop_on_first_tool" + assert agent.reset_tool_choice is False + assert agent.connectors == [] + + def test_agent_hook_context_third_positional_argument_is_turn_input() -> None: turn_input = ItemHelpers.input_to_new_input_list("hello") context = AgentHookContext(None, Usage(), turn_input) From 87d1e3c6c801321439814143eeec32c4e3cc6415 Mon Sep 17 00:00:00 2001 From: Danny Zhang Date: Sat, 16 May 2026 15:31:41 -0700 Subject: [PATCH 2/3] Add connector package demo --- examples/connectors/__init__.py | 1 + examples/connectors/package_demo.py | 230 ++++++++++++++++++++++++++++ tests/test_connector_demo.py | 16 ++ 3 files changed, 247 insertions(+) create mode 100644 examples/connectors/__init__.py create mode 100644 examples/connectors/package_demo.py create mode 100644 tests/test_connector_demo.py diff --git a/examples/connectors/__init__.py b/examples/connectors/__init__.py new file mode 100644 index 0000000000..e96496883f --- /dev/null +++ b/examples/connectors/__init__.py @@ -0,0 +1 @@ +"""Connector examples.""" diff --git a/examples/connectors/package_demo.py b/examples/connectors/package_demo.py new file mode 100644 index 0000000000..416ac44f37 --- /dev/null +++ b/examples/connectors/package_demo.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any + +from agents import Agent, Connector, FunctionTool, HostedMCPTool, RunContextWrapper, function_tool +from agents.mcp import MCPServerManager +from agents.tool_context import ToolContext + + +@function_tool +def apply_discount(amount: float, percentage: float) -> str: + """Calculate a discount amount.""" + return f"discount={amount * percentage / 100:.2f}" + + +def build_sdk_tool_connector() -> Connector: + return Connector.from_tools( + "pricing", + [apply_discount], + description="Pricing tools implemented directly in Python.", + policy_labels={"read_only"}, + ) + + +def build_hosted_connector() -> Connector: + return Connector.from_hosted_connector( + "calendar", + connector_id="connector_googlecalendar", + authorization="demo_access_token", + server_label="calendar", + require_approval="never", + description="Hosted Google Calendar connector shape.", + ) + + +def write_demo_plugin_package(package_root: Path) -> Path: + plugin_dir = package_root / "orders-plugin" + plugin_config_dir = plugin_dir / ".codex-plugin" + plugin_config_dir.mkdir(parents=True) + + (plugin_config_dir / "plugin.json").write_text( + json.dumps( + { + "name": "orders", + "version": "0.1.0", + "description": "Order lookup tools packaged like a shared plugin.", + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "Orders", + "capabilities": ["Read"], + }, + }, + indent=2, + ) + ) + (plugin_dir / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "orders": { + "command": sys.executable, + "args": ["demo_mcp_server.py"], + "cwd": ".", + } + } + }, + indent=2, + ) + ) + (plugin_dir / "demo_mcp_server.py").write_text( + "\n".join( + [ + "from mcp.server.fastmcp import FastMCP", + "", + "mcp = FastMCP('Orders connector demo')", + "", + "@mcp.tool()", + "def lookup_order(order_id: str) -> str:", + " return f'order {order_id}: fulfilled'", + "", + "if __name__ == '__main__':", + " mcp.run(transport='stdio')", + "", + ] + ) + ) + return plugin_dir + + +def build_package_connector(package_root: Path) -> Connector: + return Connector.from_package(package_root) + + +async def verify_connector_demo() -> dict[str, Any]: + sdk_connector = build_sdk_tool_connector() + hosted_connector = build_hosted_connector() + + with TemporaryDirectory(prefix="agents-connectors-demo-") as temp_dir: + package_dir = write_demo_plugin_package(Path(temp_dir)) + package_connector = build_package_connector(package_dir) + + async with MCPServerManager( + package_connector.mcp_servers, + strict=True, + connect_in_parallel=True, + ): + agent = Agent( + name="Connector demo agent", + instructions="Use the mounted connector tools when useful.", + connectors=[ + sdk_connector, + package_connector, + ], + mcp_config={"include_server_in_tool_names": True}, + ) + tools = await agent.get_all_tools(RunContextWrapper(context=None)) + tool_names = [tool.name for tool in tools] + + direct_tool = _find_function_tool(tools, "apply_discount") + mcp_tool = _find_function_tool(tools, "mcp_orders__lookup_order") + + direct_tool_result = await direct_tool.on_invoke_tool( + _tool_context("apply_discount", '{"amount":100,"percentage":25}'), + '{"amount":100,"percentage":25}', + ) + mcp_tool_result = _tool_result_text( + await mcp_tool.on_invoke_tool( + _tool_context("mcp_orders__lookup_order", '{"order_id":"demo_order_1001"}'), + '{"order_id":"demo_order_1001"}', + ) + ) + + hosted_tool = _find_hosted_mcp_tool(hosted_connector.tools) + + return { + "agent_tool_names": tool_names, + "direct_tool_result": direct_tool_result, + "mcp_tool_result": mcp_tool_result, + "package_connector_name": package_connector.name, + "package_policy_labels": sorted(package_connector.policy_labels), + "hosted_connector_label": hosted_tool.tool_config["server_label"], + "hosted_connector_id": hosted_tool.tool_config["connector_id"], + } + + +def _find_function_tool(tools: list[Any], name: str) -> FunctionTool: + for tool in tools: + if isinstance(tool, FunctionTool) and tool.name == name: + return tool + raise RuntimeError(f"Expected function tool not found: {name}") + + +def _find_hosted_mcp_tool(tools: list[Any]) -> HostedMCPTool: + for tool in tools: + if isinstance(tool, HostedMCPTool): + return tool + raise RuntimeError("Expected hosted MCP tool not found.") + + +def _tool_result_text(result: Any) -> str: + if isinstance(result, str): + return result + if isinstance(result, dict): + text = result.get("text") + if isinstance(text, str): + return text + return json.dumps(result) + + +def _tool_context(tool_name: str, tool_arguments: str) -> ToolContext[Any]: + return ToolContext( + context=None, + tool_name=tool_name, + tool_call_id=f"call_{tool_name}", + tool_arguments=tool_arguments, + ) + + +def print_summary(summary: dict[str, Any]) -> None: + print("Connector package demo") + print("======================") + print(f"Agent tools: {', '.join(summary['agent_tool_names'])}") + print(f"Direct tool output: {summary['direct_tool_result']}") + print(f"MCP tool output: {summary['mcp_tool_result']}") + print( + "Package connector: " + f"{summary['package_connector_name']} " + f"({', '.join(summary['package_policy_labels'])})" + ) + print( + "Hosted connector config: " + f"{summary['hosted_connector_label']} -> {summary['hosted_connector_id']}" + ) + + +async def main(*, verify: bool) -> None: + summary = await verify_connector_demo() + print_summary(summary) + if verify: + expected = { + "direct_tool_result": "discount=25.00", + "mcp_tool_result": "order demo_order_1001: fulfilled", + "hosted_connector_label": "calendar", + } + mismatches = { + key: (summary.get(key), expected_value) + for key, expected_value in expected.items() + if summary.get(key) != expected_value + } + if mismatches: + raise RuntimeError(f"Connector demo verification failed: {mismatches}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Demonstrate Agents SDK connector package composition." + ) + parser.add_argument( + "--verify", + action="store_true", + help="Run deterministic checks after printing the demo summary.", + ) + args = parser.parse_args() + asyncio.run(main(verify=args.verify)) diff --git a/tests/test_connector_demo.py b/tests/test_connector_demo.py new file mode 100644 index 0000000000..274f2a6f77 --- /dev/null +++ b/tests/test_connector_demo.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import pytest + + +@pytest.mark.asyncio +async def test_connector_package_demo_verifies_end_to_end() -> None: + from examples.connectors import package_demo + + summary = await package_demo.verify_connector_demo() + + assert summary["direct_tool_result"] == "discount=25.00" + assert summary["mcp_tool_result"] == "order demo_order_1001: fulfilled" + assert summary["hosted_connector_label"] == "calendar" + assert "apply_discount" in summary["agent_tool_names"] + assert "mcp_orders__lookup_order" in summary["agent_tool_names"] From eb6aa9524114be2e33a1fc67112177b31da67303 Mon Sep 17 00:00:00 2001 From: Danny Zhang Date: Sun, 17 May 2026 08:56:25 -0700 Subject: [PATCH 3/3] Harden connector package prototype --- docs/connectors.md | 125 +++++++++++++++++++++++++++++++++++++++ docs/examples.md | 5 ++ docs/ref/connectors.md | 3 + mkdocs.yml | 2 + src/agents/__init__.py | 8 ++- tests/test_connectors.py | 89 ++++++++++++++++++++++++++++ 6 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 docs/connectors.md create mode 100644 docs/ref/connectors.md diff --git a/docs/connectors.md b/docs/connectors.md new file mode 100644 index 0000000000..d7efefd7a0 --- /dev/null +++ b/docs/connectors.md @@ -0,0 +1,125 @@ +# Connectors + +Connectors package reusable tool surfaces for an [`Agent`][agents.Agent]. They do not add a +separate runtime. A connector resolves to existing SDK primitives: + +- [`Tool`][agents.tool.Tool] instances, including function tools and hosted tools. +- Local MCP servers that the SDK already knows how to expose as function tools. +- Metadata and coarse policy labels that callers can use for approval, routing, or sandbox choices. + +Use connectors when you want to mount a named bundle of tools or package-provided MCP servers on +one or more agents without manually copying every tool and server into each agent definition. + +## SDK tool connectors + +Use [`Connector.from_tools()`][agents.connectors.Connector.from_tools] when your integration is +implemented directly in Python. + +```python +from agents import Agent, Connector, function_tool + + +@function_tool +def apply_discount(amount: float, percentage: float) -> str: + return f"discount={amount * percentage / 100:.2f}" + + +pricing = Connector.from_tools( + "pricing", + [apply_discount], + description="Pricing helpers implemented directly in Python.", + policy_labels={"read_only"}, +) + +agent = Agent( + name="Assistant", + instructions="Use pricing tools when needed.", + connectors=[pricing], +) +``` + +Connector tools are combined with the agent's normal `tools` list when the SDK prepares available +tools for a run. + +## Package connectors + +Use [`Connector.from_package()`][agents.connectors.Connector.from_package] to load a connector from a +shared package layout. The initial package bridge supports: + +- `.codex-plugin/plugin.json` as the package manifest. +- `.mcp.json` for local or remote MCP server definitions referenced by `mcpServers`. +- Optional `.app.json` entries referenced by `apps` for hosted connector IDs. + +For packages with local MCP servers, connect the servers before running the agent. The connector +still mounts through `Agent(connectors=[...])`; do not add the same MCP servers again through +`Agent(mcp_servers=[...])`. + +```python +from agents import Agent, Connector +from agents.mcp import MCPServerManager + + +orders = Connector.from_package("./orders-plugin") + +async with MCPServerManager(orders.mcp_servers, strict=True): + agent = Agent( + name="Assistant", + instructions="Use order tools when needed.", + connectors=[orders], + mcp_config={"include_server_in_tool_names": True}, + ) +``` + +If a package declares hosted app connectors, pass an authorization source to +[`Connector.from_package()`][agents.connectors.Connector.from_package]. The authorization can be one +token string, a mapping keyed by app name or connector ID, or a callback. + +```python +calendar = Connector.from_package( + "./workspace-plugin", + authorization={"calendar": "conn_calendar_access_token"}, + hosted_mcp_require_approval="always", +) +``` + +## Hosted connectors + +Use [`Connector.from_hosted_connector()`][agents.connectors.Connector.from_hosted_connector] when +you already know the hosted connector ID and want the Responses API hosted MCP integration to call +it. + +```python +import os + +from agents import Agent, Connector + + +calendar = Connector.from_hosted_connector( + "calendar", + connector_id="connector_googlecalendar", + authorization=os.environ["GOOGLE_CALENDAR_AUTHORIZATION"], + server_label="google_calendar", + require_approval="never", +) + +agent = Agent( + name="Assistant", + instructions="Use calendar tools when needed.", + connectors=[calendar], +) +``` + +Hosted connector tools are represented as [`HostedMCPTool`][agents.tool.HostedMCPTool] instances. +They are sent to the Responses API like other hosted tools. + +## End-to-end demo + +See `examples/connectors/package_demo.py` for a deterministic demo that needs no API key. It builds +a direct Python tool connector, creates a temporary plugin-style MCP package, mounts both on an +agent, invokes the discovered tools, and inspects a hosted connector config. + +Run it with: + +```bash +uv run --frozen python examples/connectors/package_demo.py --verify +``` diff --git a/docs/examples.md b/docs/examples.md index 9fda81c382..698314d857 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -42,6 +42,11 @@ Check out a variety of sample implementations of the SDK in the examples section - Non-strict output types - Previous response ID usage +- **[connectors](https://github.com/openai/openai-agents-python/tree/main/examples/connectors):** + Examples for packaging reusable connector surfaces, including: + + - End-to-end connector package composition without an API key (`examples/connectors/package_demo.py`) + - **[customer_service](https://github.com/openai/openai-agents-python/tree/main/examples/customer_service):** Example customer service system for an airline. diff --git a/docs/ref/connectors.md b/docs/ref/connectors.md new file mode 100644 index 0000000000..f4a417602b --- /dev/null +++ b/docs/ref/connectors.md @@ -0,0 +1,3 @@ +# `Connectors` + +::: agents.connectors diff --git a/mkdocs.yml b/mkdocs.yml index c38e747653..65df084824 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -61,6 +61,7 @@ plugins: - Agent memory: sandbox/memory.md - Models: models/index.md - Tools: tools.md + - Connectors: connectors.md - Guardrails: guardrails.md - Running agents: running_agents.md - Streaming: streaming.md @@ -121,6 +122,7 @@ plugins: - Run error handlers: ref/run_error_handlers.md - Memory: ref/memory.md - REPL: ref/repl.md + - Connectors: ref/connectors.md - Tools: ref/tool.md - Tool context: ref/tool_context.md - Results: ref/result.md diff --git a/src/agents/__init__.py b/src/agents/__init__.py index e5a756d272..6f17f26afc 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -16,7 +16,12 @@ from .agent_output import AgentOutputSchema, AgentOutputSchemaBase from .apply_diff import apply_diff from .computer import AsyncComputer, Button, Computer, Environment -from .connectors import Connector, ConnectorComponents, ConnectorPolicyLabel +from .connectors import ( + Connector, + ConnectorComponents, + ConnectorPolicyLabel, + HostedConnectorAuthorization, +) from .editor import ApplyPatchEditor, ApplyPatchOperation, ApplyPatchResult from .exceptions import ( AgentsException, @@ -363,6 +368,7 @@ def enable_verbose_stdout_logging(): "Connector", "ConnectorComponents", "ConnectorPolicyLabel", + "HostedConnectorAuthorization", "AgentsException", "InputGuardrailTripwireTriggered", "OutputGuardrailTripwireTriggered", diff --git a/tests/test_connectors.py b/tests/test_connectors.py index 1fb9dc8f00..7f825d4527 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -8,6 +8,7 @@ from agents import ( Agent, Connector, + HostedConnectorAuthorization, HostedMCPTool, RunContextWrapper, ToolSearchTool, @@ -18,6 +19,12 @@ from tests.mcp.helpers import FakeMCPServer +def test_hosted_connector_authorization_is_exported() -> None: + authorization: HostedConnectorAuthorization = "conn_123" + + assert authorization == "conn_123" + + @pytest.mark.asyncio async def test_agent_get_all_tools_includes_connector_tools() -> None: @function_tool @@ -172,6 +179,88 @@ def test_connector_from_package_rejects_paths_outside_package(tmp_path) -> None: Connector.from_package(plugin_dir) +def test_connector_from_package_loads_app_manifest_with_authorization_mapping(tmp_path) -> None: + plugin_dir = tmp_path / "workspace" + plugin_config_dir = plugin_dir / ".codex-plugin" + plugin_config_dir.mkdir(parents=True) + (plugin_config_dir / "plugin.json").write_text( + json.dumps( + { + "name": "workspace", + "description": "Workspace connectors.", + "apps": "./.app.json", + } + ) + ) + (plugin_dir / ".app.json").write_text( + json.dumps( + { + "apps": { + "calendar": { + "id": "connector_googlecalendar", + } + } + } + ) + ) + + connector = Connector.from_package( + plugin_dir, + authorization={"calendar": "conn_calendar"}, + hosted_mcp_require_approval="always", + ) + + assert connector.policy_labels == {"network"} + assert len(connector.tools) == 1 + tool = connector.tools[0] + assert isinstance(tool, HostedMCPTool) + tool_config = cast(dict[str, Any], tool.tool_config) + assert tool_config["type"] == "mcp" + assert tool_config["server_label"] == "calendar" + assert tool_config["connector_id"] == "connector_googlecalendar" + assert tool_config["authorization"] == "conn_calendar" + assert tool_config["require_approval"] == "always" + + +def test_connector_from_package_skips_app_manifest_without_authorization(tmp_path) -> None: + plugin_dir = tmp_path / "workspace" + plugin_config_dir = plugin_dir / ".codex-plugin" + plugin_config_dir.mkdir(parents=True) + (plugin_config_dir / "plugin.json").write_text( + json.dumps( + { + "name": "workspace", + "description": "Workspace connectors.", + "apps": "./.app.json", + } + ) + ) + (plugin_dir / ".app.json").write_text( + json.dumps( + { + "apps": { + "calendar": { + "id": "connector_googlecalendar", + } + } + } + ) + ) + + connector = Connector.from_package(plugin_dir) + + assert connector.tools == [] + assert connector.policy_labels == set() + + +def test_agent_rejects_invalid_connectors() -> None: + with pytest.raises(TypeError, match="Agent connectors must be a list"): + Agent(name="assistant", connectors=cast(Any, ())) + + with pytest.raises(TypeError, match="Agent connectors must contain Connector instances"): + Agent(name="assistant", connectors=cast(Any, [object()])) + + def test_connector_from_hosted_connector_accepts_extra_tool_config() -> None: connector = Connector.from_hosted_connector( "github",