From b47ebd32f14d80ca8f3195108b5455f8083d9cdb Mon Sep 17 00:00:00 2001 From: Akshay Parihar Date: Mon, 11 May 2026 18:09:30 +0530 Subject: [PATCH 1/7] update git ignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index dcbd450..22953d9 100644 --- a/.gitignore +++ b/.gitignore @@ -170,3 +170,4 @@ proto # Claude Code documentation CLAUDE.md +.claude/* \ No newline at end of file From 3b262676b6a814d56c4203f8948c3f5e68aaaade Mon Sep 17 00:00:00 2001 From: Akshay Parihar Date: Tue, 12 May 2026 17:48:05 +0530 Subject: [PATCH 2/7] feat: add ProvidersClient and typed custom provider CRUD models Introduces typed Pydantic models for auth patterns (AuthField, OAuthConfig, AuthPattern, Provider) with typed request/response wrappers, a low-level ProvidersClient wrapping ProviderServiceStub, and ActionProviders accessible via client.actions.providers and client.connect.providers. --- scalekit/actions/actions.py | 141 +++++++- scalekit/actions/models/custom_provider.py | 331 ++++++++++++++++++ .../create_custom_provider_request.py | 60 ++++ .../delete_custom_provider_request.py | 21 ++ .../models/requests/list_providers_request.py | 43 +++ .../update_custom_provider_request.py | 57 +++ .../create_custom_provider_response.py | 46 +++ .../delete_custom_provider_response.py | 32 ++ .../responses/list_providers_response.py | 65 ++++ .../update_custom_provider_response.py | 45 +++ scalekit/actions/types.py | 21 ++ scalekit/client.py | 6 +- scalekit/providers.py | 212 +++++++++++ 13 files changed, 1074 insertions(+), 6 deletions(-) create mode 100644 scalekit/actions/models/custom_provider.py create mode 100644 scalekit/actions/models/requests/create_custom_provider_request.py create mode 100644 scalekit/actions/models/requests/delete_custom_provider_request.py create mode 100644 scalekit/actions/models/requests/list_providers_request.py create mode 100644 scalekit/actions/models/requests/update_custom_provider_request.py create mode 100644 scalekit/actions/models/responses/create_custom_provider_response.py create mode 100644 scalekit/actions/models/responses/delete_custom_provider_response.py create mode 100644 scalekit/actions/models/responses/list_providers_response.py create mode 100644 scalekit/actions/models/responses/update_custom_provider_response.py create mode 100644 scalekit/providers.py diff --git a/scalekit/actions/actions.py b/scalekit/actions/actions.py index 0771e4d..cef1a03 100644 --- a/scalekit/actions/actions.py +++ b/scalekit/actions/actions.py @@ -3,7 +3,9 @@ from scalekit.actions.types import ToolRequest,ExecuteToolResponse,MagicLinkResponse,ListConnectedAccountsResponse,DeleteConnectedAccountResponse,GetConnectedAccountAuthResponse,GetConnectedAccountDetailsResponse,ToolInput, \ UpdateConnectedAccountResponse,CreateMcpConfigResponse,ListMcpConfigsResponse,UpdateMcpConfigResponse,DeleteMcpConfigResponse, \ EnsureMcpInstanceResponse,UpdateMcpInstanceResponse,GetMcpInstanceResponse,ListMcpInstancesResponse,DeleteMcpInstanceResponse,GetMcpInstanceAuthStateResponse, \ - McpConfig,McpConfigConnectionToolMapping,VerifyConnectedAccountUserResponse + McpConfig,McpConfigConnectionToolMapping,VerifyConnectedAccountUserResponse, \ + CreateCustomProviderRequest,UpdateCustomProviderRequest,ListProvidersRequest,DeleteCustomProviderRequest, \ + CreateCustomProviderResponse,UpdateCustomProviderResponse,ListProvidersResponse,DeleteCustomProviderResponse from scalekit.actions.models.responses.create_connected_account_response import CreateConnectedAccountResponse from scalekit.actions.models.requests.create_connected_account_request import CreateConnectedAccountRequest from scalekit.actions.models.requests.update_connected_account_request import UpdateConnectedAccountRequest @@ -19,17 +21,19 @@ class ActionClient: """Class definition for Connect Client""" - def __init__(self,tools_client, connected_accounts_client, mcp_client=None): + def __init__(self,tools_client, connected_accounts_client, mcp_client=None, providers_client=None): """ Initialize ActionClient with tools, connected accounts, and MCP dependencies - + :param tools_client: ToolsClient instance :type: ToolsClient :param connected_accounts_client: ConnectedAccountsClient instance :type: ConnectedAccountsClient :param mcp_client: McpClient instance (optional) :type: McpClient - + :param providers_client: ProvidersClient instance (optional) + :type: ProvidersClient + :returns: None """ @@ -38,6 +42,8 @@ def __init__(self,tools_client, connected_accounts_client, mcp_client=None): self.connected_accounts = connected_accounts_client self._mcp_client = mcp_client self._mcp_actions = None + self._providers_client = providers_client + self._providers_actions = None self._modifiers: List[Modifier] = [] self._google = None self._langchain = None @@ -87,6 +93,20 @@ def mcp(self) -> "ActionMcp": self._mcp_actions = ActionMcp(self) return self._mcp_actions + @property + def providers(self) -> "ActionProviders": + """Expose custom provider CRUD with typed request and response objects. + + :returns: ActionProviders instance bound to the configured ProvidersClient. + :rtype: ActionProviders + :raises ValueError: If no ProvidersClient was passed at construction time. + """ + if self._providers_client is None: + raise ValueError("Providers client not initialized.") + if self._providers_actions is None: + self._providers_actions = ActionProviders(self._providers_client) + return self._providers_actions + def execute_tool( self, tool_input:ToolInput, @@ -1139,3 +1159,116 @@ def get_instance_auth_state( include_auth_links=include_auth_links, ) return GetMcpInstanceAuthStateResponse.from_proto(result_tuple[0]) + + +class ActionProviders: + """Typed action layer over ProvidersClient for custom provider CRUD. + + Accepts typed request objects and returns typed response objects. + Access via ActionClient.providers — do not instantiate directly. + """ + + def __init__(self, providers_client) -> None: + self._providers_client = providers_client + + def create_custom_provider( + self, + request: CreateCustomProviderRequest, + ) -> CreateCustomProviderResponse: + """Create a new custom provider. + + :param request: Request object containing display_name (required), + proxy_url (required), and optional description, + proxy_enabled, and auth_patterns. + :type request: CreateCustomProviderRequest + + :returns: Response containing the created provider with its server-assigned + identifier and all decoded auth_patterns. + :rtype: CreateCustomProviderResponse + + :raises ScalekitBadRequestException: If required fields are missing or invalid. + :raises ScalekitConflictException: If a provider with the same name already exists. + """ + result_tuple = self._providers_client.create_custom_provider( + display_name=request.display_name, + proxy_url=request.proxy_url, + proxy_enabled=request.proxy_enabled, + description=request.description, + auth_patterns=request.auth_patterns, + ) + return CreateCustomProviderResponse.from_proto(result_tuple[0]) + + def update_custom_provider( + self, + request: UpdateCustomProviderRequest, + ) -> UpdateCustomProviderResponse: + """Update an existing custom provider. + + Only fields set to a non-None value in the request are applied. + Fields left as None are ignored and their existing server values are kept. + + :param request: Request object containing identifier (required) and any + combination of display_name, description, proxy_url, and + auth_patterns to update. + :type request: UpdateCustomProviderRequest + + :returns: Response containing the provider's full state after the update. + :rtype: UpdateCustomProviderResponse + + :raises ScalekitNotFoundException: If no provider with the given identifier exists. + :raises ScalekitBadRequestException: If any updated field value is invalid. + """ + result_tuple = self._providers_client.update_custom_provider( + identifier=request.identifier, + display_name=request.display_name, + description=request.description, + proxy_url=request.proxy_url, + auth_patterns=request.auth_patterns, + ) + return UpdateCustomProviderResponse.from_proto(result_tuple[0]) + + def list_providers( + self, + request: ListProvidersRequest, + ) -> ListProvidersResponse: + """List providers with optional filtering and pagination. + + :param request: Request object with optional provider_type, page_size, + page_token, and identifier filters. All fields are optional — + an empty ListProvidersRequest() returns all providers. + :type request: ListProvidersRequest + + :returns: Response containing a page of providers and a next_page_token + for fetching subsequent pages. + :rtype: ListProvidersResponse + """ + result_tuple = self._providers_client.list_providers( + page_size=request.page_size, + page_token=request.page_token, + provider_type=request.provider_type, + identifier=request.identifier, + ) + return ListProvidersResponse.from_proto(result_tuple[0]) + + def delete_custom_provider( + self, + request: DeleteCustomProviderRequest, + ) -> DeleteCustomProviderResponse: + """Delete a custom provider by identifier. + + Deletion is permanent. Returns an empty response on success. Any error + (provider not found, insufficient permissions) raises a + ScalekitServerException subclass before this method returns. + + :param request: Request object containing the identifier of the provider + to delete. + :type request: DeleteCustomProviderRequest + + :returns: Empty response confirming deletion. + :rtype: DeleteCustomProviderResponse + + :raises ScalekitNotFoundException: If no provider with the given identifier exists. + :raises ScalekitForbiddenException: If the caller lacks permission to delete. + """ + result_tuple = self._providers_client.delete_custom_provider(request.identifier) + return DeleteCustomProviderResponse.from_proto(result_tuple[0]) diff --git a/scalekit/actions/models/custom_provider.py b/scalekit/actions/models/custom_provider.py new file mode 100644 index 0000000..514b111 --- /dev/null +++ b/scalekit/actions/models/custom_provider.py @@ -0,0 +1,331 @@ +from typing import List, Optional + +from google.protobuf.json_format import MessageToDict +from pydantic import BaseModel, Field + + +class AuthField(BaseModel): + """A single credential input field displayed to the user during connection setup. + + AuthField is only used with BEARER and API_KEY auth patterns — do not attach + fields to OAUTH patterns (the OAuth flow collects credentials itself). + + Use one AuthField per credential the user must supply. For a BEARER connector, add one AuthField(field_name='token', ...). For an + API_KEY connector, add one AuthField(field_name='api_key', + ...). Always set input_type='password' for tokens and keys so the UI masks the + value. + """ + + field_name: str = Field( + ..., + description=( + "Required. Machine-readable identifier for this field. Used as the key " + "when the credential value is stored and retrieved. " + "For BEARER patterns use 'token' or 'bearer_token'. " + "For API_KEY patterns use 'api_key'." + ), + ) + label: str = Field( + "", + description=( + "Optional. Human-readable label displayed above the input in the UI. " + "Example: 'API Key', 'Bearer Token'. Defaults to empty string." + ), + ) + input_type: str = Field( + "text", + description=( + "Optional. Controls how the input is rendered in the UI. " + "Accepted values: 'text' (visible input, default) or " + "'password' (masked input — use for secrets, tokens, and keys)." + ), + ) + hint: str = Field( + "", + description=( + "Optional. Placeholder or helper text shown below the input field. " + "Example: 'Find your token at Settings → API'. Defaults to empty string." + ), + ) + required: bool = Field( + False, + description=( + "Optional. Whether the user must fill this field before the connection " + "can be saved. True = field is mandatory; False = field is optional. " + "Defaults to False." + ), + ) + + def to_dict(self) -> dict: + """Serialize this field to a wire-format dict for inclusion in auth_patterns. + + :returns: Dict representation of the field. 'hint' and 'required' are omitted + when they hold their default values to keep the wire payload minimal. + :rtype: dict + """ + d: dict = { + "field_name": self.field_name, + "label": self.label, + "input_type": self.input_type, + } + if self.hint: + d["hint"] = self.hint + if self.required: + d["required"] = True + return d + + @classmethod + def from_dict(cls, d: dict) -> "AuthField": + """Deserialize an AuthField from a response dict. + + :param d: Dict containing field data as returned by the server. + :type d: dict + :returns: AuthField instance populated from the dict values. + :rtype: AuthField + """ + return cls( + field_name=d.get("field_name", ""), + label=d.get("label", ""), + input_type=d.get("input_type", "text"), + hint=d.get("hint", ""), + required=d.get("required", False), + ) + + +class OAuthConfig(BaseModel): + """OAuth configuration attached to an OAUTH auth pattern. + + Pass OAuthConfig() (all defaults) for MCP connectors that use Dynamic Client + Registration — the MCP server manages the OAuth flow itself and no static + endpoints are needed. Set pkce_enabled=False only if your OAuth server does + not support PKCE. + + This class is only used with AuthPattern(type="OAUTH"). Do not attach it to + BEARER or API_KEY patterns. + """ + + pkce_enabled: bool = Field( + True, + description=( + "Optional. Whether to enable PKCE (Proof Key for Code Exchange, RFC 7636) " + "for the OAuth authorization flow. True = PKCE on (default, recommended); " + "False = PKCE disabled. Only set to False if the OAuth server does not " + "support PKCE." + ), + ) + + def to_dict(self) -> dict: + """Serialize to a wire-format dict. + + :returns: Dict with a single key 'pkce_enabled'. Always emitted explicitly + so the server receives an unambiguous value. + :rtype: dict + """ + return {"pkce_enabled": self.pkce_enabled} + + @classmethod + def from_dict(cls, d: dict) -> "OAuthConfig": + """Deserialize an OAuthConfig from a response dict. + + :param d: Dict containing oauth_config data as returned by the server. + :type d: dict + :returns: OAuthConfig instance. pkce_enabled defaults to True if the key + is absent from the dict. + :rtype: OAuthConfig + """ + return cls(pkce_enabled=d.get("pkce_enabled", True)) + + +class AuthPattern(BaseModel): + """One authentication option available on a custom connector. + + A custom provider can have multiple auth patterns (e.g. both OAUTH and API_KEY), + giving users a choice of how to authenticate. For MCP connectors set is_mcp=True + on every pattern. + + Type guide: + - "OAUTH" — Browser-based OAuth 2.0 / 2.1 flow. Attach an OAuthConfig. + - "BEARER" — Static bearer token supplied by the user. Add AuthFields for + the token input. + - "API_KEY" — Static API key supplied by the user. Add AuthFields for the + key input. + """ + + type: str = Field( + ..., + description=( + "Required. Authentication mechanism for this pattern. " + "Accepted values: 'OAUTH' (browser OAuth flow), " + "'BEARER' (static bearer token), 'API_KEY' (static API key)." + ), + ) + display_name: str = Field( + ..., + description=( + "Required. Human-readable label for this auth option shown in the UI. " + "Accepted characters: a-z, A-Z, 0-9, and spaces. " + "Example: 'GitHub OAuth', 'API Token', 'Service Account Key'." + ), + ) + description: str = Field( + "", + description=( + "Optional. Short explanation of this auth method shown to the user. " + "Example: 'Authenticate with your GitHub account using OAuth 2.1'. " + "Defaults to empty string." + ), + ) + fields: List[AuthField] = Field( + default_factory=list, + description=( + "Optional. List of AuthField objects defining the credential inputs " + "the user must supply. Only applicable to BEARER and API_KEY types — " + "must be empty (or omitted) for OAUTH, which collects credentials " + "through the browser OAuth flow and does not use static input fields. " + "Defaults to empty list." + ), + ) + is_mcp: bool = Field( + False, + description=( + "Optional. Set to True for MCP (Model Context Protocol) server connectors. " + "When True the server sets is_custom_mcp=True on the provider. " + "Defaults to False." + ), + ) + oauth_config: Optional["OAuthConfig"] = Field( + None, + description=( + "Optional. OAuth configuration. Required when type='OAUTH'; omit for " + "'BEARER' and 'API_KEY' (must be None). " + "Pass OAuthConfig() for MCP connectors using Dynamic Client Registration. " + "Pass OAuthConfig(pkce_enabled=False) to disable PKCE. " + "Defaults to None." + ), + ) + + def to_dict(self) -> dict: + """Serialize to a wire-format dict for inclusion in the auth_patterns ListValue. + + :returns: Dict representation of this auth pattern. 'description' is omitted + when empty, 'is_mcp' when False, and 'oauth_config' when None, to + keep the wire payload minimal. + :rtype: dict + """ + d: dict = { + "type": self.type, + "display_name": self.display_name, + "fields": [f.to_dict() for f in self.fields], + } + if self.description: + d["description"] = self.description + if self.is_mcp: + d["is_mcp"] = True + if self.oauth_config is not None: + d["oauth_config"] = self.oauth_config.to_dict() + return d + + @classmethod + def from_dict(cls, d: dict) -> "AuthPattern": + """Deserialize an AuthPattern from a response dict. + + :param d: Dict containing auth pattern data as returned by the server. + The 'oauth_config' key must be present for OAuth patterns; + its absence is interpreted as oauth_config=None (non-OAuth). + :type d: dict + :returns: AuthPattern instance populated from the dict values. + :rtype: AuthPattern + """ + oauth_cfg: Optional[OAuthConfig] = None + if "oauth_config" in d: + oauth_cfg = OAuthConfig.from_dict(d["oauth_config"]) + return cls( + type=d.get("type", ""), + display_name=d.get("display_name", ""), + description=d.get("description", ""), + fields=[AuthField.from_dict(f) for f in d.get("fields", [])], + is_mcp=d.get("is_mcp", False), + oauth_config=oauth_cfg, + ) + + class Config: + validate_assignment = True + + +class Provider(BaseModel): + """A custom provider as returned by the Scalekit API. + + Produced exclusively by ProvidersClient response decoding via from_proto(). + All fields are populated from the server response — do not construct this + directly. The auth_patterns list is fully decoded so callers never need to + handle protobuf types. + """ + + identifier: str = Field( + "", + description="Unique identifier assigned by the server. Read-only.", + ) + display_name: str = Field( + "", + description=( + "Human-readable name of the provider. " + "Accepted characters: a-z, A-Z, 0-9, and spaces." + ), + ) + description: str = Field( + "", + description="Short description of the provider.", + ) + proxy_url: str = Field( + "", + description="The proxy URL through which requests to this provider are routed.", + ) + proxy_enabled: bool = Field( + False, + description="Whether request proxying is enabled for this provider.", + ) + is_custom: bool = Field( + False, + description="True for all providers created via create_custom_provider.", + ) + is_custom_mcp: bool = Field( + False, + description=( + "True when at least one auth_pattern was created with is_mcp=True. " + "Set by the server — not a field you write." + ), + ) + auth_patterns: List[AuthPattern] = Field( + default_factory=list, + description=( + "Decoded list of authentication options for this provider. " + "Currently contains at most one element — only a single auth pattern " + "is supported today. The list type is intentional for future multi-pattern support." + ), + ) + + @classmethod + def from_proto(cls, proto) -> "Provider": + """Decode a proto Provider message into a typed Provider. + + Calls MessageToDict on the auth_patterns ListValue field to produce a + plain Python list, then deserializes each element into an AuthPattern. + + :param proto: A proto Provider message instance from providers_pb2. + :returns: Provider instance with all fields populated and auth_patterns decoded. + :rtype: Provider + """ + auth_patterns_raw = MessageToDict(proto.auth_patterns) + return cls( + identifier=proto.identifier, + display_name=proto.display_name, + description=proto.description, + proxy_url=proto.proxy_url, + proxy_enabled=proto.proxy_enabled, + is_custom=proto.is_custom, + is_custom_mcp=proto.is_custom_mcp, + auth_patterns=[AuthPattern.from_dict(p) for p in auth_patterns_raw], + ) + + class Config: + validate_assignment = True diff --git a/scalekit/actions/models/requests/create_custom_provider_request.py b/scalekit/actions/models/requests/create_custom_provider_request.py new file mode 100644 index 0000000..a44cfdd --- /dev/null +++ b/scalekit/actions/models/requests/create_custom_provider_request.py @@ -0,0 +1,60 @@ +from typing import List + +from pydantic import BaseModel, Field + +from scalekit.actions.models.custom_provider import AuthPattern + + +class CreateCustomProviderRequest(BaseModel): + """Request model for creating a new custom provider. + + A custom provider represents an external service (e.g. an MCP server or a + REST API) that end users can connect to. At least one AuthPattern should be + included so users know how to authenticate with the provider. + """ + + display_name: str = Field( + ..., + description=( + "Required. Human-readable name for the provider shown in the UI and " + "API responses. Accepted characters: a-z, A-Z, 0-9, and spaces. " + "Good practice: suffix with 'MCP' for MCP server providers. " + "Example: 'GitHub Copilot MCP', 'Apify MCP Server'." + ), + ) + proxy_url: str = Field( + ..., + description=( + "Required. Base URL of the provider's server. All proxied requests are " + "routed through this URL. Must be a valid HTTPS URL. " + "Example: 'https://server.example.com/mcp'." + ), + ) + proxy_enabled: bool = Field( + True, + description=( + "Optional. Whether to enable request proxying through Scalekit for this " + "provider. True = proxy enabled (default); False = proxy disabled." + ), + ) + description: str = Field( + "", + description=( + "Optional. Short description of the provider shown in dashboards and " + "API responses. Defaults to empty string." + ), + ) + auth_patterns: List[AuthPattern] = Field( + default_factory=list, + description=( + "Optional. List of AuthPattern objects defining how users can authenticate " + "with this provider. Each pattern represents one authentication method " + "(OAUTH, BEARER, or API_KEY). At least one pattern is recommended. " + "Note: currently only a single AuthPattern is supported — pass a list " + "with exactly one element. The list type is intentional for future " + "multi-pattern support. Defaults to empty list." + ), + ) + + class Config: + validate_assignment = True diff --git a/scalekit/actions/models/requests/delete_custom_provider_request.py b/scalekit/actions/models/requests/delete_custom_provider_request.py new file mode 100644 index 0000000..40a26d2 --- /dev/null +++ b/scalekit/actions/models/requests/delete_custom_provider_request.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel, Field + + +class DeleteCustomProviderRequest(BaseModel): + """Request model for deleting a custom provider. + + Deletion is permanent. The provider and all associated configuration are + removed. Existing connected accounts for this provider are not automatically + deleted — handle those separately before deleting the provider if needed. + """ + + identifier: str = Field( + ..., + description=( + "Required. Identifier of the custom provider to delete. Obtained from " + "Provider.identifier in a create or list response." + ), + ) + + class Config: + validate_assignment = True diff --git a/scalekit/actions/models/requests/list_providers_request.py b/scalekit/actions/models/requests/list_providers_request.py new file mode 100644 index 0000000..18b274f --- /dev/null +++ b/scalekit/actions/models/requests/list_providers_request.py @@ -0,0 +1,43 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class ListProvidersRequest(BaseModel): + """Request model for listing providers with optional filtering and pagination.""" + + provider_type: Optional[int] = Field( + None, + description=( + "Optional. Filter results by provider type. Pass a ProviderType enum " + "value from scalekit.v1.providers.providers_pb2: " + "ProviderType.CUSTOM (1) = custom providers only, " + "ProviderType.DEFAULT (0) = built-in providers only, " + "ProviderType.ALL (2) = all providers. " + "Pass None (default) to return all providers." + ), + ) + page_size: Optional[int] = Field( + None, + description=( + "Optional. Maximum number of providers to return in a single response. " + "Pass None (default) to use the server's default page size." + ), + ) + page_token: Optional[str] = Field( + None, + description=( + "Optional. Pagination cursor returned as next_page_token in a previous " + "list response. Pass None (default) to fetch the first page." + ), + ) + identifier: Optional[str] = Field( + None, + description=( + "Optional. Filter to a specific provider by its identifier. " + "Pass None (default) to return all providers matching the other filters." + ), + ) + + class Config: + validate_assignment = True diff --git a/scalekit/actions/models/requests/update_custom_provider_request.py b/scalekit/actions/models/requests/update_custom_provider_request.py new file mode 100644 index 0000000..994f1af --- /dev/null +++ b/scalekit/actions/models/requests/update_custom_provider_request.py @@ -0,0 +1,57 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field + +from scalekit.actions.models.custom_provider import AuthPattern + + +class UpdateCustomProviderRequest(BaseModel): + """Request model for updating an existing custom provider. + + Only fields explicitly set to a non-None value are applied. Fields left as + None are ignored — the server keeps their existing values. To clear a field, + pass an empty string or empty list as appropriate. + """ + + identifier: str = Field( + ..., + description=( + "Required. Identifier of the provider to update. Obtained from " + "Provider.identifier in a create or list response." + ), + ) + display_name: Optional[str] = Field( + None, + description=( + "Optional. New display name for the provider. Accepted characters: " + "a-z, A-Z, 0-9, and spaces. Good practice: suffix with 'MCP' for MCP " + "server providers. Pass None (default) to leave the existing value unchanged." + ), + ) + description: Optional[str] = Field( + None, + description=( + "Optional. New description for the provider. Pass None (default) to " + "leave the existing value unchanged." + ), + ) + proxy_url: Optional[str] = Field( + None, + description=( + "Optional. New proxy URL. Must be a valid HTTPS URL if provided. " + "Pass None (default) to leave the existing value unchanged." + ), + ) + auth_patterns: Optional[List[AuthPattern]] = Field( + None, + description=( + "Optional. Replacement list of AuthPattern objects. When provided, " + "this list fully replaces the existing auth_patterns on the server — " + "it is not merged. Note: currently only a single AuthPattern is supported " + "— pass a list with exactly one element. The list type is intentional for " + "future multi-pattern support. Pass None (default) to leave auth_patterns unchanged." + ), + ) + + class Config: + validate_assignment = True diff --git a/scalekit/actions/models/responses/create_custom_provider_response.py b/scalekit/actions/models/responses/create_custom_provider_response.py new file mode 100644 index 0000000..da29d10 --- /dev/null +++ b/scalekit/actions/models/responses/create_custom_provider_response.py @@ -0,0 +1,46 @@ +from typing import Optional + +from pydantic import BaseModel, Field + +from scalekit.actions.models.custom_provider import Provider + + +class CreateCustomProviderResponse(BaseModel): + """Response returned by ActionProviders.create_custom_provider(). + + Contains the fully-decoded provider as created by the server, including + the server-assigned identifier and all decoded auth_patterns. + """ + + provider: Optional[Provider] = Field( + None, + description=( + "The newly created provider. Contains the server-assigned identifier, " + "all provided fields, and fully decoded auth_patterns. " + "None only if the server returned an empty response body." + ), + ) + + @classmethod + def from_proto(cls, proto_response) -> "CreateCustomProviderResponse": + """Decode a proto CreateProviderResponse into a typed response. + + :param proto_response: Proto CreateProviderResponse from providers_pb2. + :returns: CreateCustomProviderResponse with provider decoded. + :rtype: CreateCustomProviderResponse + """ + provider = None + if proto_response.provider: + provider = Provider.from_proto(proto_response.provider) + return cls(provider=provider) + + def to_dict(self) -> dict: + """Serialize to a plain Python dict. + + :returns: Dict with a 'provider' key. Value is None if provider is absent. + :rtype: dict + """ + return {"provider": self.provider.model_dump() if self.provider else None} + + class Config: + validate_assignment = True diff --git a/scalekit/actions/models/responses/delete_custom_provider_response.py b/scalekit/actions/models/responses/delete_custom_provider_response.py new file mode 100644 index 0000000..458d7d0 --- /dev/null +++ b/scalekit/actions/models/responses/delete_custom_provider_response.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel + + +class DeleteCustomProviderResponse(BaseModel): + """Response returned by ActionProviders.delete_custom_provider(). + + This response is intentionally empty. A successful delete is indicated by + this object being returned without an exception. Any server-side error + (not found, permission denied, etc.) raises a ScalekitServerException subclass + before this response is constructed. + """ + + @classmethod + def from_proto(cls, proto_response) -> "DeleteCustomProviderResponse": + """Decode a proto DeleteProviderResponse into a typed response. + + :param proto_response: Proto DeleteProviderResponse from providers_pb2. + :returns: Empty DeleteCustomProviderResponse instance. + :rtype: DeleteCustomProviderResponse + """ + return cls() + + def to_dict(self) -> dict: + """Serialize to a plain Python dict. + + :returns: Empty dict. + :rtype: dict + """ + return {} + + class Config: + validate_assignment = True diff --git a/scalekit/actions/models/responses/list_providers_response.py b/scalekit/actions/models/responses/list_providers_response.py new file mode 100644 index 0000000..81a1ac3 --- /dev/null +++ b/scalekit/actions/models/responses/list_providers_response.py @@ -0,0 +1,65 @@ +from typing import List + +from pydantic import BaseModel, Field + +from scalekit.actions.models.custom_provider import Provider + + +class ListProvidersResponse(BaseModel): + """Response returned by ActionProviders.list_providers(). + + Contains a page of providers matching the request filters and pagination + cursor for fetching the next page. + """ + + providers: List[Provider] = Field( + default_factory=list, + description=( + "List of providers in the current page. Each Provider has fully decoded " + "auth_patterns. Empty list if no providers match the filter." + ), + ) + next_page_token: str = Field( + "", + description=( + "Pagination cursor for the next page. Pass this value as " + "ListProvidersRequest.page_token in a subsequent call to fetch the next " + "page. Empty string when this is the last page." + ), + ) + total_size: int = Field( + 0, + description=( + "Total number of providers matching the filter across all pages. " + "May be 0 if the server does not support total count." + ), + ) + + @classmethod + def from_proto(cls, proto_response) -> "ListProvidersResponse": + """Decode a proto ListProvidersResponse into a typed response. + + :param proto_response: Proto ListProvidersResponse from providers_pb2. + :returns: ListProvidersResponse with providers decoded. + :rtype: ListProvidersResponse + """ + return cls( + providers=[Provider.from_proto(p) for p in proto_response.providers], + next_page_token=proto_response.next_page_token, + total_size=proto_response.total_size, + ) + + def to_dict(self) -> dict: + """Serialize to a plain Python dict. + + :returns: Dict with 'providers', 'next_page_token', and 'total_size' keys. + :rtype: dict + """ + return { + "providers": [p.model_dump() for p in self.providers], + "next_page_token": self.next_page_token, + "total_size": self.total_size, + } + + class Config: + validate_assignment = True diff --git a/scalekit/actions/models/responses/update_custom_provider_response.py b/scalekit/actions/models/responses/update_custom_provider_response.py new file mode 100644 index 0000000..30ca41b --- /dev/null +++ b/scalekit/actions/models/responses/update_custom_provider_response.py @@ -0,0 +1,45 @@ +from typing import Optional + +from pydantic import BaseModel, Field + +from scalekit.actions.models.custom_provider import Provider + + +class UpdateCustomProviderResponse(BaseModel): + """Response returned by ActionProviders.update_custom_provider(). + + Contains the provider's state after the update has been applied. All fields + reflect the current server-side values — not just the fields that were changed. + """ + + provider: Optional[Provider] = Field( + None, + description=( + "The updated provider reflecting its full current state after the update. " + "None only if the server returned an empty response body." + ), + ) + + @classmethod + def from_proto(cls, proto_response) -> "UpdateCustomProviderResponse": + """Decode a proto UpdateProviderResponse into a typed response. + + :param proto_response: Proto UpdateProviderResponse from providers_pb2. + :returns: UpdateCustomProviderResponse with provider decoded. + :rtype: UpdateCustomProviderResponse + """ + provider = None + if proto_response.provider: + provider = Provider.from_proto(proto_response.provider) + return cls(provider=provider) + + def to_dict(self) -> dict: + """Serialize to a plain Python dict. + + :returns: Dict with a 'provider' key. Value is None if provider is absent. + :rtype: dict + """ + return {"provider": self.provider.model_dump() if self.provider else None} + + class Config: + validate_assignment = True diff --git a/scalekit/actions/types.py b/scalekit/actions/types.py index fa0a483..0e37654 100644 --- a/scalekit/actions/types.py +++ b/scalekit/actions/types.py @@ -26,6 +26,15 @@ from .models.tool_mapping import ToolMapping from .models.mcp_config import McpConfig, McpConfigConnectionToolMapping from .models.mcp_instance import McpInstance, McpInstanceConnectionAuthState +from .models.custom_provider import AuthPattern, AuthField, OAuthConfig, Provider +from .models.requests.create_custom_provider_request import CreateCustomProviderRequest +from .models.requests.update_custom_provider_request import UpdateCustomProviderRequest +from .models.requests.list_providers_request import ListProvidersRequest +from .models.requests.delete_custom_provider_request import DeleteCustomProviderRequest +from .models.responses.create_custom_provider_response import CreateCustomProviderResponse +from .models.responses.update_custom_provider_response import UpdateCustomProviderResponse +from .models.responses.list_providers_response import ListProvidersResponse +from .models.responses.delete_custom_provider_response import DeleteCustomProviderResponse __all__ = [ @@ -60,5 +69,17 @@ 'McpConfigConnectionToolMapping', 'McpInstance', 'McpInstanceConnectionAuthState', + 'AuthPattern', + 'AuthField', + 'OAuthConfig', + 'Provider', + 'CreateCustomProviderRequest', + 'UpdateCustomProviderRequest', + 'ListProvidersRequest', + 'DeleteCustomProviderRequest', + 'CreateCustomProviderResponse', + 'UpdateCustomProviderResponse', + 'ListProvidersResponse', + 'DeleteCustomProviderResponse', 'VerifyConnectedAccountUserResponse', ] diff --git a/scalekit/client.py b/scalekit/client.py index 7022e46..0afb5d4 100644 --- a/scalekit/client.py +++ b/scalekit/client.py @@ -21,6 +21,7 @@ from scalekit.connected_accounts import ConnectedAccountsClient from scalekit.tools import ToolsClient from scalekit.actions import ActionClient +from scalekit.providers import ProvidersClient from scalekit.passwordless import PasswordlessClient from scalekit.mcp import McpClient from scalekit.sessions import SessionsClient @@ -76,8 +77,9 @@ def __init__(self, env_url: str, client_id: str, client_secret: str): self.connected_accounts = ConnectedAccountsClient(self.core_client) self.tools = ToolsClient(self.core_client) self.mcp = McpClient(self.core_client) - self.connect = ActionClient(self.tools, self.connected_accounts, self.mcp) - self.actions = ActionClient(self.tools, self.connected_accounts, self.mcp) + _providers = ProvidersClient(self.core_client) + self.connect = ActionClient(self.tools, self.connected_accounts, self.mcp, _providers) + self.actions = ActionClient(self.tools, self.connected_accounts, self.mcp, _providers) self.passwordless = PasswordlessClient(self.core_client) self.sessions = SessionsClient(self.core_client) self.auth = AuthClient(self.core_client) diff --git a/scalekit/providers.py b/scalekit/providers.py new file mode 100644 index 0000000..d567849 --- /dev/null +++ b/scalekit/providers.py @@ -0,0 +1,212 @@ +from typing import Dict, List, Optional + +from google.protobuf import struct_pb2 +from google.protobuf.json_format import ParseDict + +from scalekit.actions.models.custom_provider import AuthPattern +from scalekit.core import CoreClient +from scalekit.v1.providers.providers_pb2 import ( + CreateCustomProvider, + CreateCustomProviderRequest, + UpdateCustomProvider, + UpdateCustomProviderRequest, + DeleteProviderRequest, + ListProvidersRequest, +) +from scalekit.v1.providers.providers_pb2_grpc import ProviderServiceStub + + +def _patterns_to_list_value(patterns: List[AuthPattern]) -> struct_pb2.ListValue: + return ParseDict([p.to_dict() for p in patterns], struct_pb2.ListValue()) + + +class ProvidersClient: + """Low-level gRPC client for custom provider CRUD operations. + + All methods return (proto_response, grpc.Call) tuples — the same convention + used by every other sub-client in this SDK. Do not use this class directly; + access it through ActionClient.providers which wraps it with typed + request/response objects via ActionProviders. + """ + + def __init__(self, core_client: CoreClient): + self.core_client = core_client + self._stub = ProviderServiceStub(self.core_client.grpc_secure_channel) + + def create_custom_provider( + self, + display_name: str, + proxy_url: str, + proxy_enabled: bool = True, + description: str = "", + auth_patterns: Optional[List[AuthPattern]] = None, + icon_src: Optional[str] = None, + metadata: Optional[Dict[str, str]] = None, + ): + """Create a new custom provider. + + :param display_name: Human-readable name for the provider. Required. + Accepted characters: a-z, A-Z, 0-9, and spaces. + Good practice: suffix with 'MCP' for MCP server providers. + :type display_name: str + :param proxy_url: Base HTTPS URL of the provider's server. Required. + :type proxy_url: str + :param proxy_enabled: Whether to enable Scalekit request proxying. Defaults to True. + :type proxy_enabled: bool + :param description: Short description of the provider. Defaults to empty string. + :type description: str + :param auth_patterns: Authentication options available to users. Each element + must be an AuthPattern instance. Currently only a single + AuthPattern is supported — pass a list with exactly one + element. The list type is intentional for future + multi-pattern support. Optional. + :type auth_patterns: Optional[List[AuthPattern]] + :param icon_src: URL of the provider's icon image. Optional. + :type icon_src: Optional[str] + :param metadata: Arbitrary string key-value pairs attached to the provider. Optional. + :type metadata: Optional[Dict[str, str]] + + :returns: Tuple of (CreateProviderResponse proto, grpc.Call). + response[0].provider contains the created provider proto. + response[1].code().name == 'OK' on success. + :rtype: tuple + """ + provider = CreateCustomProvider( + display_name=display_name, + description=description, + proxy_url=proxy_url, + proxy_enabled=proxy_enabled, + ) + if auth_patterns: + provider.auth_patterns.CopyFrom(_patterns_to_list_value(auth_patterns)) + if icon_src is not None: + provider.icon_src = icon_src + if metadata: + provider.metadata.update(metadata) + return self.core_client.grpc_exec( + self._stub.CreateCustomProvider.with_call, + CreateCustomProviderRequest(provider=provider), + ) + + def update_custom_provider( + self, + identifier: str, + display_name: Optional[str] = None, + description: Optional[str] = None, + proxy_url: Optional[str] = None, + auth_patterns: Optional[List[AuthPattern]] = None, + icon_src: Optional[str] = None, + metadata: Optional[Dict[str, str]] = None, + ): + """Update an existing custom provider. + + Only fields explicitly passed with a non-None value are applied to the + provider. Fields left as None are not included in the update request and + the server keeps their existing values. + + :param identifier: Identifier of the custom provider to update. Required. + :type identifier: str + :param display_name: New display name. Accepted characters: a-z, A-Z, 0-9, + and spaces. Good practice: suffix with 'MCP' for MCP + server providers. Pass None to leave unchanged. + :type display_name: Optional[str] + :param description: New description. Pass None to leave unchanged. + :type description: Optional[str] + :param proxy_url: New proxy URL. Pass None to leave unchanged. + :type proxy_url: Optional[str] + :param auth_patterns: Replacement auth patterns. Fully replaces the existing + list when provided — it is not merged. Currently only a + single AuthPattern is supported — pass a list with exactly + one element. The list type is intentional for future + multi-pattern support. Pass None to leave unchanged. + :type auth_patterns: Optional[List[AuthPattern]] + :param icon_src: New icon URL. Pass None to leave unchanged. + :type icon_src: Optional[str] + :param metadata: Replacement metadata key-value pairs. The server replaces + the provider's entire metadata map with this value — it does + not merge. If omitted or set to None, the server clears all + existing metadata on the provider. To retain current metadata, + fetch the provider first and pass its metadata dict here. + :type metadata: Optional[Dict[str, str]] + + :returns: Tuple of (UpdateProviderResponse proto, grpc.Call). + response[0].provider contains the updated provider proto. + response[1].code().name == 'OK' on success. + :rtype: tuple + """ + provider = UpdateCustomProvider() + if display_name is not None: + provider.display_name = display_name + if description is not None: + provider.description = description + if proxy_url is not None: + provider.proxy_url = proxy_url + if auth_patterns is not None: + provider.auth_patterns.CopyFrom(_patterns_to_list_value(auth_patterns)) + if icon_src is not None: + provider.icon_src = icon_src + if metadata is not None: + provider.metadata.update(metadata) + return self.core_client.grpc_exec( + self._stub.UpdateCustomProvider.with_call, + UpdateCustomProviderRequest(identifier=identifier, provider=provider), + ) + + def delete_custom_provider(self, identifier: str): + """Delete a custom provider by identifier. + + Deletion is permanent. The provider is removed from the Scalekit catalog + and can no longer be used for new connections. + + :param identifier: Identifier of the custom provider to delete. Required. + :type identifier: str + + :returns: Tuple of (DeleteProviderResponse proto, grpc.Call). + response[1].code().name == 'OK' on success. + :rtype: tuple + """ + return self.core_client.grpc_exec( + self._stub.DeleteCustomProvider.with_call, + DeleteProviderRequest(identifier=identifier), + ) + + def list_providers( + self, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + provider_type: Optional[int] = None, + identifier: Optional[str] = None, + ): + """List providers, optionally filtered by type and identifier. + + :param page_size: Maximum number of providers to return. Pass None for + the server's default page size. + :type page_size: Optional[int] + :param page_token: Pagination cursor from a previous list response's + next_page_token. Pass None to fetch the first page. + :type page_token: Optional[str] + :param provider_type: ProviderType enum value to filter results. + ProviderType.CUSTOM=1, ProviderType.DEFAULT=0, + ProviderType.ALL=2. Pass None to return all. + :type provider_type: Optional[int] + :param identifier: Filter to a specific provider by identifier. + Pass None to return all providers. + :type identifier: Optional[str] + + :returns: Tuple of (ListProvidersResponse proto, grpc.Call). + response[0].providers contains the list of provider protos. + response[1].code().name == 'OK' on success. + :rtype: tuple + """ + filter_obj = None + if provider_type is not None: + filter_obj = ListProvidersRequest.Filter(provider_type=provider_type) + return self.core_client.grpc_exec( + self._stub.ListProviders.with_call, + ListProvidersRequest( + identifier=identifier or "", + page_size=page_size or 0, + page_token=page_token or "", + filter=filter_obj, + ), + ) From 51043be1011ecbc41a6c0eac7ece978cedc01135 Mon Sep 17 00:00:00 2001 From: Akshay Parihar Date: Tue, 12 May 2026 17:52:37 +0530 Subject: [PATCH 3/7] test: add integration tests for ProvidersClient and fix required update fields display_name and proxy_url are required by the server on every UpdateCustomProvider call. Updated UpdateCustomProviderRequest, ProvidersClient, and ActionProviders accordingly. Adds test_providers.py covering OAuth, Bearer, and API Key MCP flows. --- scalekit/actions/actions.py | 2 +- .../update_custom_provider_request.py | 21 +- scalekit/providers.py | 32 +-- tests/test_providers.py | 259 ++++++++++++++++++ 4 files changed, 286 insertions(+), 28 deletions(-) create mode 100644 tests/test_providers.py diff --git a/scalekit/actions/actions.py b/scalekit/actions/actions.py index cef1a03..5af69a0 100644 --- a/scalekit/actions/actions.py +++ b/scalekit/actions/actions.py @@ -1221,8 +1221,8 @@ def update_custom_provider( result_tuple = self._providers_client.update_custom_provider( identifier=request.identifier, display_name=request.display_name, - description=request.description, proxy_url=request.proxy_url, + description=request.description, auth_patterns=request.auth_patterns, ) return UpdateCustomProviderResponse.from_proto(result_tuple[0]) diff --git a/scalekit/actions/models/requests/update_custom_provider_request.py b/scalekit/actions/models/requests/update_custom_provider_request.py index 994f1af..21fa2f8 100644 --- a/scalekit/actions/models/requests/update_custom_provider_request.py +++ b/scalekit/actions/models/requests/update_custom_provider_request.py @@ -8,9 +8,8 @@ class UpdateCustomProviderRequest(BaseModel): """Request model for updating an existing custom provider. - Only fields explicitly set to a non-None value are applied. Fields left as - None are ignored — the server keeps their existing values. To clear a field, - pass an empty string or empty list as appropriate. + display_name and proxy_url are required by the server on every update. + Optional fields left as None are ignored — the server keeps their existing values. """ identifier: str = Field( @@ -20,12 +19,12 @@ class UpdateCustomProviderRequest(BaseModel): "Provider.identifier in a create or list response." ), ) - display_name: Optional[str] = Field( - None, + display_name: str = Field( + ..., description=( - "Optional. New display name for the provider. Accepted characters: " + "Required. Display name for the provider. Accepted characters: " "a-z, A-Z, 0-9, and spaces. Good practice: suffix with 'MCP' for MCP " - "server providers. Pass None (default) to leave the existing value unchanged." + "server providers." ), ) description: Optional[str] = Field( @@ -35,11 +34,11 @@ class UpdateCustomProviderRequest(BaseModel): "leave the existing value unchanged." ), ) - proxy_url: Optional[str] = Field( - None, + proxy_url: str = Field( + ..., description=( - "Optional. New proxy URL. Must be a valid HTTPS URL if provided. " - "Pass None (default) to leave the existing value unchanged." + "Required. Proxy URL for the provider. Must be a valid HTTPS URL " + "starting with 'https://'." ), ) auth_patterns: Optional[List[AuthPattern]] = Field( diff --git a/scalekit/providers.py b/scalekit/providers.py index d567849..2fe5ac6 100644 --- a/scalekit/providers.py +++ b/scalekit/providers.py @@ -91,29 +91,30 @@ def create_custom_provider( def update_custom_provider( self, identifier: str, - display_name: Optional[str] = None, + display_name: str, + proxy_url: str, description: Optional[str] = None, - proxy_url: Optional[str] = None, auth_patterns: Optional[List[AuthPattern]] = None, icon_src: Optional[str] = None, metadata: Optional[Dict[str, str]] = None, ): """Update an existing custom provider. - Only fields explicitly passed with a non-None value are applied to the - provider. Fields left as None are not included in the update request and - the server keeps their existing values. + display_name and proxy_url are required by the server on every update. + Optional fields left as None are not sent and the server keeps their + existing values. :param identifier: Identifier of the custom provider to update. Required. :type identifier: str - :param display_name: New display name. Accepted characters: a-z, A-Z, 0-9, - and spaces. Good practice: suffix with 'MCP' for MCP - server providers. Pass None to leave unchanged. - :type display_name: Optional[str] + :param display_name: Display name for the provider. Accepted characters: + a-z, A-Z, 0-9, and spaces. Good practice: suffix with + 'MCP' for MCP server providers. Required. + :type display_name: str + :param proxy_url: Proxy URL for the provider. Must be a valid HTTPS URL + starting with 'https://'. Required. + :type proxy_url: str :param description: New description. Pass None to leave unchanged. :type description: Optional[str] - :param proxy_url: New proxy URL. Pass None to leave unchanged. - :type proxy_url: Optional[str] :param auth_patterns: Replacement auth patterns. Fully replaces the existing list when provided — it is not merged. Currently only a single AuthPattern is supported — pass a list with exactly @@ -134,13 +135,12 @@ def update_custom_provider( response[1].code().name == 'OK' on success. :rtype: tuple """ - provider = UpdateCustomProvider() - if display_name is not None: - provider.display_name = display_name + provider = UpdateCustomProvider( + display_name=display_name, + proxy_url=proxy_url, + ) if description is not None: provider.description = description - if proxy_url is not None: - provider.proxy_url = proxy_url if auth_patterns is not None: provider.auth_patterns.CopyFrom(_patterns_to_list_value(auth_patterns)) if icon_src is not None: diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 0000000..5173750 --- /dev/null +++ b/tests/test_providers.py @@ -0,0 +1,259 @@ +from faker import Faker +from basetest import BaseTest + +from scalekit.actions.types import ( + AuthPattern, + AuthField, + OAuthConfig, + CreateCustomProviderRequest, + UpdateCustomProviderRequest, + ListProvidersRequest, + DeleteCustomProviderRequest, +) +from scalekit.v1.providers.providers_pb2 import ProviderType + + +class TestProviders(BaseTest): + """Integration tests for ActionProviders — MCP connectors only.""" + + def setUp(self): + self.faker = Faker() + self.created_identifier = None + + def tearDown(self): + if self.created_identifier: + try: + self.scalekit_client.actions.providers.delete_custom_provider( + DeleteCustomProviderRequest(identifier=self.created_identifier) + ) + except Exception: + pass + self.created_identifier = None + + # ------------------------------------------------------------------ + # OAuth MCP — create + list (OAuthConfig with pkce_enabled=True by default) + # ------------------------------------------------------------------ + + def test_oauth_mcp_create_and_list(self): + """Create an OAuth MCP provider and verify all fields including pkce_enabled.""" + suffix = self.faker.unique.random_number(digits=6) + + create_resp = self.scalekit_client.actions.providers.create_custom_provider( + CreateCustomProviderRequest( + display_name=f"Test OAuth MCP Provider {suffix}", + description="Integration test OAuth MCP connector", + proxy_url="https://server.example.com/mcp", + proxy_enabled=True, + auth_patterns=[ + AuthPattern( + type="OAUTH", + display_name="OAuth 2.1/DCR", + description="Authenticate with browser OAuth. MCP server handles DCR.", + is_mcp=True, + oauth_config=OAuthConfig(), # pkce_enabled=True by default + ) + ], + ) + ) + provider = create_resp.provider + self.assertIsNotNone(provider) + self.created_identifier = provider.identifier + + # top-level provider fields + self.assertEqual(provider.display_name, f"Test OAuth MCP Provider {suffix}") + self.assertEqual(provider.description, "Integration test OAuth MCP connector") + self.assertEqual(provider.proxy_url, "https://server.example.com/mcp") + self.assertTrue(provider.proxy_enabled) + self.assertTrue(provider.is_custom) + self.assertTrue(provider.is_custom_mcp) + + # auth_patterns — fully typed, no MessageToDict + self.assertEqual(len(provider.auth_patterns), 1) + p = provider.auth_patterns[0] + self.assertEqual(p.type, "OAUTH") + self.assertEqual(p.display_name, "OAuth 2.1/DCR") + self.assertEqual(p.description, "Authenticate with browser OAuth. MCP server handles DCR.") + self.assertEqual(p.fields, []) + self.assertTrue(p.is_mcp) + self.assertIsNotNone(p.oauth_config) + self.assertTrue(p.oauth_config.pkce_enabled) + + # verify it appears in list + list_resp = self.scalekit_client.actions.providers.list_providers( + ListProvidersRequest(provider_type=ProviderType.CUSTOM, page_size=100) + ) + listed = next( + (lp for lp in list_resp.providers if lp.identifier == self.created_identifier), + None, + ) + self.assertIsNotNone(listed, "Created MCP provider not found in list") + self.assertEqual(listed.display_name, f"Test OAuth MCP Provider {suffix}") + self.assertTrue(listed.is_custom_mcp) + lp = listed.auth_patterns[0] + self.assertEqual(lp.type, "OAUTH") + self.assertTrue(lp.is_mcp) + self.assertIsNotNone(lp.oauth_config) + self.assertTrue(lp.oauth_config.pkce_enabled) + + # ------------------------------------------------------------------ + # Bearer MCP — create + update + list + # ------------------------------------------------------------------ + + def test_bearer_mcp_create_update_and_list(self): + """Create Bearer MCP provider, update description and field hint, verify is_mcp preserved.""" + suffix = self.faker.unique.random_number(digits=6) + + create_resp = self.scalekit_client.actions.providers.create_custom_provider( + CreateCustomProviderRequest( + display_name=f"Test Bearer MCP Provider {suffix}", + description="Integration test Bearer MCP connector", + proxy_url="https://server.example.com/mcp", + proxy_enabled=True, + auth_patterns=[ + AuthPattern( + type="BEARER", + display_name="Apify Token", + description="Authenticate with Apify using your API Token.", + is_mcp=True, + fields=[ + AuthField( + field_name="token", + label="Apify Token", + input_type="password", + hint="Your Apify API Token", + required=True, + ) + ], + ) + ], + ) + ) + provider = create_resp.provider + self.assertIsNotNone(provider) + self.created_identifier = provider.identifier + + # assert create response + self.assertEqual(provider.description, "Integration test Bearer MCP connector") + self.assertEqual(provider.proxy_url, "https://server.example.com/mcp") + self.assertTrue(provider.is_custom_mcp) + p = provider.auth_patterns[0] + self.assertEqual(p.type, "BEARER") + self.assertEqual(p.display_name, "Apify Token") + self.assertTrue(p.is_mcp) + self.assertIsNone(p.oauth_config) + self.assertEqual(len(p.fields), 1) + self.assertEqual(p.fields[0].field_name, "token") + self.assertEqual(p.fields[0].hint, "Your Apify API Token") + self.assertTrue(p.fields[0].required) + + # update description and field hint + update_resp = self.scalekit_client.actions.providers.update_custom_provider( + UpdateCustomProviderRequest( + identifier=self.created_identifier, + display_name=f"Test Bearer MCP Provider {suffix}", + proxy_url="https://server.example.com/mcp", + description="Updated Bearer MCP connector description", + auth_patterns=[ + AuthPattern( + type="BEARER", + display_name="Apify Token", + description="Authenticate with Apify using your API Token.", + is_mcp=True, + fields=[ + AuthField( + field_name="token", + label="Apify Token", + input_type="password", + hint="Your Apify API Token (updated)", + required=True, + ) + ], + ) + ], + ) + ) + updated = update_resp.provider + self.assertIsNotNone(updated) + self.assertEqual(updated.description, "Updated Bearer MCP connector description") + self.assertTrue(updated.is_custom_mcp) + up = updated.auth_patterns[0] + self.assertTrue(up.is_mcp) + self.assertEqual(up.fields[0].hint, "Your Apify API Token (updated)") + + # verify update visible in list + list_resp = self.scalekit_client.actions.providers.list_providers( + ListProvidersRequest(provider_type=ProviderType.CUSTOM, page_size=100) + ) + listed = next( + (lp for lp in list_resp.providers if lp.identifier == self.created_identifier), + None, + ) + self.assertIsNotNone(listed, "Updated MCP provider not found in list") + self.assertEqual(listed.description, "Updated Bearer MCP connector description") + self.assertTrue(listed.is_custom_mcp) + lp = listed.auth_patterns[0] + self.assertEqual(lp.type, "BEARER") + self.assertTrue(lp.is_mcp) + self.assertEqual(lp.fields[0].hint, "Your Apify API Token (updated)") + + # ------------------------------------------------------------------ + # API Key MCP — create + delete + # ------------------------------------------------------------------ + + def test_api_key_mcp_create_and_delete(self): + """Create an API Key MCP provider, delete it, confirm it no longer appears in list.""" + suffix = self.faker.unique.random_number(digits=6) + + create_resp = self.scalekit_client.actions.providers.create_custom_provider( + CreateCustomProviderRequest( + display_name=f"Test API Key MCP Provider {suffix}", + description="Integration test API Key MCP connector", + proxy_url="https://server.example.com/mcp", + proxy_enabled=True, + auth_patterns=[ + AuthPattern( + type="API_KEY", + display_name="API Key", + description="Authenticate with a static API key", + is_mcp=True, + fields=[ + AuthField( + field_name="api_key", + label="API Key", + input_type="password", + hint="Your API key", + required=True, + ) + ], + ) + ], + ) + ) + provider = create_resp.provider + self.assertIsNotNone(provider) + identifier = provider.identifier + + # assert create response + self.assertEqual(provider.description, "Integration test API Key MCP connector") + self.assertEqual(provider.proxy_url, "https://server.example.com/mcp") + self.assertTrue(provider.is_custom_mcp) + p = provider.auth_patterns[0] + self.assertEqual(p.type, "API_KEY") + self.assertEqual(p.display_name, "API Key") + self.assertTrue(p.is_mcp) + self.assertIsNone(p.oauth_config) + self.assertEqual(p.fields[0].field_name, "api_key") + self.assertTrue(p.fields[0].required) + + # delete + self.scalekit_client.actions.providers.delete_custom_provider( + DeleteCustomProviderRequest(identifier=identifier) + ) + self.created_identifier = None # already deleted — skip tearDown + + # confirm gone from list + list_resp = self.scalekit_client.actions.providers.list_providers( + ListProvidersRequest(provider_type=ProviderType.CUSTOM, page_size=100) + ) + identifiers = [lp.identifier for lp in list_resp.providers] + self.assertNotIn(identifier, identifiers) From f6c54f6b0eb04b8f94b85ba05c8a9a019c7c6ff7 Mon Sep 17 00:00:00 2001 From: Akshay Parihar Date: Tue, 12 May 2026 18:10:15 +0530 Subject: [PATCH 4/7] docs: add Custom Providers section to REFERENCE.md --- REFERENCE.md | 357 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 357 insertions(+) diff --git a/REFERENCE.md b/REFERENCE.md index 87f7ce6..b66cc0b 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -6070,6 +6070,363 @@ print(f'Magic Link: {response[0].magic_link}') + + + + +## Custom Providers + +
client.actions.providers.create_custom_provider(request) -> CreateCustomProviderResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Creates a new custom provider (MCP connector) in the Scalekit catalog. Once created, the provider can be selected by organizations when setting up connections. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from scalekit.actions.types import ( + AuthPattern, + AuthField, + OAuthConfig, + CreateCustomProviderRequest, +) + +# OAuth MCP provider +response = scalekit_client.actions.providers.create_custom_provider( + CreateCustomProviderRequest( + display_name="Acme MCP", + description="Acme integration via MCP", + proxy_url="https://mcp.acme.com/mcp", + proxy_enabled=True, + auth_patterns=[ + AuthPattern( + type="OAUTH", + display_name="OAuth 2.1", + description="Authenticate via browser OAuth.", + is_mcp=True, + oauth_config=OAuthConfig(), # pkce_enabled=True by default + ) + ], + ) +) +provider = response.provider +print(f"Created provider: {provider.identifier}") + +# Bearer token MCP provider +response = scalekit_client.actions.providers.create_custom_provider( + CreateCustomProviderRequest( + display_name="Apify MCP", + description="Apify platform via MCP", + proxy_url="https://mcp.apify.com/mcp", + proxy_enabled=True, + auth_patterns=[ + AuthPattern( + type="BEARER", + display_name="Apify Token", + description="Authenticate with your Apify API Token.", + is_mcp=True, + fields=[ + AuthField( + field_name="token", + label="Apify Token", + input_type="password", + hint="Your Apify API Token", + required=True, + ) + ], + ) + ], + ) +) +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `CreateCustomProviderRequest` - Request object for creating a custom provider +- `display_name: str` - Human-readable name. Accepted characters: a-z, A-Z, 0-9, and spaces. Suffix with 'MCP' for MCP server providers (e.g., "Acme MCP"). +- `proxy_url: str` - Base HTTPS URL of the provider's server (e.g., `https://mcp.acme.com/mcp`). +- `proxy_enabled: bool` - Whether to enable Scalekit request proxying. Defaults to `True`. +- `description: str` - Short description of the provider. Defaults to empty string. +- `auth_patterns: List[AuthPattern]` - Authentication options for users. Currently only a single element is supported — the list type is intentional for future multi-pattern support. + - `type: str` - Auth mechanism: `"OAUTH"`, `"BEARER"`, or `"API_KEY"`. + - `display_name: str` - Display name for this auth option. + - `description: str` - Short description of this auth option. + - `is_mcp: bool` - Set `True` for MCP server providers. + - `oauth_config: Optional[OAuthConfig]` - Required when `type="OAUTH"`. `OAuthConfig(pkce_enabled=True)` by default. + - `fields: List[AuthField]` - Credential input fields for `BEARER` and `API_KEY` types. + +
+
+
+
+ +#### 📦 Response + +`CreateCustomProviderResponse` with a `provider` attribute (`Provider`) containing `identifier`, `display_name`, `description`, `proxy_url`, `proxy_enabled`, `is_custom`, `is_custom_mcp`, and `auth_patterns`. + +
+
+
+ +
client.actions.providers.update_custom_provider(request) -> UpdateCustomProviderResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Updates an existing custom provider. `display_name` and `proxy_url` are required by the server on every update. Optional fields omitted from the request keep their existing server values — except `auth_patterns`, which fully replaces the existing list when provided. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from scalekit.actions.types import ( + AuthPattern, + AuthField, + UpdateCustomProviderRequest, +) + +response = scalekit_client.actions.providers.update_custom_provider( + UpdateCustomProviderRequest( + identifier="prv_abc123", + display_name="Acme MCP", # required on every update + proxy_url="https://mcp.acme.com/mcp", # required on every update + description="Updated description", + auth_patterns=[ + AuthPattern( + type="BEARER", + display_name="Apify Token", + description="Authenticate with your Apify API Token.", + is_mcp=True, + fields=[ + AuthField( + field_name="token", + label="Apify Token", + input_type="password", + hint="Updated token hint", + required=True, + ) + ], + ) + ], + ) +) +updated = response.provider +print(f"Updated: {updated.description}") +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `UpdateCustomProviderRequest` - Request object for updating a custom provider +- `identifier: str` - Identifier of the provider to update. Obtained from `Provider.identifier`. +- `display_name: str` - Required on every update. Accepted characters: a-z, A-Z, 0-9, and spaces. +- `proxy_url: str` - Required on every update. Must be a valid HTTPS URL. +- `description: Optional[str]` - New description. Pass `None` to leave unchanged. +- `auth_patterns: Optional[List[AuthPattern]]` - Replacement auth patterns. When provided, fully replaces the existing list — not merged. Pass `None` to leave unchanged. + +
+
+
+
+ +#### 📦 Response + +`UpdateCustomProviderResponse` with a `provider` attribute (`Provider`) containing the updated provider details. + +
+
+
+ +
client.actions.providers.list_providers(request) -> ListProvidersResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Lists providers in the Scalekit catalog, optionally filtered by type. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from scalekit.actions.types import ListProvidersRequest +from scalekit.v1.providers.providers_pb2 import ProviderType + +# List only custom providers +response = scalekit_client.actions.providers.list_providers( + ListProvidersRequest( + provider_type=ProviderType.CUSTOM, + page_size=50, + ) +) + +for provider in response.providers: + print(f"{provider.identifier}: {provider.display_name}") + +# List all providers (custom + built-in) +response = scalekit_client.actions.providers.list_providers( + ListProvidersRequest(provider_type=ProviderType.ALL, page_size=100) +) +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `ListProvidersRequest` - Request object for listing providers +- `provider_type: Optional[int]` - `ProviderType.CUSTOM` (custom only), `ProviderType.DEFAULT` (built-in only), or `ProviderType.ALL`. Defaults to all. +- `page_size: Optional[int]` - Maximum number of providers to return. +- `page_token: Optional[str]` - Pagination cursor from a previous response's `next_page_token`. +- `identifier: Optional[str]` - Filter to a specific provider by identifier. + +
+
+
+
+ +#### 📦 Response + +`ListProvidersResponse` with a `providers` attribute (list of `Provider`) and `next_page_token` for pagination. + +
+
+
+ +
client.actions.providers.delete_custom_provider(request) -> DeleteCustomProviderResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Permanently deletes a custom provider. The provider is removed from the Scalekit catalog and can no longer be used for new connections. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from scalekit.actions.types import DeleteCustomProviderRequest + +scalekit_client.actions.providers.delete_custom_provider( + DeleteCustomProviderRequest(identifier="prv_abc123") +) +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `DeleteCustomProviderRequest` - Request object for deleting a custom provider +- `identifier: str` - Identifier of the custom provider to delete. Obtained from `Provider.identifier`. + +
+
+
+
+ +#### 📦 Response + +`DeleteCustomProviderResponse` (empty — success is indicated by no exception being raised). +
From 92b3b5af498f75ef02a20e8fb3363e58815ad7da Mon Sep 17 00:00:00 2001 From: Akshay Parihar Date: Tue, 12 May 2026 18:35:23 +0530 Subject: [PATCH 5/7] fix: address CodeRabbit review comments on PR #159 - Fix protobuf filter=None TypeError in ProvidersClient.list_providers - Constrain AuthField.input_type to Literal['text','password'] - Constrain AuthPattern.type to Literal['OAUTH','BEARER','API_KEY'] - Enforce cross-field invariants: OAUTH requires oauth_config and no fields; BEARER/API_KEY require oauth_config=None - Add HTTPS validation for proxy_url in create/update requests - Add min_length=1 to identifier in delete/update requests - Constrain provider_type to valid enum values (0/1/2) and page_size >= 1 - Enforce single-item auth_patterns in create/update requests - Replace bare except Exception in tearDown with ScalekitNotFoundException - Replace #### headings with bold text in REFERENCE.md Custom Providers section --- REFERENCE.md | 32 +++++++++---------- scalekit/actions/models/custom_provider.py | 25 ++++++++++++--- .../create_custom_provider_request.py | 14 +++++++- .../delete_custom_provider_request.py | 1 + .../models/requests/list_providers_request.py | 14 +++++++- .../update_custom_provider_request.py | 15 ++++++++- scalekit/providers.py | 15 ++++----- tests/test_providers.py | 3 +- 8 files changed, 87 insertions(+), 32 deletions(-) diff --git a/REFERENCE.md b/REFERENCE.md index b66cc0b..3fa1388 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -6080,7 +6080,7 @@ print(f'Magic Link: {response[0].magic_link}')
-#### 📝 Description +**📝 Description**
@@ -6094,7 +6094,7 @@ Creates a new custom provider (MCP connector) in the Scalekit catalog. Once crea
-#### 🔌 Usage +**🔌 Usage**
@@ -6163,7 +6163,7 @@ response = scalekit_client.actions.providers.create_custom_provider(
-#### ⚙️ Parameters +**⚙️ Parameters**
@@ -6189,7 +6189,7 @@ response = scalekit_client.actions.providers.create_custom_provider(
-#### 📦 Response +**📦 Response** `CreateCustomProviderResponse` with a `provider` attribute (`Provider`) containing `identifier`, `display_name`, `description`, `proxy_url`, `proxy_enabled`, `is_custom`, `is_custom_mcp`, and `auth_patterns`. @@ -6201,7 +6201,7 @@ response = scalekit_client.actions.providers.create_custom_provider(
-#### 📝 Description +**📝 Description**
@@ -6215,7 +6215,7 @@ Updates an existing custom provider. `display_name` and `proxy_url` are required
-#### 🔌 Usage +**🔌 Usage**
@@ -6263,7 +6263,7 @@ print(f"Updated: {updated.description}")
-#### ⚙️ Parameters +**⚙️ Parameters**
@@ -6283,7 +6283,7 @@ print(f"Updated: {updated.description}")
-#### 📦 Response +**📦 Response** `UpdateCustomProviderResponse` with a `provider` attribute (`Provider`) containing the updated provider details. @@ -6295,7 +6295,7 @@ print(f"Updated: {updated.description}")
-#### 📝 Description +**📝 Description**
@@ -6309,7 +6309,7 @@ Lists providers in the Scalekit catalog, optionally filtered by type.
-#### 🔌 Usage +**🔌 Usage**
@@ -6342,7 +6342,7 @@ response = scalekit_client.actions.providers.list_providers(
-#### ⚙️ Parameters +**⚙️ Parameters**
@@ -6361,7 +6361,7 @@ response = scalekit_client.actions.providers.list_providers(
-#### 📦 Response +**📦 Response** `ListProvidersResponse` with a `providers` attribute (list of `Provider`) and `next_page_token` for pagination. @@ -6373,7 +6373,7 @@ response = scalekit_client.actions.providers.list_providers(
-#### 📝 Description +**📝 Description**
@@ -6387,7 +6387,7 @@ Permanently deletes a custom provider. The provider is removed from the Scalekit
-#### 🔌 Usage +**🔌 Usage**
@@ -6407,7 +6407,7 @@ scalekit_client.actions.providers.delete_custom_provider(
-#### ⚙️ Parameters +**⚙️ Parameters**
@@ -6423,7 +6423,7 @@ scalekit_client.actions.providers.delete_custom_provider(
-#### 📦 Response +**📦 Response** `DeleteCustomProviderResponse` (empty — success is indicated by no exception being raised). diff --git a/scalekit/actions/models/custom_provider.py b/scalekit/actions/models/custom_provider.py index 514b111..004c504 100644 --- a/scalekit/actions/models/custom_provider.py +++ b/scalekit/actions/models/custom_provider.py @@ -1,7 +1,7 @@ -from typing import List, Optional +from typing import List, Literal, Optional from google.protobuf.json_format import MessageToDict -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, root_validator class AuthField(BaseModel): @@ -32,7 +32,7 @@ class AuthField(BaseModel): "Example: 'API Key', 'Bearer Token'. Defaults to empty string." ), ) - input_type: str = Field( + input_type: Literal["text", "password"] = Field( "text", description=( "Optional. Controls how the input is rendered in the UI. " @@ -151,7 +151,7 @@ class AuthPattern(BaseModel): key input. """ - type: str = Field( + type: Literal["OAUTH", "BEARER", "API_KEY"] = Field( ..., description=( "Required. Authentication mechanism for this pattern. " @@ -204,6 +204,23 @@ class AuthPattern(BaseModel): ), ) + @root_validator + def validate_auth_invariants(cls, values): + auth_type = values.get("type") + if auth_type is None: + return values # type field already failed validation + oauth_config = values.get("oauth_config") + fields = values.get("fields", []) + if auth_type == "OAUTH": + if oauth_config is None: + raise ValueError("oauth_config is required when type='OAUTH'") + if fields: + raise ValueError("fields must be empty when type='OAUTH'") + else: + if oauth_config is not None: + raise ValueError(f"oauth_config must be None when type='{auth_type}'") + return values + def to_dict(self) -> dict: """Serialize to a wire-format dict for inclusion in the auth_patterns ListValue. diff --git a/scalekit/actions/models/requests/create_custom_provider_request.py b/scalekit/actions/models/requests/create_custom_provider_request.py index a44cfdd..fc758a8 100644 --- a/scalekit/actions/models/requests/create_custom_provider_request.py +++ b/scalekit/actions/models/requests/create_custom_provider_request.py @@ -1,6 +1,6 @@ from typing import List -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, validator from scalekit.actions.models.custom_provider import AuthPattern @@ -56,5 +56,17 @@ class CreateCustomProviderRequest(BaseModel): ), ) + @validator("proxy_url") + def validate_proxy_url_https(cls, v): + if not v.startswith("https://"): + raise ValueError("proxy_url must be a valid HTTPS URL starting with 'https://'") + return v + + @validator("auth_patterns") + def validate_single_auth_pattern(cls, v): + if len(v) > 1: + raise ValueError("auth_patterns must contain at most one AuthPattern") + return v + class Config: validate_assignment = True diff --git a/scalekit/actions/models/requests/delete_custom_provider_request.py b/scalekit/actions/models/requests/delete_custom_provider_request.py index 40a26d2..ad42eaa 100644 --- a/scalekit/actions/models/requests/delete_custom_provider_request.py +++ b/scalekit/actions/models/requests/delete_custom_provider_request.py @@ -11,6 +11,7 @@ class DeleteCustomProviderRequest(BaseModel): identifier: str = Field( ..., + min_length=1, description=( "Required. Identifier of the custom provider to delete. Obtained from " "Provider.identifier in a create or list response." diff --git a/scalekit/actions/models/requests/list_providers_request.py b/scalekit/actions/models/requests/list_providers_request.py index 18b274f..7435fd8 100644 --- a/scalekit/actions/models/requests/list_providers_request.py +++ b/scalekit/actions/models/requests/list_providers_request.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, validator class ListProvidersRequest(BaseModel): @@ -39,5 +39,17 @@ class ListProvidersRequest(BaseModel): ), ) + @validator("provider_type") + def validate_provider_type(cls, v): + if v is not None and v not in (0, 1, 2): + raise ValueError("provider_type must be 0 (DEFAULT), 1 (CUSTOM), or 2 (ALL)") + return v + + @validator("page_size") + def validate_page_size(cls, v): + if v is not None and v < 1: + raise ValueError("page_size must be at least 1") + return v + class Config: validate_assignment = True diff --git a/scalekit/actions/models/requests/update_custom_provider_request.py b/scalekit/actions/models/requests/update_custom_provider_request.py index 21fa2f8..0e7a880 100644 --- a/scalekit/actions/models/requests/update_custom_provider_request.py +++ b/scalekit/actions/models/requests/update_custom_provider_request.py @@ -1,6 +1,6 @@ from typing import List, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, validator from scalekit.actions.models.custom_provider import AuthPattern @@ -14,6 +14,7 @@ class UpdateCustomProviderRequest(BaseModel): identifier: str = Field( ..., + min_length=1, description=( "Required. Identifier of the provider to update. Obtained from " "Provider.identifier in a create or list response." @@ -52,5 +53,17 @@ class UpdateCustomProviderRequest(BaseModel): ), ) + @validator("proxy_url") + def validate_proxy_url_https(cls, v): + if not v.startswith("https://"): + raise ValueError("proxy_url must be a valid HTTPS URL starting with 'https://'") + return v + + @validator("auth_patterns") + def validate_single_auth_pattern(cls, v): + if v is not None and len(v) != 1: + raise ValueError("auth_patterns must contain exactly one AuthPattern when provided") + return v + class Config: validate_assignment = True diff --git a/scalekit/providers.py b/scalekit/providers.py index 2fe5ac6..f049688 100644 --- a/scalekit/providers.py +++ b/scalekit/providers.py @@ -198,15 +198,14 @@ def list_providers( response[1].code().name == 'OK' on success. :rtype: tuple """ - filter_obj = None + request = ListProvidersRequest( + identifier=identifier or "", + page_size=page_size or 0, + page_token=page_token or "", + ) if provider_type is not None: - filter_obj = ListProvidersRequest.Filter(provider_type=provider_type) + request.filter.CopyFrom(ListProvidersRequest.Filter(provider_type=provider_type)) return self.core_client.grpc_exec( self._stub.ListProviders.with_call, - ListProvidersRequest( - identifier=identifier or "", - page_size=page_size or 0, - page_token=page_token or "", - filter=filter_obj, - ), + request, ) diff --git a/tests/test_providers.py b/tests/test_providers.py index 5173750..c01cc83 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -1,6 +1,7 @@ from faker import Faker from basetest import BaseTest +from scalekit.common.exceptions import ScalekitNotFoundException from scalekit.actions.types import ( AuthPattern, AuthField, @@ -26,7 +27,7 @@ def tearDown(self): self.scalekit_client.actions.providers.delete_custom_provider( DeleteCustomProviderRequest(identifier=self.created_identifier) ) - except Exception: + except ScalekitNotFoundException: pass self.created_identifier = None From 46f2363d1ca26dfbe77087fc53d7d2dd4c96a6d6 Mon Sep 17 00:00:00 2001 From: Akshay Parihar Date: Tue, 12 May 2026 18:59:35 +0530 Subject: [PATCH 6/7] fix: tighten identifier and proxy_url validation per follow-up review - Add min_length=1 to ListProvidersRequest.identifier - Strengthen proxy_url check to use urlparse (scheme=https + netloc required) in both CreateCustomProviderRequest and UpdateCustomProviderRequest --- .../models/requests/create_custom_provider_request.py | 6 ++++-- scalekit/actions/models/requests/list_providers_request.py | 1 + .../models/requests/update_custom_provider_request.py | 6 ++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/scalekit/actions/models/requests/create_custom_provider_request.py b/scalekit/actions/models/requests/create_custom_provider_request.py index fc758a8..1eea967 100644 --- a/scalekit/actions/models/requests/create_custom_provider_request.py +++ b/scalekit/actions/models/requests/create_custom_provider_request.py @@ -1,4 +1,5 @@ from typing import List +from urllib.parse import urlparse from pydantic import BaseModel, Field, validator @@ -58,8 +59,9 @@ class CreateCustomProviderRequest(BaseModel): @validator("proxy_url") def validate_proxy_url_https(cls, v): - if not v.startswith("https://"): - raise ValueError("proxy_url must be a valid HTTPS URL starting with 'https://'") + parsed = urlparse(v) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError("proxy_url must be a valid HTTPS URL (e.g. 'https://server.example.com/mcp')") return v @validator("auth_patterns") diff --git a/scalekit/actions/models/requests/list_providers_request.py b/scalekit/actions/models/requests/list_providers_request.py index 7435fd8..5077a28 100644 --- a/scalekit/actions/models/requests/list_providers_request.py +++ b/scalekit/actions/models/requests/list_providers_request.py @@ -33,6 +33,7 @@ class ListProvidersRequest(BaseModel): ) identifier: Optional[str] = Field( None, + min_length=1, description=( "Optional. Filter to a specific provider by its identifier. " "Pass None (default) to return all providers matching the other filters." diff --git a/scalekit/actions/models/requests/update_custom_provider_request.py b/scalekit/actions/models/requests/update_custom_provider_request.py index 0e7a880..d1cc5a5 100644 --- a/scalekit/actions/models/requests/update_custom_provider_request.py +++ b/scalekit/actions/models/requests/update_custom_provider_request.py @@ -1,4 +1,5 @@ from typing import List, Optional +from urllib.parse import urlparse from pydantic import BaseModel, Field, validator @@ -55,8 +56,9 @@ class UpdateCustomProviderRequest(BaseModel): @validator("proxy_url") def validate_proxy_url_https(cls, v): - if not v.startswith("https://"): - raise ValueError("proxy_url must be a valid HTTPS URL starting with 'https://'") + parsed = urlparse(v) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError("proxy_url must be a valid HTTPS URL (e.g. 'https://server.example.com/mcp')") return v @validator("auth_patterns") From cb59ebc174f63899d7d286929cfdf65758acb57d Mon Sep 17 00:00:00 2001 From: Akshay Parihar Date: Tue, 12 May 2026 19:07:12 +0530 Subject: [PATCH 7/7] fix: add skip_on_failure=True to root_validator for Pydantic v2 compat --- scalekit/actions/models/custom_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scalekit/actions/models/custom_provider.py b/scalekit/actions/models/custom_provider.py index 004c504..f32b240 100644 --- a/scalekit/actions/models/custom_provider.py +++ b/scalekit/actions/models/custom_provider.py @@ -204,7 +204,7 @@ class AuthPattern(BaseModel): ), ) - @root_validator + @root_validator(skip_on_failure=True) def validate_auth_invariants(cls, values): auth_type = values.get("type") if auth_type is None: