From 7a1057ce10d8718693b82a551bbd836524af91c8 Mon Sep 17 00:00:00 2001 From: Shyam N V Date: Tue, 11 Aug 2026 16:22:50 +0530 Subject: [PATCH 1/2] Add RPv2.1.2 resource principal authentication --- AGENTS.md | 2 +- BEST_PRACTICES.md | 2 +- src/common/CHANGELOG.md | 6 + src/common/README.md | 22 +- src/common/oracle_mcp_common/auth.py | 402 +++++++++++-- .../oracle_mcp_common/tests/test_auth.py | 557 +++++++++++++++--- src/common/pyproject.toml | 2 +- src/common/uv.lock | 6 +- src/oci-api-mcp-server/CHANGELOG.md | 1 + src/oci-api-mcp-server/pyproject.toml | 2 +- src/oci-api-mcp-server/uv.lock | 6 +- src/oci-cloud-mcp-server/CHANGELOG.md | 1 + src/oci-cloud-mcp-server/pyproject.toml | 2 +- src/oci-cloud-mcp-server/uv.lock | 6 +- src/oci-database-mcp-server/CHANGELOG.md | 1 + src/oci-database-mcp-server/pyproject.toml | 2 +- src/oci-database-mcp-server/uv.lock | 6 +- 17 files changed, 875 insertions(+), 151 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 973f7901..cc392d68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ When validating the quality of any MCP server under `src/`: - Confirm the server includes unit tests for the MCP server code. - For Python MCP servers, require unit tests to enforce at least 90% coverage through `[tool.coverage.report] fail_under = 90` in `pyproject.toml`. Do not mark validation complete if the coverage threshold is lower than 90% or if coverage fails. - For OCI Python SDK-backed servers, require every OCI client-configuration path to derive the canonical `/` `additional_user_agent` from package `__project__` and `__version__`; do not duplicate literal names or versions. Client factories may live outside `server.py`, but every path that constructs an OCI client must receive the value. Strip `-server` off the end of `__project__` when applicable; ex `oci-cloud-mcp`. -- For OCI Python SDK-backed servers, declare `oracle-mcp-common>=0.1.0,<0.2.0` and use `oracle_mcp_common.build_auth_context()` for stdio and other configured OCI credential modes instead of duplicating credential resolution, OCI profile parsing, environment-variable precedence, or signer construction. Merge the returned `AuthContext.config` with the derived `additional_user_agent`, pass `AuthContext.signer` to each OCI client, and keep the server responsible for its client type, retry and circuit-breaker policy, and lifecycle. Use `AuthOptions` only when a server must explicitly override configured authentication inputs. +- For OCI Python SDK-backed servers, declare `oracle-mcp-common>=0.2.0,<0.3.0` and use `oracle_mcp_common.build_auth_context()` for stdio and other configured OCI credential modes instead of duplicating credential resolution, OCI profile parsing, environment-variable precedence, or signer construction. Merge the returned `AuthContext.config` with the derived `additional_user_agent`, pass `AuthContext.signer` to each OCI client, and keep the server responsible for its client type, retry and circuit-breaker policy, and lifecycle. Use `AuthOptions` only when a server must explicitly override configured authentication inputs. - For an HTTP server that uses OCI IAM/IDCS request-token exchange, use `build_idcs_http_auth(required_scopes)` once for provider configuration; the server retains listener startup, `mcp.auth` assignment, request-token retrieval, and user-agent assignment. During each authenticated request, call `IDCSHttpAuth.context_for(access_token.token)` and create only caller-specific OCI SDK clients from that context. Do not inspect host/port to select credentials, call FastMCP request-context APIs from the common library, or cache an HTTP-derived signer/client globally across callers. - For OCI Python SDK-backed servers, require unit tests to assert the exact derived `additional_user_agent` for each supported client-construction authentication path: API-key, security-token, each supported principal-based path (for example, instance- and resource-principal), and HTTP/token-exchange. - For servers that invoke the OCI CLI instead of constructing OCI Python SDK clients, require the same derived value through `OCI_SDK_APPEND_USER_AGENT` in the launched process environment. diff --git a/BEST_PRACTICES.md b/BEST_PRACTICES.md index 541a3d43..ee10a6eb 100644 --- a/BEST_PRACTICES.md +++ b/BEST_PRACTICES.md @@ -127,7 +127,7 @@ Declare a bounded dependency compatible with the shared library's public API: ```toml dependencies = [ - "oracle-mcp-common>=0.1.0,<0.2.0", + "oracle-mcp-common>=0.2.0,<0.3.0", ] ``` diff --git a/src/common/CHANGELOG.md b/src/common/CHANGELOG.md index 7e2391c4..a4bf51e5 100644 --- a/src/common/CHANGELOG.md +++ b/src/common/CHANGELOG.md @@ -9,6 +9,12 @@ All notable changes to `oracle-mcp-common` are documented in this file. - Runtime package metadata now reads the installed distribution version, with `pyproject.toml` as the single source of truth. +## 0.2.0 + +### Added + +- Added RPv2.1.2 authentication with refreshed time-bound security contexts and realm-aware bootstrap endpoints. + ## 0.1.3 ### Changed diff --git a/src/common/README.md b/src/common/README.md index c619a962..35ee1d96 100644 --- a/src/common/README.md +++ b/src/common/README.md @@ -18,7 +18,7 @@ An adopting server normally declares a bounded dependency on this package: ```toml dependencies = [ - "oracle-mcp-common>=0.1.0,<0.2.0", + "oracle-mcp-common>=0.2.0,<0.3.0", ] ``` @@ -74,6 +74,7 @@ Set `OCI_MCP_AUTH_TYPE`, or pass `AuthOptions(auth_type=...)` to | `identity_domain_upst` | Identity Domains JWT-to-UPST token exchange | Uses file-backed JWT and client-secret inputs. See [Identity Domains token exchange](#identity-domains-token-exchange). | | `instance_principal` | OCI instance principal | Intended for OCI compute instances. | | `resource_principal` | OCI resource principal | Intended for supported OCI managed-resource environments. | +| `resource_principal_v212` | Database-service resource-principal exchange | Builds the v2.1.2 RPT security context and uses the OCI SDK's refreshing RPST exchange signer. See [Database-service RPv2.1.2 exchange](#database-service-rpv212-exchange). | | `instance_principal_delegation` | Instance principal and delegation token | Requires a delegation-token file. | | `resource_principal_delegation` | Resource principal and delegation token | Requires a delegation-token file. | | `oke_workload_identity` | OKE workload identity | Uses the OCI SDK's default service-account token unless an override is supplied. | @@ -82,6 +83,25 @@ Set `OCI_MCP_AUTH_TYPE`, or pass `AuthOptions(auth_type=...)` to principal environments. Select those types explicitly so a deployment's OCI identity remains predictable. +### Database-service RPv2.1.2 exchange + +`resource_principal_v212` supports the Resource Principal Session token bootstrap flow. +It requires a region and these file-safe configuration values: + +| Setting | `AuthOptions` field | Environment variable | +| --- | --- | --- | +| Tenancy OCID | `resource_principal_tenancy_id` | `OCI_MCP_RP_TENANCY_ID` | +| Resource OCID | `resource_principal_resource_id` | `OCI_MCP_RP_RESOURCE_ID` | +| Resource private-key file | `resource_principal_private_key_path` | `OCI_MCP_RP_PRIVATE_KEY_PATH` | +| Resource context HMAC key (sensitive) | `resource_principal_rci` | `OCI_MCP_RP_RCI` | +| Context base time | `resource_principal_t0` | `OCI_MCP_RP_T0` | + +RCI is sensitive HMAC key material: do not log it or place it in source control. +The RPT and RPST endpoints are resolved by the OCI SDK as realm-aware `database` +and `auth` endpoints (for example, the displayed domain is `.oraclecloud.com` in +OC1); override them only with +`OCI_MCP_RP_RPT_ENDPOINT` and `OCI_MCP_RP_RPST_ENDPOINT` when required. + ### Configuration precedence An explicit non-empty `AuthOptions` value wins over its canonical environment diff --git a/src/common/oracle_mcp_common/auth.py b/src/common/oracle_mcp_common/auth.py index 4de730a8..a575665d 100644 --- a/src/common/oracle_mcp_common/auth.py +++ b/src/common/oracle_mcp_common/auth.py @@ -6,7 +6,12 @@ from __future__ import annotations +import base64 import configparser +import datetime +import hashlib +import hmac +import json import logging import os from dataclasses import dataclass, field @@ -19,7 +24,9 @@ from fastmcp.server.auth.providers.oci import OCIProvider LOGGER = logging.getLogger(__name__) -SESSION_AUTH_GUIDANCE = "Run `oci session authenticate` to use session-token authentication." +SESSION_AUTH_GUIDANCE = ( + "Run `oci session authenticate` to use session-token authentication." +) COMPATIBILITY_WINDOW = ( "Compatibility is available from 0.1.0 until the later of 180 days or two " "published adopter-server release waves; removal is no earlier than 0.2.0." @@ -35,6 +42,7 @@ class AuthType(StrEnum): IDENTITY_DOMAIN_UPST = "identity_domain_upst" INSTANCE_PRINCIPAL = "instance_principal" RESOURCE_PRINCIPAL = "resource_principal" + RESOURCE_PRINCIPAL_V212 = "resource_principal_v212" INSTANCE_PRINCIPAL_DELEGATION = "instance_principal_delegation" RESOURCE_PRINCIPAL_DELEGATION = "resource_principal_delegation" OKE_WORKLOAD_IDENTITY = "oke_workload_identity" @@ -60,6 +68,13 @@ class AuthOptions: oke_service_account_token_path: str | None = None oke_service_account_token: str | None = None tenancy_id_override: str | None = None + resource_principal_tenancy_id: str | None = None + resource_principal_resource_id: str | None = None + resource_principal_private_key_path: str | None = None + resource_principal_rci: str | None = field(default=None, repr=False) + resource_principal_t0: str | None = None + resource_principal_rpt_endpoint: str | None = None + resource_principal_rpst_endpoint: str | None = None @dataclass(frozen=True) @@ -110,12 +125,18 @@ class IDCSHttpAuth: _client_secret: str = field(repr=False) _configured_region: str | None = field(repr=False) - def context_for(self, access_token: str | None, *, region: str | None = None) -> IDCSHttpAuthContext: + def context_for( + self, access_token: str | None, *, region: str | None = None + ) -> IDCSHttpAuthContext: """Exchange an explicitly supplied authenticated request token for OCI signing.""" token = _nonempty(access_token) if not token: - raise ValueError("HTTP requests require an authenticated IDCS access token.") - resolved_region = _nonempty(region) or self._configured_region or _env("OCI_REGION") + raise ValueError( + "HTTP requests require an authenticated IDCS access token." + ) + resolved_region = ( + _nonempty(region) or self._configured_region or _env("OCI_REGION") + ) if not resolved_region: raise ValueError("HTTP requests require an explicit region or OCI_REGION.") try: @@ -127,8 +148,12 @@ def context_for(self, access_token: str | None, *, region: str | None = None) -> region=resolved_region, ) except Exception as error: - raise ValueError("Unable to construct the HTTP IDCS token-exchange signer") from error - return IDCSHttpAuthContext(config={"region": resolved_region}, signer=signer, region=resolved_region) + raise ValueError( + "Unable to construct the HTTP IDCS token-exchange signer" + ) from error + return IDCSHttpAuthContext( + config={"region": resolved_region}, signer=signer, region=resolved_region + ) @dataclass(frozen=True) @@ -148,6 +173,13 @@ class _ResolvedInputs: oke_service_account_token_path: str | None oke_service_account_token: str | None tenancy_id_override: str | None + resource_principal_tenancy_id: str | None + resource_principal_resource_id: str | None + resource_principal_private_key_path: str | None + resource_principal_rci: str | None = field(repr=False) + resource_principal_t0: str | None + resource_principal_rpt_endpoint: str | None + resource_principal_rpst_endpoint: str | None @dataclass(frozen=True) @@ -195,7 +227,9 @@ def build_idcs_http_auth( base_url=inputs.base_url, ) except Exception as error: - raise ValueError("Unable to construct the HTTP IDCS authentication provider") from error + raise ValueError( + "Unable to construct the HTTP IDCS authentication provider" + ) from error return IDCSHttpAuth( provider=provider, _identity_domain_url=inputs.domain_url, @@ -210,13 +244,17 @@ def resolve_auth_type(options: AuthOptions | None = None) -> AuthType: options = options or AuthOptions() value = _nonempty(options.auth_type) or _env("OCI_MCP_AUTH_TYPE") if value is None: - value = _legacy_env("OCI_IOT_AUTH_TYPE", "OCI_AUTH_TYPE", "ORACLE_MCP_AUTH_METHOD") + value = _legacy_env( + "OCI_IOT_AUTH_TYPE", "OCI_AUTH_TYPE", "ORACLE_MCP_AUTH_METHOD" + ) normalized = _normalize_auth_type(value or AuthType.AUTO.value) try: return AuthType(normalized) except ValueError as error: supported = ", ".join(auth_type.value for auth_type in AuthType) - raise ValueError(f"Unsupported OCI authentication type. Supported values: {supported}") from error + raise ValueError( + f"Unsupported OCI authentication type. Supported values: {supported}" + ) from error def _resolve_inputs(options: AuthOptions | None) -> _ResolvedInputs: @@ -226,30 +264,67 @@ def _resolve_inputs(options: AuthOptions | None) -> _ResolvedInputs: config_file=resolve_config_file(options), profile_name=resolve_profile_name(options), region=_resolve_region(options.region), - identity_domain_url=_option_or_env(options.identity_domain_url, "OCI_MCP_IDENTITY_DOMAIN_URL"), + identity_domain_url=_option_or_env( + options.identity_domain_url, "OCI_MCP_IDENTITY_DOMAIN_URL" + ), upst_jwt_file=_option_or_env(options.upst_jwt_file, "OCI_MCP_UPST_JWT_FILE"), identity_domain_client_id=_option_or_env( options.identity_domain_client_id, "OCI_MCP_IDENTITY_DOMAIN_CLIENT_ID" ), identity_domain_client_secret_file=_option_or_env( - options.identity_domain_client_secret_file, "OCI_MCP_IDENTITY_DOMAIN_CLIENT_SECRET_FILE" + options.identity_domain_client_secret_file, + "OCI_MCP_IDENTITY_DOMAIN_CLIENT_SECRET_FILE", + ), + delegation_token_file=_option_or_env( + options.delegation_token_file, "OCI_MCP_DELEGATION_TOKEN_FILE" + ), + delegation_token=_option_or_env( + options.delegation_token, "OCI_MCP_DELEGATION_TOKEN" ), - delegation_token_file=_option_or_env(options.delegation_token_file, "OCI_MCP_DELEGATION_TOKEN_FILE"), - delegation_token=_option_or_env(options.delegation_token, "OCI_MCP_DELEGATION_TOKEN"), oke_service_account_token_path=_option_or_env( - options.oke_service_account_token_path, "OCI_MCP_OKE_SERVICE_ACCOUNT_TOKEN_PATH" + options.oke_service_account_token_path, + "OCI_MCP_OKE_SERVICE_ACCOUNT_TOKEN_PATH", + ), + oke_service_account_token=_option_or_env( + options.oke_service_account_token, "OCI_MCP_OKE_SERVICE_ACCOUNT_TOKEN" + ), + tenancy_id_override=_option_or_env( + options.tenancy_id_override, "OCI_MCP_TENANCY_ID_OVERRIDE" + ), + resource_principal_tenancy_id=_option_or_env( + options.resource_principal_tenancy_id, "OCI_MCP_RP_TENANCY_ID" + ), + resource_principal_resource_id=_option_or_env( + options.resource_principal_resource_id, "OCI_MCP_RP_RESOURCE_ID" + ), + resource_principal_private_key_path=_option_or_env( + options.resource_principal_private_key_path, "OCI_MCP_RP_PRIVATE_KEY_PATH" + ), + resource_principal_rci=_option_or_env( + options.resource_principal_rci, "OCI_MCP_RP_RCI" + ), + resource_principal_t0=_option_or_env( + options.resource_principal_t0, "OCI_MCP_RP_T0" + ), + resource_principal_rpt_endpoint=_option_or_env( + options.resource_principal_rpt_endpoint, "OCI_MCP_RP_RPT_ENDPOINT" + ), + resource_principal_rpst_endpoint=_option_or_env( + options.resource_principal_rpst_endpoint, "OCI_MCP_RP_RPST_ENDPOINT" ), - oke_service_account_token=_option_or_env(options.oke_service_account_token, "OCI_MCP_OKE_SERVICE_ACCOUNT_TOKEN"), - tenancy_id_override=_option_or_env(options.tenancy_id_override, "OCI_MCP_TENANCY_ID_OVERRIDE"), ) -def _resolve_idcs_http_inputs(options: IDCSHttpAuthOptions | None) -> _ResolvedIDCSHttpInputs: +def _resolve_idcs_http_inputs( + options: IDCSHttpAuthOptions | None, +) -> _ResolvedIDCSHttpInputs: options = options or IDCSHttpAuthOptions() values = { "IDCS_DOMAIN": _option_or_env(options.domain, "IDCS_DOMAIN"), "IDCS_CLIENT_ID": _option_or_env(options.client_id, "IDCS_CLIENT_ID"), - "IDCS_CLIENT_SECRET": _option_or_env(options.client_secret, "IDCS_CLIENT_SECRET"), + "IDCS_CLIENT_SECRET": _option_or_env( + options.client_secret, "IDCS_CLIENT_SECRET" + ), "IDCS_AUDIENCE": _option_or_env(options.audience, "IDCS_AUDIENCE"), "ORACLE_MCP_BASE_URL": _option_or_env(options.base_url, "ORACLE_MCP_BASE_URL"), } @@ -280,7 +355,11 @@ def resolve_profile_name(options: AuthOptions | None = None) -> str: def resolve_config_file(options: AuthOptions | None = None) -> str: """Resolve the OCI SDK configuration location.""" options = options or AuthOptions() - return _nonempty(options.config_file) or _env("OCI_CONFIG_FILE") or oci.config.DEFAULT_LOCATION + return ( + _nonempty(options.config_file) + or _env("OCI_CONFIG_FILE") + or oci.config.DEFAULT_LOCATION + ) def profile_declares_security_token(config_file: str, profile_name: str) -> bool: @@ -290,15 +369,23 @@ def profile_declares_security_token(config_file: str, profile_name: str) -> bool with Path(config_file).expanduser().open(encoding="utf-8") as config: parser.read_file(config) except (OSError, configparser.Error) as error: - raise ValueError("Unable to read OCI_CONFIG_FILE for the selected profile") from error + raise ValueError( + "Unable to read OCI_CONFIG_FILE for the selected profile" + ) from error - section = profile_name if profile_name != oci.config.DEFAULT_PROFILE else parser.default_section + section = ( + profile_name + if profile_name != oci.config.DEFAULT_PROFILE + else parser.default_section + ) if section != parser.default_section and not parser.has_section(section): raise ValueError("Selected OCI_CONFIG_PROFILE was not found in OCI_CONFIG_FILE") return _raw_profile_has_option(parser, section, "security_token_file") -def _raw_profile_has_option(parser: configparser.ConfigParser, section: str, option: str) -> bool: +def _raw_profile_has_option( + parser: configparser.ConfigParser, section: str, option: str +) -> bool: """Check a section without ConfigParser's normal DEFAULT-value inheritance.""" if section == parser.default_section: return option in parser.defaults() @@ -310,10 +397,14 @@ def _raw_profile_has_option(parser: configparser.ConfigParser, section: str, opt def _build_profile_context(inputs: _ResolvedInputs) -> AuthContext: config = _load_profile(inputs.config_file, inputs.profile_name) selected_auth_type = inputs.auth_type - direct_session = profile_declares_security_token(inputs.config_file, inputs.profile_name) + direct_session = profile_declares_security_token( + inputs.config_file, inputs.profile_name + ) if selected_auth_type is AuthType.AUTO: - selected_auth_type = AuthType.SECURITY_TOKEN if direct_session else AuthType.API_KEY + selected_auth_type = ( + AuthType.SECURITY_TOKEN if direct_session else AuthType.API_KEY + ) if selected_auth_type is AuthType.SECURITY_TOKEN and not direct_session: raise ValueError( "security_token_file must be declared directly in the selected OCI_CONFIG_PROFILE; " @@ -324,23 +415,36 @@ def _build_profile_context(inputs: _ResolvedInputs) -> AuthContext: signer = _security_token_signer(config) else: signer = _api_key_signer(config) - _warn_once("api_key", f"API-key authentication is in use. {SESSION_AUTH_GUIDANCE}") + _warn_once( + "api_key", f"API-key authentication is in use. {SESSION_AUTH_GUIDANCE}" + ) region = inputs.region or config.get("region") if region: config["region"] = region - return AuthContext(selected_auth_type, config, signer, config.get("tenancy"), region, inputs.profile_name) + return AuthContext( + selected_auth_type, + config, + signer, + config.get("tenancy"), + region, + inputs.profile_name, + ) def _load_profile(config_file: str, profile_name: str) -> dict[str, Any]: try: - return dict(oci.config.from_file(file_location=config_file, profile_name=profile_name)) + return dict( + oci.config.from_file(file_location=config_file, profile_name=profile_name) + ) except Exception as error: raise ValueError("Unable to load the selected OCI profile") from error def _api_key_signer(config: dict[str, Any]) -> Any: - _require_config_fields(config, "API-key authentication", "tenancy", "user", "fingerprint", "key_file") + _require_config_fields( + config, "API-key authentication", "tenancy", "user", "fingerprint", "key_file" + ) try: return oci.signer.Signer( tenancy=config["tenancy"], @@ -350,7 +454,9 @@ def _api_key_signer(config: dict[str, Any]) -> Any: pass_phrase=config.get("pass_phrase"), ) except Exception as error: - raise ValueError("Unable to construct the API-key signer from the selected OCI profile") from error + raise ValueError( + "Unable to construct the API-key signer from the selected OCI profile" + ) from error def _security_token_signer(config: dict[str, Any]) -> Any: @@ -360,12 +466,18 @@ def _security_token_signer(config: dict[str, Any]) -> Any: "key_file", "security_token_file", ) - token = _read_required_secret_file(config["security_token_file"], "security_token_file") + token = _read_required_secret_file( + config["security_token_file"], "security_token_file" + ) try: - private_key = oci.signer.load_private_key_from_file(config["key_file"], config.get("pass_phrase")) + private_key = oci.signer.load_private_key_from_file( + config["key_file"], config.get("pass_phrase") + ) return oci.auth.signers.SecurityTokenSigner(token, private_key) except Exception as error: - raise ValueError("Unable to construct the security-token signer from the selected OCI profile") from error + raise ValueError( + "Unable to construct the security-token signer from the selected OCI profile" + ) from error def _build_principal_context(inputs: _ResolvedInputs) -> AuthContext: @@ -374,6 +486,8 @@ def _build_principal_context(inputs: _ResolvedInputs) -> AuthContext: signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner() elif inputs.auth_type is AuthType.RESOURCE_PRINCIPAL: signer = oci.auth.signers.get_resource_principals_signer() + elif inputs.auth_type is AuthType.RESOURCE_PRINCIPAL_V212: + signer = _resource_principal_v212_signer(inputs) elif inputs.auth_type is AuthType.INSTANCE_PRINCIPAL_DELEGATION: signer = oci.auth.signers.InstancePrincipalsDelegationTokenSigner( delegation_token=_delegation_token(inputs) @@ -383,29 +497,177 @@ def _build_principal_context(inputs: _ResolvedInputs) -> AuthContext: delegation_token=_delegation_token(inputs) ) elif inputs.auth_type is AuthType.OKE_WORKLOAD_IDENTITY: - signer = oci.auth.signers.get_oke_workload_identity_resource_principal_signer( - **_oke_signer_kwargs(inputs) + signer = ( + oci.auth.signers.get_oke_workload_identity_resource_principal_signer( + **_oke_signer_kwargs(inputs) + ) ) else: raise AssertionError(f"Unhandled auth type: {inputs.auth_type}") except ValueError: raise except Exception as error: - raise ValueError(f"Unable to construct the {inputs.auth_type.value} signer") from error + raise ValueError( + f"Unable to construct the {inputs.auth_type.value} signer" + ) from error signer_region = getattr(signer, "region", None) region = inputs.region or signer_region config: dict[str, Any] = {} if region: config["region"] = region + tenancy_id = getattr(signer, "tenancy_id", None) + if inputs.auth_type is AuthType.RESOURCE_PRINCIPAL_V212: + tenancy_id = tenancy_id or inputs.resource_principal_tenancy_id tenancy_id = ( - getattr(signer, "tenancy_id", None) + tenancy_id or inputs.tenancy_id_override or _legacy_env("OCI_IOT_TENANCY_ID_OVERRIDE", "TENANCY_ID_OVERRIDE") ) return AuthContext(inputs.auth_type, config, signer, tenancy_id, region, None) +def _resource_principal_v212_signer(inputs: _ResolvedInputs) -> Any: + """Construct the SDK signer for the Database-service RPv2.1.2 exchange.""" + required = { + "OCI_MCP_RP_TENANCY_ID": inputs.resource_principal_tenancy_id, + "OCI_MCP_RP_RESOURCE_ID": inputs.resource_principal_resource_id, + "OCI_MCP_RP_PRIVATE_KEY_PATH": inputs.resource_principal_private_key_path, + "OCI_MCP_RP_RCI": inputs.resource_principal_rci, + "OCI_MCP_RP_T0": inputs.resource_principal_t0, + } + missing = [name for name, value in required.items() if not value] + if missing: + raise ValueError(f"resource_principal_v212 requires: {', '.join(missing)}") + if not inputs.region: + raise ValueError( + "resource_principal_v212 requires an explicit region or OCI_REGION" + ) + + rpt_endpoint = _resource_principal_endpoint( + inputs.resource_principal_rpt_endpoint, + oci.regions.endpoint_for("database", region=inputs.region), + ) + rpst_endpoint = _resource_principal_endpoint( + inputs.resource_principal_rpst_endpoint, + oci.regions.endpoint_for("auth", region=inputs.region), + ) + private_key_path = str( + Path(inputs.resource_principal_private_key_path).expanduser().resolve() + ) + signer_type = _resource_principal_v212_signer_type( + inputs.resource_principal_rci, inputs.resource_principal_t0 + ) + signer = signer_type( + resource_principal_token_endpoint=rpt_endpoint, + resource_principal_session_token_endpoint=rpst_endpoint, + resource_id=inputs.resource_principal_resource_id, + tenancy_id=inputs.resource_principal_tenancy_id, + private_key=private_key_path, + rp_version="2.1.2", + region=inputs.region, + security_context=_resource_principal_security_context( + inputs.resource_principal_rci, inputs.resource_principal_t0 + ), + ) + + return signer + + +def _resource_principal_v212_signer_type(rci: str, t0: str) -> type[Any]: + """Return an SDK signer subclass that creates a fresh proof for every RPT attempt.""" + sdk_signer_type = oci.auth.signers.EphemeralResourcePrincipalV21Signer + if not callable(getattr(sdk_signer_type, "make_call", None)): + raise ValueError( + "Installed OCI SDK does not support RPv2.1.2 proof refresh retries" + ) + + class ResourcePrincipalV212Signer(sdk_signer_type): + def make_call( + self, + method: str, + resource_path: str, + path_params: Any = None, + header_params: Any = None, + body: Any = None, + ) -> Any: + if method != "get" or resource_path != self.resource_principal_token_path: + return super().make_call( + method, resource_path, path_params, header_params, body + ) + + def request_rpt() -> Any: + security_context = _resource_principal_security_context(rci, t0) + self.security_context = security_context + return self.base_client.call_api( + resource_path=resource_path, + method=method, + path_params=path_params, + header_params={"security-context": security_context}, + body=body, + response_type=oci.base_client.BYTES_RESPONSE_TYPE, + ) + + if self.retry_strategy: + return self.retry_strategy.make_retrying_call(request_rpt) + return request_rpt() + + return ResourcePrincipalV212Signer + + +def _resource_principal_endpoint(value: str | None, default: str) -> str: + endpoint = value or default + parsed = urlparse(endpoint) + if ( + parsed.scheme != "https" + or not parsed.netloc + or parsed.params + or parsed.query + or parsed.fragment + ): + raise ValueError( + "Resource principal exchange endpoints must be absolute https URLs without query or fragment" + ) + return endpoint.rstrip("/") + + +def _resource_principal_security_context(rci: str, t0: str) -> str: + """Build the v2.1.2 security context required by Database-service RPT retrieval.""" + try: + rci_key = base64.b64decode(rci, validate=True) + t0_value = datetime.datetime.fromisoformat(t0.replace("Z", "+00:00")) + if t0_value.tzinfo is None: + raise ValueError + now = datetime.datetime.now(datetime.timezone.utc) + elapsed_milliseconds = int((now - t0_value).total_seconds() * 1000) + if not rci_key or elapsed_milliseconds < 0: + raise ValueError + digest = hmac.new( + rci_key, elapsed_milliseconds.to_bytes(8, "big"), hashlib.sha1 + ).digest() + except (ValueError, OverflowError) as error: + raise ValueError( + "resource_principal_v212 requires a valid OCI_MCP_RP_RCI and OCI_MCP_RP_T0" + ) from error + + offset = digest[-1] & 0x0F + binary = int.from_bytes(digest[offset : offset + 4], byteorder="big") & 0x7FFF_FFFF + return json.dumps( + { + "RPTSecurityContext": { + "contextVersion": "V1", + "keyType": "REGULAR_RPT", + "securitySignature": { + "currentUTCTime": now.isoformat(timespec="microseconds").replace( + "+00:00", "Z" + ), + "Signature": f"{binary % 100000000:08d}", + }, + } + } + ) + + def _build_token_exchange_context(inputs: _ResolvedInputs) -> AuthContext: url = inputs.identity_domain_url _validate_identity_domain_url(url) @@ -413,21 +675,33 @@ def _build_token_exchange_context(inputs: _ResolvedInputs) -> AuthContext: client_id = inputs.identity_domain_client_id secret_file = inputs.identity_domain_client_secret_file if not client_id: - raise ValueError("OCI_MCP_IDENTITY_DOMAIN_CLIENT_ID is required for identity_domain_upst") - client_secret = _read_required_secret_file(secret_file, "OCI_MCP_IDENTITY_DOMAIN_CLIENT_SECRET_FILE") + raise ValueError( + "OCI_MCP_IDENTITY_DOMAIN_CLIENT_ID is required for identity_domain_upst" + ) + client_secret = _read_required_secret_file( + secret_file, "OCI_MCP_IDENTITY_DOMAIN_CLIENT_SECRET_FILE" + ) _read_required_secret_file(jwt_file, "OCI_MCP_UPST_JWT_FILE") region = inputs.region if not region: - raise ValueError("identity_domain_upst requires an explicit region or OCI_REGION") + raise ValueError( + "identity_domain_upst requires an explicit region or OCI_REGION" + ) def read_jwt() -> str: return _read_required_secret_file(jwt_file, "OCI_MCP_UPST_JWT_FILE") try: - signer = oci.auth.signers.TokenExchangeSigner(read_jwt, url, client_id, client_secret, region=region) + signer = oci.auth.signers.TokenExchangeSigner( + read_jwt, url, client_id, client_secret, region=region + ) except Exception as error: - raise ValueError("Unable to construct the identity_domain_upst signer") from error - return AuthContext(AuthType.IDENTITY_DOMAIN_UPST, {"region": region}, signer, None, region, None) + raise ValueError( + "Unable to construct the identity_domain_upst signer" + ) from error + return AuthContext( + AuthType.IDENTITY_DOMAIN_UPST, {"region": region}, signer, None, region, None + ) def _delegation_token(inputs: _ResolvedInputs) -> str: @@ -436,7 +710,9 @@ def _delegation_token(inputs: _ResolvedInputs) -> str: if inline is None: inline = _legacy_env("OCI_IOT_DELEGATION_TOKEN") if token_file and inline: - raise ValueError("Set only OCI_MCP_DELEGATION_TOKEN_FILE; inline delegation tokens are deprecated.") + raise ValueError( + "Set only OCI_MCP_DELEGATION_TOKEN_FILE; inline delegation tokens are deprecated." + ) if token_file: return _read_required_secret_file(token_file, "OCI_MCP_DELEGATION_TOKEN_FILE") if inline: @@ -446,7 +722,9 @@ def _delegation_token(inputs: _ResolvedInputs) -> str: f"{COMPATIBILITY_WINDOW}", ) return inline - raise ValueError("OCI_MCP_DELEGATION_TOKEN_FILE is required for delegation authentication") + raise ValueError( + "OCI_MCP_DELEGATION_TOKEN_FILE is required for delegation authentication" + ) def _oke_signer_kwargs(inputs: _ResolvedInputs) -> dict[str, str]: @@ -457,7 +735,9 @@ def _oke_signer_kwargs(inputs: _ResolvedInputs) -> dict[str, str]: if inline is None: inline = _legacy_env("OCI_IOT_OKE_SERVICE_ACCOUNT_TOKEN") if token_path and inline: - raise ValueError("Set only OCI_MCP_OKE_SERVICE_ACCOUNT_TOKEN_PATH; inline OKE tokens are deprecated.") + raise ValueError( + "Set only OCI_MCP_OKE_SERVICE_ACCOUNT_TOKEN_PATH; inline OKE tokens are deprecated." + ) if token_path: return {"service_account_token_path": token_path} if inline: @@ -476,10 +756,14 @@ def _resolve_region(explicit_region: str | None) -> str | None: def _validate_identity_domain_url(url: str | None) -> None: if not url: - raise ValueError("OCI_MCP_IDENTITY_DOMAIN_URL is required for identity_domain_upst") + raise ValueError( + "OCI_MCP_IDENTITY_DOMAIN_URL is required for identity_domain_upst" + ) parsed = urlparse(url) if parsed.scheme != "https" or not parsed.netloc: - raise ValueError("OCI_MCP_IDENTITY_DOMAIN_URL must be an absolute https:// URL with a host") + raise ValueError( + "OCI_MCP_IDENTITY_DOMAIN_URL must be an absolute https:// URL with a host" + ) def _normalize_idcs_domain(value: str | None) -> str: @@ -504,8 +788,15 @@ def _validate_mcp_base_url(value: str | None) -> str: if not value: raise ValueError("ORACLE_MCP_BASE_URL is required") parsed = urlparse(value) - if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.query or parsed.fragment: - raise ValueError("ORACLE_MCP_BASE_URL must be an absolute http:// or https:// URL") + if ( + parsed.scheme not in {"http", "https"} + or not parsed.netloc + or parsed.query + or parsed.fragment + ): + raise ValueError( + "ORACLE_MCP_BASE_URL must be an absolute http:// or https:// URL" + ) return value @@ -518,11 +809,15 @@ def _normalize_required_scopes(required_scopes: Sequence[str]) -> list[str]: return scopes -def _require_config_fields(config: dict[str, Any], description: str, *fields: str) -> None: +def _require_config_fields( + config: dict[str, Any], description: str, *fields: str +) -> None: missing = [field for field in fields if not _nonempty(config.get(field))] if missing: names = ", ".join(missing) - raise ValueError(f"{description} profile is missing required fields: {names}. {SESSION_AUTH_GUIDANCE}") + raise ValueError( + f"{description} profile is missing required fields: {names}. {SESSION_AUTH_GUIDANCE}" + ) def _read_required_secret_file(path: str | None, variable: str) -> str: @@ -570,7 +865,10 @@ def _legacy_env(*names: str) -> str | None: for name in names: value = _env(name) if value: - _warn_once(f"legacy_{name}", f"{name} is deprecated; use the OCI_MCP_* equivalent. {COMPATIBILITY_WINDOW}") + _warn_once( + f"legacy_{name}", + f"{name} is deprecated; use the OCI_MCP_* equivalent. {COMPATIBILITY_WINDOW}", + ) return value return None diff --git a/src/common/oracle_mcp_common/tests/test_auth.py b/src/common/oracle_mcp_common/tests/test_auth.py index 09bea9b5..550bef3d 100644 --- a/src/common/oracle_mcp_common/tests/test_auth.py +++ b/src/common/oracle_mcp_common/tests/test_auth.py @@ -6,12 +6,13 @@ from __future__ import annotations +import json +from datetime import datetime, timezone from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock import pytest - from oracle_mcp_common import auth @@ -31,6 +32,13 @@ def clear_environment(monkeypatch): "OCI_MCP_OKE_SERVICE_ACCOUNT_TOKEN_PATH", "OCI_MCP_OKE_SERVICE_ACCOUNT_TOKEN", "OCI_MCP_TENANCY_ID_OVERRIDE", + "OCI_MCP_RP_TENANCY_ID", + "OCI_MCP_RP_RESOURCE_ID", + "OCI_MCP_RP_PRIVATE_KEY_PATH", + "OCI_MCP_RP_RCI", + "OCI_MCP_RP_T0", + "OCI_MCP_RP_RPT_ENDPOINT", + "OCI_MCP_RP_RPST_ENDPOINT", "OCI_IOT_AUTH_TYPE", "OCI_IOT_DELEGATION_TOKEN", "OCI_IOT_OKE_SERVICE_ACCOUNT_TOKEN_PATH", @@ -83,7 +91,10 @@ def patch_api_signer(monkeypatch): def test_resolve_auth_type_uses_explicit_option_over_environment(monkeypatch): monkeypatch.setenv("OCI_MCP_AUTH_TYPE", "security_token") - assert auth.resolve_auth_type(auth.AuthOptions(auth_type="api-key")) is auth.AuthType.API_KEY + assert ( + auth.resolve_auth_type(auth.AuthOptions(auth_type="api-key")) + is auth.AuthType.API_KEY + ) def test_resolve_auth_type_uses_canonical_environment_and_validates(monkeypatch): @@ -106,8 +117,14 @@ def test_empty_values_are_unset_and_legacy_mode_is_mapped(monkeypatch, caplog): def test_profile_and_config_resolution_honor_explicit_canonical_and_legacy(monkeypatch): monkeypatch.setenv("OCI_CONFIG_PROFILE", "canonical") monkeypatch.setenv("OCI_CONFIG_FILE", "/canonical/config") - assert auth.resolve_profile_name(auth.AuthOptions(profile_name="explicit")) == "explicit" - assert auth.resolve_config_file(auth.AuthOptions(config_file="/explicit/config")) == "/explicit/config" + assert ( + auth.resolve_profile_name(auth.AuthOptions(profile_name="explicit")) + == "explicit" + ) + assert ( + auth.resolve_config_file(auth.AuthOptions(config_file="/explicit/config")) + == "/explicit/config" + ) monkeypatch.delenv("OCI_CONFIG_PROFILE") monkeypatch.setenv("ORACLE_MCP_AUTH_PROFILE", "legacy") @@ -125,29 +142,44 @@ def test_profile_classifier_excludes_default_inheritance(tmp_path): assert auth.profile_declares_security_token(str(config_file), "DEFAULT") -@pytest.mark.parametrize("content, profile", [("[API]\n", "MISSING"), ("not an ini file", "API")]) -def test_profile_classifier_reports_unreadable_or_missing_sections(tmp_path, content, profile): +@pytest.mark.parametrize( + "content, profile", [("[API]\n", "MISSING"), ("not an ini file", "API")] +) +def test_profile_classifier_reports_unreadable_or_missing_sections( + tmp_path, content, profile +): config_file = write_config(tmp_path, content) with pytest.raises(ValueError, match="OCI_CONFIG"): auth.profile_declares_security_token(str(config_file), profile) -def test_api_key_context_uses_selected_profile_and_safe_warning(tmp_path, monkeypatch, caplog, api_config): +def test_api_key_context_uses_selected_profile_and_safe_warning( + tmp_path, monkeypatch, caplog, api_config +): config_file = write_config(tmp_path, "[API]\n") loaded = patch_profile(monkeypatch, api_config) signer, constructor = patch_api_signer(monkeypatch) context = auth.build_auth_context( - auth.AuthOptions(auth_type="api_key", config_file=str(config_file), profile_name="API") + auth.AuthOptions( + auth_type="api_key", config_file=str(config_file), profile_name="API" + ) ) assert context.auth_type is auth.AuthType.API_KEY assert context.signer is signer assert context.region == "us-phoenix-1" - assert loaded.call_args.kwargs == {"file_location": str(config_file), "profile_name": "API"} + assert loaded.call_args.kwargs == { + "file_location": str(config_file), + "profile_name": "API", + } assert constructor.call_args.kwargs["private_key_file_location"] == "/keys/api.pem" assert "API-key authentication" in caplog.text - auth.build_auth_context(auth.AuthOptions(auth_type="api_key", config_file=str(config_file), profile_name="API")) + auth.build_auth_context( + auth.AuthOptions( + auth_type="api_key", config_file=str(config_file), profile_name="API" + ) + ) assert caplog.text.count("API-key authentication") == 1 @@ -156,26 +188,42 @@ def test_api_key_missing_fields_are_actionable_and_secret_safe(tmp_path, monkeyp patch_profile(monkeypatch, {"tenancy": "secret-tenancy", "key_file": "/secret/key"}) with pytest.raises(ValueError, match="user, fingerprint") as error: - auth.build_auth_context(auth.AuthOptions(auth_type="api_key", config_file=str(config_file), profile_name="API")) + auth.build_auth_context( + auth.AuthOptions( + auth_type="api_key", config_file=str(config_file), profile_name="API" + ) + ) assert "secret-tenancy" not in str(error.value) assert "/secret/key" not in str(error.value) -def test_security_token_context_reads_direct_token_and_constructs_signer(tmp_path, monkeypatch, api_config): +def test_security_token_context_reads_direct_token_and_constructs_signer( + tmp_path, monkeypatch, api_config +): token_file = tmp_path / "token" token_file.write_text(" session-token \n", encoding="utf-8") - config_file = write_config(tmp_path, f"[SESSION]\nsecurity_token_file = {token_file}\n") + config_file = write_config( + tmp_path, f"[SESSION]\nsecurity_token_file = {token_file}\n" + ) config = {**api_config, "security_token_file": str(token_file)} patch_profile(monkeypatch, config) private_key = object() - monkeypatch.setattr(auth.oci.signer, "load_private_key_from_file", MagicMock(return_value=private_key)) + monkeypatch.setattr( + auth.oci.signer, + "load_private_key_from_file", + MagicMock(return_value=private_key), + ) signer = object() constructor = MagicMock(return_value=signer) monkeypatch.setattr(auth.oci.auth.signers, "SecurityTokenSigner", constructor) context = auth.build_auth_context( - auth.AuthOptions(auth_type="security_token", config_file=str(config_file), profile_name="SESSION") + auth.AuthOptions( + auth_type="security_token", + config_file=str(config_file), + profile_name="SESSION", + ) ) assert context.auth_type is auth.AuthType.SECURITY_TOKEN @@ -183,60 +231,54 @@ def test_security_token_context_reads_direct_token_and_constructs_signer(tmp_pat assert constructor.call_args.args == ("session-token", private_key) -def test_security_token_context_requires_only_key_and_token_files(tmp_path, monkeypatch): - token_file = tmp_path / "token" - token_file.write_text("session-token", encoding="utf-8") - key_file = tmp_path / "session.pem" - key_file.write_text("private-key", encoding="utf-8") +def test_security_token_requires_direct_declaration_even_if_sdk_config_inherits( + tmp_path, monkeypatch, api_config +): config_file = write_config( - tmp_path, - f"[SESSION]\nkey_file = {key_file}\nsecurity_token_file = {token_file}\n", + tmp_path, "[DEFAULT]\nsecurity_token_file = /inherited\n[API]\n" ) - private_key = object() - key_loader = MagicMock(return_value=private_key) - monkeypatch.setattr(auth.oci.signer, "load_private_key_from_file", key_loader) - signer = object() - constructor = MagicMock(return_value=signer) - monkeypatch.setattr(auth.oci.auth.signers, "SecurityTokenSigner", constructor) - - context = auth.build_auth_context( - auth.AuthOptions(auth_type="security_token", config_file=str(config_file), profile_name="SESSION") - ) - - assert context.auth_type is auth.AuthType.SECURITY_TOKEN - assert context.signer is signer - assert key_loader.call_args.args == (str(key_file), None) - assert constructor.call_args.args == ("session-token", private_key) - - -def test_security_token_requires_direct_declaration_even_if_sdk_config_inherits(tmp_path, monkeypatch, api_config): - config_file = write_config(tmp_path, "[DEFAULT]\nsecurity_token_file = /inherited\n[API]\n") patch_profile(monkeypatch, {**api_config, "security_token_file": "/inherited"}) with pytest.raises(ValueError, match="declared directly"): auth.build_auth_context( - auth.AuthOptions(auth_type="security_token", config_file=str(config_file), profile_name="API") + auth.AuthOptions( + auth_type="security_token", + config_file=str(config_file), + profile_name="API", + ) ) -def test_auto_uses_api_key_when_default_token_is_inherited(tmp_path, monkeypatch, api_config): - config_file = write_config(tmp_path, "[DEFAULT]\nsecurity_token_file = /inherited\n[API]\n") +def test_auto_uses_api_key_when_default_token_is_inherited( + tmp_path, monkeypatch, api_config +): + config_file = write_config( + tmp_path, "[DEFAULT]\nsecurity_token_file = /inherited\n[API]\n" + ) patch_profile(monkeypatch, {**api_config, "security_token_file": "/inherited"}) _, constructor = patch_api_signer(monkeypatch) - context = auth.build_auth_context(auth.AuthOptions(config_file=str(config_file), profile_name="API")) + context = auth.build_auth_context( + auth.AuthOptions(config_file=str(config_file), profile_name="API") + ) assert context.auth_type is auth.AuthType.API_KEY assert constructor.called -def test_auto_direct_unreadable_token_fails_without_api_key_fallback(tmp_path, monkeypatch, api_config): - config_file = write_config(tmp_path, "[SESSION]\nsecurity_token_file = /unreadable\n") +def test_auto_direct_unreadable_token_fails_without_api_key_fallback( + tmp_path, monkeypatch, api_config +): + config_file = write_config( + tmp_path, "[SESSION]\nsecurity_token_file = /unreadable\n" + ) patch_profile(monkeypatch, {**api_config, "security_token_file": "/unreadable"}) _, constructor = patch_api_signer(monkeypatch) with pytest.raises(ValueError, match="Unable to read security_token_file"): - auth.build_auth_context(auth.AuthOptions(config_file=str(config_file), profile_name="SESSION")) + auth.build_auth_context( + auth.AuthOptions(config_file=str(config_file), profile_name="SESSION") + ) assert not constructor.called @@ -248,18 +290,30 @@ def test_profile_region_resolution_precedence(tmp_path, monkeypatch, api_config) monkeypatch.setenv("OCI_REGION", "us-ashburn-1") environment_context = auth.build_auth_context( - auth.AuthOptions(auth_type="api_key", config_file=str(config_file), profile_name="API") + auth.AuthOptions( + auth_type="api_key", config_file=str(config_file), profile_name="API" + ) ) explicit_context = auth.build_auth_context( - auth.AuthOptions(auth_type="api_key", config_file=str(config_file), profile_name="API", region="eu-frankfurt-1") + auth.AuthOptions( + auth_type="api_key", + config_file=str(config_file), + profile_name="API", + region="eu-frankfurt-1", + ) ) assert environment_context.config["region"] == "us-ashburn-1" assert explicit_context.config["region"] == "eu-frankfurt-1" -@pytest.mark.parametrize("url", ["http://insecure.example", "example.identity.oraclecloud.com", "https:///no-host"]) -def test_identity_domain_upst_validates_url_before_signer_construction(monkeypatch, url): +@pytest.mark.parametrize( + "url", + ["http://insecure.example", "example.identity.oraclecloud.com", "https:///no-host"], +) +def test_identity_domain_upst_validates_url_before_signer_construction( + monkeypatch, url +): constructor = MagicMock() monkeypatch.setattr(auth.oci.auth.signers, "TokenExchangeSigner", constructor) monkeypatch.setenv("OCI_MCP_IDENTITY_DOMAIN_URL", url) @@ -307,8 +361,17 @@ def test_identity_domain_upst_constructs_with_dynamic_jwt(tmp_path, monkeypatch) @pytest.mark.parametrize( "options, variable", [ - (auth.AuthOptions(auth_type="identity_domain_upst"), "OCI_MCP_IDENTITY_DOMAIN_URL"), - (auth.AuthOptions(auth_type="identity_domain_upst", identity_domain_url="https://example.com"), "OCI_MCP_IDENTITY_DOMAIN_CLIENT_ID"), + ( + auth.AuthOptions(auth_type="identity_domain_upst"), + "OCI_MCP_IDENTITY_DOMAIN_URL", + ), + ( + auth.AuthOptions( + auth_type="identity_domain_upst", + identity_domain_url="https://example.com", + ), + "OCI_MCP_IDENTITY_DOMAIN_CLIENT_ID", + ), ( auth.AuthOptions( auth_type="identity_domain_upst", @@ -324,7 +387,9 @@ def test_identity_domain_upst_missing_inputs_are_safe(options, variable): auth.build_auth_context(options) -def test_identity_domain_upst_empty_files_and_constructor_failure_are_safe(tmp_path, monkeypatch): +def test_identity_domain_upst_empty_files_and_constructor_failure_are_safe( + tmp_path, monkeypatch +): jwt_file = tmp_path / "jwt" secret_file = tmp_path / "secret" jwt_file.write_text("", encoding="utf-8") @@ -342,7 +407,11 @@ def test_identity_domain_upst_empty_files_and_constructor_failure_are_safe(tmp_p assert "top-secret" not in str(error.value) jwt_file.write_text("jwt", encoding="utf-8") - monkeypatch.setattr(auth.oci.auth.signers, "TokenExchangeSigner", MagicMock(side_effect=RuntimeError("top-secret"))) + monkeypatch.setattr( + auth.oci.auth.signers, + "TokenExchangeSigner", + MagicMock(side_effect=RuntimeError("top-secret")), + ) with pytest.raises(ValueError, match="Unable to construct") as error: auth.build_auth_context(options) assert "top-secret" not in str(error.value) @@ -355,14 +424,18 @@ def test_identity_domain_upst_empty_files_and_constructor_failure_are_safe(tmp_p (auth.AuthType.RESOURCE_PRINCIPAL, "get_resource_principals_signer", {}), ], ) -def test_principal_types_do_not_load_profiles(monkeypatch, auth_type, signer_name, expected_kwargs): +def test_principal_types_do_not_load_profiles( + monkeypatch, auth_type, signer_name, expected_kwargs +): signer = SimpleNamespace(region="us-phoenix-1", tenancy_id="tenant") constructor = MagicMock(return_value=signer) monkeypatch.setattr(auth.oci.auth.signers, signer_name, constructor) profile_loader = MagicMock() monkeypatch.setattr(auth.oci.config, "from_file", profile_loader) - context = auth.build_auth_context(auth.AuthOptions(auth_type=auth_type, region="us-ashburn-1")) + context = auth.build_auth_context( + auth.AuthOptions(auth_type=auth_type, region="us-ashburn-1") + ) assert context.auth_type is auth_type assert context.config == {"region": "us-ashburn-1"} @@ -371,27 +444,307 @@ def test_principal_types_do_not_load_profiles(monkeypatch, auth_type, signer_nam assert not profile_loader.called +def test_resource_principal_v212_uses_sdk_exchange_signer(tmp_path, monkeypatch): + signer = SimpleNamespace( + region="us-phoenix-1", + _get_resource_principal_token_and_service_principal_session_token=MagicMock(), + ) + constructor = MagicMock(return_value=signer) + monkeypatch.setattr( + auth.oci.auth.signers, "EphemeralResourcePrincipalV21Signer", constructor + ) + monkeypatch.setattr( + auth, "_resource_principal_v212_signer_type", lambda *_: constructor + ) + key_file = tmp_path / "resource-principal.pem" + key_file.write_text("private-key", encoding="utf-8") + + context = auth.build_auth_context( + auth.AuthOptions( + auth_type="resource_principal_v212", + region="us-phoenix-1", + resource_principal_tenancy_id="ocid1.tenancy.oc1..example", + resource_principal_resource_id="ocid1.dbsystem.oc1..example", + resource_principal_private_key_path=str(key_file), + resource_principal_rci="c2VjcmV0LXJjaQ==", + resource_principal_t0="2020-01-01T00:00:00Z", + resource_principal_rpt_endpoint="https://database.example.com/", + resource_principal_rpst_endpoint="https://auth.example.com/", + ) + ) + + assert context.auth_type is auth.AuthType.RESOURCE_PRINCIPAL_V212 + assert context.config == {"region": "us-phoenix-1"} + assert context.signer is signer + assert context.tenancy_id == "ocid1.tenancy.oc1..example" + assert constructor.call_args.kwargs | {"security_context": "present"} == { + "resource_principal_token_endpoint": "https://database.example.com", + "resource_principal_session_token_endpoint": "https://auth.example.com", + "resource_id": "ocid1.dbsystem.oc1..example", + "tenancy_id": "ocid1.tenancy.oc1..example", + "private_key": str(key_file.resolve()), + "rp_version": "2.1.2", + "region": "us-phoenix-1", + "security_context": "present", + } + security_context = constructor.call_args.kwargs["security_context"] + assert ( + json.loads(security_context)["RPTSecurityContext"]["keyType"] == "REGULAR_RPT" + ) + + +def test_resource_principal_tenancy_input_is_ignored_for_other_auth_types(monkeypatch): + signer = SimpleNamespace(region="us-phoenix-1", tenancy_id=None) + constructor = MagicMock(return_value=signer) + monkeypatch.setattr( + auth.oci.auth.signers, + "get_resource_principals_signer", + constructor, + ) + + context = auth.build_auth_context( + auth.AuthOptions( + auth_type="resource_principal", + resource_principal_tenancy_id="stale-rp-tenancy", + ) + ) + + assert context.tenancy_id is None + + +@pytest.mark.parametrize( + ("region", "database", "identity"), + [ + ("us-langley-1", "oraclegovcloud.com", "oraclegovcloud.com"), + ("uk-gov-london-1", "oraclegovcloud.uk", "oraclegovcloud.uk"), + ("eu-madrid-2", "oraclecloud.eu", "oraclecloud.eu"), + ], +) +def test_resource_principal_v212_uses_realm_aware_endpoints( + tmp_path, monkeypatch, region, database, identity +): + signer = SimpleNamespace( + _get_resource_principal_token_and_service_principal_session_token=MagicMock() + ) + constructor = MagicMock(return_value=signer) + monkeypatch.setattr( + auth.oci.auth.signers, "EphemeralResourcePrincipalV21Signer", constructor + ) + monkeypatch.setattr( + auth, "_resource_principal_v212_signer_type", lambda *_: constructor + ) + key_file = tmp_path / "resource-principal.pem" + key_file.write_text("private-key", encoding="utf-8") + + auth.build_auth_context( + auth.AuthOptions( + auth_type="resource_principal_v212", + region=region, + resource_principal_tenancy_id="tenant", + resource_principal_resource_id="resource", + resource_principal_private_key_path=str(key_file), + resource_principal_rci="c2VjcmV0LXJjaQ==", + resource_principal_t0="2020-01-01T00:00:00Z", + ) + ) + + kwargs = constructor.call_args.kwargs + assert kwargs["resource_principal_token_endpoint"].endswith(database) + assert kwargs["resource_principal_session_token_endpoint"].endswith(identity) + + +def test_resource_principal_security_context_has_known_signature(monkeypatch): + frozen = datetime(2020, 1, 1, 0, 0, 1, tzinfo=timezone.utc) + + class FrozenDatetime(datetime): + @classmethod + def now(cls, tz=None): + return frozen if tz else frozen.replace(tzinfo=None) + + monkeypatch.setattr(auth.datetime, "datetime", FrozenDatetime) + security_context = json.loads( + auth._resource_principal_security_context( + "c2VjcmV0LXJjaQ==", "2020-01-01T00:00:00Z" + ) + ) + + signature = security_context["RPTSecurityContext"]["securitySignature"] + assert signature == { + "currentUTCTime": "2020-01-01T00:00:01.000000Z", + "Signature": "65269319", + } + + +def test_resource_principal_v212_regenerates_security_context_on_refresh( + tmp_path, monkeypatch +): + signer = SimpleNamespace( + _get_resource_principal_token_and_service_principal_session_token=MagicMock() + ) + constructor = MagicMock(return_value=signer) + monkeypatch.setattr( + auth.oci.auth.signers, "EphemeralResourcePrincipalV21Signer", constructor + ) + monkeypatch.setattr( + auth, "_resource_principal_v212_signer_type", lambda *_: constructor + ) + contexts = iter(["first", "second"]) + monkeypatch.setattr( + auth, "_resource_principal_security_context", lambda *_: next(contexts) + ) + key_file = tmp_path / "resource-principal.pem" + key_file.write_text("private-key", encoding="utf-8") + context = auth.build_auth_context( + auth.AuthOptions( + auth_type="resource_principal_v212", + region="us-phoenix-1", + resource_principal_tenancy_id="tenant", + resource_principal_resource_id="resource", + resource_principal_private_key_path=str(key_file), + resource_principal_rci="c2VjcmV0LXJjaQ==", + resource_principal_t0="2020-01-01T00:00:00Z", + ) + ) + + assert context.signer is signer + assert constructor.call_args.kwargs["security_context"] == "first" + + +def test_rpv212_initial_rpt_retries_regenerate_the_security_context(monkeypatch): + headers = [] + + class RetryStrategy: + def make_retrying_call(self, request): + request() + return request() + + class SDKSigner: + def __init__(self, **_kwargs): + self.resource_principal_token_path = "/rpt" + self.retry_strategy = RetryStrategy() + self.base_client = SimpleNamespace( + call_api=lambda **kwargs: headers.append(kwargs["header_params"]) + ) + self.make_call("get", "/rpt") + + def make_call(self, *_args, **_kwargs): + raise AssertionError("The RPv2.1.2 hook must override the SDK method") + + monkeypatch.setattr( + auth.oci.auth.signers, "EphemeralResourcePrincipalV21Signer", SDKSigner + ) + contexts = iter(["first", "second"]) + monkeypatch.setattr( + auth, "_resource_principal_security_context", lambda *_: next(contexts) + ) + + signer_type = auth._resource_principal_v212_signer_type("rci", "t0") + signer = signer_type(security_context="initial") + + assert headers == [ + {"security-context": "first"}, + {"security-context": "second"}, + ] + assert signer.security_context == "second" + + +def test_rpv212_rejects_an_sdk_without_a_retry_hook(monkeypatch): + class IncompatibleSDKSigner: + pass + + monkeypatch.setattr( + auth.oci.auth.signers, + "EphemeralResourcePrincipalV21Signer", + IncompatibleSDKSigner, + ) + + with pytest.raises(ValueError, match="does not support RPv2.1.2"): + auth._resource_principal_v212_signer_type("rci", "t0") + + +def test_resource_principal_rci_is_redacted_from_representations_and_errors(caplog): + secret = "sentinel-rci-secret" + options = auth.AuthOptions(resource_principal_rci=secret) + inputs = auth._resolve_inputs(options) + + assert secret not in repr(options) + assert secret not in repr(inputs) + with pytest.raises(ValueError) as error: + auth._resource_principal_security_context(secret, "bad-timestamp") + assert secret not in str(error.value) + assert secret not in caplog.text + + +@pytest.mark.parametrize( + "t0", + ["bad timestamp", "2020-01-01T00:00:00", "2100-01-01T00:00:00Z"], +) +def test_resource_principal_security_context_rejects_invalid_timestamps(t0): + with pytest.raises(ValueError, match="OCI_MCP_RP_RCI and OCI_MCP_RP_T0"): + auth._resource_principal_security_context("c2VjcmV0LXJjaQ==", t0) + + +@pytest.mark.parametrize( + "options, message", + [ + ( + auth.AuthOptions( + auth_type="resource_principal_v212", region="us-phoenix-1" + ), + "OCI_MCP_RP_TENANCY_ID", + ), + ( + auth.AuthOptions( + auth_type="resource_principal_v212", + region="us-phoenix-1", + resource_principal_tenancy_id="tenant", + resource_principal_resource_id="resource", + resource_principal_private_key_path="/key", + resource_principal_rci="not base64", + resource_principal_t0="bad timestamp", + ), + "OCI_MCP_RP_RCI", + ), + ], +) +def test_resource_principal_v212_rejects_invalid_inputs(options, message): + with pytest.raises(ValueError, match=message): + auth.build_auth_context(options) + + @pytest.mark.parametrize( "auth_type, signer_name", [ - (auth.AuthType.INSTANCE_PRINCIPAL_DELEGATION, "InstancePrincipalsDelegationTokenSigner"), - (auth.AuthType.RESOURCE_PRINCIPAL_DELEGATION, "get_resource_principal_delegation_token_signer"), + ( + auth.AuthType.INSTANCE_PRINCIPAL_DELEGATION, + "InstancePrincipalsDelegationTokenSigner", + ), + ( + auth.AuthType.RESOURCE_PRINCIPAL_DELEGATION, + "get_resource_principal_delegation_token_signer", + ), ], ) -def test_delegation_principal_types_read_file_tokens(tmp_path, monkeypatch, auth_type, signer_name): +def test_delegation_principal_types_read_file_tokens( + tmp_path, monkeypatch, auth_type, signer_name +): token_file = tmp_path / "delegation" token_file.write_text(" delegation-token \n", encoding="utf-8") signer = SimpleNamespace(region="us-phoenix-1") constructor = MagicMock(return_value=signer) monkeypatch.setattr(auth.oci.auth.signers, signer_name, constructor) - context = auth.build_auth_context(auth.AuthOptions(auth_type=auth_type, delegation_token_file=str(token_file))) + context = auth.build_auth_context( + auth.AuthOptions(auth_type=auth_type, delegation_token_file=str(token_file)) + ) assert context.signer is signer assert constructor.call_args.kwargs["delegation_token"] == "delegation-token" -def test_delegation_sources_validate_conflicts_and_legacy_warning(tmp_path, monkeypatch, caplog): +def test_delegation_sources_validate_conflicts_and_legacy_warning( + tmp_path, monkeypatch, caplog +): token_file = tmp_path / "delegation" token_file.write_text("token", encoding="utf-8") with pytest.raises(ValueError, match="Set only OCI_MCP_DELEGATION_TOKEN_FILE"): @@ -403,12 +756,16 @@ def test_delegation_sources_validate_conflicts_and_legacy_warning(tmp_path, monk ) ) with pytest.raises(ValueError, match="OCI_MCP_DELEGATION_TOKEN_FILE is required"): - auth.build_auth_context(auth.AuthOptions(auth_type="instance_principal_delegation")) + auth.build_auth_context( + auth.AuthOptions(auth_type="instance_principal_delegation") + ) monkeypatch.setenv("OCI_IOT_DELEGATION_TOKEN", "legacy-token") signer = SimpleNamespace(region="us-phoenix-1") constructor = MagicMock(return_value=signer) - monkeypatch.setattr(auth.oci.auth.signers, "InstancePrincipalsDelegationTokenSigner", constructor) + monkeypatch.setattr( + auth.oci.auth.signers, "InstancePrincipalsDelegationTokenSigner", constructor + ) auth.build_auth_context(auth.AuthOptions(auth_type="instance_principal_delegation")) assert constructor.call_args.kwargs["delegation_token"] == "legacy-token" assert "legacy-token" not in caplog.text @@ -417,18 +774,26 @@ def test_delegation_sources_validate_conflicts_and_legacy_warning(tmp_path, monk def test_oke_uses_default_path_and_inline_compatibility(monkeypatch, caplog): constructor = MagicMock(return_value=SimpleNamespace(region="us-phoenix-1")) - monkeypatch.setattr(auth.oci.auth.signers, "get_oke_workload_identity_resource_principal_signer", constructor) + monkeypatch.setattr( + auth.oci.auth.signers, + "get_oke_workload_identity_resource_principal_signer", + constructor, + ) auth.build_auth_context(auth.AuthOptions(auth_type="oke_workload_identity")) assert constructor.call_args.kwargs == {} auth.build_auth_context( - auth.AuthOptions(auth_type="oke_workload_identity", oke_service_account_token_path="/token") + auth.AuthOptions( + auth_type="oke_workload_identity", oke_service_account_token_path="/token" + ) ) assert constructor.call_args.kwargs == {"service_account_token_path": "/token"} auth.build_auth_context( - auth.AuthOptions(auth_type="oke_workload_identity", oke_service_account_token="inline-token") + auth.AuthOptions( + auth_type="oke_workload_identity", oke_service_account_token="inline-token" + ) ) assert constructor.call_args.kwargs == {"service_account_token": "inline-token"} assert "inline-token" not in caplog.text @@ -456,17 +821,29 @@ def test_oke_conflicting_overrides_and_signer_failures_are_safe(monkeypatch): @pytest.mark.parametrize( "auth_type, signer_name, options", [ - (auth.AuthType.INSTANCE_PRINCIPAL, "InstancePrincipalsSecurityTokenSigner", auth.AuthOptions(auth_type="instance_principal")), - (auth.AuthType.RESOURCE_PRINCIPAL, "get_resource_principals_signer", auth.AuthOptions(auth_type="resource_principal")), + ( + auth.AuthType.INSTANCE_PRINCIPAL, + "InstancePrincipalsSecurityTokenSigner", + auth.AuthOptions(auth_type="instance_principal"), + ), + ( + auth.AuthType.RESOURCE_PRINCIPAL, + "get_resource_principals_signer", + auth.AuthOptions(auth_type="resource_principal"), + ), ( auth.AuthType.INSTANCE_PRINCIPAL_DELEGATION, "InstancePrincipalsDelegationTokenSigner", - auth.AuthOptions(auth_type="instance_principal_delegation", delegation_token="token"), + auth.AuthOptions( + auth_type="instance_principal_delegation", delegation_token="token" + ), ), ( auth.AuthType.RESOURCE_PRINCIPAL_DELEGATION, "get_resource_principal_delegation_token_signer", - auth.AuthOptions(auth_type="resource_principal_delegation", delegation_token="token"), + auth.AuthOptions( + auth_type="resource_principal_delegation", delegation_token="token" + ), ), ( auth.AuthType.OKE_WORKLOAD_IDENTITY, @@ -475,10 +852,18 @@ def test_oke_conflicting_overrides_and_signer_failures_are_safe(monkeypatch): ), ], ) -def test_every_principal_signer_failure_is_secret_safe(monkeypatch, auth_type, signer_name, options): - monkeypatch.setattr(auth.oci.auth.signers, signer_name, MagicMock(side_effect=RuntimeError("private details"))) +def test_every_principal_signer_failure_is_secret_safe( + monkeypatch, auth_type, signer_name, options +): + monkeypatch.setattr( + auth.oci.auth.signers, + signer_name, + MagicMock(side_effect=RuntimeError("private details")), + ) - with pytest.raises(ValueError, match=f"Unable to construct the {auth_type.value} signer") as error: + with pytest.raises( + ValueError, match=f"Unable to construct the {auth_type.value} signer" + ) as error: auth.build_auth_context(options) assert "private details" not in str(error.value) @@ -488,7 +873,11 @@ def test_canonical_values_override_aliases_and_tenancy_alias_is_supported(monkey monkeypatch.setenv("OCI_MCP_AUTH_TYPE", "instance_principal") monkeypatch.setenv("OCI_IOT_AUTH_TYPE", "resource_principal") signer = SimpleNamespace(region="us-phoenix-1", tenancy_id=None) - monkeypatch.setattr(auth.oci.auth.signers, "InstancePrincipalsSecurityTokenSigner", MagicMock(return_value=signer)) + monkeypatch.setattr( + auth.oci.auth.signers, + "InstancePrincipalsSecurityTokenSigner", + MagicMock(return_value=signer), + ) monkeypatch.setenv("TENANCY_ID_OVERRIDE", "legacy-tenancy") context = auth.build_auth_context() @@ -530,7 +919,9 @@ def test_idcs_http_auth_builds_provider_and_creates_request_context(monkeypatch) monkeypatch.setattr(auth, "OCIProvider", provider_constructor) signer = object() signer_constructor = MagicMock(return_value=signer) - monkeypatch.setattr(auth.oci.auth.signers, "TokenExchangeSigner", signer_constructor) + monkeypatch.setattr( + auth.oci.auth.signers, "TokenExchangeSigner", signer_constructor + ) http_auth = auth.build_idcs_http_auth(["openid", "oci_mcp.example.invoke"]) context = http_auth.context_for("request-token") @@ -569,7 +960,9 @@ def test_idcs_http_auth_explicit_options_and_request_region_win(monkeypatch): provider_constructor = MagicMock(return_value=object()) monkeypatch.setattr(auth, "OCIProvider", provider_constructor) signer_constructor = MagicMock(return_value=object()) - monkeypatch.setattr(auth.oci.auth.signers, "TokenExchangeSigner", signer_constructor) + monkeypatch.setattr( + auth.oci.auth.signers, "TokenExchangeSigner", signer_constructor + ) http_auth = auth.build_idcs_http_auth( ["openid"], @@ -601,7 +994,9 @@ def test_idcs_http_auth_explicit_options_and_request_region_win(monkeypatch): ({"ORACLE_MCP_BASE_URL": "mcp.example.com"}, "ORACLE_MCP_BASE_URL"), ], ) -def test_idcs_http_auth_rejects_invalid_provider_inputs_before_construction(monkeypatch, environment, message): +def test_idcs_http_auth_rejects_invalid_provider_inputs_before_construction( + monkeypatch, environment, message +): set_idcs_http_environment(monkeypatch, **environment) provider_constructor = MagicMock() monkeypatch.setattr(auth, "OCIProvider", provider_constructor) @@ -617,7 +1012,9 @@ def test_idcs_http_auth_requires_scopes_and_safe_request_token_and_region(monkey provider_constructor = MagicMock(return_value=object()) signer_constructor = MagicMock() monkeypatch.setattr(auth, "OCIProvider", provider_constructor) - monkeypatch.setattr(auth.oci.auth.signers, "TokenExchangeSigner", signer_constructor) + monkeypatch.setattr( + auth.oci.auth.signers, "TokenExchangeSigner", signer_constructor + ) with pytest.raises(ValueError, match="required_scopes"): auth.build_idcs_http_auth([]) diff --git a/src/common/pyproject.toml b/src/common/pyproject.toml index 2816d6d3..6ff82d5a 100644 --- a/src/common/pyproject.toml +++ b/src/common/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oracle-mcp-common" -version = "0.1.3" +version = "0.2.0" description = "Shared utilities for Oracle MCP Python servers" readme = "README.md" requires-python = ">=3.13" diff --git a/src/common/uv.lock b/src/common/uv.lock index 09c04581..59d02d5d 100644 --- a/src/common/uv.lock +++ b/src/common/uv.lock @@ -772,7 +772,7 @@ wheels = [ [[package]] name = "oracle-mcp-common" -version = "0.1.3" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "fastmcp" }, @@ -1256,8 +1256,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "sys_platform != 'win32'" }, + { name = "jeepney", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ diff --git a/src/oci-api-mcp-server/CHANGELOG.md b/src/oci-api-mcp-server/CHANGELOG.md index 7fb3287d..9ee645f7 100644 --- a/src/oci-api-mcp-server/CHANGELOG.md +++ b/src/oci-api-mcp-server/CHANGELOG.md @@ -9,6 +9,7 @@ - Prevented the denylist generator from following destination symlinks when writing files. - Restricted execution to OCI CLI 3.89.3 installed in the MCP server environment. - Excluded development artifacts, local configuration, and container build files from source-distribution packages. +- Updated the `oracle-mcp-common` compatibility requirement to 0.2. ## 2.1.4 diff --git a/src/oci-api-mcp-server/pyproject.toml b/src/oci-api-mcp-server/pyproject.toml index a91137c4..85ea6a66 100644 --- a/src/oci-api-mcp-server/pyproject.toml +++ b/src/oci-api-mcp-server/pyproject.toml @@ -12,7 +12,7 @@ authors = [ dependencies = [ "fastmcp==3.4.5", "oci-cli==3.89.3", - "oracle-mcp-common>=0.1.2,<0.2.0", + "oracle-mcp-common>=0.2.0,<0.3.0", ] classifiers = [ diff --git a/src/oci-api-mcp-server/uv.lock b/src/oci-api-mcp-server/uv.lock index 7ac8eea8..771376d7 100644 --- a/src/oci-api-mcp-server/uv.lock +++ b/src/oci-api-mcp-server/uv.lock @@ -833,7 +833,7 @@ wheels = [ [[package]] name = "oracle-mcp-common" -version = "0.1.3" +version = "0.2.0" source = { editable = "../common" } dependencies = [ { name = "fastmcp" }, @@ -1335,8 +1335,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "sys_platform != 'win32'" }, + { name = "jeepney", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ diff --git a/src/oci-cloud-mcp-server/CHANGELOG.md b/src/oci-cloud-mcp-server/CHANGELOG.md index 152949fc..737364ed 100644 --- a/src/oci-cloud-mcp-server/CHANGELOG.md +++ b/src/oci-cloud-mcp-server/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changed - Excluded development artifacts, local configuration, and container build files from source-distribution packages. +- Updated the `oracle-mcp-common` compatibility requirement to 0.2. ## 2.2.2 diff --git a/src/oci-cloud-mcp-server/pyproject.toml b/src/oci-cloud-mcp-server/pyproject.toml index 41850661..d4aff68e 100644 --- a/src/oci-cloud-mcp-server/pyproject.toml +++ b/src/oci-cloud-mcp-server/pyproject.toml @@ -12,7 +12,7 @@ authors = [ dependencies = [ "fastmcp==3.4.5", "oci==2.182.1", - "oracle-mcp-common>=0.1.2,<0.2.0", + "oracle-mcp-common>=0.2.0,<0.3.0", ] classifiers = [ diff --git a/src/oci-cloud-mcp-server/uv.lock b/src/oci-cloud-mcp-server/uv.lock index 069fcc07..93458711 100644 --- a/src/oci-cloud-mcp-server/uv.lock +++ b/src/oci-cloud-mcp-server/uv.lock @@ -786,7 +786,7 @@ wheels = [ [[package]] name = "oracle-mcp-common" -version = "0.1.3" +version = "0.2.0" source = { editable = "../common" } dependencies = [ { name = "fastmcp" }, @@ -1295,8 +1295,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "sys_platform != 'win32'" }, + { name = "jeepney", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ diff --git a/src/oci-database-mcp-server/CHANGELOG.md b/src/oci-database-mcp-server/CHANGELOG.md index b3d7d060..eea7d428 100644 --- a/src/oci-database-mcp-server/CHANGELOG.md +++ b/src/oci-database-mcp-server/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to OCI Database MCP Server are documented in this file. ### Changed - Excluded development artifacts, local configuration, and container build files from source-distribution packages. +- Updated the `oracle-mcp-common` compatibility requirement to 0.2 and synchronized package metadata. ## 1.3.2 diff --git a/src/oci-database-mcp-server/pyproject.toml b/src/oci-database-mcp-server/pyproject.toml index 2d1fea47..6142721b 100644 --- a/src/oci-database-mcp-server/pyproject.toml +++ b/src/oci-database-mcp-server/pyproject.toml @@ -12,7 +12,7 @@ authors = [ dependencies = [ "fastmcp==3.4.5", "oci==2.182.1", - "oracle-mcp-common>=0.1.2,<0.2.0", + "oracle-mcp-common>=0.2.0,<0.3.0", "pytest-cov>=7.0.0", ] diff --git a/src/oci-database-mcp-server/uv.lock b/src/oci-database-mcp-server/uv.lock index 71eaf848..cda5ced0 100644 --- a/src/oci-database-mcp-server/uv.lock +++ b/src/oci-database-mcp-server/uv.lock @@ -786,7 +786,7 @@ wheels = [ [[package]] name = "oracle-mcp-common" -version = "0.1.3" +version = "0.2.0" source = { editable = "../common" } dependencies = [ { name = "fastmcp" }, @@ -1295,8 +1295,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "sys_platform != 'win32'" }, + { name = "jeepney", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ From 7625667e79a9c270e35c54e0a518a770d39d83dd Mon Sep 17 00:00:00 2001 From: Shyam N V Date: Fri, 11 Sep 2026 20:30:09 +0530 Subject: [PATCH 2/2] chore: bump OCI API, Database, Cloud MCP server version --- src/oci-api-mcp-server/CHANGELOG.md | 6 ++++++ src/oci-api-mcp-server/pyproject.toml | 2 +- src/oci-api-mcp-server/uv.lock | 2 +- src/oci-cloud-mcp-server/CHANGELOG.md | 6 ++++++ src/oci-cloud-mcp-server/pyproject.toml | 2 +- src/oci-cloud-mcp-server/uv.lock | 2 +- src/oci-database-mcp-server/CHANGELOG.md | 6 ++++++ src/oci-database-mcp-server/pyproject.toml | 2 +- src/oci-database-mcp-server/uv.lock | 2 +- 9 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/oci-api-mcp-server/CHANGELOG.md b/src/oci-api-mcp-server/CHANGELOG.md index 9ee645f7..38590085 100644 --- a/src/oci-api-mcp-server/CHANGELOG.md +++ b/src/oci-api-mcp-server/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2.1.6 + +### Changed + +- Published a new release carrying the `oracle-mcp-common` 0.2 compatibility requirement. + ## 2.1.5 ### Security diff --git a/src/oci-api-mcp-server/pyproject.toml b/src/oci-api-mcp-server/pyproject.toml index 85ea6a66..bc83e918 100644 --- a/src/oci-api-mcp-server/pyproject.toml +++ b/src/oci-api-mcp-server/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oracle.oci-api-mcp-server" -version = "2.1.5" +version = "2.1.6" description = "OCI CLI MCP server" readme = "README.md" requires-python = ">=3.13" diff --git a/src/oci-api-mcp-server/uv.lock b/src/oci-api-mcp-server/uv.lock index 771376d7..c0ffde02 100644 --- a/src/oci-api-mcp-server/uv.lock +++ b/src/oci-api-mcp-server/uv.lock @@ -860,7 +860,7 @@ dev = [ [[package]] name = "oracle-oci-api-mcp-server" -version = "2.1.5" +version = "2.1.6" source = { editable = "." } dependencies = [ { name = "fastmcp" }, diff --git a/src/oci-cloud-mcp-server/CHANGELOG.md b/src/oci-cloud-mcp-server/CHANGELOG.md index 737364ed..31d30896 100644 --- a/src/oci-cloud-mcp-server/CHANGELOG.md +++ b/src/oci-cloud-mcp-server/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2.2.4 + +### Changed + +- Published a new release carrying the `oracle-mcp-common` 0.2 compatibility requirement. + ## 2.2.3 ### Changed diff --git a/src/oci-cloud-mcp-server/pyproject.toml b/src/oci-cloud-mcp-server/pyproject.toml index d4aff68e..d7b8c86d 100644 --- a/src/oci-cloud-mcp-server/pyproject.toml +++ b/src/oci-cloud-mcp-server/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oracle.oci-cloud-mcp-server" -version = "2.2.3" +version = "2.2.4" description = "OCI Python SDK MCP server" readme = "README.md" requires-python = ">=3.13" diff --git a/src/oci-cloud-mcp-server/uv.lock b/src/oci-cloud-mcp-server/uv.lock index 93458711..190dfa83 100644 --- a/src/oci-cloud-mcp-server/uv.lock +++ b/src/oci-cloud-mcp-server/uv.lock @@ -813,7 +813,7 @@ dev = [ [[package]] name = "oracle-oci-cloud-mcp-server" -version = "2.2.3" +version = "2.2.4" source = { editable = "." } dependencies = [ { name = "fastmcp" }, diff --git a/src/oci-database-mcp-server/CHANGELOG.md b/src/oci-database-mcp-server/CHANGELOG.md index eea7d428..a5837c9b 100644 --- a/src/oci-database-mcp-server/CHANGELOG.md +++ b/src/oci-database-mcp-server/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to OCI Database MCP Server are documented in this file. +## 1.3.4 + +### Changed + +- Published a new release carrying the `oracle-mcp-common` 0.2 compatibility requirement. + ## 1.3.3 ### Changed diff --git a/src/oci-database-mcp-server/pyproject.toml b/src/oci-database-mcp-server/pyproject.toml index 6142721b..ac7d1b5a 100644 --- a/src/oci-database-mcp-server/pyproject.toml +++ b/src/oci-database-mcp-server/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oracle.oci-database-mcp-server" -version = "1.3.3" +version = "1.3.4" description = "OCI Database Service MCP server" readme = "README.md" requires-python = ">=3.13" diff --git a/src/oci-database-mcp-server/uv.lock b/src/oci-database-mcp-server/uv.lock index cda5ced0..25fa9476 100644 --- a/src/oci-database-mcp-server/uv.lock +++ b/src/oci-database-mcp-server/uv.lock @@ -813,7 +813,7 @@ dev = [ [[package]] name = "oracle-oci-database-mcp-server" -version = "1.3.3" +version = "1.3.4" source = { editable = "." } dependencies = [ { name = "fastmcp" },