From 6420bb32f6a013677ebf579b483a050b3c9514b2 Mon Sep 17 00:00:00 2001 From: Danny Zhang Date: Sat, 16 May 2026 15:16:26 -0700 Subject: [PATCH 1/5] 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 12c8ca34f69d4416a6b2aa45da5ce9a816b0dfa0 Mon Sep 17 00:00:00 2001 From: Danny Zhang Date: Sat, 16 May 2026 15:31:41 -0700 Subject: [PATCH 2/5] 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 ff07f6ee963d3cfda56dcd2bd312affbfab03706 Mon Sep 17 00:00:00 2001 From: Danny Zhang Date: Sun, 17 May 2026 08:56:25 -0700 Subject: [PATCH 3/5] 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", From 67694d2e39bd1a56072d238daafa6df627afd3f7 Mon Sep 17 00:00:00 2001 From: Danny Zhang Date: Mon, 18 May 2026 19:13:10 -0700 Subject: [PATCH 4/5] Add connector registry for installed plugins --- docs/connectors.md | 57 +++++- docs/examples.md | 2 +- examples/connectors/package_demo.py | 52 ++++- src/agents/__init__.py | 4 + src/agents/connectors.py | 289 +++++++++++++++++++++++++++- tests/test_connector_demo.py | 1 + tests/test_connectors.py | 111 +++++++++++ 7 files changed, 495 insertions(+), 21 deletions(-) diff --git a/docs/connectors.md b/docs/connectors.md index d7efefd7a0..b2aa339dc5 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -10,6 +10,58 @@ separate runtime. A connector resolves to existing SDK primitives: 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. +## Installed plugin registries + +Use [`ConnectorRegistry`][agents.connectors.ConnectorRegistry] when your application already has +installed plugin records from a Unified Plugins-style directory, marketplace, or workspace plugin +service. The SDK does not fetch those records itself; the registry is an adapter over records that +Codex, ChatGPT, your control plane, or tests have already provided. + +A registry record can point at a mounted local package, hosted app connector IDs, or both. Loading +the record still produces a normal [`Connector`][agents.connectors.Connector]. + +```python +from agents import Agent, Connector, ConnectorRegistry + + +registry = ConnectorRegistry.from_plugin_records( + [ + { + "id": "plugin_orders", + "name": "orders", + "package_path": "./orders-plugin", + }, + { + "id": "plugin_calendar", + "name": "calendar", + "apps": { + "calendar": { + "id": "connector_googlecalendar", + } + }, + }, + ] +) + +orders = Connector.from_installed_plugin("plugin_orders", registry) +calendar = Connector.from_installed_plugin( + "plugin_calendar", + registry, + authorization={"calendar": "conn_calendar_access_token"}, + hosted_mcp_require_approval="always", +) + +agent = Agent( + name="Assistant", + instructions="Use installed connector plugins when needed.", + connectors=[orders, calendar], +) +``` + +This keeps package discovery, marketplace installation, workspace sharing, admin policy, and cloud +sync outside the SDK runtime while giving those systems a stable place to hand installed plugin +records to the SDK. + ## SDK tool connectors Use [`Connector.from_tools()`][agents.connectors.Connector.from_tools] when your integration is @@ -115,8 +167,9 @@ 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. +a direct Python tool connector, creates a temporary plugin-style MCP package, loads that package +through a `ConnectorRegistry` record, mounts the resolved connector on an agent, invokes the +discovered tools, and inspects a hosted connector config loaded from a registry record. Run it with: diff --git a/docs/examples.md b/docs/examples.md index 698314d857..191ac7c9d6 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -45,7 +45,7 @@ Check out a variety of sample implementations of the SDK in the examples section - **[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`) + - End-to-end connector registry and 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/examples/connectors/package_demo.py b/examples/connectors/package_demo.py index 416ac44f37..2ae1ae04ba 100644 --- a/examples/connectors/package_demo.py +++ b/examples/connectors/package_demo.py @@ -8,7 +8,15 @@ from tempfile import TemporaryDirectory from typing import Any -from agents import Agent, Connector, FunctionTool, HostedMCPTool, RunContextWrapper, function_tool +from agents import ( + Agent, + Connector, + ConnectorRegistry, + FunctionTool, + HostedMCPTool, + RunContextWrapper, + function_tool, +) from agents.mcp import MCPServerManager from agents.tool_context import ToolContext @@ -29,13 +37,25 @@ def build_sdk_tool_connector() -> Connector: 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.", + registry = ConnectorRegistry.from_plugin_records( + [ + { + "id": "plugin_calendar", + "name": "calendar", + "description": "Hosted Google Calendar connector shape.", + "apps": { + "calendar": { + "id": "connector_googlecalendar", + } + }, + } + ] + ) + return Connector.from_installed_plugin( + "plugin_calendar", + registry, + authorization={"calendar": "demo_access_token"}, + hosted_mcp_require_approval="never", ) @@ -94,7 +114,17 @@ def write_demo_plugin_package(package_root: Path) -> Path: def build_package_connector(package_root: Path) -> Connector: - return Connector.from_package(package_root) + registry = ConnectorRegistry.from_plugin_records( + [ + { + "id": "plugin_orders", + "name": "orders", + "package_path": str(package_root), + "source": "unified_plugins_demo", + } + ] + ) + return Connector.from_installed_plugin("plugin_orders", registry) async def verify_connector_demo() -> dict[str, Any]: @@ -144,6 +174,7 @@ async def verify_connector_demo() -> dict[str, Any]: "mcp_tool_result": mcp_tool_result, "package_connector_name": package_connector.name, "package_policy_labels": sorted(package_connector.policy_labels), + "package_registry_source": package_connector.metadata["unified_plugin"]["source"], "hosted_connector_label": hosted_tool.tool_config["server_label"], "hosted_connector_id": hosted_tool.tool_config["connector_id"], } @@ -191,7 +222,8 @@ def print_summary(summary: dict[str, Any]) -> None: print( "Package connector: " f"{summary['package_connector_name']} " - f"({', '.join(summary['package_policy_labels'])})" + f"({', '.join(summary['package_policy_labels'])}, " + f"{summary['package_registry_source']})" ) print( "Hosted connector config: " diff --git a/src/agents/__init__.py b/src/agents/__init__.py index 6f17f26afc..f4931b212a 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -19,7 +19,9 @@ from .connectors import ( Connector, ConnectorComponents, + ConnectorPlugin, ConnectorPolicyLabel, + ConnectorRegistry, HostedConnectorAuthorization, ) from .editor import ApplyPatchEditor, ApplyPatchOperation, ApplyPatchResult @@ -367,7 +369,9 @@ def enable_verbose_stdout_logging(): "Button", "Connector", "ConnectorComponents", + "ConnectorPlugin", "ConnectorPolicyLabel", + "ConnectorRegistry", "HostedConnectorAuthorization", "AgentsException", "InputGuardrailTripwireTriggered", diff --git a/src/agents/connectors.py b/src/agents/connectors.py index f08209aaae..9bc8a75e19 100644 --- a/src/agents/connectors.py +++ b/src/agents/connectors.py @@ -32,6 +32,73 @@ """Authorization source for hosted connectors loaded from a package app manifest.""" +@dataclass(frozen=True) +class ConnectorPlugin: + """Installed plugin record that can be loaded as an Agents SDK connector. + + This is intentionally a small adapter surface for Unified Plugins-style directory records. The + SDK does not fetch from a specific cloud API here; callers pass the records they got from Codex, + ChatGPT, or another plugin registry. + """ + + id: str + name: str + description: str | None = None + package_path: Path | None = None + hosted_connectors: Mapping[str, Mapping[str, Any]] = field(default_factory=dict) + metadata: Mapping[str, Any] = field(default_factory=dict) + + @classmethod + def from_record( + cls, + record: Mapping[str, Any], + *, + package_root: str | Path | None = None, + ) -> ConnectorPlugin: + """Create a plugin descriptor from a Unified Plugins-style directory record.""" + plugin_id = _optional_record_str(record, ("id", "plugin_id", "pluginId"), "Plugin id") + name = _optional_record_str(record, ("name", "slug"), "Plugin name") + if plugin_id is None and name is None: + raise UserError("Plugin record must include a non-empty 'id' or 'name'") + plugin_id = plugin_id or cast(str, name) + name = name or plugin_id + + description = _optional_record_str(record, ("description",), "Plugin description") + package_path = _plugin_package_path(record, package_root=package_root) + hosted_connectors = _plugin_hosted_connector_configs(record) + metadata = { + key: value + for key, value in record.items() + if key + not in { + "apps", + "connectors", + "description", + "hostedConnectors", + "hosted_connectors", + "id", + "localPath", + "local_path", + "name", + "packagePath", + "package_path", + "path", + "pluginId", + "plugin_id", + "slug", + } + } + + return cls( + id=plugin_id, + name=name, + description=description, + package_path=package_path, + hosted_connectors=hosted_connectors, + metadata=metadata, + ) + + @dataclass(frozen=True) class ConnectorComponents: """Resolved runtime surfaces exposed by a connector package.""" @@ -280,6 +347,105 @@ def from_package( policy_labels=policy_labels, ) + @classmethod + def from_installed_plugin( + cls, + plugin: str, + registry: ConnectorRegistry, + *, + authorization: HostedConnectorAuthorization | None = None, + hosted_mcp_require_approval: RequireApprovalSetting = None, + ) -> Connector: + """Load an installed Unified Plugins-style record through a connector registry. + + The registry is caller-provided so the SDK can consume records from Codex, ChatGPT, a + workspace plugin service, or tests without depending on one cloud transport. + """ + return registry.load( + plugin, + authorization=authorization, + hosted_mcp_require_approval=hosted_mcp_require_approval, + ) + + +class ConnectorRegistry: + """In-memory adapter over installed plugin records. + + `ConnectorRegistry` is the bridge point for Unified Plugins integration. It accepts + already-fetched directory records and resolves them into the same `Connector` surface used by + local package connectors. + """ + + def __init__(self, plugins: Iterable[ConnectorPlugin]) -> None: + self._plugins = tuple(plugins) + by_id: dict[str, ConnectorPlugin] = {} + by_name: dict[str, ConnectorPlugin] = {} + for plugin in self._plugins: + if plugin.id in by_id: + raise UserError(f"Duplicate connector plugin id: {plugin.id}") + by_id[plugin.id] = plugin + if plugin.name in by_name: + raise UserError(f"Duplicate connector plugin name: {plugin.name}") + by_name[plugin.name] = plugin + self._by_id = by_id + self._by_name = by_name + + @classmethod + def from_plugin_records( + cls, + records: Iterable[Mapping[str, Any]], + *, + package_root: str | Path | None = None, + ) -> ConnectorRegistry: + """Create a registry from Unified Plugins-style directory records.""" + return cls( + ConnectorPlugin.from_record(record, package_root=package_root) for record in records + ) + + def list_plugins(self) -> tuple[ConnectorPlugin, ...]: + """Return installed plugin descriptors in registry order.""" + return self._plugins + + def get(self, plugin: str) -> ConnectorPlugin: + """Resolve a plugin by id or name.""" + if plugin in self._by_id: + return self._by_id[plugin] + if plugin in self._by_name: + return self._by_name[plugin] + raise UserError(f"Connector plugin not found: {plugin}") + + def load( + self, + plugin: str, + *, + authorization: HostedConnectorAuthorization | None = None, + hosted_mcp_require_approval: RequireApprovalSetting = None, + ) -> Connector: + """Load an installed plugin as a connector.""" + plugin_record = self.get(plugin) + if plugin_record.package_path is not None: + connector = Connector.from_package( + plugin_record.package_path, + authorization=authorization, + hosted_mcp_require_approval=hosted_mcp_require_approval, + ) + else: + connector = Connector( + name=plugin_record.name, + description=plugin_record.description, + ) + + hosted_tools = _load_hosted_connector_tools( + plugin_record.hosted_connectors, + authorization=authorization, + require_approval=hosted_mcp_require_approval, + ) + connector.tools.extend(hosted_tools) + if hosted_tools: + connector.policy_labels.add("network") + connector.metadata["unified_plugin"] = _plugin_metadata(plugin_record) + return connector + def _build_hosted_mcp_tool_config( *, @@ -427,16 +593,24 @@ def _load_manifest_app_tools( 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}") + app_configs = _hosted_connector_configs(apps, f"App manifest apps: {app_manifest_path}") + return _load_hosted_connector_tools( + app_configs, + authorization=authorization, + require_approval=require_approval, + ) + + +def _load_hosted_connector_tools( + app_configs: Mapping[str, Mapping[str, Any]], + *, + authorization: HostedConnectorAuthorization | None, + require_approval: RequireApprovalSetting, +) -> list[Tool]: 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}") + for app_name, raw_config in app_configs.items(): + connector_id = _hosted_connector_id(raw_config, f"App id for {app_name!r}") resolved_authorization = _resolve_authorization( authorization, app_name, connector_id, raw_config ) @@ -454,6 +628,93 @@ def _load_manifest_app_tools( return tools +def _plugin_package_path( + record: Mapping[str, Any], + *, + package_root: str | Path | None, +) -> Path | None: + path_value = _optional_record_str( + record, + ("package_path", "packagePath", "local_path", "localPath", "path"), + "Plugin package path", + ) + if path_value is None: + return None + path = Path(path_value).expanduser() + if not path.is_absolute() and package_root is not None: + path = Path(package_root).expanduser() / path + return path.resolve() + + +def _plugin_hosted_connector_configs( + record: Mapping[str, Any], +) -> dict[str, dict[str, Any]]: + for key in ("apps", "hosted_connectors", "hostedConnectors", "connectors"): + raw_configs = record.get(key) + if raw_configs is not None: + return _hosted_connector_configs(raw_configs, f"Plugin {key}") + return {} + + +def _hosted_connector_configs(value: Any, field_name: str) -> dict[str, dict[str, Any]]: + if isinstance(value, Mapping): + configs: dict[str, dict[str, Any]] = {} + for app_name, raw_config in value.items(): + if not isinstance(app_name, str): + raise UserError(f"{field_name} names must be strings") + configs[app_name] = _hosted_connector_config(raw_config, f"{field_name} {app_name!r}") + return configs + + if isinstance(value, list): + configs = {} + for raw_config in value: + if not isinstance(raw_config, Mapping): + raise UserError(f"{field_name} entries must be objects") + config = dict(raw_config) + connector_id = _hosted_connector_id(config, f"{field_name} id") + app_name = ( + _optional_str(config.get("name"), f"{field_name} name") + or _optional_str(config.get("server_label"), f"{field_name} server_label") + or _optional_str(config.get("serverLabel"), f"{field_name} serverLabel") + or connector_id + ) + config.setdefault("id", connector_id) + configs[app_name] = config + return configs + + raise UserError(f"{field_name} must be an object or a list of objects") + + +def _hosted_connector_config(value: Any, field_name: str) -> dict[str, Any]: + if isinstance(value, str): + return {"id": value} + if not isinstance(value, Mapping): + raise UserError(f"{field_name} config must be an object") + return dict(value) + + +def _hosted_connector_id(config: Mapping[str, Any], field_name: str) -> str: + return _expect_str( + config.get("id") or config.get("connector_id") or config.get("connectorId"), + field_name, + ) + + +def _plugin_metadata(plugin: ConnectorPlugin) -> dict[str, Any]: + metadata = dict(plugin.metadata) + metadata["id"] = plugin.id + metadata["name"] = plugin.name + if plugin.description is not None: + metadata["description"] = plugin.description + if plugin.package_path is not None: + metadata["package_path"] = str(plugin.package_path) + if plugin.hosted_connectors: + metadata["hosted_connectors"] = { + app_name: dict(config) for app_name, config in plugin.hosted_connectors.items() + } + return metadata + + def _resolve_authorization( authorization: HostedConnectorAuthorization | None, app_name: str, @@ -513,6 +774,18 @@ def _optional_str(value: Any, field_name: str) -> str | None: return _expect_str(value, field_name) +def _optional_record_str( + record: Mapping[str, Any], + keys: tuple[str, ...], + field_name: str, +) -> str | None: + for key in keys: + value = record.get(key) + if value is not None: + return _expect_str(value, field_name) + return None + + 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") diff --git a/tests/test_connector_demo.py b/tests/test_connector_demo.py index 274f2a6f77..47ba84714b 100644 --- a/tests/test_connector_demo.py +++ b/tests/test_connector_demo.py @@ -11,6 +11,7 @@ async def test_connector_package_demo_verifies_end_to_end() -> None: assert summary["direct_tool_result"] == "discount=25.00" assert summary["mcp_tool_result"] == "order demo_order_1001: fulfilled" + assert summary["package_registry_source"] == "unified_plugins_demo" assert summary["hosted_connector_label"] == "calendar" assert "apply_discount" in summary["agent_tool_names"] assert "mcp_orders__lookup_order" in summary["agent_tool_names"] diff --git a/tests/test_connectors.py b/tests/test_connectors.py index 7f825d4527..d812ee3b2f 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -8,6 +8,7 @@ from agents import ( Agent, Connector, + ConnectorRegistry, HostedConnectorAuthorization, HostedMCPTool, RunContextWrapper, @@ -253,6 +254,116 @@ def test_connector_from_package_skips_app_manifest_without_authorization(tmp_pat assert connector.policy_labels == set() +def test_connector_registry_loads_installed_plugin_package(tmp_path) -> None: + plugin_dir = tmp_path / "orders" + 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": "1.0.0", + "description": "Order lookup plugin.", + "mcpServers": "./.mcp.json", + } + ) + ) + (plugin_dir / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "orders": { + "command": "python", + "args": ["server.py"], + "cwd": ".", + } + } + } + ) + ) + + registry = ConnectorRegistry.from_plugin_records( + [ + { + "id": "plugin_orders", + "name": "orders", + "package_path": str(plugin_dir), + "source": "unified_plugins", + } + ] + ) + connector = Connector.from_installed_plugin("plugin_orders", registry) + + assert [plugin.id for plugin in registry.list_plugins()] == ["plugin_orders"] + assert connector.name == "orders" + assert connector.description == "Order lookup plugin." + assert connector.metadata["unified_plugin"]["id"] == "plugin_orders" + assert connector.metadata["unified_plugin"]["source"] == "unified_plugins" + assert connector.policy_labels == {"local_execution"} + assert len(connector.mcp_servers) == 1 + server = connector.mcp_servers[0] + assert isinstance(server, MCPServerStdio) + assert str(server.params.cwd) == str(plugin_dir) + + +def test_connector_registry_loads_hosted_app_connector_record() -> None: + registry = ConnectorRegistry.from_plugin_records( + [ + { + "id": "plugin_workspace", + "name": "workspace", + "description": "Workspace apps.", + "apps": { + "calendar": { + "id": "connector_googlecalendar", + } + }, + } + ] + ) + + connector = Connector.from_installed_plugin( + "workspace", + registry, + authorization={"calendar": "conn_calendar"}, + hosted_mcp_require_approval="always", + ) + + assert connector.name == "workspace" + assert connector.description == "Workspace apps." + 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_registry_skips_hosted_apps_without_authorization() -> None: + registry = ConnectorRegistry.from_plugin_records( + [ + { + "id": "plugin_workspace", + "name": "workspace", + "apps": { + "calendar": { + "id": "connector_googlecalendar", + } + }, + } + ] + ) + + connector = Connector.from_installed_plugin("plugin_workspace", registry) + + 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, ())) From 53d24ca4fae751264faf319211b65dc383feb6df Mon Sep 17 00:00:00 2001 From: Danny Zhang Date: Mon, 18 May 2026 19:36:22 -0700 Subject: [PATCH 5/5] Harden connector registry plugin records --- docs/connectors.md | 26 +++- examples/connectors/package_demo.py | 26 +++- src/agents/connectors.py | 178 ++++++++++++++++++++++++++-- tests/test_connector_demo.py | 3 +- tests/test_connectors.py | 134 +++++++++++++++++++++ 5 files changed, 347 insertions(+), 20 deletions(-) diff --git a/docs/connectors.md b/docs/connectors.md index b2aa339dc5..88755769cc 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -29,25 +29,36 @@ registry = ConnectorRegistry.from_plugin_records( { "id": "plugin_orders", "name": "orders", - "package_path": "./orders-plugin", + "mount": { + "path": "orders-plugin", + }, + "policyLabels": ["read_only"], }, { "id": "plugin_calendar", "name": "calendar", + "policy": { + "labels": ["read_only"], + }, "apps": { "calendar": { - "id": "connector_googlecalendar", + "connectorId": "connector_googlecalendar", + "authorizationAlias": "calendar_connection", + "serverLabel": "google_calendar", + "allowedTools": ["events_search"], + "requireApproval": "never", } }, }, - ] + ], + package_root="./mounted-plugins", ) orders = Connector.from_installed_plugin("plugin_orders", registry) calendar = Connector.from_installed_plugin( "plugin_calendar", registry, - authorization={"calendar": "conn_calendar_access_token"}, + authorization={"calendar_connection": "conn_calendar_access_token"}, hosted_mcp_require_approval="always", ) @@ -62,6 +73,13 @@ This keeps package discovery, marketplace installation, workspace sharing, admin sync outside the SDK runtime while giving those systems a stable place to hand installed plugin records to the SDK. +Registry records accept either direct package paths such as `package_path` or nested mounted-package +paths such as `mount.path`. When `package_root` is supplied, relative and absolute package paths +must resolve inside that root. Hosted app records can declare auth aliases, server labels, allowed +tools, approval settings, and deferred loading flags; auth aliases are resolved through the +`authorization` mapping passed to `Connector.from_installed_plugin()`. Top-level `policyLabels` or +`policy.labels` are merged into the connector's policy labels. + ## SDK tool connectors Use [`Connector.from_tools()`][agents.connectors.Connector.from_tools] when your integration is diff --git a/examples/connectors/package_demo.py b/examples/connectors/package_demo.py index 2ae1ae04ba..463e3b0c68 100644 --- a/examples/connectors/package_demo.py +++ b/examples/connectors/package_demo.py @@ -43,9 +43,17 @@ def build_hosted_connector() -> Connector: "id": "plugin_calendar", "name": "calendar", "description": "Hosted Google Calendar connector shape.", + "policy": { + "labels": ["read_only"], + }, "apps": { "calendar": { - "id": "connector_googlecalendar", + "connectorId": "connector_googlecalendar", + "authorizationAlias": "calendar_connection", + "serverLabel": "google_calendar", + "allowedTools": ["events_search"], + "requireApproval": "never", + "deferLoading": True, } }, } @@ -54,8 +62,8 @@ def build_hosted_connector() -> Connector: return Connector.from_installed_plugin( "plugin_calendar", registry, - authorization={"calendar": "demo_access_token"}, - hosted_mcp_require_approval="never", + authorization={"calendar_connection": "demo_access_token"}, + hosted_mcp_require_approval="always", ) @@ -119,10 +127,14 @@ def build_package_connector(package_root: Path) -> Connector: { "id": "plugin_orders", "name": "orders", - "package_path": str(package_root), + "mount": { + "path": package_root.name, + }, + "policyLabels": ["read_only"], "source": "unified_plugins_demo", } - ] + ], + package_root=package_root.parent, ) return Connector.from_installed_plugin("plugin_orders", registry) @@ -177,6 +189,7 @@ async def verify_connector_demo() -> dict[str, Any]: "package_registry_source": package_connector.metadata["unified_plugin"]["source"], "hosted_connector_label": hosted_tool.tool_config["server_label"], "hosted_connector_id": hosted_tool.tool_config["connector_id"], + "hosted_allowed_tools": hosted_tool.tool_config["allowed_tools"], } @@ -238,7 +251,8 @@ async def main(*, verify: bool) -> None: expected = { "direct_tool_result": "discount=25.00", "mcp_tool_result": "order demo_order_1001: fulfilled", - "hosted_connector_label": "calendar", + "hosted_connector_label": "google_calendar", + "hosted_allowed_tools": ["events_search"], } mismatches = { key: (summary.get(key), expected_value) diff --git a/src/agents/connectors.py b/src/agents/connectors.py index 9bc8a75e19..d3f88f3217 100644 --- a/src/agents/connectors.py +++ b/src/agents/connectors.py @@ -25,6 +25,17 @@ ] """Coarse policy labels callers can use to route connector approval and sandbox decisions.""" +_CONNECTOR_POLICY_LABELS: set[str] = { + "read_only", + "write", + "destructive", + "external_send", + "network", + "secret_access", + "local_execution", + "sandbox_required", +} + HostedConnectorAuthorization = ( str | Mapping[str, str] | Callable[[str, str, Mapping[str, Any]], str | None] @@ -47,6 +58,7 @@ class ConnectorPlugin: package_path: Path | None = None hosted_connectors: Mapping[str, Mapping[str, Any]] = field(default_factory=dict) metadata: Mapping[str, Any] = field(default_factory=dict) + policy_labels: tuple[ConnectorPolicyLabel, ...] = () @classmethod def from_record( @@ -66,6 +78,7 @@ def from_record( description = _optional_record_str(record, ("description",), "Plugin description") package_path = _plugin_package_path(record, package_root=package_root) hosted_connectors = _plugin_hosted_connector_configs(record) + policy_labels = _plugin_policy_labels(record) metadata = { key: value for key, value in record.items() @@ -83,6 +96,9 @@ def from_record( "packagePath", "package_path", "path", + "policy", + "policyLabels", + "policy_labels", "pluginId", "plugin_id", "slug", @@ -96,6 +112,7 @@ def from_record( package_path=package_path, hosted_connectors=hosted_connectors, metadata=metadata, + policy_labels=policy_labels, ) @@ -440,6 +457,7 @@ def load( authorization=authorization, require_approval=hosted_mcp_require_approval, ) + connector.policy_labels.update(plugin_record.policy_labels) connector.tools.extend(hosted_tools) if hosted_tools: connector.policy_labels.add("network") @@ -611,6 +629,20 @@ def _load_hosted_connector_tools( tools: list[Tool] = [] for app_name, raw_config in app_configs.items(): connector_id = _hosted_connector_id(raw_config, f"App id for {app_name!r}") + server_label = ( + _optional_record_str(raw_config, ("server_label", "serverLabel"), "App server label") + or app_name + ) + allowed_tools = _optional_record_str_list( + raw_config, ("allowed_tools", "allowedTools"), f"Allowed tools for {app_name!r}" + ) + app_require_approval = cast( + RequireApprovalSetting, + raw_config.get("require_approval", raw_config.get("requireApproval", require_approval)), + ) + defer_loading = _optional_record_bool( + raw_config, ("defer_loading", "deferLoading"), f"Defer loading for {app_name!r}" + ) resolved_authorization = _resolve_authorization( authorization, app_name, connector_id, raw_config ) @@ -620,8 +652,10 @@ def _load_hosted_connector_tools( app_name, connector_id=connector_id, authorization=resolved_authorization, - server_label=app_name, - require_approval=require_approval, + server_label=server_label, + allowed_tools=allowed_tools, + require_approval=app_require_approval, + defer_loading=defer_loading, ) tools.extend(connector.tools) @@ -633,17 +667,66 @@ def _plugin_package_path( *, package_root: str | Path | None, ) -> Path | None: + path_value = _plugin_package_path_value(record) + if path_value is None: + return None + root_path = Path(package_root).expanduser().resolve() if package_root is not None else None + path = Path(path_value).expanduser() + candidate = ( + path.resolve() if path.is_absolute() else ((root_path or Path.cwd()) / path).resolve() + ) + if root_path is not None and not _is_relative_to(candidate, root_path): + raise UserError( + f"Plugin package path must stay inside the connector package root: {path_value}" + ) + return candidate + + +def _plugin_package_path_value(record: Mapping[str, Any]) -> str | None: path_value = _optional_record_str( record, ("package_path", "packagePath", "local_path", "localPath", "path"), "Plugin package path", ) - if path_value is None: - return None - path = Path(path_value).expanduser() - if not path.is_absolute() and package_root is not None: - path = Path(package_root).expanduser() / path - return path.resolve() + if path_value is not None: + return path_value + + for key in ("package", "mount", "local_package", "localPackage"): + nested = record.get(key) + if nested is None: + continue + if not isinstance(nested, Mapping): + raise UserError(f"Plugin {key} must be an object") + path_value = _optional_record_str( + nested, + ("path", "package_path", "packagePath", "local_path", "localPath"), + f"Plugin {key} path", + ) + if path_value is not None: + return path_value + return None + + +def _plugin_policy_labels(record: Mapping[str, Any]) -> tuple[ConnectorPolicyLabel, ...]: + policy_labels = _optional_record_policy_labels( + record, ("policy_labels", "policyLabels"), "Plugin policy labels" + ) + if policy_labels is not None: + return policy_labels + + policy = record.get("policy") + if policy is None: + return () + if not isinstance(policy, Mapping): + raise UserError("Plugin policy must be an object") + return ( + _optional_record_policy_labels( + policy, + ("labels", "policy_labels", "policyLabels"), + "Plugin policy labels", + ) + or () + ) def _plugin_hosted_connector_configs( @@ -712,6 +795,8 @@ def _plugin_metadata(plugin: ConnectorPlugin) -> dict[str, Any]: metadata["hosted_connectors"] = { app_name: dict(config) for app_name, config in plugin.hosted_connectors.items() } + if plugin.policy_labels: + metadata["policy_labels"] = sorted(plugin.policy_labels) return metadata @@ -726,10 +811,38 @@ def _resolve_authorization( if isinstance(authorization, str): return authorization if isinstance(authorization, Mapping): - return authorization.get(app_name) or authorization.get(connector_id) + for key in _authorization_lookup_keys(app_name, connector_id, app_config): + token = authorization.get(key) + if token is not None: + return token + return None return authorization(app_name, connector_id, app_config) +def _authorization_lookup_keys( + app_name: str, + connector_id: str, + app_config: Mapping[str, Any], +) -> tuple[str, ...]: + keys = [app_name, connector_id] + for field_name in ( + "authorization_alias", + "authorizationAlias", + "authorization_ref", + "authorizationRef", + "auth_alias", + "authAlias", + "auth_reference", + "authReference", + "connection_id", + "connectionId", + ): + value = app_config.get(field_name) + if isinstance(value, str) and value: + keys.append(value) + return tuple(dict.fromkeys(keys)) + + def _read_json_object(path: Path) -> dict[str, Any]: try: value = json.loads(path.read_text()) @@ -786,6 +899,53 @@ def _optional_record_str( return None +def _optional_record_str_list( + record: Mapping[str, Any], + keys: tuple[str, ...], + field_name: str, +) -> list[str] | None: + for key in keys: + value = record.get(key) + if value is not None: + return _expect_str_list(value, field_name) + return None + + +def _optional_record_bool( + record: Mapping[str, Any], + keys: tuple[str, ...], + field_name: str, +) -> bool: + for key in keys: + value = record.get(key) + if value is not None: + if not isinstance(value, bool): + raise UserError(f"{field_name} must be a boolean") + return value + return False + + +def _optional_record_policy_labels( + record: Mapping[str, Any], + keys: tuple[str, ...], + field_name: str, +) -> tuple[ConnectorPolicyLabel, ...] | None: + for key in keys: + value = record.get(key) + if value is not None: + return _expect_policy_labels(value, field_name) + return None + + +def _expect_policy_labels(value: Any, field_name: str) -> tuple[ConnectorPolicyLabel, ...]: + 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 policy label strings") + unknown = sorted(set(value) - _CONNECTOR_POLICY_LABELS) + if unknown: + raise UserError(f"{field_name} contains unknown labels: {', '.join(unknown)}") + return tuple(cast(ConnectorPolicyLabel, item) for item in value) + + 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") diff --git a/tests/test_connector_demo.py b/tests/test_connector_demo.py index 47ba84714b..9b176b8f3e 100644 --- a/tests/test_connector_demo.py +++ b/tests/test_connector_demo.py @@ -12,6 +12,7 @@ async def test_connector_package_demo_verifies_end_to_end() -> None: assert summary["direct_tool_result"] == "discount=25.00" assert summary["mcp_tool_result"] == "order demo_order_1001: fulfilled" assert summary["package_registry_source"] == "unified_plugins_demo" - assert summary["hosted_connector_label"] == "calendar" + assert summary["hosted_connector_label"] == "google_calendar" + assert summary["hosted_allowed_tools"] == ["events_search"] assert "apply_discount" in summary["agent_tool_names"] assert "mcp_orders__lookup_order" in summary["agent_tool_names"] diff --git a/tests/test_connectors.py b/tests/test_connectors.py index d812ee3b2f..928e2daf06 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -343,6 +343,140 @@ def test_connector_registry_loads_hosted_app_connector_record() -> None: assert tool_config["require_approval"] == "always" +def test_connector_registry_resolves_auth_alias_and_hosted_options() -> None: + registry = ConnectorRegistry.from_plugin_records( + [ + { + "id": "plugin_workspace", + "name": "workspace", + "apps": { + "calendar": { + "connectorId": "connector_googlecalendar", + "authorizationAlias": "google_calendar_connection", + "serverLabel": "google_calendar", + "allowedTools": ["events_search"], + "requireApproval": "never", + "deferLoading": True, + } + }, + } + ] + ) + + connector = Connector.from_installed_plugin( + "plugin_workspace", + registry, + authorization={"google_calendar_connection": "conn_calendar"}, + hosted_mcp_require_approval="always", + ) + + tool = connector.tools[0] + assert isinstance(tool, HostedMCPTool) + tool_config = cast(dict[str, Any], tool.tool_config) + assert tool_config["server_label"] == "google_calendar" + assert tool_config["connector_id"] == "connector_googlecalendar" + assert tool_config["authorization"] == "conn_calendar" + assert tool_config["allowed_tools"] == ["events_search"] + assert tool_config["require_approval"] == "never" + assert tool_config["defer_loading"] is True + + +def test_connector_registry_merges_policy_labels_from_plugin_record() -> None: + registry = ConnectorRegistry.from_plugin_records( + [ + { + "id": "plugin_workspace", + "name": "workspace", + "policy": { + "labels": ["read_only", "external_send"], + }, + "apps": { + "calendar": { + "id": "connector_googlecalendar", + } + }, + } + ] + ) + + connector = Connector.from_installed_plugin( + "plugin_workspace", + registry, + authorization={"calendar": "conn_calendar"}, + ) + + assert connector.policy_labels == {"read_only", "external_send", "network"} + assert connector.metadata["unified_plugin"]["policy_labels"] == [ + "external_send", + "read_only", + ] + + +def test_connector_registry_resolves_mounted_package_paths(tmp_path) -> None: + plugins_root = tmp_path / "mounted-plugins" + plugin_dir = plugins_root / "orders" + 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": "1.0.0", + "description": "Mounted order plugin.", + "mcpServers": "./.mcp.json", + } + ) + ) + (plugin_dir / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "orders": { + "command": "python", + "args": ["server.py"], + "cwd": ".", + } + } + } + ) + ) + + registry = ConnectorRegistry.from_plugin_records( + [ + { + "id": "plugin_orders", + "name": "orders", + "mount": { + "path": "orders", + }, + } + ], + package_root=plugins_root, + ) + + connector = Connector.from_installed_plugin("plugin_orders", registry) + + assert connector.metadata["unified_plugin"]["package_path"] == str(plugin_dir.resolve()) + assert connector.description == "Mounted order plugin." + assert len(connector.mcp_servers) == 1 + + +def test_connector_registry_rejects_mounted_paths_outside_package_root(tmp_path) -> None: + with pytest.raises(UserError, match="must stay inside the connector package root"): + ConnectorRegistry.from_plugin_records( + [ + { + "id": "plugin_orders", + "name": "orders", + "mount": { + "path": "../outside", + }, + } + ], + package_root=tmp_path / "mounted-plugins", + ) + + def test_connector_registry_skips_hosted_apps_without_authorization() -> None: registry = ConnectorRegistry.from_plugin_records( [