diff --git a/src/runloop_api_client/sdk/__init__.py b/src/runloop_api_client/sdk/__init__.py index edfe3f148..e90b86ac1 100644 --- a/src/runloop_api_client/sdk/__init__.py +++ b/src/runloop_api_client/sdk/__init__.py @@ -14,6 +14,7 @@ SnapshotOps, BenchmarkOps, BlueprintOps, + McpConfigOps, GatewayConfigOps, NetworkPolicyOps, StorageObjectOps, @@ -29,6 +30,7 @@ AsyncSnapshotOps, AsyncBenchmarkOps, AsyncBlueprintOps, + AsyncMcpConfigOps, AsyncGatewayConfigOps, AsyncNetworkPolicyOps, AsyncStorageObjectOps, @@ -40,6 +42,7 @@ from .benchmark import Benchmark from .blueprint import Blueprint from .execution import Execution +from .mcp_config import McpConfig from .async_agent import AsyncAgent from .async_devbox import AsyncDevbox, AsyncNamedShell from .async_scorer import AsyncScorer @@ -53,6 +56,7 @@ from .async_benchmark import AsyncBenchmark from .async_blueprint import AsyncBlueprint from .async_execution import AsyncExecution +from .async_mcp_config import AsyncMcpConfig from .execution_result import ExecutionResult from .scenario_builder import ScenarioBuilder from .async_scenario_run import AsyncScenarioRun @@ -86,6 +90,8 @@ "AsyncStorageObjectOps", "NetworkPolicyOps", "AsyncNetworkPolicyOps", + "McpConfigOps", + "AsyncMcpConfigOps", "GatewayConfigOps", "AsyncGatewayConfigOps", # Resource classes @@ -118,6 +124,8 @@ "AsyncStorageObject", "NetworkPolicy", "AsyncNetworkPolicy", + "McpConfig", + "AsyncMcpConfig", "GatewayConfig", "AsyncGatewayConfig", "NamedShell", diff --git a/src/runloop_api_client/sdk/_types.py b/src/runloop_api_client/sdk/_types.py index 1eea070a2..a488f585e 100644 --- a/src/runloop_api_client/sdk/_types.py +++ b/src/runloop_api_client/sdk/_types.py @@ -13,11 +13,14 @@ ScenarioListParams, BenchmarkListParams, BlueprintListParams, + McpConfigListParams, ObjectDownloadParams, ScenarioUpdateParams, BenchmarkCreateParams, BenchmarkUpdateParams, BlueprintCreateParams, + McpConfigCreateParams, + McpConfigUpdateParams, DevboxUploadFileParams, GatewayConfigListParams, NetworkPolicyListParams, @@ -266,6 +269,18 @@ class SDKNetworkPolicyUpdateParams(NetworkPolicyUpdateParams, LongRequestOptions pass +class SDKMcpConfigCreateParams(McpConfigCreateParams, LongRequestOptions): + pass + + +class SDKMcpConfigListParams(McpConfigListParams, BaseRequestOptions): + pass + + +class SDKMcpConfigUpdateParams(McpConfigUpdateParams, LongRequestOptions): + pass + + class SDKGatewayConfigCreateParams(GatewayConfigCreateParams, LongRequestOptions): pass diff --git a/src/runloop_api_client/sdk/async_.py b/src/runloop_api_client/sdk/async_.py index c35ae98ae..872e80530 100644 --- a/src/runloop_api_client/sdk/async_.py +++ b/src/runloop_api_client/sdk/async_.py @@ -23,8 +23,10 @@ SDKScorerCreateParams, SDKBenchmarkListParams, SDKBlueprintListParams, + SDKMcpConfigListParams, SDKBenchmarkCreateParams, SDKBlueprintCreateParams, + SDKMcpConfigCreateParams, SDKDiskSnapshotListParams, SDKGatewayConfigListParams, SDKNetworkPolicyListParams, @@ -42,6 +44,7 @@ from .async_snapshot import AsyncSnapshot from .async_benchmark import AsyncBenchmark from .async_blueprint import AsyncBlueprint +from .async_mcp_config import AsyncMcpConfig from ..lib.context_loader import TarFilter, build_directory_tar from .async_gateway_config import AsyncGatewayConfig from .async_network_policy import AsyncNetworkPolicy @@ -1011,6 +1014,62 @@ async def list(self, **params: Unpack[SDKGatewayConfigListParams]) -> list[Async return [AsyncGatewayConfig(self._client, item.id) for item in page.gateway_configs] +class AsyncMcpConfigOps: + """High-level async manager for creating and managing MCP configurations. + + Accessed via ``runloop.mcp_config`` from :class:`AsyncRunloopSDK`, provides methods + to create, retrieve, update, delete, and list MCP configs. MCP configs define + how to connect to upstream MCP (Model Context Protocol) servers, specifying the + target endpoint and which tools are allowed. + + Example: + >>> runloop = AsyncRunloopSDK() + >>> mcp_config = await runloop.mcp_config.create( + ... name="my-mcp-server", + ... endpoint="https://mcp.example.com", + ... allowed_tools=["*"], + ... ) + """ + + def __init__(self, client: AsyncRunloop) -> None: + """Initialize AsyncMcpConfigOps. + + :param client: AsyncRunloop client instance + :type client: AsyncRunloop + """ + self._client = client + + async def create(self, **params: Unpack[SDKMcpConfigCreateParams]) -> AsyncMcpConfig: + """Create a new MCP config. + + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKMcpConfigCreateParams` for available parameters + :return: The newly created MCP config + :rtype: AsyncMcpConfig + """ + response = await self._client.mcp_configs.create(**params) + return AsyncMcpConfig(self._client, response.id) + + def from_id(self, mcp_config_id: str) -> AsyncMcpConfig: + """Get an AsyncMcpConfig instance for an existing MCP config ID. + + :param mcp_config_id: ID of the MCP config + :type mcp_config_id: str + :return: AsyncMcpConfig instance for the given ID + :rtype: AsyncMcpConfig + """ + return AsyncMcpConfig(self._client, mcp_config_id) + + async def list(self, **params: Unpack[SDKMcpConfigListParams]) -> list[AsyncMcpConfig]: + """List all MCP configs, optionally filtered by parameters. + + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKMcpConfigListParams` for available parameters + :return: List of MCP configs + :rtype: list[AsyncMcpConfig] + """ + page = await self._client.mcp_configs.list(**params) + return [AsyncMcpConfig(self._client, item.id) for item in page.mcp_configs] + + class AsyncRunloopSDK: """High-level asynchronous entry point for the Runloop SDK. @@ -1040,6 +1099,8 @@ class AsyncRunloopSDK: :vartype network_policy: AsyncNetworkPolicyOps :ivar gateway_config: High-level async interface for gateway config management :vartype gateway_config: AsyncGatewayConfigOps + :ivar mcp_config: High-level async interface for MCP config management + :vartype mcp_config: AsyncMcpConfigOps Example: >>> runloop = AsyncRunloopSDK() # Uses RUNLOOP_API_KEY env var @@ -1055,6 +1116,7 @@ class AsyncRunloopSDK: devbox: AsyncDevboxOps blueprint: AsyncBlueprintOps gateway_config: AsyncGatewayConfigOps + mcp_config: AsyncMcpConfigOps network_policy: AsyncNetworkPolicyOps scenario: AsyncScenarioOps scorer: AsyncScorerOps @@ -1104,6 +1166,7 @@ def __init__( self.devbox = AsyncDevboxOps(self.api) self.blueprint = AsyncBlueprintOps(self.api) self.gateway_config = AsyncGatewayConfigOps(self.api) + self.mcp_config = AsyncMcpConfigOps(self.api) self.network_policy = AsyncNetworkPolicyOps(self.api) self.scenario = AsyncScenarioOps(self.api) self.scorer = AsyncScorerOps(self.api) diff --git a/src/runloop_api_client/sdk/async_mcp_config.py b/src/runloop_api_client/sdk/async_mcp_config.py new file mode 100644 index 000000000..c0bafcf03 --- /dev/null +++ b/src/runloop_api_client/sdk/async_mcp_config.py @@ -0,0 +1,95 @@ +"""McpConfig resource class for asynchronous operations.""" + +from __future__ import annotations + +from typing_extensions import Unpack, override + +from ._types import BaseRequestOptions, LongRequestOptions, SDKMcpConfigUpdateParams +from .._client import AsyncRunloop +from ..types.mcp_config_view import McpConfigView + + +class AsyncMcpConfig: + """Asynchronous wrapper around an MCP config resource. + + MCP configs define how to connect to upstream MCP (Model Context Protocol) servers. + They specify the target endpoint and which tools are allowed. Use with devboxes to + securely connect to MCP servers. + + Example: + >>> runloop = AsyncRunloopSDK() + >>> mcp_config = await runloop.mcp_config.create( + ... name="my-mcp-server", + ... endpoint="https://mcp.example.com", + ... allowed_tools=["*"], + ... ) + >>> info = await mcp_config.get_info() + >>> print(f"MCP Config: {info.name}") + """ + + def __init__( + self, + client: AsyncRunloop, + mcp_config_id: str, + ) -> None: + """Initialize the wrapper. + + :param client: Generated AsyncRunloop client + :type client: AsyncRunloop + :param mcp_config_id: McpConfig ID returned by the API + :type mcp_config_id: str + """ + self._client = client + self._id = mcp_config_id + + @override + def __repr__(self) -> str: + return f"" + + @property + def id(self) -> str: + """Return the MCP config ID. + + :return: Unique MCP config ID + :rtype: str + """ + return self._id + + async def get_info( + self, + **options: Unpack[BaseRequestOptions], + ) -> McpConfigView: + """Retrieve the latest MCP config details. + + :param options: Optional request configuration + :return: API response describing the MCP config + :rtype: McpConfigView + """ + return await self._client.mcp_configs.retrieve( + self._id, + **options, + ) + + async def update(self, **params: Unpack[SDKMcpConfigUpdateParams]) -> McpConfigView: + """Update the MCP config. + + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKMcpConfigUpdateParams` for available parameters + :return: Updated MCP config view + :rtype: McpConfigView + """ + return await self._client.mcp_configs.update(self._id, **params) + + async def delete( + self, + **options: Unpack[LongRequestOptions], + ) -> McpConfigView: + """Delete the MCP config. This action is irreversible. + + :param options: Optional long-running request configuration + :return: API response acknowledging deletion + :rtype: McpConfigView + """ + return await self._client.mcp_configs.delete( + self._id, + **options, + ) diff --git a/src/runloop_api_client/sdk/mcp_config.py b/src/runloop_api_client/sdk/mcp_config.py new file mode 100644 index 000000000..6459f05dc --- /dev/null +++ b/src/runloop_api_client/sdk/mcp_config.py @@ -0,0 +1,95 @@ +"""McpConfig resource class for synchronous operations.""" + +from __future__ import annotations + +from typing_extensions import Unpack, override + +from ._types import BaseRequestOptions, LongRequestOptions, SDKMcpConfigUpdateParams +from .._client import Runloop +from ..types.mcp_config_view import McpConfigView + + +class McpConfig: + """Synchronous wrapper around an MCP config resource. + + MCP configs define how to connect to upstream MCP (Model Context Protocol) servers. + They specify the target endpoint and which tools are allowed. Use with devboxes to + securely connect to MCP servers. + + Example: + >>> runloop = RunloopSDK() + >>> mcp_config = runloop.mcp_config.create( + ... name="my-mcp-server", + ... endpoint="https://mcp.example.com", + ... allowed_tools=["*"], + ... ) + >>> info = mcp_config.get_info() + >>> print(f"MCP Config: {info.name}") + """ + + def __init__( + self, + client: Runloop, + mcp_config_id: str, + ) -> None: + """Initialize the wrapper. + + :param client: Generated Runloop client + :type client: Runloop + :param mcp_config_id: McpConfig ID returned by the API + :type mcp_config_id: str + """ + self._client = client + self._id = mcp_config_id + + @override + def __repr__(self) -> str: + return f"" + + @property + def id(self) -> str: + """Return the MCP config ID. + + :return: Unique MCP config ID + :rtype: str + """ + return self._id + + def get_info( + self, + **options: Unpack[BaseRequestOptions], + ) -> McpConfigView: + """Retrieve the latest MCP config details. + + :param options: Optional request configuration + :return: API response describing the MCP config + :rtype: McpConfigView + """ + return self._client.mcp_configs.retrieve( + self._id, + **options, + ) + + def update(self, **params: Unpack[SDKMcpConfigUpdateParams]) -> McpConfigView: + """Update the MCP config. + + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKMcpConfigUpdateParams` for available parameters + :return: Updated MCP config view + :rtype: McpConfigView + """ + return self._client.mcp_configs.update(self._id, **params) + + def delete( + self, + **options: Unpack[LongRequestOptions], + ) -> McpConfigView: + """Delete the MCP config. This action is irreversible. + + :param options: Optional long-running request configuration + :return: API response acknowledging deletion + :rtype: McpConfigView + """ + return self._client.mcp_configs.delete( + self._id, + **options, + ) diff --git a/src/runloop_api_client/sdk/sync.py b/src/runloop_api_client/sdk/sync.py index 938576508..a43fa198b 100644 --- a/src/runloop_api_client/sdk/sync.py +++ b/src/runloop_api_client/sdk/sync.py @@ -23,8 +23,10 @@ SDKScorerCreateParams, SDKBenchmarkListParams, SDKBlueprintListParams, + SDKMcpConfigListParams, SDKBenchmarkCreateParams, SDKBlueprintCreateParams, + SDKMcpConfigCreateParams, SDKDiskSnapshotListParams, SDKGatewayConfigListParams, SDKNetworkPolicyListParams, @@ -41,6 +43,7 @@ from .snapshot import Snapshot from .benchmark import Benchmark from .blueprint import Blueprint +from .mcp_config import McpConfig from .gateway_config import GatewayConfig from .network_policy import NetworkPolicy from .storage_object import StorageObject @@ -1036,6 +1039,62 @@ def list(self, **params: Unpack[SDKGatewayConfigListParams]) -> list[GatewayConf return [GatewayConfig(self._client, item.id) for item in page.gateway_configs] +class McpConfigOps: + """High-level manager for creating and managing MCP configurations. + + Accessed via ``runloop.mcp_config`` from :class:`RunloopSDK`, provides methods + to create, retrieve, update, delete, and list MCP configs. MCP configs define + how to connect to upstream MCP (Model Context Protocol) servers, specifying the + target endpoint and which tools are allowed. + + Example: + >>> runloop = RunloopSDK() + >>> mcp_config = runloop.mcp_config.create( + ... name="my-mcp-server", + ... endpoint="https://mcp.example.com", + ... allowed_tools=["*"], + ... ) + """ + + def __init__(self, client: Runloop) -> None: + """Initialize McpConfigOps. + + :param client: Runloop client instance + :type client: Runloop + """ + self._client = client + + def create(self, **params: Unpack[SDKMcpConfigCreateParams]) -> McpConfig: + """Create a new MCP config. + + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKMcpConfigCreateParams` for available parameters + :return: The newly created MCP config + :rtype: McpConfig + """ + response = self._client.mcp_configs.create(**params) + return McpConfig(self._client, response.id) + + def from_id(self, mcp_config_id: str) -> McpConfig: + """Get a McpConfig instance for an existing MCP config ID. + + :param mcp_config_id: ID of the MCP config + :type mcp_config_id: str + :return: McpConfig instance for the given ID + :rtype: McpConfig + """ + return McpConfig(self._client, mcp_config_id) + + def list(self, **params: Unpack[SDKMcpConfigListParams]) -> list[McpConfig]: + """List all MCP configs, optionally filtered by parameters. + + :param params: See :typeddict:`~runloop_api_client.sdk._types.SDKMcpConfigListParams` for available parameters + :return: List of MCP configs + :rtype: list[McpConfig] + """ + page = self._client.mcp_configs.list(**params) + return [McpConfig(self._client, item.id) for item in page.mcp_configs] + + class RunloopSDK: """High-level synchronous entry point for the Runloop SDK. @@ -1065,6 +1124,8 @@ class RunloopSDK: :vartype network_policy: NetworkPolicyOps :ivar gateway_config: High-level interface for gateway config management :vartype gateway_config: GatewayConfigOps + :ivar mcp_config: High-level interface for MCP config management + :vartype mcp_config: McpConfigOps Example: >>> runloop = RunloopSDK() # Uses RUNLOOP_API_KEY env var @@ -1080,6 +1141,7 @@ class RunloopSDK: devbox: DevboxOps blueprint: BlueprintOps gateway_config: GatewayConfigOps + mcp_config: McpConfigOps network_policy: NetworkPolicyOps scenario: ScenarioOps scorer: ScorerOps @@ -1129,6 +1191,7 @@ def __init__( self.devbox = DevboxOps(self.api) self.blueprint = BlueprintOps(self.api) self.gateway_config = GatewayConfigOps(self.api) + self.mcp_config = McpConfigOps(self.api) self.network_policy = NetworkPolicyOps(self.api) self.scenario = ScenarioOps(self.api) self.scorer = ScorerOps(self.api) diff --git a/tests/smoketests/sdk/test_mcp_config.py b/tests/smoketests/sdk/test_mcp_config.py new file mode 100644 index 000000000..63c3a27ac --- /dev/null +++ b/tests/smoketests/sdk/test_mcp_config.py @@ -0,0 +1,113 @@ +"""Synchronous SDK smoke tests for MCP Config operations.""" + +from __future__ import annotations + +import pytest + +from runloop_api_client.sdk import RunloopSDK +from tests.smoketests.utils import unique_name + +pytestmark = [pytest.mark.smoketest] + +THIRTY_SECOND_TIMEOUT = 30 + +MCP_CONFIG_TEST_ENDPOINT = "https://mcp.example.com" + + +class TestMcpConfigLifecycle: + """Test MCP config lifecycle operations.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_mcp_config_full_lifecycle(self, sdk_client: RunloopSDK) -> None: + """Test complete MCP config lifecycle: create, get_info, update, delete.""" + name = unique_name("sdk-mcp-config") + mcp_config = sdk_client.mcp_config.create( + name=name, + endpoint=MCP_CONFIG_TEST_ENDPOINT, + allowed_tools=["*"], + description="SDK smoke test MCP config", + ) + + try: + assert mcp_config is not None + assert mcp_config.id is not None + assert len(mcp_config.id) > 0 + + # Get info + info = mcp_config.get_info() + assert info.id == mcp_config.id + assert info.name == name + assert info.endpoint == MCP_CONFIG_TEST_ENDPOINT + assert info.description == "SDK smoke test MCP config" + assert info.allowed_tools is not None + assert "*" in info.allowed_tools + assert info.create_time_ms is not None + assert info.create_time_ms > 0 + + # Update name and description + updated_name = unique_name("sdk-mcp-config-updated") + result = mcp_config.update( + name=updated_name, + description="Updated description", + ) + assert result.name == updated_name + assert result.description == "Updated description" + + # Update endpoint + result = mcp_config.update( + endpoint="https://mcp.updated-example.com", + ) + assert result.endpoint == "https://mcp.updated-example.com" + + # Update allowed_tools + result = mcp_config.update( + allowed_tools=["github.search_*", "github.get_*"], + ) + assert "github.search_*" in result.allowed_tools + assert "github.get_*" in result.allowed_tools + assert len(result.allowed_tools) == 2 + + # Verify all updates persisted + info = mcp_config.get_info() + assert info.name == updated_name + assert info.description == "Updated description" + assert info.endpoint == "https://mcp.updated-example.com" + assert "github.search_*" in info.allowed_tools + assert "github.get_*" in info.allowed_tools + finally: + result = mcp_config.delete() + assert result is not None + + +class TestMcpConfigListing: + """Test MCP config listing and retrieval operations.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_mcp_config_list_and_retrieve(self, sdk_client: RunloopSDK) -> None: + """Test listing MCP configs and retrieving by ID.""" + config1 = sdk_client.mcp_config.create( + name=unique_name("sdk-mcp-config-list-1"), + endpoint=MCP_CONFIG_TEST_ENDPOINT, + allowed_tools=["*"], + ) + config2 = sdk_client.mcp_config.create( + name=unique_name("sdk-mcp-config-list-2"), + endpoint=MCP_CONFIG_TEST_ENDPOINT, + allowed_tools=["github.search_*"], + ) + + try: + configs = sdk_client.mcp_config.list(limit=100) + assert isinstance(configs, list) + config_ids = [c.id for c in configs] + assert config1.id in config_ids + assert config2.id in config_ids + + # Retrieve by ID + retrieved = sdk_client.mcp_config.from_id(config1.id) + assert retrieved.id == config1.id + info = retrieved.get_info() + assert info.id == config1.id + finally: + config1.delete() + config2.delete()