diff --git a/adapter-contract/src/nemo_fabric_adapter_contract/models.py b/adapter-contract/src/nemo_fabric_adapter_contract/models.py index b84905197..42348d1cd 100644 --- a/adapter-contract/src/nemo_fabric_adapter_contract/models.py +++ b/adapter-contract/src/nemo_fabric_adapter_contract/models.py @@ -218,6 +218,8 @@ class AgentMcpServerConfig(AgentContractBlock): url: str args: list[str] = _empty_list() env: dict[str, str] = _empty_dict() + authentication: dict[str, JsonValue] | None = _optional() + custom_headers: dict[str, str] = _empty_dict() allowed_tools: list[str] | None = _optional() blocked_tools: list[str] = _empty_list() diff --git a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py index 706cc571d..e7910b3ec 100644 --- a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py +++ b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py @@ -12,6 +12,7 @@ import os import shutil import subprocess +from collections.abc import Mapping from dataclasses import asdict from dataclasses import dataclass from dataclasses import is_dataclass @@ -31,6 +32,7 @@ from claude_agent_sdk import HookMatcher from claude_agent_sdk._errors import MessageParseError from nemo_fabric_adapters.common import lifecycle +from nemo_fabric_adapters.common import mcp_auth from nemo_fabric_adapters.common import relay_artifacts from nemo_fabric_adapters.common import relay_gateway from nemo_fabric_adapters.common import relay_hooks @@ -59,8 +61,11 @@ "ANTHROPIC_SERVICE_ACCOUNT_ID", "ANTHROPIC_WORKSPACE_ID", "APPDATA", + "BROWSER", "CLAUDE_CONFIG_DIR", "COMSPEC", + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", "HOME", "HTTP_PROXY", "HTTPS_PROXY", @@ -80,9 +85,12 @@ "TMPDIR", "USER", "USERPROFILE", + "WAYLAND_DISPLAY", + "XAUTHORITY", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", "http_proxy", "https_proxy", "no_proxy", @@ -291,16 +299,9 @@ def _model_environment( def _mcp_servers(payload: dict[str, Any]) -> dict[str, Any]: - native = ( - _mapping(common_utils.capability_plan(payload), name="capability_plan").get( - "native" - ) - or {} - ) - servers = _mapping(native, name="capability_plan.native").get("mcp_servers") or {} + servers = _native_mcp_server_specs(payload) result: dict[str, Any] = {} - for name, raw in sorted(_mapping(servers, name="native MCP servers").items()): - server = _mapping(raw, name=f"MCP server {name}") + for name, server in sorted(servers.items()): transport = server.get("transport") url = server.get("url") if not isinstance(url, str) or not url: @@ -324,10 +325,178 @@ def _mcp_servers(payload: dict[str, Any]) -> dict[str, Any]: "claude_invalid_configuration", f"unsupported MCP transport: {transport}", ) + if headers := server.get("custom_headers"): + try: + result[name]["headers"] = mcp_auth.normalize_custom_headers( + name, headers + ) + except mcp_auth.McpAuthConfigError as error: + raise AdapterConfigError( + "claude_invalid_configuration", str(error) + ) from error + if authentication := server.get("authentication"): + if transport == "stdio": + raise AdapterConfigError( + "claude_invalid_configuration", + f"MCP server {name} authentication is not supported for stdio; " + "provide credentials through env", + ) + _mcp_oauth_config(name, authentication) return result -def _stage_mcp_config(payload: dict[str, Any]) -> ClaudeMcpSettings | None: +def _native_mcp_server_specs(payload: dict[str, Any]) -> dict[str, dict[str, Any]]: + native = ( + _mapping(common_utils.capability_plan(payload), name="capability_plan").get( + "native" + ) + or {} + ) + servers = _mapping(native, name="capability_plan.native").get("mcp_servers") or {} + return { + name: _mapping(raw, name=f"MCP server {name}") + for name, raw in _mapping(servers, name="native MCP servers").items() + } + + +def _mcp_oauth_config(name: str, value: Any) -> mcp_auth.McpOAuth2Config: + try: + if isinstance(value, Mapping) and value.get("type") == "service_account": + raise mcp_auth.McpAuthConfigError( + f"MCP server {name!r} service_account authentication is not supported by Claude" + ) + return mcp_auth.parse_oauth2_config(name, value) + except mcp_auth.McpAuthConfigError as error: + raise AdapterConfigError("claude_invalid_configuration", str(error)) from error + + +def _authenticated_mcp_servers(payload: dict[str, Any]) -> dict[str, dict[str, Any]]: + servers = _native_mcp_server_specs(payload) + mapped = _mcp_servers(payload) + return { + name: mapped[name] + for name, server in sorted(servers.items()) + if server.get("authentication") + } + + +def _mcp_authentication(payload: dict[str, Any], name: str) -> mcp_auth.McpOAuth2Config: + server = _native_mcp_server_specs(payload)[name] + return _mcp_oauth_config(name, server.get("authentication")) + + +async def _self_authenticate_http_mcp_server( + server_name: str, + server_url: str, + server_type: str, + config: mcp_auth.McpOAuth2Config, + *, + timeout: float, +) -> str: + """Run an OAuth authorization_code flow in-process and return the access token. + + Uses mcp_auth.create_mcp_oauth_provider and an HTTP request to trigger the + 401→OAuth dance. The HTTP method is chosen by transport type: SSE servers + expect GET; streamable-http servers only issue the 401 auth challenge on POST + (a bare GET returns 405 or 200 and never triggers the OAuth flow). + On success, the token is stored in the provider context and returned as a string. + Raises ClaudeAdapterError on failure; re-raises TimeoutError so the caller can + map it to claude_mcp_authentication_timed_out. + """ + import httpx + + try: + provider = mcp_auth.create_mcp_oauth_provider( + server_name, + server_url, + config, + client_name=config.client_name or "nemo-fabric", + ) + except mcp_auth.McpAuthConfigError as error: + raise ClaudeAdapterError( + "claude_mcp_authentication_failed", + f"MCP server {server_name!r} OAuth configuration is invalid: {error}", + metadata={"server": server_name}, + ) from error + + try: + async with asyncio.timeout(timeout): + async with httpx.AsyncClient(auth=provider) as http_client: + if server_type == "sse": + await http_client.get(server_url) + else: + await http_client.post( + server_url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + json={ + "jsonrpc": "2.0", + "id": 0, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { + "name": "nemo-fabric", + "version": "0.0.0", + }, + }, + }, + ) + except TimeoutError: + raise + except mcp_auth.McpAuthConfigError as error: + raise ClaudeAdapterError( + "claude_mcp_authentication_failed", + f"Claude Code could not authenticate MCP server {server_name!r}: {error}", + metadata={"server": server_name}, + ) from error + except asyncio.CancelledError: + raise + except Exception as error: + raise ClaudeAdapterError( + "claude_mcp_authentication_failed", + f"Claude Code could not authenticate MCP server {server_name!r}", + metadata={"server": server_name}, + ) from error + + access_token = mcp_auth.access_token(provider) + if not access_token: + raise ClaudeAdapterError( + "claude_mcp_authentication_failed", + f"MCP server {server_name!r} OAuth flow did not return an access token", + metadata={"server": server_name}, + ) + return access_token + + +async def _prefetch_mcp_oauth_tokens(payload: dict[str, Any]) -> dict[str, str]: + """Run OAuth flows for HTTP/SSE MCP servers before Claude launches. + + Returns {server_name: access_token}. Tokens are projected into the staged + config as ${VAR} references by _stage_mcp_config so the literal credential + never appears in the on-disk config file. + """ + servers = _authenticated_mcp_servers(payload) + tokens: dict[str, str] = {} + for name, server in servers.items(): + authentication = _mcp_authentication(payload, name) + tokens[name] = await _self_authenticate_http_mcp_server( + name, + server["url"], + server["type"], + authentication, + timeout=authentication.authorization_timeout_seconds, + ) + return tokens + + +def _stage_mcp_config( + payload: dict[str, Any], + oauth_tokens: dict[str, str] | None = None, +) -> ClaudeMcpSettings | None: # Dictionary-valued ClaudeAgentOptions.mcp_servers are JSON-serialized by # claude-agent-sdk into the literal `--mcp-config` command-line argument, # where MCP credentials can be observed by process-inspection tools. Passing @@ -342,33 +511,48 @@ def _stage_mcp_config(payload: dict[str, Any]) -> ClaudeMcpSettings | None: return None fabric_runtime_id = runtime_id(payload) environment: dict[str, str] = {} + + def project_environment_value(server_name: str, value_name: str, value: str) -> str: + projection_key = ( + sha256(f"{fabric_runtime_id}\0{server_name}\0{value_name}".encode()) + .hexdigest() + .upper() + ) + projected_name = f"NEMO_FABRIC_CLAUDE_MCP_{projection_key}" + environment[projected_name] = value + return f"${{{projected_name}}}" + for server_name, server in servers.items(): raw_environment = server.get("env") - if raw_environment is None: - continue - server_environment = _mapping( - raw_environment, - name=f"MCP server {server_name} env", - ) - projected_environment: dict[str, str] = {} - for variable_name, value in sorted(server_environment.items()): - if not isinstance(variable_name, str) or not variable_name: - raise AdapterConfigError( - "claude_invalid_configuration", - f"MCP server {server_name} env names must be non-empty strings", - ) - if not isinstance(value, str): - raise AdapterConfigError( - "claude_invalid_configuration", - f"MCP server {server_name} env values must be strings", + if raw_environment is not None: + server_environment = _mapping( + raw_environment, + name=f"MCP server {server_name} env", + ) + projected_environment: dict[str, str] = {} + for variable_name, value in sorted(server_environment.items()): + if not isinstance(variable_name, str) or not variable_name: + raise AdapterConfigError( + "claude_invalid_configuration", + f"MCP server {server_name} env names must be non-empty strings", + ) + if not isinstance(value, str): + raise AdapterConfigError( + "claude_invalid_configuration", + f"MCP server {server_name} env values must be strings", + ) + projected_environment[variable_name] = project_environment_value( + server_name, variable_name, value ) - projection_key = sha256( - f"{fabric_runtime_id}\0{server_name}\0{variable_name}".encode() - ).hexdigest().upper() - projected_name = f"NEMO_FABRIC_CLAUDE_MCP_{projection_key}" - projected_environment[variable_name] = f"${{{projected_name}}}" - environment[projected_name] = value - server["env"] = projected_environment + server["env"] = projected_environment + + if oauth_tokens and (token := oauth_tokens.get(server_name)): + token_reference = project_environment_value( + server_name, "oauth_access_token", token + ) + server.setdefault("headers", {})["Authorization"] = ( + f"Bearer {token_reference}" + ) config_root = ( _artifact_root(payload) @@ -402,8 +586,7 @@ def _cleanup_mcp_config(config_path: Path | None) -> None: if config_path is None: return try: - config_path.unlink(missing_ok=True) - config_path.parent.rmdir() + shutil.rmtree(config_path.parent) except OSError: LOGGER.exception("Claude MCP runtime configuration could not be removed") @@ -610,6 +793,7 @@ def build_options( payload: dict[str, Any], *, relay: ClaudeRelaySettings | None = None, + oauth_tokens: dict[str, str] | None = None, ) -> ClaudeAgentOptions: settings = _settings(payload) permission_mode = settings.get("permission_mode") @@ -645,7 +829,7 @@ def build_options( payload, relay_gateway_url=relay.gateway.url if relay is not None else None, ) - mcp = _stage_mcp_config(payload) + mcp = _stage_mcp_config(payload, oauth_tokens) if mcp is not None: environment.update(mcp.environment) try: @@ -679,6 +863,10 @@ def timeout_seconds(payload: dict[str, Any]) -> float: return _positive_number(value, name="timeout_seconds") +def _remaining_timeout(deadline: float) -> float: + return max(0.0, deadline - asyncio.get_running_loop().time()) + + def _artifact_root(payload: dict[str, Any]) -> Path: artifacts = common_utils.runtime_context(payload).get("artifacts") or {} root = artifacts.get("root") if isinstance(artifacts, dict) else None @@ -930,6 +1118,7 @@ def __init__(self) -> None: self._fabric_runtime_id: str | None = None self._claude_session_id: str | None = None self._client: ClaudeSDKClient | None = None + self._mcp_authentication_checked: dict[str, bool] = {} self._relay: ClaudeRelaySettings | None = None self._gateway_process: subprocess.Popen[Any] | None = None self._mcp_config_path: Path | None = None @@ -946,11 +1135,20 @@ async def start(self, payload: dict[str, Any]) -> None: relay = prepare_claude_relay(payload) self._relay = relay self._gateway_process = _start_relay_gateway(payload, relay) - options = build_options(payload, relay=relay) + oauth_tokens = await _prefetch_mcp_oauth_tokens(payload) + options = build_options(payload, relay=relay, oauth_tokens=oauth_tokens) + if isinstance(options.mcp_servers, Path): self._mcp_config_path = options.mcp_servers + client = ClaudeSDKClient(options) await client.connect() + except TimeoutError as error: + self._cleanup_failed_start() + raise lifecycle.LifecycleError( + "claude_mcp_authentication_timed_out", + "Claude MCP OAuth authentication timed out during startup", + ) from error except ClaudeAdapterError as error: self._cleanup_failed_start() raise _as_lifecycle_error(error) from error @@ -990,11 +1188,25 @@ async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: "Claude runtime cannot accept another invocation after a runtime failure", ) + invocation_deadline = ( + asyncio.get_running_loop().time() + timeout_seconds(payload) + ) try: prompt = request_prompt(payload) - invocation_timeout = timeout_seconds(payload) + await self._authenticate_mcp_servers( + payload, + client, + invocation_deadline, + ) except ClaudeAdapterError as error: output = adapter_failure(error) + except TimeoutError: + output = _failure( + "claude_mcp_authentication_timed_out", + "Claude MCP authentication timed out", + ) + except ClaudeSDKError as error: + output = sdk_failure(error) else: relay = self._relay atif_before = ( @@ -1007,7 +1219,7 @@ async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: payload, client, prompt, - invocation_timeout, + _remaining_timeout(invocation_deadline), ) if ( output.get("completed") @@ -1037,19 +1249,72 @@ async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: output = _relay_output(output, self._relay) return output + async def _authenticate_mcp_servers( + self, + payload: dict[str, Any], + client: ClaudeSDKClient, + invocation_deadline: float, + ) -> None: + for name in _authenticated_mcp_servers(payload): + if self._mcp_authentication_checked.get(name, False): + continue + + self._mcp_authentication_checked[name] = False + status = await self._mcp_server_status( + client, + name, + min(5.0, _remaining_timeout(invocation_deadline)), + ) + if status != "connected": + raise ClaudeAdapterError( + "claude_mcp_unavailable", + f"Claude MCP server {name!r} is unavailable", + metadata={"server": name, "status": status}, + ) + + self._mcp_authentication_checked[name] = True + + async def _mcp_server_status( + self, + client: ClaudeSDKClient, + name: str, + timeout: float, + ) -> str: + async with asyncio.timeout(timeout): + while True: + response = await client.get_mcp_status() + match = next( + ( + server + for server in response.get("mcpServers", []) + if server.get("name") == name + ), + None, + ) + if match is None: + raise ClaudeAdapterError( + "claude_mcp_unavailable", + f"Claude MCP server {name!r} was not loaded", + metadata={"server": name, "status": "missing"}, + ) + status = str(match.get("status") or "unknown") + if status != "pending": + return status + await asyncio.sleep(0.25) + async def _run_query( self, payload: dict[str, Any], client: ClaudeSDKClient, prompt: str, - invocation_timeout: float, + remaining_timeout: float, ) -> dict[str, Any]: """Run one SDK query and normalize its terminal result.""" messages: list[Message] = [] result: ResultMessage | None = None try: - async with asyncio.timeout(invocation_timeout): + async with asyncio.timeout(remaining_timeout): await client.query(prompt) async for message in client.receive_response(): if isinstance(message, ResultMessage): @@ -1105,6 +1370,7 @@ async def stop(self) -> None: self._start_payload = None self._fabric_runtime_id = None self._claude_session_id = None + self._mcp_authentication_checked = {} self._unusable = True disconnect_error: BaseException | None = None diff --git a/adapters/claude/uv.lock b/adapters/claude/uv.lock index 227982408..f89e9b9e5 100644 --- a/adapters/claude/uv.lock +++ b/adapters/claude/uv.lock @@ -379,6 +379,13 @@ name = "nemo-fabric-adapters-common" version = "0.2.0" source = { editable = "../common" } +[package.metadata] +requires-dist = [ + { name = "httpx", marker = "extra == 'mcp-oauth'", specifier = ">=0.27,<1" }, + { name = "mcp", marker = "extra == 'mcp-oauth'", specifier = ">=1.26,<1.29" }, +] +provides-extras = ["mcp-oauth"] + [[package]] name = "pycparser" version = "3.0" diff --git a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py index e6d368f60..bae9e9bf1 100644 --- a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py +++ b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py @@ -12,6 +12,7 @@ import math import os import subprocess +from collections.abc import Mapping from dataclasses import asdict, dataclass, is_dataclass from enum import Enum from pathlib import Path @@ -26,7 +27,13 @@ TransportClosedError, is_retryable_error, ) -from openai_codex.generated.v2_all import SkillsExtraRootsSetResponse +from openai_codex.generated.v2_all import ( + ListMcpServerStatusResponse, + McpAuthStatus, + McpServerOauthLoginCompletedNotification, + McpServerOauthLoginResponse, + SkillsExtraRootsSetResponse, +) from openai_codex.types import Personality, ReasoningEffort, TurnStatus import nemo_fabric_adapters.common.relay_gateway as relay_gateway @@ -34,6 +41,7 @@ import nemo_fabric_adapters.common.relay_artifacts as relay_artifacts import nemo_fabric_adapters.common.utils as common_utils from nemo_fabric_adapters.common import lifecycle +from nemo_fabric_adapters.common import mcp_auth DEFAULT_TIMEOUT_SECONDS = 1800.0 @@ -52,6 +60,7 @@ "CODEX_HOME", "CODEX_SQLITE_HOME", "COMSPEC", + "DBUS_SESSION_BUS_ADDRESS", "HOME", "HTTP_PROXY", "HTTPS_PROXY", @@ -74,6 +83,7 @@ "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", "http_proxy", "https_proxy", "no_proxy", @@ -198,9 +208,77 @@ def _native_mcp_servers(payload: dict[str, Any]) -> dict[str, dict[str, Any]]: "codex_invalid_configuration", f"unsupported Codex MCP transport: {transport}", ) + if headers := server.get("custom_headers"): + try: + normalized_headers = mcp_auth.normalize_custom_headers(name, headers) + except mcp_auth.McpAuthConfigError as error: + raise AdapterConfigError( + "codex_invalid_configuration", str(error) + ) from error + result[name]["http_headers"] = normalized_headers + if authentication := server.get("authentication"): + oauth = _mcp_oauth_config(name, authentication) + if oauth.client_secret_env: + raise AdapterConfigError( + "codex_invalid_configuration", + f"MCP server {name} authentication.client_secret_env is not supported by Codex", + ) + if oauth.client_id: + raise AdapterConfigError( + "codex_invalid_configuration", + f"MCP server {name} authentication.client_id is not supported by Codex", + ) + if oauth.client_name: + raise AdapterConfigError( + "codex_invalid_configuration", + f"MCP server {name} authentication.client_name is not supported by Codex", + ) + if oauth.token_endpoint_auth_method: + raise AdapterConfigError( + "codex_invalid_configuration", + f"MCP server {name} authentication.token_endpoint_auth_method is not supported by Codex", + ) + result[name]["auth"] = "oauth" + if oauth.scopes: + result[name]["scopes"] = list(oauth.scopes) return result +def _mcp_oauth_config(name: str, value: Any) -> mcp_auth.McpOAuth2Config: + try: + if isinstance(value, Mapping) and value.get("type") == "service_account": + raise mcp_auth.McpAuthConfigError( + f"MCP server {name!r} service_account authentication is not supported by Codex" + ) + return mcp_auth.parse_oauth2_config(name, value) + except mcp_auth.McpAuthConfigError as error: + raise AdapterConfigError("codex_invalid_configuration", str(error)) from error + + +def _mcp_oauth_callback_url(payload: dict[str, Any]) -> str | None: + servers = _mapping( + _native_capabilities(payload).get("mcp_servers"), name="native MCP servers" + ) + values = { + oauth.redirect_uri + for name, raw in servers.items() + if ( + ( + authentication := _mapping(raw, name=f"MCP server {name}").get( + "authentication" + ) + ) + and (oauth := _mcp_oauth_config(name, authentication)).redirect_uri + ) + } + if len(values) > 1: + raise AdapterConfigError( + "codex_invalid_configuration", + "Codex supports only one MCP OAuth callback URL per adapter process", + ) + return next(iter(values)) if values else None + + def _native_skill_paths(payload: dict[str, Any]) -> list[Path]: values = _native_capabilities(payload).get("skill_paths", []) if not isinstance(values, list) or any( @@ -260,6 +338,151 @@ async def _register_skill_roots(codex: AsyncCodex, skill_paths: list[Path]) -> N ) +def _mcp_oauth_servers( + payload: dict[str, Any], +) -> dict[str, mcp_auth.McpOAuth2Config]: + servers = _mapping( + _native_capabilities(payload).get("mcp_servers"), + name="native MCP servers", + ) + result: dict[str, mcp_auth.McpOAuth2Config] = {} + for name, raw in servers.items(): + server = _mapping(raw, name=f"MCP server {name}") + authentication = server.get("authentication") + if not authentication: + continue + result[name] = _mcp_oauth_config(name, authentication) + return result + + +def _codex_protocol_client(codex: AsyncCodex) -> Any: + client = getattr(codex, "_client", None) + if not callable(getattr(client, "request", None)) or not callable( + getattr(client, "next_notification", None) + ): + raise AdapterConfigError( + "codex_invalid_configuration", + "Codex SDK does not expose the required MCP OAuth requests", + ) + return client + + +async def _mcp_auth_statuses( + client: Any, *, thread_id: str +) -> dict[str, McpAuthStatus]: + statuses: dict[str, McpAuthStatus] = {} + cursor: str | None = None + while True: + params: dict[str, Any] = { + "detail": "toolsAndAuthOnly", + "threadId": thread_id, + } + if cursor is not None: + params["cursor"] = cursor + response = await client.request( + "mcpServerStatus/list", + params, + response_model=ListMcpServerStatusResponse, + ) + statuses.update({server.name: server.auth_status for server in response.data}) + cursor = response.next_cursor + if cursor is None: + return statuses + + +async def _login_mcp_server( + client: Any, + *, + name: str, + scopes: list[str] | None, + thread_id: str, + timeout: float, +) -> None: + params: dict[str, Any] = { + "name": name, + "threadId": thread_id, + "timeoutSecs": timeout, + } + if scopes: + params["scopes"] = scopes + response = await client.request( + "mcpServer/oauth/login", + params, + response_model=McpServerOauthLoginResponse, + ) + opened = await mcp_auth.open_authorization_url(response.authorization_url) + if not opened: + raise AdapterConfigError( + "codex_mcp_authentication_failed", + f"Codex could not open a browser to authenticate MCP server {name!r}", + ) + + try: + async with asyncio.timeout(timeout): + while True: + notification = await client.next_notification() + completed = notification.payload + if ( + notification.method == "mcpServer/oauthLogin/completed" + and isinstance(completed, McpServerOauthLoginCompletedNotification) + and completed.name == name + and completed.thread_id in {None, thread_id} + ): + if completed.success: + return + raise AdapterConfigError( + "codex_mcp_authentication_failed", + f"Codex MCP OAuth login failed for server {name!r}", + ) + except TimeoutError as error: + raise AdapterConfigError( + "codex_mcp_authentication_failed", + f"Codex MCP OAuth login timed out for server {name!r}", + ) from error + + +async def _authenticate_mcp_servers( + codex: AsyncCodex, + thread: Any, + payload: dict[str, Any], + invocation_timeout_seconds: float, +) -> None: + oauth_servers = _mcp_oauth_servers(payload) + if not oauth_servers: + return + + client = _codex_protocol_client(codex) + thread_id = str(thread.id) + try: + statuses = await _mcp_auth_statuses(client, thread_id=thread_id) + for name, oauth in oauth_servers.items(): + status = statuses.get(name) + if status in {McpAuthStatus.o_auth, McpAuthStatus.bearer_token}: + continue + if status != McpAuthStatus.not_logged_in: + raise AdapterConfigError( + "codex_mcp_authentication_failed", + f"Codex MCP server {name!r} does not support the configured OAuth login", + ) + await _login_mcp_server( + client, + name=name, + scopes=list(oauth.scopes) or None, + thread_id=thread_id, + timeout=min( + invocation_timeout_seconds, + oauth.authorization_timeout_seconds, + ), + ) + except CodexAdapterError: + raise + except (CodexError, RuntimeError, OSError) as error: + raise AdapterConfigError( + "codex_mcp_authentication_failed", + "Codex MCP OAuth login could not be completed", + ) from error + + def resolve_cwd(payload: dict[str, Any]) -> Path: environment = _mapping( common_utils.environment_payload(payload), name="runtime environment" @@ -620,6 +843,8 @@ def thread_config( mcp_servers = _native_mcp_servers(payload) if mcp_servers: config["mcp_servers"] = mcp_servers + if callback_url := _mcp_oauth_callback_url(payload): + config["mcp_oauth_callback_url"] = callback_url overrides = _mapping( _settings(payload).get("config_overrides"), name="harness.settings.config_overrides", @@ -993,6 +1218,7 @@ def __init__(self) -> None: self._thread: Any = None self._relay: CodexRelaySettings | None = None self._gateway_process: subprocess.Popen[Any] | None = None + self._mcp_authentication_checked = False self._unusable = False async def start(self, payload: dict[str, Any]) -> None: @@ -1071,9 +1297,18 @@ async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: try: request_prompt(payload) - timeout_seconds(payload) + invocation_timeout_seconds = timeout_seconds(payload) _reasoning_effort(payload) _output_schema(payload) + if not self._mcp_authentication_checked: + await _authenticate_mcp_servers( + self._client, + self._thread, + self._start_payload, + invocation_timeout_seconds, + ) + self._mcp_authentication_checked = True + relay = self._relay atif_before = ( relay_artifacts.snapshot_atif_files(relay.plugin_config) @@ -1123,6 +1358,7 @@ async def stop(self) -> None: self._start_payload = None self._thread = None self._fabric_runtime_id = None + self._mcp_authentication_checked = False self._unusable = True close_error: BaseException | None = None diff --git a/adapters/codex/uv.lock b/adapters/codex/uv.lock index abc48eca6..a0489ec43 100644 --- a/adapters/codex/uv.lock +++ b/adapters/codex/uv.lock @@ -42,6 +42,13 @@ name = "nemo-fabric-adapters-common" version = "0.2.0" source = { editable = "../common" } +[package.metadata] +requires-dist = [ + { name = "httpx", marker = "extra == 'mcp-oauth'", specifier = ">=0.27,<1" }, + { name = "mcp", marker = "extra == 'mcp-oauth'", specifier = ">=1.26,<1.29" }, +] +provides-extras = ["mcp-oauth"] + [[package]] name = "openai-codex" version = "0.144.4" diff --git a/adapters/common/pyproject.toml b/adapters/common/pyproject.toml index 0a3bc1f0c..1cbb1018c 100644 --- a/adapters/common/pyproject.toml +++ b/adapters/common/pyproject.toml @@ -25,6 +25,13 @@ license-files = ["LICENSE"] readme = "pypi.md" requires-python = ">=3.11" +[project.optional-dependencies] +mcp-oauth = [ + "httpx>=0.27,<1", + # Hermes Agent 0.19 pins this version in its MCP extra. + "mcp>=1.26,<1.29", +] + [project.urls] Repository = "https://github.com/NVIDIA/NeMo-Fabric" Homepage = "https://github.com/NVIDIA/NeMo-Fabric" diff --git a/adapters/common/src/nemo_fabric_adapters/common/mcp_auth.py b/adapters/common/src/nemo_fabric_adapters/common/mcp_auth.py new file mode 100644 index 000000000..99c493da7 --- /dev/null +++ b/adapters/common/src/nemo_fabric_adapters/common/mcp_auth.py @@ -0,0 +1,716 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared MCP OAuth configuration and protocol helpers for Fabric adapters.""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import logging +import math +import os +import socket +import time +import webbrowser +from collections.abc import Awaitable +from collections.abc import Callable +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any +from urllib.parse import parse_qs +from urllib.parse import quote +from urllib.parse import urlencode +from urllib.parse import urlparse + +LOGGER = logging.getLogger(__name__) + + +class McpAuthConfigError(ValueError): + """Invalid normalized MCP authentication configuration.""" + + +@dataclass(frozen=True) +class McpOAuth2Config: + """Harness-neutral OAuth2 fields from an MCP server configuration.""" + + client_id: str | None + client_secret_env: str | None + scopes: tuple[str, ...] + redirect_uri: str | None + enable_dynamic_registration: bool = True + client_name: str | None = None + token_endpoint_auth_method: str | None = None + authorization_timeout_seconds: float = 300.0 + + @property + def scope(self) -> str | None: + value = " ".join(self.scopes) + return value or None + + +@dataclass(frozen=True) +class McpServiceAccountConfig: + """Harness-neutral OAuth client-credentials fields.""" + + client_id: str + client_secret_env: str + token_url: str + scopes: tuple[str, ...] + token_endpoint_auth_method: str = "client_secret_basic" + token_cache_buffer_seconds: float = 300.0 + + @property + def scope(self) -> str | None: + value = " ".join(self.scopes) + return value or None + + +TOKEN_ENDPOINT_AUTH_METHODS = { + "none", + "client_secret_post", + "client_secret_basic", +} + + +def _string_tuple(server_name: str, field: str, value: Any) -> tuple[str, ...]: + if value is None: + return () + if not isinstance(value, list) or any(not isinstance(item, str) for item in value): + raise McpAuthConfigError( + f"MCP server {server_name!r} authentication.{field} must be a list of strings" + ) + return tuple(value) + + +def _positive_timeout(server_name: str, field: str, value: Any) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0: + raise McpAuthConfigError( + f"MCP server {server_name!r} authentication.{field} must be greater than zero" + ) + return float(value) + + +def _token_endpoint_auth_method( + server_name: str, + value: Any, + *, + default: str | None, +) -> str | None: + if value is None: + return default + if value not in TOKEN_ENDPOINT_AUTH_METHODS: + supported = ", ".join(sorted(TOKEN_ENDPOINT_AUTH_METHODS)) + raise McpAuthConfigError( + f"MCP server {server_name!r} authentication.token_endpoint_auth_method " + f"must be one of: {supported}" + ) + return str(value) + + +def parse_oauth2_config(server_name: str, value: Any) -> McpOAuth2Config: + """Parse Fabric's normalized OAuth2 mapping without applying harness policy.""" + + if not isinstance(value, Mapping) or value.get("type") != "oauth2": + raise McpAuthConfigError( + f"MCP server {server_name!r} has unsupported authentication type" + ) + client_id = ( + str(raw_client_id) if (raw_client_id := value.get("client_id")) else None + ) + secret_env = ( + str(raw_secret_env) + if (raw_secret_env := value.get("client_secret_env")) + else None + ) + dynamic_registration = value.get("enable_dynamic_registration", True) + if not isinstance(dynamic_registration, bool): + raise McpAuthConfigError( + f"MCP server {server_name!r} authentication.enable_dynamic_registration must be a boolean" + ) + if secret_env and not client_id: + raise McpAuthConfigError( + f"MCP server {server_name!r} authentication.client_secret_env requires client_id" + ) + if not client_id and not dynamic_registration: + raise McpAuthConfigError( + f"MCP server {server_name!r} authentication.client_id is required when " + "dynamic registration is disabled" + ) + method = _token_endpoint_auth_method( + server_name, + value.get("token_endpoint_auth_method"), + default=None, + ) + if ( + method in {"client_secret_basic", "client_secret_post"} + and client_id + and not secret_env + ): + raise McpAuthConfigError( + f"MCP server {server_name!r} authentication.token_endpoint_auth_method " + "requires client_secret_env for a pre-registered client" + ) + if method == "none" and secret_env: + raise McpAuthConfigError( + f"MCP server {server_name!r} authentication.token_endpoint_auth_method " + "'none' cannot use client_secret_env" + ) + return McpOAuth2Config( + client_id=client_id, + client_secret_env=secret_env, + scopes=_string_tuple(server_name, "scopes", value.get("scopes")), + redirect_uri=( + str(redirect_uri) if (redirect_uri := value.get("redirect_uri")) else None + ), + enable_dynamic_registration=dynamic_registration, + client_name=( + str(client_name) if (client_name := value.get("client_name")) else None + ), + token_endpoint_auth_method=method, + authorization_timeout_seconds=_positive_timeout( + server_name, + "authorization_timeout_seconds", + value.get("authorization_timeout_seconds", 300), + ), + ) + + +def parse_service_account_config( + server_name: str, value: Any +) -> McpServiceAccountConfig: + """Parse Fabric's normalized OAuth client-credentials mapping.""" + + if not isinstance(value, Mapping) or value.get("type") != "service_account": + raise McpAuthConfigError( + f"MCP server {server_name!r} has unsupported authentication type" + ) + required: dict[str, str] = {} + for field in ("client_id", "client_secret_env", "token_url"): + item = value.get(field) + if not isinstance(item, str) or not item.strip(): + raise McpAuthConfigError( + f"MCP server {server_name!r} authentication.{field} is required" + ) + required[field] = item + method = _token_endpoint_auth_method( + server_name, + value.get("token_endpoint_auth_method"), + default="client_secret_basic", + ) + if method == "none": + raise McpAuthConfigError( + f"MCP server {server_name!r} service_account authentication does not support " + "token_endpoint_auth_method 'none'" + ) + buffer = value.get("token_cache_buffer_seconds", 300) + if isinstance(buffer, bool) or not isinstance(buffer, (int, float)) or buffer < 0: + raise McpAuthConfigError( + f"MCP server {server_name!r} authentication.token_cache_buffer_seconds " + "must be zero or greater" + ) + return McpServiceAccountConfig( + client_id=required["client_id"], + client_secret_env=required["client_secret_env"], + token_url=required["token_url"], + scopes=_string_tuple(server_name, "scopes", value.get("scopes")), + token_endpoint_auth_method=method, + token_cache_buffer_seconds=float(buffer), + ) + + +def contains_crlf(value: str) -> bool: + """Return whether a string contains a carriage return or line feed.""" + + return "\r" in value or "\n" in value + + +def normalize_custom_headers(server_name: str, value: dict[str, str]) -> dict[str, str]: + """Validate and expand an MCP custom-header mapping.""" + + if not isinstance(value, Mapping): + raise McpAuthConfigError( + f"MCP server {server_name!r} custom_headers must be a mapping" + ) + results: dict[str, str] = {} + for name, item in value.items(): + if contains_crlf(name) or contains_crlf(item): + raise McpAuthConfigError( + f"MCP server {server_name!r} custom_headers contain invalid characters in {name!r}" + ) + expanded_item = os.path.expandvars(item) + if contains_crlf(expanded_item): + raise McpAuthConfigError( + f"MCP server {server_name!r} custom_headers contain invalid characters in {name!r}" + ) + results[name] = expanded_item + + return results + + +def resolve_client_secret( + server_name: str, + config: McpOAuth2Config | McpServiceAccountConfig, + environment: Mapping[str, str] | None = None, + *, + require_client_id: bool = False, +) -> str | None: + """Resolve a configured OAuth client secret without retaining or logging it.""" + + if config.client_secret_env is None: + return None + if require_client_id and config.client_id is None: + raise McpAuthConfigError( + f"MCP server {server_name!r} authentication.client_secret_env requires client_id" + ) + source = os.environ if environment is None else environment + secret = source.get(config.client_secret_env) + if not secret: + raise McpAuthConfigError( + f"MCP server {server_name!r} authentication.client_secret_env references an unset environment variable" + ) + return secret + + +def loopback_callback_port(redirect_uri: str) -> int: + """Return the explicit port from an HTTP loopback OAuth redirect URI.""" + + parsed = urlparse(redirect_uri) + if ( + parsed.scheme != "http" + or parsed.hostname not in {"127.0.0.1", "localhost"} + or parsed.port is None + ): + raise McpAuthConfigError( + "authentication.redirect_uri must be an HTTP loopback URI with an explicit port" + ) + return parsed.port + + +async def open_authorization_url(authorization_url: str) -> bool: + """Open an OAuth authorization URL without blocking the event loop.""" + + return await asyncio.to_thread(webbrowser.open, authorization_url) + + +AuthorizationUrlHandler = Callable[[str, str], Awaitable[bool | None]] + + +async def _default_authorization_url_handler( + _server_name: str, authorization_url: str +) -> bool: + return await open_authorization_url(authorization_url) + + +class _McpOAuthMemoryStorage: + def __init__(self, client_info: Any = None): + self.tokens: Any = None + self.client_info = client_info + + async def get_tokens(self) -> Any: + return self.tokens + + async def set_tokens(self, tokens: Any) -> None: + self.tokens = tokens + + async def get_client_info(self) -> Any: + return self.client_info + + async def set_client_info(self, client_info: Any) -> None: + self.client_info = client_info + + +class _LoopbackOAuthCallback: + """Own a single-use loopback callback listener.""" + + def __init__(self, redirect_uri: str | None, timeout: float): + self._timeout = timeout + self._server: asyncio.AbstractServer | None = None + self._result: asyncio.Future[tuple[str, str | None]] | None = None + self._start_lock = asyncio.Lock() + self._socket: socket.socket | None = None + + if redirect_uri is None: + self._host = "127.0.0.1" + self._path = "/callback" + self._port = 0 + self._reserve_socket() + self.redirect_uri = f"http://127.0.0.1:{self._port}{self._path}" + else: + parsed = urlparse(redirect_uri) + self._port = loopback_callback_port(redirect_uri) + self._host = parsed.hostname or "127.0.0.1" + self._path = parsed.path or "/" + self.redirect_uri = redirect_uri + self._reserve_socket() + + def _reserve_socket(self) -> None: + listener: socket.socket | None = None + try: + if self._host == "localhost": + addresses = socket.getaddrinfo( + self._host, + self._port, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + ) + resolved = next( + ( + address + for address in addresses + if address[4][0] in {"127.0.0.1", "::1"} + ), + None, + ) + if resolved is None: + raise McpAuthConfigError( + "localhost did not resolve to an IP loopback address" + ) + family, socktype, proto, _, bind_address = resolved + else: + family = socket.AF_INET + socktype = socket.SOCK_STREAM + proto = socket.IPPROTO_TCP + bind_address = (self._host, self._port) + listener = socket.socket(family, socktype, proto) + listener.bind(bind_address) + listener.setblocking(False) + except OSError as error: + if listener is not None: + listener.close() + raise McpAuthConfigError( + f"could not bind MCP OAuth callback listener on {self._host}:{self._port}" + ) from error + self._socket = listener + self._port = int(listener.getsockname()[1]) + + async def start(self) -> None: + async with self._start_lock: + if self._server is not None: + return + if self._socket is None: + raise McpAuthConfigError( + "MCP OAuth callback listener cannot be restarted after it is closed" + ) + loop = asyncio.get_running_loop() + self._result = loop.create_future() + listener = self._socket + self._socket = None + if listener is None: + raise RuntimeError("OAuth callback socket was not reserved") + try: + self._server = await asyncio.start_server( + self._handle_callback, + sock=listener, + limit=16 * 1024, + ) + except BaseException: + listener.close() + self._result.cancel() + self._result = None + raise + + async def wait(self) -> tuple[str, str | None]: + if self._result is None: + raise RuntimeError("OAuth callback listener was not started") + try: + async with asyncio.timeout(self._timeout): + return await self._result + except TimeoutError as error: + raise McpAuthConfigError( + f"MCP OAuth authorization timed out after {self._timeout:g} seconds" + ) from error + finally: + await self.close() + + async def close(self) -> None: + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None + if self._result is not None and not self._result.done(): + self._result.cancel() + self._result = None + + def close_reserved_socket(self) -> None: + if self._socket is not None: + self._socket.close() + self._socket = None + + def __del__(self) -> None: + self.close_reserved_socket() + + async def _write_response( + self, + writer: asyncio.StreamWriter, + status: bytes, + body: bytes, + ) -> None: + writer.write( + b"HTTP/1.1 " + + status + + b"\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: " + + str(len(body)).encode("ascii") + + b"\r\nConnection: close\r\n\r\n" + + body + ) + try: + await writer.drain() + finally: + writer.close() + try: + await writer.wait_closed() + except (AttributeError, OSError): + pass + + async def _handle_callback( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + try: + request = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=5.0) + request_line = request.split(b"\r\n", 1)[0].decode("ascii") + method, target, _ = request_line.split(" ", 2) + parsed = urlparse(target) + except ( + TimeoutError, + ValueError, + UnicodeDecodeError, + asyncio.IncompleteReadError, + asyncio.LimitOverrunError, + ): + await self._write_response(writer, b"400 Bad Request", b"Invalid request.") + return + + if method != "GET" or parsed.path != self._path: + await self._write_response(writer, b"404 Not Found", b"Not found.") + return + + query = parse_qs(parsed.query, keep_blank_values=True) + error_code = query.get("error", [None])[0] + error_description = query.get("error_description", [None])[0] + code = query.get("code", [""])[0] + state = query.get("state", [None])[0] + if error_code: + if self._result is not None and not self._result.done(): + message = f"MCP OAuth authorization failed: {error_code!r}" + if error_description: + message += f": {error_description!r}" + self._result.set_exception(McpAuthConfigError(message)) + await self._write_response( + writer, + b"400 Bad Request", + b"OAuth authorization was not completed.", + ) + return + if not code or not state: + if self._result is not None and not self._result.done(): + self._result.set_exception( + McpAuthConfigError( + "MCP OAuth callback did not include code and state" + ) + ) + await self._write_response( + writer, b"400 Bad Request", b"Invalid OAuth callback." + ) + return + + if self._result is not None and not self._result.done(): + self._result.set_result((code, state)) + await self._write_response( + writer, + b"200 OK", + b"OAuth authorization complete. You may close this window.", + ) + + +def create_mcp_oauth_provider( + server_name: str, + server_url: str, + config: McpOAuth2Config, + *, + client_name: str, + authorization_url_handler: AuthorizationUrlHandler | None = None, +) -> Any: + """Build an MCP SDK OAuth provider for a harness without native OAuth support.""" + + from mcp.client.auth import OAuthClientProvider + from mcp.shared.auth import OAuthClientInformationFull + from mcp.shared.auth import OAuthClientMetadata + + callback = _LoopbackOAuthCallback( + config.redirect_uri, + config.authorization_timeout_seconds, + ) + redirect_uri = callback.redirect_uri + token_endpoint_auth_method = config.token_endpoint_auth_method or ( + "client_secret_post" if config.client_secret_env else "none" + ) + + metadata = OAuthClientMetadata( + client_name=config.client_name or client_name, + redirect_uris=[redirect_uri], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + token_endpoint_auth_method=token_endpoint_auth_method, + scope=config.scope, + ) + + client_info = None + if config.client_id is not None: + client_info = OAuthClientInformationFull( + client_id=config.client_id, + client_secret=resolve_client_secret( + server_name, + config, + require_client_id=True, + ), + redirect_uris=[redirect_uri], + grant_types=metadata.grant_types, + response_types=metadata.response_types, + token_endpoint_auth_method=metadata.token_endpoint_auth_method, + scope=metadata.scope, + ) + elif config.client_secret_env is not None: + raise McpAuthConfigError( + f"MCP server {server_name!r} authentication.client_secret_env requires client_id" + ) + + handler = authorization_url_handler or _default_authorization_url_handler + + async def redirect_handler(authorization_url: str) -> None: + await callback.start() + LOGGER.warning("MCP server '%s' requires OAuth authorization", server_name) + try: + opened = await handler(server_name, authorization_url) + except BaseException: + await callback.close() + raise + if opened is False: + await callback.close() + raise McpAuthConfigError( + f"MCP server {server_name!r} authorization URL could not be opened" + ) + + provider = OAuthClientProvider( + server_url=server_url, + client_metadata=metadata, + storage=_McpOAuthMemoryStorage(client_info), + redirect_handler=redirect_handler, + callback_handler=callback.wait, + timeout=config.authorization_timeout_seconds, + ) + setattr(provider, "_fabric_oauth_callback", callback) + return provider + + +def access_token(provider: Any) -> str | None: + """Return the current access token from an MCP SDK OAuth provider.""" + + context = getattr(provider, "context", None) + tokens = getattr(context, "current_tokens", None) if context is not None else None + return getattr(tokens, "access_token", None) if tokens is not None else None + + +def create_mcp_service_account_auth( + server_name: str, + config: McpServiceAccountConfig, + environment: Mapping[str, str] | None = None, +) -> Any: + """Build an expiry-aware HTTPX auth provider for OAuth client credentials.""" + + import httpx + + client_secret = resolve_client_secret(server_name, config, environment) + if client_secret is None: + raise McpAuthConfigError( + f"MCP server {server_name!r} authentication.client_secret_env is required" + ) + + class ServiceAccountAuth(httpx.Auth): + requires_response_body = True + + def __init__(self) -> None: + self._access_token: str | None = None + self._expires_at = 0.0 + self._lock = asyncio.Lock() + + def _token_is_valid(self) -> bool: + return ( + self._access_token is not None and time.monotonic() < self._expires_at + ) + + def _token_request(self) -> httpx.Request: + data = {"grant_type": "client_credentials"} + if config.scope: + data["scope"] = config.scope + headers = {"Content-Type": "application/x-www-form-urlencoded"} + if config.token_endpoint_auth_method == "client_secret_basic": + encoded_id = quote(config.client_id, safe="") + encoded_secret = quote(client_secret, safe="") + credentials = base64.b64encode( + f"{encoded_id}:{encoded_secret}".encode() + ).decode() + headers["Authorization"] = f"Basic {credentials}" + else: + data["client_id"] = config.client_id + data["client_secret"] = client_secret + return httpx.Request( + "POST", + config.token_url, + headers=headers, + content=urlencode(data).encode(), + ) + + async def _accept_token_response(self, response: httpx.Response) -> None: + if response.status_code != 200: + await response.aread() + raise McpAuthConfigError( + f"MCP server {server_name!r} service-account token request failed " + f"with HTTP {response.status_code}" + ) + try: + payload = json.loads((await response.aread()).decode("utf-8")) + access_token = payload["access_token"] + token_type = payload.get("token_type", "") + expires_in = float(payload.get("expires_in", 3600)) + if ( + not isinstance(access_token, str) + or not access_token + or not math.isfinite(expires_in) + or expires_in <= 0 + ): + raise ValueError + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error: + raise McpAuthConfigError( + f"MCP server {server_name!r} returned an invalid service-account token response" + ) from error + if not isinstance(token_type, str) or token_type.lower() != "bearer": + raise McpAuthConfigError( + f"MCP server {server_name!r} returned unsupported token_type {token_type!r}" + ) + self._access_token = access_token + usable_for = max(0.0, expires_in - config.token_cache_buffer_seconds) + self._expires_at = time.monotonic() + usable_for + + async def async_auth_flow(self, request: httpx.Request) -> Any: + if not self._token_is_valid(): + async with self._lock: + if not self._token_is_valid(): + token_response = yield self._token_request() + await self._accept_token_response(token_response) + assert self._access_token is not None + request_token = self._access_token + request.headers["Authorization"] = f"Bearer {request_token}" + response = yield request + if response.status_code == 401: + async with self._lock: + if self._access_token == request_token: + self._access_token = None + token_response = yield self._token_request() + await self._accept_token_response(token_response) + assert self._access_token is not None + request.headers["Authorization"] = f"Bearer {self._access_token}" + yield request + + return ServiceAccountAuth() diff --git a/adapters/common/uv.lock b/adapters/common/uv.lock index b7d8656f9..419afe75d 100644 --- a/adapters/common/uv.lock +++ b/adapters/common/uv.lock @@ -1,8 +1,740 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform != 'win32'", +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "mcp" +version = "1.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, +] [[package]] name = "nemo-fabric-adapters-common" version = "0.2.0" source = { editable = "." } + +[package.optional-dependencies] +mcp-oauth = [ + { name = "httpx" }, + { name = "mcp" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", marker = "extra == 'mcp-oauth'", specifier = ">=0.27,<1" }, + { name = "mcp", marker = "extra == 'mcp-oauth'", specifier = ">=1.26,<1.29" }, +] +provides-extras = ["mcp-oauth"] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/00/b42a44342a054d58cb1115d7c8aa9cb4290dd9442f9c1b91a4b8173dba22/sse_starlette-3.4.8.tar.gz", hash = "sha256:ed89ffbb75cbf78a5fe2f2109cd584792ee7f9dfac96f791db546df8f15f3f9c", size = 32548, upload-time = "2026-08-05T11:19:49.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/3a/764912c58293d95b6dcdf4cc255f9d10de310580ced547b082eb9d72018c/sse_starlette-3.4.8-py3-none-any.whl", hash = "sha256:6e82314c786709a3cd9520f2285cf9fff90e181e598e8a357b0cf80f66afba0d", size = 16516, upload-time = "2026-08-05T11:19:48.748Z" }, +] + +[[package]] +name = "starlette" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/3c/76d2fd1f1357ed0f0108d8a5aa233dcf16e2946a8559c84912fe08e01ac7/starlette-1.4.1.tar.gz", hash = "sha256:b7332de6e9375593a29ba9eee1e6ecfeb3eb2043e2e19a13b4b71da73ff35540", size = 2709041, upload-time = "2026-08-05T15:17:26.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/0a/67e95f21498de41433babf7b1db0eeab449eb58872dfb831b27747a70fd0/starlette-1.4.1-py3-none-any.whl", hash = "sha256:7d078e0fbefae0d2cecfb80a799d6fb84b1c0c6acd4f14ac79d17d0e7ec27f19", size = 74019, upload-time = "2026-08-05T15:17:24.357Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" }, +] diff --git a/adapters/deepagents/pyproject.toml b/adapters/deepagents/pyproject.toml index 70de4a1b2..44903c4f5 100644 --- a/adapters/deepagents/pyproject.toml +++ b/adapters/deepagents/pyproject.toml @@ -25,7 +25,7 @@ license-files = ["LICENSE"] readme = "pypi.md" requires-python = ">=3.11" dependencies = [ - "nemo-fabric-adapters-common == 0.2.0", + "nemo-fabric-adapters-common[mcp-oauth] == 0.2.0", "langchain-mcp-adapters>=0.1,<0.3.0", "langchain-openai>=0.3", # Requires 3.x: the 2.x line is incompatible with the langgraph core deepagents diff --git a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py index 9be0e0804..e0d6e2d17 100644 --- a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py +++ b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py @@ -11,9 +11,11 @@ from __future__ import annotations +import asyncio import hashlib import inspect import json +import logging import os import uuid from collections.abc import Callable @@ -24,9 +26,11 @@ from langchain.agents.middleware import AgentMiddleware from langchain_core.messages import ToolMessage from nemo_fabric_adapters.common import lifecycle +from nemo_fabric_adapters.common import mcp_auth import nemo_fabric_adapters.common.utils as common_utils HARNESS = "deepagents" +LOGGER = logging.getLogger(__name__) # Providers we serve through the OpenAI-compatible ``ChatOpenAI`` client. OPENAI_COMPATIBLE_PROVIDERS = {"nvidia", "openai", "openai-compatible"} # MCP transports langchain-mcp-adapters accepts (after normalization). @@ -235,10 +239,12 @@ def resolve_backend(payload: dict[str, Any]) -> Any: return FilesystemBackend(root_dir=str(root), virtual_mode=True) -async def resolve_tools(payload: dict[str, Any]) -> list[Any] | None: +async def resolve_tools( + payload: dict[str, Any], oauth_callbacks: list[Any] | None = None +) -> list[Any] | None: """Resolve Fabric MCP servers into Deep Agents tools.""" - tools = await _mcp_tools(payload) + tools = await _mcp_tools(payload, oauth_callbacks) return tools or None @@ -274,10 +280,15 @@ def resolve_skills(payload: dict[str, Any]) -> list[str] | None: return skills or None -async def _mcp_tools(payload: dict[str, Any]) -> list[Any]: +async def _mcp_tools( + payload: dict[str, Any], oauth_callbacks: list[Any] | None = None +) -> list[Any]: native = common_utils.capability_plan(payload).get("native") or {} servers = native.get("mcp_servers") or {} - connections = {name: _mcp_connection(name, spec) for name, spec in servers.items()} + connections = { + name: _mcp_connection(name, spec, oauth_callbacks) + for name, spec in servers.items() + } if not connections: return [] from langchain_mcp_adapters.client import MultiServerMCPClient @@ -286,7 +297,11 @@ async def _mcp_tools(payload: dict[str, Any]) -> list[Any]: return list(await client.get_tools()) -def _mcp_connection(name: str, spec: dict[str, Any]) -> dict[str, Any]: +def _mcp_connection( + name: str, + spec: dict[str, Any], + oauth_callbacks: list[Any] | None = None, +) -> dict[str, Any]: # A misconfigured server must fail loudly, not be silently dropped. if not isinstance(spec, dict): raise AdapterConfigError(f"MCP server '{name}' must be a mapping.") @@ -312,7 +327,44 @@ def _mcp_connection(name: str, spec: dict[str, Any]) -> dict[str, Any]: raise AdapterConfigError( f"MCP server '{name}' has unsupported transport '{transport}'." ) - return {"transport": transport, "url": target} + connection = {"transport": transport, "url": target} + if headers := spec.get("custom_headers"): + try: + normalized_headers = mcp_auth.normalize_custom_headers(name, headers) + except mcp_auth.McpAuthConfigError as error: + raise AdapterConfigError(f"{error}.") from error + connection["headers"] = normalized_headers + if authentication := spec.get("authentication"): + connection["auth"] = _mcp_http_auth( + name, target, authentication, oauth_callbacks + ) + return connection + + +def _mcp_http_auth( + name: str, + server_url: str, + raw: Any, + oauth_callbacks: list[Any] | None = None, +) -> Any: + try: + if isinstance(raw, dict) and raw.get("type") == "service_account": + return mcp_auth.create_mcp_service_account_auth( + name, + mcp_auth.parse_service_account_config(name, raw), + ) + config = mcp_auth.parse_oauth2_config(name, raw) + provider = mcp_auth.create_mcp_oauth_provider( + name, + server_url, + config, + client_name="NeMo Fabric Deep Agents", + ) + if oauth_callbacks is not None: + oauth_callbacks.append(provider._fabric_oauth_callback) + return provider + except mcp_auth.McpAuthConfigError as error: + raise AdapterConfigError(f"{error}.") from error # --- runtime state --------------------------------------------------------- @@ -360,11 +412,14 @@ async def close_checkpointer(checkpointer: Any) -> None: async def build_agent_kwargs( - payload: dict[str, Any], model: Any, settings: dict[str, Any] + payload: dict[str, Any], + model: Any, + settings: dict[str, Any], + oauth_callbacks: list[Any] | None = None, ) -> dict[str, Any]: kwargs: dict[str, Any] = { "model": model, - "tools": await resolve_tools(payload), + "tools": await resolve_tools(payload, oauth_callbacks), # deepagents 0.5.x/0.6.x take the system prompt as ``system_prompt``. "system_prompt": common_utils.system_instruction(payload), "skills": resolve_skills(payload), @@ -484,6 +539,7 @@ def __init__(self) -> None: self._relay_scope_type: Any = None self._relay_plugin_config: dict[str, Any] | None = None self._callback_handler_type: Any = None + self._mcp_oauth_callbacks: list[Any] = [] self._telemetry_quarantine: str | None = None self._telemetry_quarantine_cause: str | None = None @@ -517,7 +573,9 @@ async def start(self, payload: dict[str, Any]) -> None: relay_enabled, ) - agent_kwargs = await build_agent_kwargs(payload, model, settings) + agent_kwargs = await build_agent_kwargs( + payload, model, settings, self._mcp_oauth_callbacks + ) if runtime_id: self._checkpointer = await open_checkpointer( checkpointer_path(payload, runtime_id) @@ -729,6 +787,7 @@ def _telemetry_output( async def stop(self) -> None: checkpointer = self._checkpointer + oauth_callbacks = self._mcp_oauth_callbacks self._start_payload = None self._runtime_id = None self._model_name = None @@ -744,7 +803,11 @@ async def stop(self) -> None: self._relay_scope_type = None self._relay_plugin_config = None self._callback_handler_type = None + self._mcp_oauth_callbacks = [] self._started = False + for callback in oauth_callbacks: + callback.close_reserved_socket() + await callback.close() if checkpointer is not None: await close_checkpointer(checkpointer) diff --git a/adapters/deepagents/uv.lock b/adapters/deepagents/uv.lock index bc1eef8ec..477789765 100644 --- a/adapters/deepagents/uv.lock +++ b/adapters/deepagents/uv.lock @@ -837,6 +837,19 @@ name = "nemo-fabric-adapters-common" version = "0.2.0" source = { editable = "../common" } +[package.optional-dependencies] +mcp-oauth = [ + { name = "httpx" }, + { name = "mcp" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", marker = "extra == 'mcp-oauth'", specifier = ">=0.27,<1" }, + { name = "mcp", marker = "extra == 'mcp-oauth'", specifier = ">=1.26,<1.29" }, +] +provides-extras = ["mcp-oauth"] + [[package]] name = "nemo-fabric-adapters-deepagents" version = "0.2.0" @@ -845,7 +858,7 @@ dependencies = [ { name = "langchain-mcp-adapters" }, { name = "langchain-openai" }, { name = "langgraph-checkpoint-sqlite" }, - { name = "nemo-fabric-adapters-common" }, + { name = "nemo-fabric-adapters-common", extra = ["mcp-oauth"] }, ] [package.optional-dependencies] @@ -875,7 +888,7 @@ requires-dist = [ { name = "langgraph", marker = "extra == 'full'", specifier = ">=1.2,<2.0" }, { name = "langgraph", marker = "extra == 'harness'", specifier = ">=1.2,<2.0" }, { name = "langgraph-checkpoint-sqlite", specifier = ">=3.0,<4.0" }, - { name = "nemo-fabric-adapters-common", editable = "../common" }, + { name = "nemo-fabric-adapters-common", extras = ["mcp-oauth"], editable = "../common" }, { name = "nemo-relay", marker = "extra == 'relay'", specifier = ">=0.6.0,<0.7" }, { name = "nemo-relay", extras = ["deepagents"], marker = "extra == 'full'", specifier = ">=0.6.0,<0.7" }, ] @@ -885,10 +898,13 @@ provides-extras = ["harness", "relay", "full"] name = "nemo-relay" version = "0.6.0" source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/db/44d7258ee620c5cce6dc588983fcb11be3ee03ae71089a65686c14d9ee02/nemo_relay-0.6.0.tar.gz", hash = "sha256:f3d3088019609bc953357b5598a47481dc3e7dc8f11ecf27002ede251f37eb7b", size = 1071046, upload-time = "2026-08-03T14:55:49.702Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/25/65/d320016505457cc30971f575e8dadffb923b7cfc780ab8bb25a4ce9d305c/nemo_relay-0.6.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:ad5dae6febf6532d7b113abc2a404679c8feffc499df3034b93d9a078185d2bb", size = 9917779, upload-time = "2026-07-22T20:07:48.961Z" }, { url = "https://files.pythonhosted.org/packages/ae/c0/f33250e71c4206da1b339072893f9a1e39295fe1aceb9a2fef4b8620a0f2/nemo_relay-0.6.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0cd9570f64c6956fe3bfb82af1cdb3ee70cb50b51098cdb0de831c3f9b4e904", size = 8888375, upload-time = "2026-07-22T20:07:51.049Z" }, { url = "https://files.pythonhosted.org/packages/a3/f4/d1dfaed022da0f6f14765a122867f976a69cc520fe1faaf99757f5719d1f/nemo_relay-0.6.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:849daa9e45158ac581e54506e0fcc7a24f557d1ed06dbdc074f5de7a00393cbc", size = 9336372, upload-time = "2026-07-22T20:07:53.224Z" }, + { url = "https://files.pythonhosted.org/packages/60/9e/f8b80509eef5e05702b940a3d1e2f60c962548d87dec2d712fd3804e6cd4/nemo_relay-0.6.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8c80e534b76bb0455cfc222aaf5c10fa064c088b3c0d5e137eb3c97db46dbc47", size = 10578834, upload-time = "2026-07-30T15:44:44.726Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a0/84ee49d45a1a874457f2f9260d30fd573af30c9be360d9d97b8bb7835ad9/nemo_relay-0.6.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c8bc4a792a2f8c35ddef1b900cc43be2b2bbbfcd5e7cf65aa0829c04d25eb77f", size = 10849651, upload-time = "2026-07-30T15:44:40.48Z" }, { url = "https://files.pythonhosted.org/packages/3a/b0/908d77f75b9054e1e403da78f7d9430249a45939070d4298abbb41a04b5b/nemo_relay-0.6.0-cp311-abi3-win_amd64.whl", hash = "sha256:bfbbedfd130fa95c9b8c04643c30910df850e0ed3500beeab82b00ca2d94e7ea", size = 9613425, upload-time = "2026-07-22T20:07:55.175Z" }, { url = "https://files.pythonhosted.org/packages/cd/71/c438b9d746303ff7f270d99f13b250bf947cdac3e52de2a83fd132cbca0b/nemo_relay-0.6.0-cp311-abi3-win_arm64.whl", hash = "sha256:82fe132943399d89e6ec34dc28df0be7bbe41b84f6698c545928b8816b6010f6", size = 9034810, upload-time = "2026-07-22T20:07:57.467Z" }, ] diff --git a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py index edfd7bc83..8cfc1106d 100755 --- a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py +++ b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py @@ -15,6 +15,7 @@ import json import logging import os +import sys from contextlib import redirect_stdout from io import StringIO from pathlib import Path @@ -34,6 +35,7 @@ # answering while the trial still reports success). See FABRIC-85. DEFAULT_MAX_ITERATIONS: int = 90 LOGGER = logging.getLogger(__name__) +hermes_mcp_server_config = configuration.hermes_mcp_server_config def main() -> None: @@ -55,6 +57,7 @@ def __init__(self) -> None: self._hermes_config_path: Path | None = None self._hermes_config: dict[str, Any] = {} self._enabled_toolsets: list[str] | None = None + self._mcp_authentication_checked = False self._conversation_history: list[dict[str, Any]] | None = None self._session_db: Any = None self._agent: Any = None @@ -76,7 +79,9 @@ async def start(self, payload: dict[str, Any]) -> None: "hermes_invalid_config", "Hermes requires a validated AgentConfig", ) - runtime_context = RuntimeContext.from_mapping(payload.get("runtime_context")) + runtime_context = RuntimeContext.from_mapping( + payload.get("runtime_context") + ) telemetry.validate_hermes_telemetry_provider(runtime_context) self._agent_config = agent_config self._settings = configuration._settings(agent_config) @@ -222,9 +227,13 @@ async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: user_message = json.dumps(user_message, sort_keys=True) instructions = agent_config.instructions system_prompt = ( - instructions.system.content if instructions and instructions.system else None + instructions.system.content + if instructions and instructions.system + else None ) + await self._authenticate_mcp_servers() + def run_hermes_turn() -> tuple[dict[str, Any], str]: try: return _invoke_hermes_turn( @@ -303,6 +312,107 @@ def clear_active_invoke_task( ) return output + async def _authenticate_mcp_servers(self) -> None: + if self._mcp_authentication_checked: + return + + oauth_server_names = { + name + for name, server in (self._hermes_config.get("mcp_servers") or {}).items() + if server.get("auth") == "oauth" + } + if not oauth_server_names: + self._mcp_authentication_checked = True + return + + from tools.mcp_oauth import force_interactive_oauth + from tools.mcp_tool import ( + discover_mcp_tools, + get_mcp_status, + refresh_agent_mcp_tools, + ) + + statuses = { + status["name"]: status for status in await asyncio.to_thread(get_mcp_status) + } + disconnected = { + name + for name in oauth_server_names + if not statuses.get(name, {}).get("connected") + } + if disconnected: + + def authenticate() -> None: + lifecycle_stdin = sys.stdin + try: + # Hermes forces interactive OAuth to enable the browser flow, + # which also starts an optional stdin paste reader. Fabric's + # stdin carries lifecycle messages, so give only that fallback + # an immediate EOF while the loopback callback remains active. + sys.stdin = StringIO() + with redirect_stdout(StringIO()), force_interactive_oauth(): + discover_mcp_tools() + finally: + sys.stdin = lifecycle_stdin + + try: + await asyncio.to_thread(authenticate) + except Exception as error: + raise lifecycle.LifecycleError( + "hermes_mcp_authentication_failed", + "Hermes could not authenticate the configured MCP servers", + metadata={"servers": sorted(disconnected)}, + ) from error + + statuses = { + status["name"]: status + for status in await asyncio.to_thread(get_mcp_status) + } + disconnected = { + name + for name in oauth_server_names + if not statuses.get(name, {}).get("connected") + } + if disconnected: + raise lifecycle.LifecycleError( + "hermes_mcp_authentication_failed", + "Hermes could not authenticate the configured MCP servers", + metadata={"servers": sorted(disconnected)}, + ) + + await asyncio.to_thread( + refresh_agent_mcp_tools, + self._agent, + quiet_mode=True, + ) + + self._mcp_authentication_checked = True + + def _finalize_relay_session(self) -> None: + if ( + self._relay_plugin_config is None + or self._agent is None + or self._invoke_hook is None + or not self._relay_session_pending + ): + return + if not self._relay_finalize_hook_invoked: + self._invoke_hook( + "on_session_finalize", + session_id=getattr(self._agent, "session_id", ""), + model=getattr(self._agent, "model", None) or self._relay_model_name, + platform=getattr(self._agent, "platform", None) or "fabric", + ) + self._relay_finalize_hook_invoked = True + # Relay subscriber callbacks are queued. The long-lived plugin context + # does not flush them until runtime shutdown, but invocation results + # must include artifacts produced by this turn. + from nemo_relay import subscribers + + subscribers.flush() + self._relay_session_pending = False + self._relay_finalize_hook_invoked = False + async def stop(self) -> None: active_invoke_task = self._active_invoke_task errors: list[BaseException] = [] @@ -329,6 +439,7 @@ async def stop(self) -> None: self._hermes_config_path = None self._hermes_config = {} self._enabled_toolsets = None + self._mcp_authentication_checked = False self._conversation_history = None self._relay_plugin_config = None self._relay_plugin_config_path = None diff --git a/adapters/hermes/src/nemo_fabric_adapters/hermes/configuration.py b/adapters/hermes/src/nemo_fabric_adapters/hermes/configuration.py index 904bc0ea4..8e8766b0f 100644 --- a/adapters/hermes/src/nemo_fabric_adapters/hermes/configuration.py +++ b/adapters/hermes/src/nemo_fabric_adapters/hermes/configuration.py @@ -6,12 +6,14 @@ from __future__ import annotations import os +from collections.abc import Mapping from pathlib import Path from typing import Any from nemo_fabric_adapter_contract.models import AgentConfig from nemo_fabric_adapter_contract.models import AgentMcpServerConfig from nemo_fabric_adapter_contract.models import AgentModelConfig +from nemo_fabric_adapters.common import mcp_auth import nemo_fabric_adapters.common.utils as common_utils @@ -104,7 +106,7 @@ def build_hermes_config( mcp_servers = agent_config.mcp.servers if agent_config.mcp is not None else {} if mcp_servers: config["mcp_servers"] = { - name: hermes_mcp_server_config(server) + name: hermes_mcp_server_config(server, name=name) for name, server in sorted(mcp_servers.items()) } @@ -138,13 +140,17 @@ def write_hermes_config( return config_path, config -def hermes_mcp_server_config(server: AgentMcpServerConfig) -> dict[str, Any]: +def hermes_mcp_server_config( + server: AgentMcpServerConfig, *, name: str = "configured" +) -> dict[str, Any]: transport = server.transport.strip().lower() target = os.path.expandvars(server.url).strip() - if not target: - raise ValueError("MCP server mapping requires a URL") if transport == "stdio": + if server.authentication: + raise ValueError("MCP authentication is not supported for stdio transport") + if server.custom_headers: + raise ValueError("MCP custom_headers are not supported for stdio transport") return common_utils.without_none( { "enabled": True, @@ -154,7 +160,57 @@ def hermes_mcp_server_config(server: AgentMcpServerConfig) -> dict[str, Any]: } ) - return {"enabled": True, "url": target, "transport": transport} + result: dict[str, Any] = { + "enabled": True, + "url": target, + "transport": transport, + } + if headers := server.custom_headers: + try: + result["headers"] = mcp_auth.normalize_custom_headers(name, headers) + except mcp_auth.McpAuthConfigError as error: + raise ValueError(str(error)) from error + if authentication := server.authentication: + raw_authentication = authentication + try: + if ( + isinstance(authentication, Mapping) + and authentication.get("type") == "service_account" + ): + raise mcp_auth.McpAuthConfigError( + f"MCP server {name!r} service_account authentication is not supported by Hermes" + ) + authentication = mcp_auth.parse_oauth2_config(name, authentication) + except mcp_auth.McpAuthConfigError as error: + raise ValueError(str(error)) from error + if authentication.client_name: + raise ValueError( + f"MCP server {name!r} authentication.client_name is not supported by Hermes" + ) + if authentication.token_endpoint_auth_method: + raise ValueError( + f"MCP server {name!r} authentication.token_endpoint_auth_method is not supported by Hermes" + ) + if "authorization_timeout_seconds" in raw_authentication: + raise ValueError( + f"MCP server {name!r} authentication.authorization_timeout_seconds is not supported by Hermes" + ) + oauth = common_utils.without_none( + { + "client_id": authentication.client_id, + "scope": authentication.scope, + "redirect_uri": authentication.redirect_uri, + } + ) + if secret_env := authentication.client_secret_env: + try: + mcp_auth.resolve_client_secret(name, authentication) + except mcp_auth.McpAuthConfigError as error: + raise ValueError(str(error)) from error + oauth["client_secret"] = f"${{{secret_env}}}" + result["auth"] = "oauth" + result["oauth"] = oauth + return result def summarize_hermes_config(config: dict[str, Any]) -> dict[str, Any]: diff --git a/adapters/hermes/uv.lock b/adapters/hermes/uv.lock index 90895680f..7f1ef4bc4 100644 --- a/adapters/hermes/uv.lock +++ b/adapters/hermes/uv.lock @@ -631,6 +631,13 @@ name = "nemo-fabric-adapters-common" version = "0.2.0" source = { editable = "../common" } +[package.metadata] +requires-dist = [ + { name = "httpx", marker = "extra == 'mcp-oauth'", specifier = ">=0.27,<1" }, + { name = "mcp", marker = "extra == 'mcp-oauth'", specifier = ">=1.26,<1.29" }, +] +provides-extras = ["mcp-oauth"] + [[package]] name = "nemo-fabric-adapters-hermes" version = "0.2.0" diff --git a/crates/fabric-core/src/agent_config.rs b/crates/fabric-core/src/agent_config.rs index b17a753ae..7f1c69fa3 100644 --- a/crates/fabric-core/src/agent_config.rs +++ b/crates/fabric-core/src/agent_config.rs @@ -12,6 +12,7 @@ use serde_json::Value; use crate::config::{ AdapterConfigField, AdapterDescriptor, CapabilityPlan, FabricConfig, InstructionMode, + McpTransport, }; /// Configuration projected southbound to one adapter target. @@ -166,6 +167,12 @@ pub struct AgentMcpServerConfig { /// Environment variables passed to an MCP stdio process. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub env: BTreeMap, + /// Authentication used by an HTTP MCP server. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authentication: Option>, + /// HTTP headers passed to an MCP server. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub custom_headers: BTreeMap, /// MCP tool names to expose. `None` exposes every discovered tool. #[serde(default, skip_serializing_if = "Option::is_none")] pub allowed_tools: Option>, @@ -317,10 +324,23 @@ pub(crate) fn project_agent_config( ( name.clone(), AgentMcpServerConfig { - transport: server.transport.clone(), + transport: match server.transport { + McpTransport::Stdio => "stdio", + McpTransport::Sse => "sse", + McpTransport::StreamableHttp => "streamable-http", + } + .to_string(), url: server.url.clone(), args: server.args.clone(), env: server.env.clone(), + authentication: server.authentication.as_ref().map(|authentication| { + serde_json::to_value(authentication) + .expect("MCP authentication must serialize") + .as_object() + .expect("MCP authentication must serialize as an object") + .clone() + }), + custom_headers: server.custom_headers.clone(), allowed_tools: server.allowed_tools.clone(), blocked_tools: server.blocked_tools.clone(), extensions: server.extensions.clone(), diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index 3de976523..10fb8901c 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -707,12 +707,118 @@ pub struct McpConfig { pub extensions: BTreeMap, } +/// MCP server transport. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum McpTransport { + /// Standard input/output transport. + Stdio, + /// Server-Sent Events transport. + Sse, + /// Streamable HTTP transport. + StreamableHttp, +} + +/// OAuth client authentication method used at the token endpoint. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum OAuthTokenEndpointAuthMethod { + /// Public client without a client secret. + None, + /// Send the client secret in the token request body. + ClientSecretPost, + /// Send the client credentials with HTTP Basic authentication. + ClientSecretBasic, +} + +fn default_true() -> bool { + true +} + +fn is_true(value: &bool) -> bool { + *value +} + +fn default_mcp_oauth_timeout_seconds() -> u64 { + 300 +} + +fn is_default_mcp_oauth_timeout_seconds(value: &u64) -> bool { + *value == default_mcp_oauth_timeout_seconds() +} + +fn default_mcp_token_cache_buffer_seconds() -> u64 { + 300 +} + +fn is_default_mcp_token_cache_buffer_seconds(value: &u64) -> bool { + *value == default_mcp_token_cache_buffer_seconds() +} + +/// MCP server authentication configuration. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum McpAuthenticationConfig { + /// OAuth 2.0 authorization-code authentication. + #[serde(rename = "oauth2")] + OAuth2 { + /// Pre-registered OAuth client identifier. Omit to allow dynamic registration. + #[serde(default, skip_serializing_if = "Option::is_none")] + client_id: Option, + /// Environment variable containing the OAuth client secret. + #[serde(default, skip_serializing_if = "Option::is_none")] + client_secret_env: Option, + /// OAuth scopes requested by the MCP client. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + scopes: Vec, + /// OAuth callback URI for clients that require a pre-registered redirect URI. + #[serde(default, skip_serializing_if = "Option::is_none")] + redirect_uri: Option, + /// Whether the client may register dynamically when `client_id` is omitted. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + enable_dynamic_registration: bool, + /// Client name advertised during dynamic registration. + #[serde(default, skip_serializing_if = "Option::is_none")] + client_name: Option, + /// Client authentication method used at the token endpoint. + #[serde(default, skip_serializing_if = "Option::is_none")] + token_endpoint_auth_method: Option, + /// Maximum time to wait for interactive authorization. + #[serde( + default = "default_mcp_oauth_timeout_seconds", + skip_serializing_if = "is_default_mcp_oauth_timeout_seconds" + )] + authorization_timeout_seconds: u64, + }, + /// OAuth 2.0 client-credentials authentication for headless workloads. + ServiceAccount { + /// OAuth client identifier. + client_id: String, + /// Environment variable containing the OAuth client secret. + client_secret_env: String, + /// OAuth token endpoint. + token_url: String, + /// OAuth scopes requested by the MCP client. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + scopes: Vec, + /// Client authentication method used at the token endpoint. + #[serde(default, skip_serializing_if = "Option::is_none")] + token_endpoint_auth_method: Option, + /// Refresh the cached token this many seconds before expiry. + #[serde( + default = "default_mcp_token_cache_buffer_seconds", + skip_serializing_if = "is_default_mcp_token_cache_buffer_seconds" + )] + token_cache_buffer_seconds: u64, + }, +} + /// MCP server configuration. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct McpServerConfig { /// MCP transport. - pub transport: String, - /// MCP server URL for network transports or executable for stdio. + pub transport: McpTransport, + /// MCP server URL or process command (when transport=stdio), depending on transport. pub url: String, /// Command-line arguments passed to an MCP stdio server process. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -720,6 +826,9 @@ pub struct McpServerConfig { /// Environment variables passed to an MCP stdio server process. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub env: BTreeMap, + /// Authentication used by an HTTP MCP server. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authentication: Option, /// How NeMo Fabric exposes the MCP capability to the harness. pub exposure: McpExposure, /// MCP tool names to expose. `None` exposes every tool discovered from the server. @@ -731,6 +840,9 @@ pub struct McpServerConfig { /// Additive MCP server fields. #[serde(default, flatten)] pub extensions: BTreeMap, + /// HTTP headers passed to an MCP server when transport is `sse` or `streamable_http`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub custom_headers: BTreeMap, } /// MCP exposure strategy. @@ -1324,6 +1436,134 @@ pub(crate) fn validate_config(config: &FabricConfig) -> Result<()> { if let Some(mcp) = &config.mcp { for (server_name, server) in &mcp.servers { let field = format!("mcp.servers.{server_name}"); + if server.transport == McpTransport::Stdio + && (server.authentication.is_some() || !server.custom_headers.is_empty()) + { + return invalid_config( + &field, + "authentication and custom_headers require an HTTP transport", + ); + } + if let Some(authentication) = &server.authentication { + match authentication { + McpAuthenticationConfig::OAuth2 { + client_id, + client_secret_env, + scopes, + redirect_uri, + enable_dynamic_registration, + client_name, + token_endpoint_auth_method, + authorization_timeout_seconds, + } => { + validate_names(&format!("{field}.authentication.scopes"), scopes)?; + if client_id + .as_ref() + .is_some_and(|value| value.trim().is_empty()) + { + return invalid_config( + format!("{field}.authentication.client_id"), + "must be a non-empty string", + ); + } + if client_secret_env + .as_ref() + .is_some_and(|value| value.trim().is_empty()) + { + return invalid_config( + format!("{field}.authentication.client_secret_env"), + "must be a non-empty string", + ); + } + if client_secret_env.is_some() && client_id.is_none() { + return invalid_config( + format!("{field}.authentication.client_secret_env"), + "requires client_id", + ); + } + if !enable_dynamic_registration && client_id.is_none() { + return invalid_config( + format!("{field}.authentication.client_id"), + "is required when dynamic registration is disabled", + ); + } + if matches!( + token_endpoint_auth_method, + Some( + OAuthTokenEndpointAuthMethod::ClientSecretBasic + | OAuthTokenEndpointAuthMethod::ClientSecretPost + ) + ) && client_secret_env.is_none() + && client_id.is_some() + { + return invalid_config( + format!("{field}.authentication.token_endpoint_auth_method"), + "requires client_secret_env for a pre-registered client", + ); + } + if *token_endpoint_auth_method == Some(OAuthTokenEndpointAuthMethod::None) + && client_secret_env.is_some() + { + return invalid_config( + format!("{field}.authentication.token_endpoint_auth_method"), + "`none` cannot be combined with client_secret_env", + ); + } + if redirect_uri + .as_ref() + .is_some_and(|value| value.trim().is_empty()) + { + return invalid_config( + format!("{field}.authentication.redirect_uri"), + "must be a non-empty string", + ); + } + if client_name + .as_ref() + .is_some_and(|value| value.trim().is_empty()) + { + return invalid_config( + format!("{field}.authentication.client_name"), + "must be a non-empty string", + ); + } + if *authorization_timeout_seconds == 0 { + return invalid_config( + format!("{field}.authentication.authorization_timeout_seconds"), + "must be greater than zero", + ); + } + } + McpAuthenticationConfig::ServiceAccount { + client_id, + client_secret_env, + token_url, + scopes, + token_endpoint_auth_method, + .. + } => { + for (name, value) in [ + ("client_id", client_id), + ("client_secret_env", client_secret_env), + ("token_url", token_url), + ] { + if value.trim().is_empty() { + return invalid_config( + format!("{field}.authentication.{name}"), + "must be a non-empty string", + ); + } + } + validate_names(&format!("{field}.authentication.scopes"), scopes)?; + if *token_endpoint_auth_method == Some(OAuthTokenEndpointAuthMethod::None) { + return invalid_config( + format!("{field}.authentication.token_endpoint_auth_method"), + "service_account requires client_secret_basic or client_secret_post", + ); + } + } + } + } if let Some(allowed_tools) = &server.allowed_tools { validate_names(&format!("{field}.allowed_tools"), allowed_tools)?; if let Some(name) = allowed_tools @@ -2222,10 +2462,12 @@ fn resolve_capability_plan( ( name.clone(), McpServerPlan { - transport: server.transport.clone(), + transport: server.transport, url: server.url.clone(), args: server.args.clone(), env: server.env.clone(), + authentication: server.authentication.clone(), + custom_headers: server.custom_headers.clone(), exposure: server.exposure, extensions: server.extensions.clone(), allowed_tools: server.allowed_tools.clone(), @@ -2686,7 +2928,7 @@ pub enum CapabilityTarget { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct McpServerPlan { /// MCP transport. - pub transport: String, + pub transport: McpTransport, /// MCP server URL for network transports or executable for stdio. pub url: String, /// Command-line arguments passed to an MCP stdio server process. @@ -2695,6 +2937,12 @@ pub struct McpServerPlan { /// Environment variables passed to an MCP stdio server process. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub env: BTreeMap, + /// Authentication used by an HTTP MCP server. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authentication: Option, + /// HTTP headers passed to an MCP server. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub custom_headers: BTreeMap, /// Exposure strategy. pub exposure: McpExposure, /// Additive MCP server fields from author config. @@ -2918,7 +3166,7 @@ mod tests { .mcp_servers .get("analyzer") .expect("native analyzer mcp server"); - assert_eq!(server.transport, "stdio"); + assert_eq!(server.transport, McpTransport::Stdio); assert_eq!(server.url, "/tmp/analyzer-mcp"); assert_eq!(server.exposure, McpExposure::HarnessNative); assert_eq!(server.args, vec!["--stdio".to_string()]); @@ -2932,6 +3180,123 @@ mod tests { assert!(server.extensions.is_empty()); } + #[test] + fn mcp_transport_rejects_unknown_values() { + let error = serde_json::from_value::(serde_json::json!({ + "transport": "websocket", + "url": "https://mcp.example", + "exposure": "harness_native" + })) + .expect_err("unknown MCP transport"); + + assert!(error.to_string().contains("unknown variant `websocket`")); + } + + #[test] + fn mcp_oauth_authentication_rejects_unknown_fields() { + let error = serde_json::from_value::(serde_json::json!({ + "type": "oauth2", + "client_id": "fabric-client", + "unknown": true + })) + .expect_err("unknown OAuth field"); + + assert!(error.to_string().contains("unknown field `unknown`")); + } + + #[test] + fn mcp_service_account_authentication_rejects_unknown_fields() { + let error = serde_json::from_value::(serde_json::json!({ + "type": "service_account", + "client_id": "fabric-client", + "client_secret_env": "MCP_CLIENT_SECRET", + "token_url": "https://auth.example/token", + "unknown": true + })) + .expect_err("unknown service-account field"); + + assert!(error.to_string().contains("unknown field `unknown`")); + } + + #[test] + fn mcp_http_authentication_and_headers_survive_capability_planning() { + let mut config = typed_config("nvidia.fabric.hermes"); + config.skills = None; + config.mcp = Some(McpConfig { + servers: BTreeMap::from([( + "jira".to_string(), + serde_json::from_value(serde_json::json!({ + "transport": "streamable-http", + "url": "https://mcp.example/jira", + "exposure": "harness_native", + "custom_headers": {"X-Tenant": "fabric"}, + "authentication": { + "type": "oauth2", + "client_id": "fabric-client", + "client_secret_env": "MCP_CLIENT_SECRET", + "scopes": ["read:jira", "write:jira"], + "redirect_uri": "http://127.0.0.1:8765/callback", + "enable_dynamic_registration": false, + "client_name": "NeMo Fabric", + "token_endpoint_auth_method": "client_secret_post", + "authorization_timeout_seconds": 120 + } + })) + .expect("authenticated MCP server"), + )]), + extensions: BTreeMap::new(), + }); + + let plan = resolve_run_plan_from_config(config, ResolveContext::new(repository_root())) + .expect("authenticated Hermes MCP plan"); + let server = plan + .capability_plan + .native + .mcp_servers + .get("jira") + .expect("native Jira MCP server"); + + assert_eq!(server.transport, McpTransport::StreamableHttp); + assert_eq!( + server.custom_headers, + BTreeMap::from([("X-Tenant".to_string(), "fabric".to_string())]) + ); + assert_eq!( + server.authentication, + Some(McpAuthenticationConfig::OAuth2 { + client_id: Some("fabric-client".to_string()), + client_secret_env: Some("MCP_CLIENT_SECRET".to_string()), + scopes: vec!["read:jira".to_string(), "write:jira".to_string()], + redirect_uri: Some("http://127.0.0.1:8765/callback".to_string()), + enable_dynamic_registration: false, + client_name: Some("NeMo Fabric".to_string()), + token_endpoint_auth_method: Some(OAuthTokenEndpointAuthMethod::ClientSecretPost,), + authorization_timeout_seconds: 120, + }) + ); + let projected = plan + .agent_config + .mcp + .as_ref() + .and_then(|mcp| mcp.servers.get("jira")) + .expect("projected Jira MCP server"); + assert_eq!(projected.custom_headers, server.custom_headers); + assert_eq!( + projected + .authentication + .as_ref() + .and_then(|authentication| authentication.get("type")), + Some(&serde_json::json!("oauth2")) + ); + assert_eq!( + projected + .authentication + .as_ref() + .and_then(|authentication| authentication.get("client_secret_env")), + Some(&serde_json::json!("MCP_CLIENT_SECRET")) + ); + } + #[test] fn agent_config_projects_only_harness_native_mcp_servers() { let path = repository_root().join("adapters/hermes/fabric-adapter.json"); @@ -2979,6 +3344,120 @@ mod tests { ); } + #[test] + fn mcp_service_account_authentication_survives_capability_planning() { + let mut config = typed_config("nvidia.fabric.langchain.deepagents"); + config.skills = None; + config.mcp = Some(McpConfig { + servers: BTreeMap::from([( + "automation".to_string(), + serde_json::from_value(serde_json::json!({ + "transport": "streamable-http", + "url": "https://mcp.example/automation", + "exposure": "harness_native", + "authentication": { + "type": "service_account", + "client_id": "fabric-client", + "client_secret_env": "MCP_CLIENT_SECRET", + "token_url": "https://auth.example/token", + "scopes": ["mcp:invoke"], + "token_endpoint_auth_method": "client_secret_basic", + "token_cache_buffer_seconds": 60 + } + })) + .expect("service-account MCP server"), + )]), + extensions: BTreeMap::new(), + }); + + let plan = resolve_run_plan_from_config(config, ResolveContext::new(repository_root())) + .expect("authenticated Deep Agents MCP plan"); + let server = plan + .capability_plan + .native + .mcp_servers + .get("automation") + .expect("native automation MCP server"); + + assert_eq!( + server.authentication, + Some(McpAuthenticationConfig::ServiceAccount { + client_id: "fabric-client".to_string(), + client_secret_env: "MCP_CLIENT_SECRET".to_string(), + token_url: "https://auth.example/token".to_string(), + scopes: vec!["mcp:invoke".to_string()], + token_endpoint_auth_method: Some(OAuthTokenEndpointAuthMethod::ClientSecretBasic,), + token_cache_buffer_seconds: 60, + }) + ); + } + + #[test] + fn mcp_oauth_allows_dynamic_registration_to_supply_client_secret() { + let mut config = typed_config("nvidia.fabric.langchain.deepagents"); + config.mcp = Some(McpConfig { + servers: BTreeMap::from([( + "docs".to_string(), + serde_json::from_value(serde_json::json!({ + "transport": "streamable-http", + "url": "https://mcp.example/docs", + "exposure": "harness_native", + "authentication": { + "type": "oauth2", + "token_endpoint_auth_method": "client_secret_post" + } + })) + .expect("dynamically registered MCP server"), + )]), + extensions: BTreeMap::new(), + }); + + validate_config(&config).expect("dynamic registration supplies client credentials"); + } + + #[test] + fn rejects_invalid_mcp_authentication_policy() { + let cases = [ + ( + serde_json::json!({ + "type": "oauth2", + "enable_dynamic_registration": false + }), + "client_id", + ), + ( + serde_json::json!({ + "type": "service_account", + "client_id": "fabric-client", + "client_secret_env": "MCP_CLIENT_SECRET", + "token_url": "https://auth.example/token", + "token_endpoint_auth_method": "none" + }), + "token_endpoint_auth_method", + ), + ]; + + for (authentication, expected) in cases { + let mut config = typed_config("nvidia.fabric.langchain.deepagents"); + config.mcp = Some(McpConfig { + servers: BTreeMap::from([( + "invalid".to_string(), + serde_json::from_value(serde_json::json!({ + "transport": "streamable-http", + "url": "https://mcp.example/invalid", + "exposure": "harness_native", + "authentication": authentication, + })) + .expect("syntactically valid MCP server"), + )]), + extensions: BTreeMap::new(), + }); + + let error = validate_config(&config).expect_err("invalid MCP auth must fail"); + assert!(error.to_string().contains(expected), "{error}"); + } + } + #[test] fn resolves_complete_typed_config_with_explicit_base_dir() { let base_dir = repository_root(); @@ -3368,10 +3847,12 @@ mod tests { servers: BTreeMap::from([( "docs".to_string(), McpServerConfig { - transport: "streamable-http".to_string(), + transport: McpTransport::StreamableHttp, url: "https://mcp.example".to_string(), args: Vec::new(), env: BTreeMap::new(), + authentication: None, + custom_headers: BTreeMap::new(), exposure: McpExposure::FabricManaged, allowed_tools: None, blocked_tools: Vec::new(), @@ -3416,10 +3897,12 @@ mod tests { servers: BTreeMap::from([( "docs".to_string(), McpServerConfig { - transport: "streamable-http".to_string(), + transport: McpTransport::StreamableHttp, url: "https://mcp.example".to_string(), args: Vec::new(), env: BTreeMap::new(), + authentication: None, + custom_headers: BTreeMap::new(), exposure: McpExposure::HarnessNative, allowed_tools: Some(Vec::new()), blocked_tools: vec!["delete".to_string()], @@ -3466,10 +3949,12 @@ mod tests { servers: BTreeMap::from([( "docs".to_string(), McpServerConfig { - transport: "streamable-http".to_string(), + transport: McpTransport::StreamableHttp, url: "https://mcp.example".to_string(), args: Vec::new(), env: BTreeMap::new(), + authentication: None, + custom_headers: BTreeMap::new(), exposure: McpExposure::HarnessNative, allowed_tools, blocked_tools, @@ -3503,10 +3988,12 @@ mod tests { servers: BTreeMap::from([( "docs".to_string(), McpServerConfig { - transport: "streamable-http".to_string(), + transport: McpTransport::StreamableHttp, url: "https://mcp.example".to_string(), args: Vec::new(), env: BTreeMap::new(), + authentication: None, + custom_headers: BTreeMap::new(), exposure: McpExposure::HarnessNative, allowed_tools: Some(vec!["search".to_string()]), blocked_tools: vec!["search".to_string()], @@ -3547,10 +4034,12 @@ mod tests { servers: BTreeMap::from([( "docs".to_string(), McpServerConfig { - transport: "streamable-http".to_string(), + transport: McpTransport::StreamableHttp, url: "https://mcp.example".to_string(), args: Vec::new(), env: BTreeMap::new(), + authentication: None, + custom_headers: BTreeMap::new(), exposure: McpExposure::HarnessNative, allowed_tools, blocked_tools, diff --git a/crates/fabric-core/src/lib.rs b/crates/fabric-core/src/lib.rs index deb608954..a01cbaeb1 100644 --- a/crates/fabric-core/src/lib.rs +++ b/crates/fabric-core/src/lib.rs @@ -25,8 +25,9 @@ pub use config::{ AgentRuntimeConfig, AgentSkillConfig, AgentToolDefinition, AgentToolsConfig, AgentWorkflowConfig, AgentWorkflowEntrypointConfig, CapabilityPlan, ControlLocation, EnvironmentConfig, EnvironmentOwnership, EnvironmentPlan, FabricConfig, HarnessConfig, - InstructionConfig, InstructionMode, InstructionsConfig, McpConfig, McpExposure, McpServerPlan, - MetadataConfig, ModelConfig, ResolutionStrategy, ResolveContext, ResolvedAdapterDescriptor, + InstructionConfig, InstructionMode, InstructionsConfig, McpAuthenticationConfig, McpConfig, + McpExposure, McpServerPlan, McpTransport, MetadataConfig, ModelConfig, + OAuthTokenEndpointAuthMethod, ResolutionStrategy, ResolveContext, ResolvedAdapterDescriptor, RunPlan, RuntimeCapabilities, RuntimeConfig, SkillConfig, TelemetryConfig, TelemetryPlan, TelemetryProvider, TelemetryProviderConfig, ToolDefinitionConfig, ToolsConfig, WorkflowConfig, WorkflowEntrypointConfig, load_adapter_descriptor, resolve_diagnostic_plan_from_config, diff --git a/docs/adapter-contract/normalized-configuration.md b/docs/adapter-contract/normalized-configuration.md index 2cb3128c1..5b6b4b5e7 100644 --- a/docs/adapter-contract/normalized-configuration.md +++ b/docs/adapter-contract/normalized-configuration.md @@ -38,7 +38,7 @@ FabricConfig + adapter descriptor + resolved capability plan | `instructions` | Portable instructions the adapter can apply. | | `runtime` | Target behavior such as the maximum number of turns. | | `skills` | Skill paths resolved for the task environment. | -| `mcp` | Named MCP servers and effective per-server tool policy. | +| `mcp` | Named MCP servers, HTTP authentication metadata and custom headers, and effective per-server tool policy. | | `tools` | Named tool or tool-group definitions plus effective selection and blocking policy. | | `workflow` | Custom-agent or workflow entry point and construction settings. | | `extensions` | Adapter-owned fields validated at a declared extension point. | @@ -55,6 +55,9 @@ The descriptor controls the projection: - Scalar normalized fields are included only when `config.accepts` declares that the adapter can apply them. - Resolved native skills and MCP servers come from the capability plan. +- HTTP MCP authentication metadata and custom headers remain attached to each + projected server. Authentication contains credential environment-variable + names, not resolved secret values. - `harness.settings` and a configured `workflow` are validated against the selected descriptor before startup. - Named tool definitions are validated individually against diff --git a/docs/reference/api/python-library-reference/index.md b/docs/reference/api/python-library-reference/index.md index 717f2bea2..2a3db1a8f 100644 --- a/docs/reference/api/python-library-reference/index.md +++ b/docs/reference/api/python-library-reference/index.md @@ -29,6 +29,7 @@ SPDX-License-Identifier: Apache-2.0 --> - [`models.HarnessConfig`](./nemo_fabric.models.md#class-harnessconfig): Harness adapter selection plus adapter-owned settings. - [`models.InstructionConfig`](./nemo_fabric.models.md#class-instructionconfig): One portable instruction value. - [`models.InstructionsConfig`](./nemo_fabric.models.md#class-instructionsconfig): Harness-neutral agent instructions. +- [`models.McpAuthenticationConfig`](./nemo_fabric.models.md#class-mcpauthenticationconfig): MCP server authentication configuration. - [`models.McpConfig`](./nemo_fabric.models.md#class-mcpconfig): MCP capability configuration. - [`models.McpServerConfig`](./nemo_fabric.models.md#class-mcpserverconfig): MCP server configuration. - [`models.MetadataConfig`](./nemo_fabric.models.md#class-metadataconfig): Human-readable agent identity. diff --git a/docs/reference/api/python-library-reference/nemo_fabric.models.md b/docs/reference/api/python-library-reference/nemo_fabric.models.md index 1ecc6fb5c..5a1457e97 100644 --- a/docs/reference/api/python-library-reference/nemo_fabric.models.md +++ b/docs/reference/api/python-library-reference/nemo_fabric.models.md @@ -825,6 +825,86 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- +## class `McpAuthenticationConfig` + +MCP server authentication configuration. + + + +### Fields + +The model defines the following fields: + +| Field | Type | Required | Default | Constraints | Description | +| --- | --- | --- | --- | --- | --- | +| `type` | `Literal['oauth2', 'service_account']` | Yes | — | — | — | +| `client_id` | `str \| None` | No | `None` | — | — | +| `client_secret_env` | `str \| None` | No | `None` | — | — | +| `scopes` | `list[str]` | No | `list()` | — | — | +| `redirect_uri` | `str \| None` | No | `None` | — | — | +| `enable_dynamic_registration` | `bool` | No | `True` | — | — | +| `client_name` | `str \| None` | No | `None` | — | — | +| `token_endpoint_auth_method` | `Literal['none', 'client_secret_post', 'client_secret_basic'] \| None` | No | `None` | — | — | +| `authorization_timeout_seconds` | `int` | No | `300` | `Gt(gt=0)` | — | +| `token_url` | `str \| None` | No | `None` | — | — | +| `token_cache_buffer_seconds` | `int` | No | `300` | `Ge(ge=0)` | — | + +--- + +### property extra_fields + +Return fields preserved by the extension point for this model. + +--- + +### property model_extra + +Get extra fields set during validation. + + + +**Returns:** + A dictionary of extra fields, or `None` if `config.extra` is not set to `"allow"`. + +--- + +### property model_fields_set + +Returns the set of fields that have been explicitly set on this model instance. + + + +**Returns:** + A set of strings representing the fields that have been set, i.e. that were not filled from defaults. + + + +--- + + +### classmethod `from_mapping` + +```python +def from_mapping(value: Mapping[str, Any]) -> Self +``` + +Validate a mapping using this Pydantic model. + +--- + + +### method `to_mapping` + +```python +def to_mapping() -> dict[str, Any] +``` + +Return a detached JSON-compatible mapping for Rust/core calls. + + +--- + + ## class `McpServerConfig` MCP server configuration. @@ -837,10 +917,12 @@ The model defines the following fields: | Field | Type | Required | Default | Constraints | Description | | --- | --- | --- | --- | --- | --- | -| `transport` | `str` | Yes | — | `MinLen(min_length=1)` | — | +| `transport` | `Literal['stdio', 'sse', 'streamable-http']` | Yes | — | — | — | | `url` | `str` | Yes | — | `MinLen(min_length=1)` | MCP server URL for network transports or executable for stdio. | | `args` | `list[str]` | No | `list()` | — | Command-line arguments passed to an MCP stdio server process. | | `env` | `dict[str, str]` | No | `dict()` | — | — | +| `authentication` | `McpAuthenticationConfig \| None` | No | `None` | — | — | +| `custom_headers` | `dict[str, str]` | No | `dict()` | — | HTTP headers passed to an MCP server when transport is sse or streamable-http. | | `exposure` | `Literal['harness_native', 'fabric_managed']` | No | `'harness_native'` | — | — | | `allowed_tools` | `list[str] \| None` | No | `None` | — | MCP tools to expose. None exposes every discovered tool; an empty list exposes no tools. | | `blocked_tools` | `list[str]` | No | `list()` | — | MCP tools to block after applying the optional allowlist. | @@ -958,6 +1040,8 @@ def add_server( url: str, args: Sequence[str] | None = None, env: Mapping[str, str] | None = None, + authentication: McpAuthenticationConfig | None = None, + custom_headers: Mapping[str, str] | None = None, exposure: Literal['harness_native', 'fabric_managed'] = 'harness_native', allowed_tools: Sequence[str] | None = None, blocked_tools: Sequence[str] = (), @@ -2246,6 +2330,8 @@ def add_mcp_server( url: str, args: Sequence[str] | None = None, env: Mapping[str, str] | None = None, + authentication: McpAuthenticationConfig | None = None, + custom_headers: Mapping[str, str] | None = None, exposure: Literal['harness_native', 'fabric_managed'] = 'harness_native', allowed_tools: Sequence[str] | None = None, blocked_tools: Sequence[str] = (), diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/adapter-contract/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/adapter-contract/index.mdx index a7db84551..cc5736076 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/adapter-contract/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/adapter-contract/index.mdx @@ -3,7 +3,7 @@ title: "Module adapter_contract" sidebar-title: "adapter_contract" slug: "/reference/api/rust-library-reference/nemo-fabric-core/adapter_contract" description: "Shared southbound adapter contract metadata." -position: 95 +position: 98 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-config/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-config/index.mdx index b02a4c353..7c179dd2e 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-config/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-config/index.mdx @@ -3,7 +3,7 @@ title: "Module agent_config" sidebar-title: "agent_config" slug: "/reference/api/rust-library-reference/nemo-fabric-core/agent_config" description: "Configuration projected southbound to an adapter target." -position: 96 +position: 99 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-config/struct-agentmcpserverconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-config/struct-agentmcpserverconfig.mdx index 355dc128c..fe7e97bbe 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-config/struct-agentmcpserverconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-config/struct-agentmcpserverconfig.mdx @@ -10,7 +10,7 @@ SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p nemo-fabric-core`. -
String,\n    pub url: String,\n    pub args: Vec<String>,\n    pub env: BTreeMap<String, String>,\n    pub allowed_tools: Option<Vec<String>>,\n    pub blocked_tools: Vec<String>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
String,\n    pub url: String,\n    pub args: Vec<String>,\n    pub env: BTreeMap<String, String>,\n    pub authentication: Option<Map<String, Value>>,\n    pub custom_headers: BTreeMap<String, String>,\n    pub allowed_tools: Option<Vec<String>>,\n    pub blocked_tools: Vec<String>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
One MCP server routed to an adapter target. @@ -32,6 +32,14 @@ Command-line arguments passed to an MCP stdio process. Environment variables passed to an MCP stdio process. +### `authentication: Option>` + +Authentication used by an HTTP MCP server. + +### `custom_headers: BTreeMap` + +HTTP headers passed to an MCP server. + ### `allowed_tools: Option>` MCP tool names to expose. `None` exposes every discovered tool. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-execution/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-execution/index.mdx index 1ed654c03..bcd236797 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-execution/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-execution/index.mdx @@ -3,7 +3,7 @@ title: "Module agent_execution" sidebar-title: "agent_execution" slug: "/reference/api/rust-library-reference/nemo-fabric-core/agent_execution" description: "Request and result structures exchanged with an adapter target." -position: 97 +position: 100 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpauthenticationconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpauthenticationconfig.mdx new file mode 100644 index 000000000..91e059ee3 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpauthenticationconfig.mdx @@ -0,0 +1,162 @@ +--- +title: "Enum McpAuthentication Config" +sidebar-title: "McpAuthenticationConfig" +description: "MCP server authentication configuration." +position: 42 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
Option<String>,\n        client_secret_env: Option<String>,\n        scopes: Vec<String>,\n        redirect_uri: Option<String>,\n        enable_dynamic_registration: bool,\n        client_name: Option<String>,\n        token_endpoint_auth_method: Option<OAuthTokenEndpointAuthMethod>,\n        authorization_timeout_seconds: u64,\n    },\n    ServiceAccount {\n        client_id: String,\n        client_secret_env: String,\n        token_url: String,\n        scopes: Vec<String>,\n        token_endpoint_auth_method: Option<OAuthTokenEndpointAuthMethod>,\n        token_cache_buffer_seconds: u64,\n    },\n}"}} />
+ +MCP server authentication configuration. + +## Variants + +### `OAuth2` + +
+ +OAuth 2.0 authorization-code authentication. + +#### Fields + +### `client_id: Option` + +Pre-registered OAuth client identifier. Omit to allow dynamic registration. + +### `client_secret_env: Option` + +Environment variable containing the OAuth client secret. + +### `scopes: Vec` + +OAuth scopes requested by the MCP client. + +### `redirect_uri: Option` + +OAuth callback URI for clients that require a pre-registered redirect URI. + +### `enable_dynamic_registration: bool` + +Whether the client may register dynamically when `client_id` is omitted. + +### `client_name: Option` + +Client name advertised during dynamic registration. + +### `token_endpoint_auth_method: Option` + +Client authentication method used at the token endpoint. + +### `authorization_timeout_seconds: u64` + +Maximum time to wait for interactive authorization. + +### `ServiceAccount` + +
+ +OAuth 2.0 client-credentials authentication for headless workloads. + +#### Fields + +### `client_id: String` + +OAuth client identifier. + +### `client_secret_env: String` + +Environment variable containing the OAuth client secret. + +### `token_url: String` + +OAuth token endpoint. + +### `scopes: Vec` + +OAuth scopes requested by the MCP client. + +### `token_endpoint_auth_method: Option` + +Client authentication method used at the token endpoint. + +### `token_cache_buffer_seconds: u64` + +Refresh the cached token this many seconds before expiry. + +## Trait Implementations + +### `impl Clone for McpAuthenticationConfig` + +
Clone for McpAuthenticationConfig"}} />
+ +#### `clone` + +
clone(&self) -> McpAuthenticationConfig"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for McpAuthenticationConfig` + +
Debug for McpAuthenticationConfig"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for McpAuthenticationConfig` + +
Deserialize<'de> for McpAuthenticationConfig"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for McpAuthenticationConfig` + +
McpAuthenticationConfig"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for McpAuthenticationConfig` + +
PartialEq for McpAuthenticationConfig"}} />
+ +#### `eq` + +
eq(&self, other: &McpAuthenticationConfig) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for McpAuthenticationConfig` + +
Serialize for McpAuthenticationConfig"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for McpAuthenticationConfig` + +
StructuralPartialEq for McpAuthenticationConfig"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpexposure.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpexposure.mdx index a1350ff04..98d4ab62a 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpexposure.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcpexposure.mdx @@ -2,7 +2,7 @@ title: "Enum McpExposure" sidebar-title: "McpExposure" description: "MCP exposure strategy." -position: 43 +position: 44 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcptransport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcptransport.mdx new file mode 100644 index 000000000..7f893b8e8 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcptransport.mdx @@ -0,0 +1,122 @@ +--- +title: "Enum McpTransport" +sidebar-title: "McpTransport" +description: "MCP server transport." +position: 46 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +```rust +pub enum McpTransport { + Stdio, + Sse, + StreamableHttp, +} +``` + +MCP server transport. + +## Variants + +### `Stdio` + +
+ +Standard input/output transport. + +### `Sse` + +
+ +Server-Sent Events transport. + +### `StreamableHttp` + +
+ +Streamable HTTP transport. + +## Trait Implementations + +### `impl Clone for McpTransport` + +
Clone for McpTransport"}} />
+ +#### `clone` + +
clone(&self) -> McpTransport"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for McpTransport` + +
Debug for McpTransport"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for McpTransport` + +
Deserialize<'de> for McpTransport"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for McpTransport` + +
McpTransport"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for McpTransport` + +
PartialEq for McpTransport"}} />
+ +#### `eq` + +
eq(&self, other: &McpTransport) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for McpTransport` + +
Serialize for McpTransport"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for McpTransport` + +
Copy for McpTransport"}} />
+ +### `impl Eq for McpTransport` + +
Eq for McpTransport"}} />
+ +### `impl StructuralPartialEq for McpTransport` + +
StructuralPartialEq for McpTransport"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-oauthtokenendpointauthmethod.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-oauthtokenendpointauthmethod.mdx new file mode 100644 index 000000000..6a2372aa8 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-oauthtokenendpointauthmethod.mdx @@ -0,0 +1,122 @@ +--- +title: "Enum OAuth Token Endpoint Auth Method" +sidebar-title: "OAuthTokenEndpointAuthMethod" +description: "OAuth client authentication method used at the token endpoint." +position: 49 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +```rust +pub enum OAuthTokenEndpointAuthMethod { + None, + ClientSecretPost, + ClientSecretBasic, +} +``` + +OAuth client authentication method used at the token endpoint. + +## Variants + +### `None` + +
+ +Public client without a client secret. + +### `ClientSecretPost` + +
+ +Send the client secret in the token request body. + +### `ClientSecretBasic` + +
+ +Send the client credentials with HTTP Basic authentication. + +## Trait Implementations + +### `impl Clone for OAuthTokenEndpointAuthMethod` + +
Clone for OAuthTokenEndpointAuthMethod"}} />
+ +#### `clone` + +
clone(&self) -> OAuthTokenEndpointAuthMethod"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for OAuthTokenEndpointAuthMethod` + +
Debug for OAuthTokenEndpointAuthMethod"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for OAuthTokenEndpointAuthMethod` + +
Deserialize<'de> for OAuthTokenEndpointAuthMethod"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for OAuthTokenEndpointAuthMethod` + +
OAuthTokenEndpointAuthMethod"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for OAuthTokenEndpointAuthMethod` + +
PartialEq for OAuthTokenEndpointAuthMethod"}} />
+ +#### `eq` + +
eq(&self, other: &OAuthTokenEndpointAuthMethod) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for OAuthTokenEndpointAuthMethod` + +
Serialize for OAuthTokenEndpointAuthMethod"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for OAuthTokenEndpointAuthMethod` + +
Copy for OAuthTokenEndpointAuthMethod"}} />
+ +### `impl Eq for OAuthTokenEndpointAuthMethod` + +
Eq for OAuthTokenEndpointAuthMethod"}} />
+ +### `impl StructuralPartialEq for OAuthTokenEndpointAuthMethod` + +
StructuralPartialEq for OAuthTokenEndpointAuthMethod"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx index c5ad5872f..7dcfe167d 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atif Storage Config" sidebar-title: "RelayAtifStorageConfig" description: "Relay ATIF remote storage configuration." -position: 66 +position: 69 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx index 5e1d53bf7..898670318 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Mode" sidebar-title: "RelayAtofMode" description: "Relay ATOF file mode." -position: 67 +position: 70 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofsinkconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofsinkconfig.mdx index 04f7e794a..a2737ff83 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofsinkconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofsinkconfig.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Sink Config" sidebar-title: "RelayAtofSinkConfig" description: "Relay ATOF sink configuration." -position: 68 +position: 71 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamfieldnamepolicy.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamfieldnamepolicy.mdx index fc99427bb..6d14c925b 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamfieldnamepolicy.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamfieldnamepolicy.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Stream Field Name Policy" sidebar-title: "RelayAtofStreamFieldNamePolicy" description: "Relay ATOF stream field-name policy." -position: 69 +position: 72 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamtransport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamtransport.mdx index f16aae436..e418962b6 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamtransport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamtransport.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Stream Transport" sidebar-title: "RelayAtofStreamTransport" description: "Relay ATOF stream transport." -position: 70 +position: 73 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx index 089545d98..1b35c1708 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Otlp Transport" sidebar-title: "RelayOtlpTransport" description: "Relay OTLP transport." -position: 71 +position: 74 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx index 0394305d5..dc29ff649 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Unsupported Behavior" sidebar-title: "RelayUnsupportedBehavior" description: "Relay unsupported/unknown config handling." -position: 72 +position: 75 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx index 7e3600dfe..249bf01cc 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx @@ -2,7 +2,7 @@ title: "Enum Resolution Strategy" sidebar-title: "ResolutionStrategy" description: "Adapter install or availability strategy." -position: 47 +position: 50 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx index e4dea73cd..9e9d6572d 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx @@ -2,7 +2,7 @@ title: "Enum Telemetry Provider" sidebar-title: "TelemetryProvider" description: "Telemetry runtime provider." -position: 56 +position: 59 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx index dde905ba4..fe88fabd3 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx @@ -2,7 +2,7 @@ title: "Function load_adapter_descriptor" sidebar-title: "load_adapter_descriptor" description: "Load an adapter descriptor from JSON package metadata." -position: 62 +position: 65 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx index 60c16b47c..7fd6e4ae2 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx @@ -2,7 +2,7 @@ title: "Function resolve_run_plan_from_config" sidebar-title: "resolve_run_plan_from_config" description: "Resolve a typed NVIDIA NeMo Fabric config into a runnable plan." -position: 63 +position: 66 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx index 497053954..eeb456ebb 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx @@ -2,7 +2,7 @@ title: "Module config" sidebar-title: "config" description: "NeMo Fabric config models and loading helpers." -position: 98 +position: 101 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -83,7 +83,10 @@ NeMo Fabric config models and loading helpers. - [ControlLocation](enum-controllocation.mdx): Where NeMo Fabric control code runs relative to the environment. - [EnvironmentOwnership](enum-environmentownership.mdx): Whether NeMo Fabric owns the underlying environment resource. - [InstructionMode](enum-instructionmode.mdx): How an instruction value is applied to the selected harness. +- [McpAuthenticationConfig](enum-mcpauthenticationconfig.mdx): MCP server authentication configuration. - [McpExposure](enum-mcpexposure.mdx): MCP exposure strategy. +- [McpTransport](enum-mcptransport.mdx): MCP server transport. +- [OAuthTokenEndpointAuthMethod](enum-oauthtokenendpointauthmethod.mdx): OAuth client authentication method used at the token endpoint. - [RelayAtifStorageConfig](enum-relayatifstorageconfig.mdx): Relay ATIF remote storage configuration. - [RelayAtofMode](enum-relayatofmode.mdx): Relay ATOF file mode. - [RelayAtofSinkConfig](enum-relayatofsinkconfig.mdx): Relay ATOF sink configuration. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpconfig.mdx index afd3fe7e4..226348ed2 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpconfig.mdx @@ -2,7 +2,7 @@ title: "Struct McpConfig" sidebar-title: "McpConfig" description: "MCP capability configuration." -position: 42 +position: 43 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverconfig.mdx index bf85c1e85..ca5b99ae7 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverconfig.mdx @@ -9,19 +9,19 @@ SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p nemo-fabric-core`. -
String,\n    pub url: String,\n    pub args: Vec<String>,\n    pub env: BTreeMap<String, String>,\n    pub exposure: McpExposure,\n    pub allowed_tools: Option<Vec<String>>,\n    pub blocked_tools: Vec<String>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
McpTransport,\n    pub url: String,\n    pub args: Vec<String>,\n    pub env: BTreeMap<String, String>,\n    pub authentication: Option<McpAuthenticationConfig>,\n    pub exposure: McpExposure,\n    pub allowed_tools: Option<Vec<String>>,\n    pub blocked_tools: Vec<String>,\n    pub extensions: BTreeMap<String, Value>,\n    pub custom_headers: BTreeMap<String, String>,\n}"}} />
MCP server configuration. ## Fields -### `transport: String` +### `transport: McpTransport` MCP transport. ### `url: String` -MCP server URL for network transports or executable for stdio. +MCP server URL or process command (when transport=stdio), depending on transport. ### `args: Vec` @@ -31,6 +31,10 @@ Command-line arguments passed to an MCP stdio server process. Environment variables passed to an MCP stdio server process. +### `authentication: Option` + +Authentication used by an HTTP MCP server. + ### `exposure: McpExposure` How NeMo Fabric exposes the MCP capability to the harness. @@ -47,6 +51,10 @@ MCP tool names to block after applying the optional allowlist. Additive MCP server fields. +### `custom_headers: BTreeMap` + +HTTP headers passed to an MCP server when transport is `sse` or `streamable_http`. + ## Trait Implementations ### `impl Clone for McpServerConfig` diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverplan.mdx index 258caf561..042517cf4 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-mcpserverplan.mdx @@ -2,20 +2,20 @@ title: "Struct McpServer Plan" sidebar-title: "McpServerPlan" description: "Resolved MCP server exposure." -position: 44 +position: 45 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p nemo-fabric-core`. -
String,\n    pub url: String,\n    pub args: Vec<String>,\n    pub env: BTreeMap<String, String>,\n    pub exposure: McpExposure,\n    pub extensions: BTreeMap<String, Value>,\n    pub allowed_tools: Option<Vec<String>>,\n    pub blocked_tools: Vec<String>,\n}"}} />
+
McpTransport,\n    pub url: String,\n    pub args: Vec<String>,\n    pub env: BTreeMap<String, String>,\n    pub authentication: Option<McpAuthenticationConfig>,\n    pub custom_headers: BTreeMap<String, String>,\n    pub exposure: McpExposure,\n    pub extensions: BTreeMap<String, Value>,\n    pub allowed_tools: Option<Vec<String>>,\n    pub blocked_tools: Vec<String>,\n}"}} />
Resolved MCP server exposure. ## Fields -### `transport: String` +### `transport: McpTransport` MCP transport. @@ -31,6 +31,14 @@ Command-line arguments passed to an MCP stdio server process. Environment variables passed to an MCP stdio server process. +### `authentication: Option` + +Authentication used by an HTTP MCP server. + +### `custom_headers: BTreeMap` + +HTTP headers passed to an MCP server. + ### `exposure: McpExposure` Exposure strategy. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-metadataconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-metadataconfig.mdx index fea6fcdb3..5d06d4010 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-metadataconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-metadataconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Metadata Config" sidebar-title: "MetadataConfig" description: "Human-readable metadata." -position: 45 +position: 47 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-modelconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-modelconfig.mdx index 1c582c869..1ffd2df63 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-modelconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-modelconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Model Config" sidebar-title: "ModelConfig" description: "Model configuration." -position: 46 +position: 48 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx index fcb32d2a5..db7782610 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx @@ -2,7 +2,7 @@ title: "Struct Resolve Context" sidebar-title: "ResolveContext" description: "Source context used when resolving an in-memory NeMo Fabric config." -position: 48 +position: 51 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx index a9ca7c0e2..5be344365 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx @@ -2,7 +2,7 @@ title: "Struct Resolved Adapter Descriptor" sidebar-title: "ResolvedAdapterDescriptor" description: "Adapter descriptor selected for a run plan." -position: 49 +position: 52 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx index 9febefd6c..16d5f17a3 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx @@ -2,7 +2,7 @@ title: "Struct RunPlan" sidebar-title: "RunPlan" description: "Resolved NeMo Fabric run plan." -position: 50 +position: 53 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx index bbfb9adcd..10d72676c 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx @@ -2,7 +2,7 @@ title: "Struct Runtime Capabilities" sidebar-title: "RuntimeCapabilities" description: "Lifecycle behavior implemented by a resolved runtime path." -position: 51 +position: 54 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx index be51cdd77..240987760 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Runtime Config" sidebar-title: "RuntimeConfig" description: "Invocation runtime contract." -position: 52 +position: 55 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx index 5a647a619..8433f4a42 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Skill Config" sidebar-title: "SkillConfig" description: "Skill capability configuration." -position: 53 +position: 56 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx index 249a6d537..04a9e62bb 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Telemetry Config" sidebar-title: "TelemetryConfig" description: "Telemetry configuration." -position: 54 +position: 57 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx index adf235ded..a3342350a 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx @@ -2,7 +2,7 @@ title: "Struct Telemetry Plan" sidebar-title: "TelemetryPlan" description: "Resolved telemetry plan." -position: 55 +position: 58 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx index cb7693c40..1d87de5e2 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Telemetry Provider Config" sidebar-title: "TelemetryProviderConfig" description: "Provider-specific telemetry configuration." -position: 57 +position: 60 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-tooldefinitionconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-tooldefinitionconfig.mdx index 0c7f3c1ce..7388ea8ac 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-tooldefinitionconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-tooldefinitionconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Tool Definition Config" sidebar-title: "ToolDefinitionConfig" description: "One named normalized tool or tool-group definition." -position: 58 +position: 61 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx index 85ad664e8..1c186a136 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Tools Config" sidebar-title: "ToolsConfig" description: "Harness-neutral tool capability configuration." -position: 59 +position: 62 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowconfig.mdx index be06a73b4..a8ddd312a 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Workflow Config" sidebar-title: "WorkflowConfig" description: "Adapter-owned workflow selection and immutable construction settings." -position: 60 +position: 63 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowentrypointconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowentrypointconfig.mdx index 4a0770415..e47b7ebe3 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowentrypointconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowentrypointconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Workflow Entrypoint Config" sidebar-title: "WorkflowEntrypointConfig" description: "Adapter-owned workflow entry point." -position: 61 +position: 64 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx index 58c232644..1aa89af27 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx @@ -2,7 +2,7 @@ title: "Enum Doctor Status" sidebar-title: "DoctorStatus" description: "Diagnostic status." -position: 66 +position: 69 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx index 8f44b9bec..8c3633634 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx @@ -2,7 +2,7 @@ title: "Function doctor_plan" sidebar-title: "doctor_plan" description: "Inspect a resolved run plan without mutating the environment." -position: 67 +position: 70 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx index 5a4d351ad..bbd8ea2dd 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx @@ -2,7 +2,7 @@ title: "Module doctor" sidebar-title: "doctor" description: "Plan diagnostics for NeMo Fabric." -position: 99 +position: 102 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx index 0027cf075..061b24fc4 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx @@ -2,7 +2,7 @@ title: "Struct Doctor Check" sidebar-title: "DoctorCheck" description: "Diagnostic check result." -position: 64 +position: 67 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx index b75cfae4c..9eeb13aeb 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx @@ -2,7 +2,7 @@ title: "Struct Doctor Report" sidebar-title: "DoctorReport" description: "Diagnostic report for a resolved run plan." -position: 65 +position: 68 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx index 993c5e96e..86d107008 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx @@ -2,7 +2,7 @@ title: "Enum Fabric Error" sidebar-title: "FabricError" description: "Errors raised by NeMo Fabric config loading and validation." -position: 68 +position: 71 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx index 369e30a0d..6595af5cb 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx @@ -2,7 +2,7 @@ title: "Module error" sidebar-title: "error" description: "Error types for NeMo Fabric core." -position: 100 +position: 103 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx index d99e18d4a..389202d9e 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx @@ -2,7 +2,7 @@ title: "Type Alias Result" sidebar-title: "Result" description: "Core NeMo Fabric result type." -position: 69 +position: 72 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx index 0605b0b15..8c68be830 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx @@ -2,7 +2,7 @@ title: "Function version" sidebar-title: "version" description: "Returns the crate version compiled into this build." -position: 103 +position: 106 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx index 9a7468ac9..88a6ce7f9 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx @@ -54,11 +54,14 @@ Core config and runtime contract for NeMo Fabric. - `pub use config::InstructionConfig;` - `pub use config::InstructionMode;` - `pub use config::InstructionsConfig;` +- `pub use config::McpAuthenticationConfig;` - `pub use config::McpConfig;` - `pub use config::McpExposure;` - `pub use config::McpServerPlan;` +- `pub use config::McpTransport;` - `pub use config::MetadataConfig;` - `pub use config::ModelConfig;` +- `pub use config::OAuthTokenEndpointAuthMethod;` - `pub use config::ResolutionStrategy;` - `pub use config::ResolveContext;` - `pub use config::ResolvedAdapterDescriptor;` diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx index fd6af6635..4b08ebab0 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx @@ -2,7 +2,7 @@ title: "Module runtime" sidebar-title: "runtime" description: "Runtime invocation helpers." -position: 101 +position: 104 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx index 7a1bf0fb5..ff020abed 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx @@ -2,7 +2,7 @@ title: "Module schema" sidebar-title: "schema" description: "JSON Schema generation for the public NeMo Fabric contract." -position: 102 +position: 105 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/python/src/nemo_fabric/__init__.py b/python/src/nemo_fabric/__init__.py index 1183f4016..2f676f3e3 100644 --- a/python/src/nemo_fabric/__init__.py +++ b/python/src/nemo_fabric/__init__.py @@ -16,6 +16,7 @@ from nemo_fabric.models import HarnessConfig from nemo_fabric.models import InstructionConfig from nemo_fabric.models import InstructionsConfig +from nemo_fabric.models import McpAuthenticationConfig from nemo_fabric.models import McpConfig from nemo_fabric.models import McpServerConfig from nemo_fabric.models import MetadataConfig @@ -76,6 +77,7 @@ "InstructionConfig", "InstructionsConfig", "InvokeStream", + "McpAuthenticationConfig", "McpConfig", "McpServerConfig", "MetadataConfig", diff --git a/python/src/nemo_fabric/models.py b/python/src/nemo_fabric/models.py index e1f4a387a..30cc140c2 100644 --- a/python/src/nemo_fabric/models.py +++ b/python/src/nemo_fabric/models.py @@ -292,10 +292,125 @@ def remove_path(self, path: str | Path) -> Self: return self +class McpAuthenticationConfig(FabricBaseModel): + """MCP server authentication configuration.""" + + type: Literal["oauth2", "service_account"] + client_id: str | None = None + client_secret_env: str | None = None + scopes: list[str] = Field( + default_factory=list, exclude_if=lambda value: not value + ) + redirect_uri: str | None = None + enable_dynamic_registration: bool = Field( + default=True, exclude_if=lambda value: value + ) + client_name: str | None = None + token_endpoint_auth_method: ( + Literal["none", "client_secret_post", "client_secret_basic"] | None + ) = None + authorization_timeout_seconds: int = Field( + default=300, gt=0, exclude_if=lambda value: value == 300 + ) + token_url: str | None = None + token_cache_buffer_seconds: int = Field( + default=300, ge=0, exclude_if=lambda value: value == 300 + ) + + @field_validator( + "client_id", + "client_secret_env", + "redirect_uri", + "client_name", + "token_url", + ) + @classmethod + def _validate_optional_nonblank(cls, value: str | None) -> str | None: + if value is not None and not value.strip(): + raise ValueError("authentication values must not be empty") + return value + + @field_validator("scopes") + @classmethod + def _validate_scopes(cls, value: list[str]) -> list[str]: + if any(not scope.strip() for scope in value): + raise ValueError("authentication scopes must not be empty") + return value + + @model_validator(mode="after") + def _validate_authentication_type(self) -> Self: + if self.type == "oauth2": + if self.client_secret_env and not self.client_id: + raise ValueError("client_secret_env requires client_id") + if not self.client_id and not self.enable_dynamic_registration: + raise ValueError( + "oauth2 authentication requires client_id when dynamic registration is disabled" + ) + if self.token_url is not None: + raise ValueError( + "token_url is only valid for service_account authentication" + ) + if self.token_cache_buffer_seconds != 300: + raise ValueError( + "token_cache_buffer_seconds is only valid for service_account authentication" + ) + if ( + self.token_endpoint_auth_method + in { + "client_secret_basic", + "client_secret_post", + } + and self.client_id is not None + and not self.client_secret_env + ): + raise ValueError( + "token_endpoint_auth_method requires client_secret_env for a pre-registered client" + ) + if ( + self.token_endpoint_auth_method == "none" + and self.client_secret_env is not None + ): + raise ValueError( + "token_endpoint_auth_method 'none' cannot use client_secret_env" + ) + return self + + missing = [ + name + for name, value in ( + ("client_id", self.client_id), + ("client_secret_env", self.client_secret_env), + ("token_url", self.token_url), + ) + if not value + ] + if missing: + raise ValueError( + "service_account authentication requires " + ", ".join(missing) + ) + if self.redirect_uri is not None: + raise ValueError("redirect_uri is only valid for oauth2 authentication") + if self.client_name is not None: + raise ValueError("client_name is only valid for oauth2 authentication") + if self.authorization_timeout_seconds != 300: + raise ValueError( + "authorization_timeout_seconds is only valid for oauth2 authentication" + ) + if not self.enable_dynamic_registration: + raise ValueError( + "enable_dynamic_registration is only valid for oauth2 authentication" + ) + if self.token_endpoint_auth_method == "none": + raise ValueError( + "service_account authentication requires client_secret_basic or client_secret_post" + ) + return self + + class McpServerConfig(FabricBaseModel): """MCP server configuration.""" - transport: str = Field(min_length=1) + transport: Literal["stdio", "sse", "streamable-http"] url: str = Field( min_length=1, description=( @@ -308,6 +423,17 @@ class McpServerConfig(FabricBaseModel): description="Command-line arguments passed to an MCP stdio server process.", ) env: dict[str, str] = Field(default_factory=dict, exclude_if=lambda value: not value) + authentication: McpAuthenticationConfig | None = Field( + default=None, exclude_if=lambda value: value is None + ) + custom_headers: dict[str, str] = Field( + default_factory=dict, + exclude_if=lambda value: not value, + description=( + "HTTP headers passed to an MCP server when transport is sse or " + "streamable-http." + ), + ) exposure: Literal["harness_native", "fabric_managed"] = "harness_native" allowed_tools: list[str] | None = Field( default=None, @@ -370,6 +496,8 @@ def add_server( url: str, args: Sequence[str] | None = None, env: Mapping[str, str] | None = None, + authentication: McpAuthenticationConfig | None = None, + custom_headers: Mapping[str, str] | None = None, exposure: Literal["harness_native", "fabric_managed"] = "harness_native", allowed_tools: Sequence[str] | None = None, blocked_tools: Sequence[str] = (), @@ -384,6 +512,8 @@ def add_server( extensions = dict(extra_fields or {}) legacy_args = extensions.pop("args", ()) legacy_env = extensions.pop("env", None) + legacy_authentication = extensions.pop("authentication", None) + legacy_custom_headers = extensions.pop("custom_headers", None) if isinstance(allowed_tools, str): raise TypeError("allowed_tools must be a sequence of strings, not a string") if isinstance(blocked_tools, str): @@ -394,6 +524,14 @@ def add_server( url=url, args=list(args if args is not None else legacy_args), env=env if env is not None else legacy_env or {}, + authentication=( + authentication if authentication is not None else legacy_authentication + ), + custom_headers=( + custom_headers + if custom_headers is not None + else legacy_custom_headers or {} + ), exposure=exposure, allowed_tools=None if allowed_tools is None else list(allowed_tools), blocked_tools=list(blocked_tools), @@ -729,6 +867,8 @@ def add_mcp_server( url: str, args: Sequence[str] | None = None, env: Mapping[str, str] | None = None, + authentication: McpAuthenticationConfig | None = None, + custom_headers: Mapping[str, str] | None = None, exposure: Literal["harness_native", "fabric_managed"] = "harness_native", allowed_tools: Sequence[str] | None = None, blocked_tools: Sequence[str] = (), @@ -748,6 +888,8 @@ def add_mcp_server( url=url, args=args, env=env, + authentication=authentication, + custom_headers=custom_headers, exposure=exposure, allowed_tools=allowed_tools, blocked_tools=blocked_tools, diff --git a/python/src/nemo_fabric/types.py b/python/src/nemo_fabric/types.py index 258423ecf..3dead1f7d 100644 --- a/python/src/nemo_fabric/types.py +++ b/python/src/nemo_fabric/types.py @@ -719,6 +719,7 @@ def add_server( *, transport: str, url: str, + authentication: Mapping[str, Any] | None = None, exposure: str = "harness_native", allowed_tools: Sequence[str] | None = None, blocked_tools: Sequence[str] = (), @@ -766,12 +767,20 @@ def add_server( {} if extra_fields is None else extra_fields, "mcp server extra_fields", ) + legacy_authentication = extensions.pop("authentication", None) reserved = self._SERVER_FIELDS.intersection(extensions) if reserved: field = sorted(reserved)[0] raise FabricConfigError( f"mcp server extra_fields must not contain reserved field {field!r}" ) + authentication_value = ( + authentication if authentication is not None else legacy_authentication + ) + if authentication_value is not None: + server["authentication"] = _mapping( + authentication_value, "mcp server authentication" + ) server.update(extensions) servers = dict(self.get("servers", {})) servers[_required_text(name, "mcp server name")] = server @@ -1051,6 +1060,7 @@ def add_mcp_server( *, transport: str, url: str, + authentication: Mapping[str, Any] | None = None, exposure: str = "harness_native", allowed_tools: Sequence[str] | None = None, blocked_tools: Sequence[str] = (), @@ -1062,6 +1072,7 @@ def add_mcp_server( name, transport=transport, url=url, + authentication=authentication, exposure=exposure, allowed_tools=allowed_tools, blocked_tools=blocked_tools, diff --git a/schemas/adapter-contract/agent-config.schema.json b/schemas/adapter-contract/agent-config.schema.json index 2aad9230f..1d0f0cc4d 100644 --- a/schemas/adapter-contract/agent-config.schema.json +++ b/schemas/adapter-contract/agent-config.schema.json @@ -106,6 +106,14 @@ }, "type": "array" }, + "authentication": { + "additionalProperties": true, + "description": "Authentication used by an HTTP MCP server.", + "type": [ + "object", + "null" + ] + }, "blocked_tools": { "description": "MCP tool names blocked after applying the optional allowlist.", "items": { @@ -113,6 +121,13 @@ }, "type": "array" }, + "custom_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "HTTP headers passed to an MCP server.", + "type": "object" + }, "env": { "additionalProperties": { "type": "string" diff --git a/schemas/agent.schema.json b/schemas/agent.schema.json index c36304df5..1b116d99d 100644 --- a/schemas/agent.schema.json +++ b/schemas/agent.schema.json @@ -172,6 +172,134 @@ }, "type": "object" }, + "McpAuthenticationConfig": { + "description": "MCP server authentication configuration.", + "oneOf": [ + { + "additionalProperties": false, + "description": "OAuth 2.0 authorization-code authentication.", + "properties": { + "authorization_timeout_seconds": { + "description": "Maximum time to wait for interactive authorization.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "client_id": { + "description": "Pre-registered OAuth client identifier. Omit to allow dynamic registration.", + "type": [ + "string", + "null" + ] + }, + "client_name": { + "description": "Client name advertised during dynamic registration.", + "type": [ + "string", + "null" + ] + }, + "client_secret_env": { + "description": "Environment variable containing the OAuth client secret.", + "type": [ + "string", + "null" + ] + }, + "enable_dynamic_registration": { + "description": "Whether the client may register dynamically when `client_id` is omitted.", + "type": "boolean" + }, + "redirect_uri": { + "description": "OAuth callback URI for clients that require a pre-registered redirect URI.", + "type": [ + "string", + "null" + ] + }, + "scopes": { + "description": "OAuth scopes requested by the MCP client.", + "items": { + "type": "string" + }, + "type": "array" + }, + "token_endpoint_auth_method": { + "anyOf": [ + { + "$ref": "#/$defs/OAuthTokenEndpointAuthMethod" + }, + { + "type": "null" + } + ], + "description": "Client authentication method used at the token endpoint." + }, + "type": { + "const": "oauth2", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "OAuth 2.0 client-credentials authentication for headless workloads.", + "properties": { + "client_id": { + "description": "OAuth client identifier.", + "type": "string" + }, + "client_secret_env": { + "description": "Environment variable containing the OAuth client secret.", + "type": "string" + }, + "scopes": { + "description": "OAuth scopes requested by the MCP client.", + "items": { + "type": "string" + }, + "type": "array" + }, + "token_cache_buffer_seconds": { + "description": "Refresh the cached token this many seconds before expiry.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "token_endpoint_auth_method": { + "anyOf": [ + { + "$ref": "#/$defs/OAuthTokenEndpointAuthMethod" + }, + { + "type": "null" + } + ], + "description": "Client authentication method used at the token endpoint." + }, + "token_url": { + "description": "OAuth token endpoint.", + "type": "string" + }, + "type": { + "const": "service_account", + "type": "string" + } + }, + "required": [ + "type", + "client_id", + "client_secret_env", + "token_url" + ], + "type": "object" + } + ] + }, "McpConfig": { "additionalProperties": true, "description": "MCP capability configuration.", @@ -222,6 +350,17 @@ }, "type": "array" }, + "authentication": { + "anyOf": [ + { + "$ref": "#/$defs/McpAuthenticationConfig" + }, + { + "type": "null" + } + ], + "description": "Authentication used by an HTTP MCP server." + }, "blocked_tools": { "description": "MCP tool names to block after applying the optional allowlist.", "items": { @@ -229,6 +368,13 @@ }, "type": "array" }, + "custom_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "HTTP headers passed to an MCP server when transport is `sse` or `streamable_http`.", + "type": "object" + }, "env": { "additionalProperties": { "type": "string" @@ -241,11 +387,11 @@ "description": "How NeMo Fabric exposes the MCP capability to the harness." }, "transport": { - "description": "MCP transport.", - "type": "string" + "$ref": "#/$defs/McpTransport", + "description": "MCP transport." }, "url": { - "description": "MCP server URL for network transports or executable for stdio.", + "description": "MCP server URL or process command (when transport=stdio), depending on transport.", "type": "string" } }, @@ -256,6 +402,26 @@ ], "type": "object" }, + "McpTransport": { + "description": "MCP server transport.", + "oneOf": [ + { + "const": "stdio", + "description": "Standard input/output transport.", + "type": "string" + }, + { + "const": "sse", + "description": "Server-Sent Events transport.", + "type": "string" + }, + { + "const": "streamable-http", + "description": "Streamable HTTP transport.", + "type": "string" + } + ] + }, "MetadataConfig": { "additionalProperties": true, "description": "Human-readable metadata.", @@ -323,6 +489,26 @@ ], "type": "object" }, + "OAuthTokenEndpointAuthMethod": { + "description": "OAuth client authentication method used at the token endpoint.", + "oneOf": [ + { + "const": "none", + "description": "Public client without a client secret.", + "type": "string" + }, + { + "const": "client_secret_post", + "description": "Send the client secret in the token request body.", + "type": "string" + }, + { + "const": "client_secret_basic", + "description": "Send the client credentials with HTTP Basic authentication.", + "type": "string" + } + ] + }, "RelayAtifConfig": { "additionalProperties": true, "description": "Relay ATIF export configuration.", diff --git a/schemas/run-plan.schema.json b/schemas/run-plan.schema.json index 452f14fda..399225601 100644 --- a/schemas/run-plan.schema.json +++ b/schemas/run-plan.schema.json @@ -538,6 +538,14 @@ }, "type": "array" }, + "authentication": { + "additionalProperties": true, + "description": "Authentication used by an HTTP MCP server.", + "type": [ + "object", + "null" + ] + }, "blocked_tools": { "description": "MCP tool names blocked after applying the optional allowlist.", "items": { @@ -545,6 +553,13 @@ }, "type": "array" }, + "custom_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "HTTP headers passed to an MCP server.", + "type": "object" + }, "env": { "additionalProperties": { "type": "string" @@ -1297,6 +1312,134 @@ }, "type": "object" }, + "McpAuthenticationConfig": { + "description": "MCP server authentication configuration.", + "oneOf": [ + { + "additionalProperties": false, + "description": "OAuth 2.0 authorization-code authentication.", + "properties": { + "authorization_timeout_seconds": { + "description": "Maximum time to wait for interactive authorization.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "client_id": { + "description": "Pre-registered OAuth client identifier. Omit to allow dynamic registration.", + "type": [ + "string", + "null" + ] + }, + "client_name": { + "description": "Client name advertised during dynamic registration.", + "type": [ + "string", + "null" + ] + }, + "client_secret_env": { + "description": "Environment variable containing the OAuth client secret.", + "type": [ + "string", + "null" + ] + }, + "enable_dynamic_registration": { + "description": "Whether the client may register dynamically when `client_id` is omitted.", + "type": "boolean" + }, + "redirect_uri": { + "description": "OAuth callback URI for clients that require a pre-registered redirect URI.", + "type": [ + "string", + "null" + ] + }, + "scopes": { + "description": "OAuth scopes requested by the MCP client.", + "items": { + "type": "string" + }, + "type": "array" + }, + "token_endpoint_auth_method": { + "anyOf": [ + { + "$ref": "#/$defs/OAuthTokenEndpointAuthMethod" + }, + { + "type": "null" + } + ], + "description": "Client authentication method used at the token endpoint." + }, + "type": { + "const": "oauth2", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "OAuth 2.0 client-credentials authentication for headless workloads.", + "properties": { + "client_id": { + "description": "OAuth client identifier.", + "type": "string" + }, + "client_secret_env": { + "description": "Environment variable containing the OAuth client secret.", + "type": "string" + }, + "scopes": { + "description": "OAuth scopes requested by the MCP client.", + "items": { + "type": "string" + }, + "type": "array" + }, + "token_cache_buffer_seconds": { + "description": "Refresh the cached token this many seconds before expiry.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "token_endpoint_auth_method": { + "anyOf": [ + { + "$ref": "#/$defs/OAuthTokenEndpointAuthMethod" + }, + { + "type": "null" + } + ], + "description": "Client authentication method used at the token endpoint." + }, + "token_url": { + "description": "OAuth token endpoint.", + "type": "string" + }, + "type": { + "const": "service_account", + "type": "string" + } + }, + "required": [ + "type", + "client_id", + "client_secret_env", + "token_url" + ], + "type": "object" + } + ] + }, "McpConfig": { "additionalProperties": true, "description": "MCP capability configuration.", @@ -1347,6 +1490,17 @@ }, "type": "array" }, + "authentication": { + "anyOf": [ + { + "$ref": "#/$defs/McpAuthenticationConfig" + }, + { + "type": "null" + } + ], + "description": "Authentication used by an HTTP MCP server." + }, "blocked_tools": { "description": "MCP tool names to block after applying the optional allowlist.", "items": { @@ -1354,6 +1508,13 @@ }, "type": "array" }, + "custom_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "HTTP headers passed to an MCP server when transport is `sse` or `streamable_http`.", + "type": "object" + }, "env": { "additionalProperties": { "type": "string" @@ -1366,11 +1527,11 @@ "description": "How NeMo Fabric exposes the MCP capability to the harness." }, "transport": { - "description": "MCP transport.", - "type": "string" + "$ref": "#/$defs/McpTransport", + "description": "MCP transport." }, "url": { - "description": "MCP server URL for network transports or executable for stdio.", + "description": "MCP server URL or process command (when transport=stdio), depending on transport.", "type": "string" } }, @@ -1402,6 +1563,17 @@ }, "type": "array" }, + "authentication": { + "anyOf": [ + { + "$ref": "#/$defs/McpAuthenticationConfig" + }, + { + "type": "null" + } + ], + "description": "Authentication used by an HTTP MCP server." + }, "blocked_tools": { "description": "MCP tool names to block after applying the optional allowlist.", "items": { @@ -1409,6 +1581,13 @@ }, "type": "array" }, + "custom_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "HTTP headers passed to an MCP server.", + "type": "object" + }, "env": { "additionalProperties": { "type": "string" @@ -1421,8 +1600,8 @@ "description": "Exposure strategy." }, "transport": { - "description": "MCP transport.", - "type": "string" + "$ref": "#/$defs/McpTransport", + "description": "MCP transport." }, "url": { "description": "MCP server URL for network transports or executable for stdio.", @@ -1436,6 +1615,26 @@ ], "type": "object" }, + "McpTransport": { + "description": "MCP server transport.", + "oneOf": [ + { + "const": "stdio", + "description": "Standard input/output transport.", + "type": "string" + }, + { + "const": "sse", + "description": "Server-Sent Events transport.", + "type": "string" + }, + { + "const": "streamable-http", + "description": "Streamable HTTP transport.", + "type": "string" + } + ] + }, "MetadataConfig": { "additionalProperties": true, "description": "Human-readable metadata.", @@ -1503,6 +1702,26 @@ ], "type": "object" }, + "OAuthTokenEndpointAuthMethod": { + "description": "OAuth client authentication method used at the token endpoint.", + "oneOf": [ + { + "const": "none", + "description": "Public client without a client secret.", + "type": "string" + }, + { + "const": "client_secret_post", + "description": "Send the client secret in the token request body.", + "type": "string" + }, + { + "const": "client_secret_basic", + "description": "Send the client credentials with HTTP Basic authentication.", + "type": "string" + } + ] + }, "RelayAtifConfig": { "additionalProperties": true, "description": "Relay ATIF export configuration.", diff --git a/skills/nemo-fabric-integrate/references/config-mapping.md b/skills/nemo-fabric-integrate/references/config-mapping.md index d2a92e262..dda3a4510 100644 --- a/skills/nemo-fabric-integrate/references/config-mapping.md +++ b/skills/nemo-fabric-integrate/references/config-mapping.md @@ -40,7 +40,7 @@ Construct the nested config directly, then adjust capabilities with helper methods that edit the typed config in place and return it: - `add_skill_path(path)` / `remove_skill_path(path)` -- `add_mcp_server(name, *, transport, url, args, env, exposure, allowed_tools, blocked_tools, ...)` / `remove_mcp_server(name)` +- `add_mcp_server(name, *, transport, url, args, env, authentication, custom_headers, exposure, allowed_tools, blocked_tools, ...)` / `remove_mcp_server(name)` - `enable_relay(...)` for NVIDIA NeMo Relay observability in the `relay` block - `ToolsConfig(enabled=..., blocked=...)` for tool policy - `add_tool_definition(name, kind=..., ref=..., settings=...)` / `remove_tool_definition(name)` diff --git a/tests/adapter_contract/test_agent_config.py b/tests/adapter_contract/test_agent_config.py index 461e3e5ba..737d6f91a 100644 --- a/tests/adapter_contract/test_agent_config.py +++ b/tests/adapter_contract/test_agent_config.py @@ -171,6 +171,30 @@ def test_agent_model_config_rejects_float_overflow(): ) +def test_agent_mcp_server_config_preserves_http_authentication(): + server = AgentMcpServerConfig.from_mapping( + { + "transport": "streamable-http", + "url": "https://mcp.example.test/mcp", + "authentication": { + "type": "oauth2", + "client_id": "fabric-client", + }, + "custom_headers": {"X-Tenant": "fabric"}, + } + ) + + assert server.to_mapping() == { + "transport": "streamable-http", + "url": "https://mcp.example.test/mcp", + "authentication": { + "type": "oauth2", + "client_id": "fabric-client", + }, + "custom_headers": {"X-Tenant": "fabric"}, + } + + def test_agent_config_model_tracks_rust_schema_root_fields(): rust_schema = json.loads( (ROOT / "schemas/adapter-contract/agent-config.schema.json").read_text( diff --git a/tests/adapters/test_adapter_package_metadata.py b/tests/adapters/test_adapter_package_metadata.py index 3b35130cb..c5f241f0a 100644 --- a/tests/adapters/test_adapter_package_metadata.py +++ b/tests/adapters/test_adapter_package_metadata.py @@ -78,7 +78,7 @@ def load_pyproject(path: str) -> dict: ( "adapters/deepagents", [ - f"nemo-fabric-adapters-common == {PACKAGE_VERSION}", + f"nemo-fabric-adapters-common[mcp-oauth] == {PACKAGE_VERSION}", "langchain-mcp-adapters>=0.1,<0.3.0", "langchain-openai>=0.3", "langgraph-checkpoint-sqlite>=3.0,<4.0", @@ -99,12 +99,15 @@ def test_adapter_runtime_dependencies(path: str, expected: list[str]): assert sorted(project.get("dependencies", [])) == sorted(expected) +def test_common_mcp_oauth_extra_declares_protocol_dependencies(): + extras = load_pyproject("adapters/common")["project"]["optional-dependencies"] + assert extras == {"mcp-oauth": ["httpx>=0.27,<1", "mcp>=1.26,<1.29"]} + def test_adapter_contract_offers_optional_pydantic_interop(): extras = load_pyproject("adapter-contract")["project"]["optional-dependencies"] assert extras == {"pydantic": ["pydantic>=2.12,<3"]} - def test_adapter_test_dependency_group_matches_leaf_harnesses(): manifest = load_pyproject("") expected = [ diff --git a/tests/adapters/test_adapters_common_mcp_auth.py b/tests/adapters/test_adapters_common_mcp_auth.py new file mode 100644 index 000000000..e8d44cb86 --- /dev/null +++ b/tests/adapters/test_adapters_common_mcp_auth.py @@ -0,0 +1,484 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import os +import socket +from types import SimpleNamespace +from urllib.parse import urlparse +from unittest.mock import AsyncMock +from unittest.mock import MagicMock + +import pytest +import httpx +from nemo_fabric_adapters.common import mcp_auth + + +def test_parse_oauth2_config_normalizes_fields(): + config = mcp_auth.parse_oauth2_config( + "docs", + { + "type": "oauth2", + "client_id": "fabric-client", + "client_secret_env": "FABRIC_MCP_CLIENT_SECRET", + "scopes": ["read", "write"], + "redirect_uri": "http://127.0.0.1:8765/callback", + }, + ) + + assert config == mcp_auth.McpOAuth2Config( + client_id="fabric-client", + client_secret_env="FABRIC_MCP_CLIENT_SECRET", + scopes=("read", "write"), + redirect_uri="http://127.0.0.1:8765/callback", + ) + assert config.scope == "read write" + + +def test_parse_oauth2_config_allows_dynamic_registration_to_supply_client_secret(): + config = mcp_auth.parse_oauth2_config( + "docs", + { + "type": "oauth2", + "token_endpoint_auth_method": "client_secret_post", + }, + ) + + assert config.client_id is None + assert config.client_secret_env is None + assert config.enable_dynamic_registration is True + assert config.token_endpoint_auth_method == "client_secret_post" + + +@pytest.mark.parametrize( + ("provider", "expected"), + [ + ( + SimpleNamespace( + context=SimpleNamespace( + current_tokens=SimpleNamespace(access_token="fabric-token") + ) + ), + "fabric-token", + ), + (SimpleNamespace(context=SimpleNamespace(current_tokens=None)), None), + (SimpleNamespace(context=None), None), + (SimpleNamespace(), None), + ], +) +def test_access_token(provider, expected): + assert mcp_auth.access_token(provider) == expected + + +@pytest.mark.parametrize("value", [None, {}, {"type": "bearer"}]) +def test_parse_oauth2_config_rejects_unsupported_authentication(value): + with pytest.raises(mcp_auth.McpAuthConfigError, match="unsupported"): + mcp_auth.parse_oauth2_config("docs", value) + + +def test_normalize_custom_headers_requires_mapping(): + with pytest.raises(mcp_auth.McpAuthConfigError, match="must be a mapping"): + mcp_auth.normalize_custom_headers("docs", ["X-Tenant", "fabric"]) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("X-Tenant", False), + ("X-Tenant\r", True), + ("X-Tenant\nvalue", True), + ("X-Tenant\r\nvalue", True), + ], +) +def test_contains_crlf(value, expected): + assert mcp_auth.contains_crlf(value) is expected + + +@pytest.mark.parametrize( + ("name", "value"), + [ + ("X-Foo\r", "bar"), + ("X-Foo\n", "bar"), + ("X-Foo", "bar\r"), + ("X-Foo", "bar\nX-Evil: injected"), + ], +) +def test_normalize_custom_headers_rejects_newlines(name, value): + with pytest.raises(mcp_auth.McpAuthConfigError) as error: + mcp_auth.normalize_custom_headers("docs", {name: value}) + + assert str(error.value) == ( + f"MCP server 'docs' custom_headers contain invalid characters in {name!r}" + ) + + +def test_normalize_custom_headers_expands_environment_variables(): + os.environ["FABRIC_HEADER_VALUE"] = "fabric" + + assert mcp_auth.normalize_custom_headers( + "docs", {"X-Tenant": "${FABRIC_HEADER_VALUE}"} + ) == {"X-Tenant": "fabric"} + + +def test_normalize_custom_headers_rejects_newlines_after_expansion(): + os.environ["FABRIC_HEADER_VALUE"] = "fabric\r\nX-Evil: injected" + + with pytest.raises(mcp_auth.McpAuthConfigError, match="invalid characters"): + mcp_auth.normalize_custom_headers( + "docs", {"X-Tenant": "${FABRIC_HEADER_VALUE}"} + ) + + +def test_resolve_client_secret_uses_named_environment_variable(): + os.environ["FABRIC_MCP_CLIENT_SECRET"] = "oauth-secret" + config = mcp_auth.McpOAuth2Config( + client_id="fabric-client", + client_secret_env="FABRIC_MCP_CLIENT_SECRET", + scopes=(), + redirect_uri=None, + ) + + assert ( + mcp_auth.resolve_client_secret("docs", config, require_client_id=True) + == "oauth-secret" + ) + + +def test_resolve_client_secret_requires_client_id_when_requested(): + config = mcp_auth.McpOAuth2Config( + client_id=None, + client_secret_env="FABRIC_MCP_CLIENT_SECRET", + scopes=(), + redirect_uri=None, + ) + + with pytest.raises(mcp_auth.McpAuthConfigError, match="requires client_id"): + mcp_auth.resolve_client_secret( + "docs", + config, + {"FABRIC_MCP_CLIENT_SECRET": "oauth-secret"}, + require_client_id=True, + ) + + +def test_resolve_client_secret_rejects_unset_environment_variable(): + config = mcp_auth.McpOAuth2Config( + client_id="fabric-client", + client_secret_env="FABRIC_MCP_CLIENT_SECRET", + scopes=(), + redirect_uri=None, + ) + + with pytest.raises(mcp_auth.McpAuthConfigError, match="unset"): + mcp_auth.resolve_client_secret("docs", config, {}) + + +@pytest.mark.parametrize( + "redirect_uri", + [ + "https://127.0.0.1:8765/callback", + "http://example.com:8765/callback", + "http://127.0.0.1/callback", + ], +) +def test_loopback_callback_port_rejects_unsupported_redirects(redirect_uri): + with pytest.raises(mcp_auth.McpAuthConfigError, match="loopback"): + mcp_auth.loopback_callback_port(redirect_uri) + + +@pytest.mark.parametrize( + "redirect_uri", + ["http://127.0.0.1:8765/callback", "http://localhost:8765/callback"], +) +def test_loopback_callback_port_accepts_ip_and_hostname(redirect_uri): + assert mcp_auth.loopback_callback_port(redirect_uri) == 8765 + + +@pytest.mark.parametrize("opened", [True, False]) +async def test_open_authorization_url_uses_shared_browser_helper(monkeypatch, opened): + open_browser = MagicMock(return_value=opened) + monkeypatch.setattr(mcp_auth.webbrowser, "open", open_browser) + to_thread = AsyncMock(return_value=opened) + monkeypatch.setattr(mcp_auth.asyncio, "to_thread", to_thread) + + assert await mcp_auth.open_authorization_url("https://auth.example.test") is opened + + to_thread.assert_awaited_once_with( + open_browser, + "https://auth.example.test", + ) + + +async def test_create_mcp_oauth_provider_maps_client_configuration(): + os.environ["FABRIC_MCP_CLIENT_SECRET"] = "oauth-secret" + config = mcp_auth.McpOAuth2Config( + client_id="fabric-client", + client_secret_env="FABRIC_MCP_CLIENT_SECRET", + scopes=("read", "write"), + redirect_uri="http://127.0.0.1:8765/callback", + client_name="Configured Client", + token_endpoint_auth_method="client_secret_basic", + authorization_timeout_seconds=42, + ) + + auth = mcp_auth.create_mcp_oauth_provider( + "jira", + "https://mcp.example.test/jira", + config, + client_name="NeMo Fabric Test", + ) + + assert str(auth.context.server_url) == "https://mcp.example.test/jira" + assert auth.context.client_metadata.client_name == "Configured Client" + assert auth.context.client_metadata.scope == "read write" + assert ( + auth.context.client_metadata.token_endpoint_auth_method == "client_secret_basic" + ) + assert auth.context.timeout == 42 + client_info = await auth.context.storage.get_client_info() + assert client_info.client_id == "fabric-client" + assert client_info.client_secret == "oauth-secret" + + +async def test_mcp_oauth_provider_starts_listener_before_authorization_handler(): + config = mcp_auth.McpOAuth2Config( + client_id=None, + client_secret_env=None, + scopes=(), + redirect_uri=None, + ) + callback_uri = "" + + async def authorization_handler(_name, _url): + parsed = urlparse(callback_uri) + reader, writer = await asyncio.open_connection(parsed.hostname, parsed.port) + writer.write( + f"GET {parsed.path}?code=oauth-code&state=oauth-state HTTP/1.1\r\n" + f"Host: {parsed.hostname}\r\n\r\n".encode() + ) + await writer.drain() + response = await reader.read() + writer.close() + await writer.wait_closed() + assert b"200 OK" in response + return True + + auth = mcp_auth.create_mcp_oauth_provider( + "docs", + "https://mcp.example.test/docs", + config, + client_name="NeMo Fabric Test", + authorization_url_handler=authorization_handler, + ) + callback_uri = str(auth.context.client_metadata.redirect_uris[0]) + + await auth.context.redirect_handler("https://auth.example.test/authorize") + assert await auth.context.callback_handler() == ( + "oauth-code", + "oauth-state", + ) + + +@pytest.mark.parametrize( + ("query", "expected"), + [ + ( + "error=access_denied&state=oauth-state", + "MCP OAuth authorization failed: 'access_denied'", + ), + ( + "error=access_denied&error_description=User+denied+access&state=oauth-state", + "MCP OAuth authorization failed: 'access_denied': 'User denied access'", + ), + ], +) +async def test_loopback_callback_reports_oauth_error(query, expected): + callback = mcp_auth._LoopbackOAuthCallback(None, timeout=1) + await callback.start() + parsed = urlparse(callback.redirect_uri) + reader, writer = await asyncio.open_connection(parsed.hostname, parsed.port) + writer.write( + f"GET {parsed.path}?{query} HTTP/1.1\r\n" + f"Host: {parsed.hostname}\r\n\r\n".encode() + ) + await writer.drain() + response = await reader.read() + writer.close() + await writer.wait_closed() + + assert b"400 Bad Request" in response + with pytest.raises(mcp_auth.McpAuthConfigError) as error: + await callback.wait() + + assert str(error.value) == expected + + +async def test_loopback_callback_times_out_closes_listener_and_cannot_restart(): + callback = mcp_auth._LoopbackOAuthCallback(None, timeout=0.01) + await callback.start() + + with pytest.raises(mcp_auth.McpAuthConfigError, match="timed out"): + await callback.wait() + + with pytest.raises(mcp_auth.McpAuthConfigError, match="cannot be restarted"): + await callback.start() + + +async def test_loopback_callback_listens_on_preferred_localhost_address(): + reservation = mcp_auth._LoopbackOAuthCallback(None, timeout=1) + port = urlparse(reservation.redirect_uri).port + reservation.close_reserved_socket() + assert port is not None + + callback = mcp_auth._LoopbackOAuthCallback( + f"http://localhost:{port}/callback", + timeout=1, + ) + await callback.start() + family, _, _, _, address = next( + entry + for entry in socket.getaddrinfo( + "localhost", + port, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + ) + if entry[4][0] in {"127.0.0.1", "::1"} + ) + reader, writer = await asyncio.open_connection( + address[0], + address[1], + family=family, + ) + writer.write( + b"GET /callback?code=oauth-code&state=oauth-state HTTP/1.1\r\n" + b"Host: localhost\r\n\r\n" + ) + await writer.drain() + response = await reader.read() + writer.close() + await writer.wait_closed() + + assert b"200 OK" in response + assert await callback.wait() == ("oauth-code", "oauth-state") + + +def test_parse_service_account_config_normalizes_fields(): + config = mcp_auth.parse_service_account_config( + "automation", + { + "type": "service_account", + "client_id": "fabric-client", + "client_secret_env": "FABRIC_MCP_CLIENT_SECRET", + "token_url": "https://auth.example.test/token", + "scopes": ["mcp:invoke"], + "token_endpoint_auth_method": "client_secret_post", + "token_cache_buffer_seconds": 60, + }, + ) + + assert config == mcp_auth.McpServiceAccountConfig( + client_id="fabric-client", + client_secret_env="FABRIC_MCP_CLIENT_SECRET", + token_url="https://auth.example.test/token", + scopes=("mcp:invoke",), + token_endpoint_auth_method="client_secret_post", + token_cache_buffer_seconds=60, + ) + + +async def test_service_account_auth_caches_token_and_refreshes_after_401(): + config = mcp_auth.McpServiceAccountConfig( + client_id="fabric client", + client_secret_env="FABRIC_MCP_CLIENT_SECRET", + token_url="https://auth.example.test/token", + scopes=("mcp:invoke",), + token_cache_buffer_seconds=60, + ) + auth = mcp_auth.create_mcp_service_account_auth( + "automation", + config, + {"FABRIC_MCP_CLIENT_SECRET": "oauth secret"}, + ) + + request = httpx.Request("POST", "https://mcp.example.test/mcp") + flow = auth.async_auth_flow(request) + token_request = await anext(flow) + assert str(token_request.url) == "https://auth.example.test/token" + assert token_request.headers["Authorization"].startswith("Basic ") + assert token_request.content == b"grant_type=client_credentials&scope=mcp%3Ainvoke" + + authorized = await flow.asend( + httpx.Response( + 200, + json={ + "access_token": "first-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + request=token_request, + ) + ) + assert authorized.headers["Authorization"] == "Bearer first-token" + with pytest.raises(StopAsyncIteration): + await flow.asend(httpx.Response(200, request=authorized)) + + cached_flow = auth.async_auth_flow( + httpx.Request("POST", "https://mcp.example.test/mcp") + ) + cached_request = await anext(cached_flow) + assert str(cached_request.url) == "https://mcp.example.test/mcp" + assert cached_request.headers["Authorization"] == "Bearer first-token" + + retry_token_request = await cached_flow.asend( + httpx.Response(401, request=cached_request) + ) + retried_request = await cached_flow.asend( + httpx.Response( + 200, + json={ + "access_token": "second-token", + "token_type": "bearer", + "expires_in": 3600, + }, + request=retry_token_request, + ) + ) + assert retried_request.headers["Authorization"] == "Bearer second-token" + with pytest.raises(StopAsyncIteration): + await cached_flow.asend(httpx.Response(200, request=retried_request)) + + +@pytest.mark.parametrize( + ("payload", "token_type"), + [ + ({"access_token": "token", "expires_in": 3600}, ""), + ( + {"access_token": "token", "token_type": "mac", "expires_in": 3600}, + "mac", + ), + ], +) +async def test_service_account_auth_rejects_unsupported_token_type(payload, token_type): + config = mcp_auth.McpServiceAccountConfig( + client_id="fabric-client", + client_secret_env="FABRIC_MCP_CLIENT_SECRET", + token_url="https://auth.example.test/token", + scopes=(), + ) + auth = mcp_auth.create_mcp_service_account_auth( + "automation", + config, + {"FABRIC_MCP_CLIENT_SECRET": "oauth-secret"}, + ) + flow = auth.async_auth_flow(httpx.Request("POST", "https://mcp.example.test/mcp")) + token_request = await anext(flow) + + with pytest.raises(mcp_auth.McpAuthConfigError) as error: + await flow.asend(httpx.Response(200, json=payload, request=token_request)) + + assert str(error.value) == ( + f"MCP server 'automation' returned unsupported token_type {token_type!r}" + ) diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index 4ecbdc8da..69eb8477b 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -264,6 +264,341 @@ def test_build_options_maps_normalized_capabilities_and_claude_settings(claude_p assert "ANTHROPIC_BASE_URL" not in options.env +def test_build_options_maps_mcp_headers_and_oauth(claude_payload): + server = claude_payload["capability_plan"]["native"]["mcp_servers"]["docs"] + server["custom_headers"] = {"X-Tenant": "fabric"} + server["authentication"] = { + "type": "oauth2", + "client_id": "fabric-client", + "redirect_uri": "http://127.0.0.1:8765/callback", + } + + options = adapter.build_options(claude_payload) + mcp_servers = json.loads(options.mcp_servers.read_text(encoding="utf-8"))[ + "mcpServers" + ] + + # OAuth is handled in-process at start(); no oauth block is staged for Claude. + assert mcp_servers["docs"] == { + "type": "http", + "url": "https://mcp.example.test", + "headers": {"X-Tenant": "fabric"}, + } + assert "oauth" not in mcp_servers["docs"] + + +def test_claude_maps_mcp_oauth_scopes(claude_payload): + server = claude_payload["capability_plan"]["native"]["mcp_servers"]["docs"] + server["authentication"] = {"type": "oauth2", "scopes": ["read"]} + + options = adapter.build_options(claude_payload) + mcp_servers = json.loads(options.mcp_servers.read_text(encoding="utf-8"))[ + "mcpServers" + ] + + assert "oauth" not in mcp_servers["docs"] + assert mcp_servers["docs"] == {"type": "http", "url": "https://mcp.example.test"} + + +def test_claude_maps_mcp_oauth_client_secret_configuration(claude_payload): + server = claude_payload["capability_plan"]["native"]["mcp_servers"]["docs"] + server["authentication"] = { + "type": "oauth2", + "client_id": "fabric-client", + "client_secret_env": "FABRIC_MCP_CLIENT_SECRET", + } + + options = adapter.build_options(claude_payload) + mcp_servers = json.loads(options.mcp_servers.read_text(encoding="utf-8"))[ + "mcpServers" + ] + + assert "oauth" not in mcp_servers["docs"] + assert mcp_servers["docs"] == {"type": "http", "url": "https://mcp.example.test"} + + +def test_claude_rejects_mcp_service_account_authentication(claude_payload): + server = claude_payload["capability_plan"]["native"]["mcp_servers"]["docs"] + server["authentication"] = { + "type": "service_account", + "client_id": "fabric-client", + "client_secret_env": "FABRIC_MCP_CLIENT_SECRET", + "token_url": "https://auth.example.test/token", + } + + with pytest.raises(adapter.AdapterConfigError, match="service_account"): + adapter.build_options(claude_payload) + + +def test_claude_rejects_authenticated_stdio_server_without_exposing_env(claude_payload): + server = claude_payload["capability_plan"]["native"]["mcp_servers"]["repo"] + server["authentication"] = {"type": "oauth2"} + credential = server["env"]["REPO_MCP_MODE"] + + with pytest.raises(adapter.AdapterConfigError, match="stdio") as caught: + adapter.build_options(claude_payload) + + assert caught.value.code == "claude_invalid_configuration" + assert credential not in str(caught.value) + + +def test_claude_forwards_browser_environment_for_mcp_login(claude_payload): + browser_environment = { + "DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus", + "DISPLAY": ":1", + "XAUTHORITY": "/run/user/1000/xauthority", + "XDG_RUNTIME_DIR": "/run/user/1000", + } + os.environ.update(browser_environment) + + options = adapter.build_options(claude_payload) + + assert {name: options.env[name] for name in browser_environment} == ( + browser_environment + ) + + +def test_claude_projects_oauth_token_into_child_environment(claude_payload): + server = claude_payload["capability_plan"]["native"]["mcp_servers"]["docs"] + server["authentication"] = {"type": "oauth2"} + server["custom_headers"] = {"X-Tenant": "fabric"} + + options = adapter.build_options(claude_payload, oauth_tokens={"docs": "tok-abc123"}) + serialized_mcp = options.mcp_servers.read_text(encoding="utf-8") + mcp_config = json.loads(serialized_mcp) + + assert "tok-abc123" not in serialized_mcp + authorization = mcp_config["mcpServers"]["docs"]["headers"]["Authorization"] + assert authorization.startswith("Bearer ${NEMO_FABRIC_CLAUDE_MCP_") + assert authorization.endswith("}") + projected_name = authorization.removeprefix("Bearer ${").removesuffix("}") + assert options.env[projected_name] == "tok-abc123" + assert mcp_config["mcpServers"]["docs"]["headers"]["X-Tenant"] == "fabric" + + +async def test_claude_prefetches_oauth_tokens_before_connect( + claude_payload, monkeypatch +): + server = claude_payload["capability_plan"]["native"]["mcp_servers"]["docs"] + server["authentication"] = { + "type": "oauth2", + "authorization_timeout_seconds": 12, + } + self_auth = AsyncMock(return_value="tok-prefetched") + monkeypatch.setattr(adapter, "_self_authenticate_http_mcp_server", self_auth) + + tokens = await adapter._prefetch_mcp_oauth_tokens(claude_payload) + + assert tokens == {"docs": "tok-prefetched"} + call = self_auth.await_args + assert call.args[:3] == ("docs", "https://mcp.example.test", "http") + assert call.kwargs["timeout"] == 12 + + +async def test_claude_preserves_missing_oauth_token_error(monkeypatch): + import httpx + + mock_provider = MagicMock() + mock_http_client = MagicMock() + mock_http_client.post = AsyncMock() + mock_http_client_context = MagicMock() + mock_http_client_context.__aenter__ = AsyncMock(return_value=mock_http_client) + mock_http_client_context.__aexit__ = AsyncMock(return_value=None) + monkeypatch.setattr( + httpx, + "AsyncClient", + MagicMock(return_value=mock_http_client_context), + ) + monkeypatch.setattr( + adapter.mcp_auth, + "create_mcp_oauth_provider", + MagicMock(return_value=mock_provider), + ) + access_token = MagicMock(return_value=None) + monkeypatch.setattr(adapter.mcp_auth, "access_token", access_token) + config = adapter.mcp_auth.McpOAuth2Config( + client_id=None, + client_secret_env=None, + scopes=(), + redirect_uri=None, + ) + + with pytest.raises(adapter.ClaudeAdapterError) as caught: + await adapter._self_authenticate_http_mcp_server( + "docs", + "https://mcp.example.test", + "http", + config, + timeout=12, + ) + + access_token.assert_called_once_with(mock_provider) + assert caught.value.code == "claude_mcp_authentication_failed" + assert caught.value.message == ( + "MCP server 'docs' OAuth flow did not return an access token" + ) + assert caught.value.metadata == {"server": "docs"} + + +async def test_claude_checks_mcp_connectivity_on_first_invoke(claude_payload): + server = claude_payload["capability_plan"]["native"]["mcp_servers"]["docs"] + server["authentication"] = {"type": "oauth2"} + client = MagicMock(spec=adapter.ClaudeSDKClient) + client.get_mcp_status = AsyncMock( + return_value={"mcpServers": [{"name": "docs", "status": "connected"}]} + ) + runtime = adapter.ClaudeRuntime() + + await runtime._authenticate_mcp_servers( + claude_payload, + client, + asyncio.get_running_loop().time() + 30, + ) + + client.get_mcp_status.assert_awaited() + assert runtime._mcp_authentication_checked == {"docs": True} + + +async def test_claude_raises_when_mcp_server_unavailable_at_invoke(claude_payload): + server = claude_payload["capability_plan"]["native"]["mcp_servers"]["docs"] + server["authentication"] = {"type": "oauth2"} + client = MagicMock(spec=adapter.ClaudeSDKClient) + client.get_mcp_status = AsyncMock( + return_value={"mcpServers": [{"name": "docs", "status": "failed"}]} + ) + runtime = adapter.ClaudeRuntime() + + with pytest.raises(adapter.ClaudeAdapterError) as caught: + await runtime._authenticate_mcp_servers( + claude_payload, + client, + asyncio.get_running_loop().time() + 30, + ) + + assert caught.value.code == "claude_mcp_unavailable" + assert caught.value.metadata == {"server": "docs", "status": "failed"} + assert runtime._mcp_authentication_checked == {"docs": False} + + +async def test_claude_tracks_mcp_authentication_per_server(claude_payload): + servers = claude_payload["capability_plan"]["native"]["mcp_servers"] + servers["docs"]["authentication"] = {"type": "oauth2"} + servers["issues"] = { + "transport": "streamable-http", + "url": "https://issues.example.test", + "authentication": {"type": "oauth2"}, + } + client = MagicMock(spec=adapter.ClaudeSDKClient) + client.get_mcp_status = AsyncMock( + side_effect=[ + { + "mcpServers": [ + {"name": "docs", "status": "connected"}, + {"name": "issues", "status": "failed"}, + ] + }, + { + "mcpServers": [ + {"name": "docs", "status": "connected"}, + {"name": "issues", "status": "failed"}, + ] + }, + { + "mcpServers": [ + {"name": "docs", "status": "connected"}, + {"name": "issues", "status": "connected"}, + ] + }, + ] + ) + runtime = adapter.ClaudeRuntime() + + with pytest.raises(adapter.ClaudeAdapterError): + await runtime._authenticate_mcp_servers( + claude_payload, + client, + asyncio.get_running_loop().time() + 30, + ) + + assert runtime._mcp_authentication_checked == { + "docs": True, + "issues": False, + } + + await runtime._authenticate_mcp_servers( + claude_payload, + client, + asyncio.get_running_loop().time() + 30, + ) + + assert client.get_mcp_status.await_count == 3 + assert runtime._mcp_authentication_checked == { + "docs": True, + "issues": True, + } + + +async def test_claude_mcp_status_checks_use_remaining_invocation_budget( + claude_payload, monkeypatch +): + servers = claude_payload["capability_plan"]["native"]["mcp_servers"] + servers["docs"]["authentication"] = {"type": "oauth2"} + servers["issues"] = { + "transport": "streamable-http", + "url": "https://issues.example.test", + "authentication": {"type": "oauth2"}, + } + runtime = adapter.ClaudeRuntime() + status = AsyncMock(return_value="connected") + monkeypatch.setattr(runtime, "_mcp_server_status", status) + remaining_timeout = MagicMock(side_effect=[4.0, 1.5]) + monkeypatch.setattr(adapter, "_remaining_timeout", remaining_timeout) + client = MagicMock(spec=adapter.ClaudeSDKClient) + + await runtime._authenticate_mcp_servers(claude_payload, client, 100.0) + + assert status.await_args_list[0].args == (client, "docs", 4.0) + assert status.await_args_list[1].args == (client, "issues", 1.5) + assert remaining_timeout.call_count == 2 + + +async def test_claude_mcp_status_polling_respects_timeout(): + client = MagicMock(spec=adapter.ClaudeSDKClient) + client.get_mcp_status = AsyncMock( + return_value={"mcpServers": [{"name": "docs", "status": "pending"}]} + ) + + with pytest.raises(TimeoutError): + await adapter.ClaudeRuntime()._mcp_server_status(client, "docs", 0.01) + + +async def test_claude_invoke_passes_remaining_budget_to_query( + claude_payload, monkeypatch +): + runtime = adapter.ClaudeRuntime() + runtime._start_payload = { + key: value for key, value in claude_payload.items() if key != "request" + } + runtime._fabric_runtime_id = claude_payload["runtime_context"]["runtime_id"] + runtime._client = MagicMock(spec=adapter.ClaudeSDKClient) + authenticate = AsyncMock() + run_query = AsyncMock(return_value={"completed": False}) + monkeypatch.setattr(runtime, "_authenticate_mcp_servers", authenticate) + monkeypatch.setattr(runtime, "_run_query", run_query) + remaining_timeout = MagicMock(return_value=7.0) + monkeypatch.setattr(adapter, "_remaining_timeout", remaining_timeout) + loop = asyncio.get_running_loop() + before = loop.time() + + await runtime.invoke(lifecycle_invocation(claude_payload)) + + after = loop.time() + invocation_deadline = authenticate.await_args.args[2] + assert before + 30 <= invocation_deadline <= after + 30 + remaining_timeout.assert_called_once_with(invocation_deadline) + assert run_query.await_args.args[3] == 7.0 + + async def test_tool_policy_hooks_gate_built_in_and_mcp_tools(claude_payload): claude_payload["config"]["tools"] = { "enabled": ["Read", "Edit"], @@ -728,6 +1063,18 @@ async def connect(self): assert not staged_paths[0].exists() +def test_cleanup_mcp_config_removes_runtime_directory(tmp_path): + config_root = tmp_path / "mcp" + config_root.mkdir() + config_path = config_root / "mcp.json" + config_path.write_text("{}", encoding="utf-8") + (config_root / "abandoned").write_text("partial", encoding="utf-8") + + adapter._cleanup_mcp_config(config_path) + + assert not config_root.exists() + + async def test_claude_runtime_owns_one_relay_gateway_until_stop( relay_payload, monkeypatch, tmp_path ): @@ -1054,7 +1401,14 @@ async def test_runtime_stop_reports_relay_plugin_cleanup_failure( relay.plugin_path.mkdir() process = MagicMock() mock_stop = MagicMock() - mock_rmtree = MagicMock(side_effect=OSError("raw plugin cleanup failure")) + real_rmtree = adapter.shutil.rmtree + + def remove_tree(path): + if path == relay.plugin_path: + raise OSError("raw plugin cleanup failure") + real_rmtree(path) + + mock_rmtree = MagicMock(side_effect=remove_tree) monkeypatch.setattr(adapter, "prepare_claude_relay", MagicMock(return_value=relay)) monkeypatch.setattr( adapter.relay_gateway, @@ -1083,6 +1437,7 @@ async def responses(_client) -> AsyncIterator[ResultMessage]: key: value for key, value in relay_payload.items() if key != "request" } await runtime.start(start_payload) + mcp_config_root = runtime._mcp_config_path.parent output = await runtime.invoke(lifecycle_invocation(relay_payload)) with pytest.raises(adapter.lifecycle.LifecycleError) as caught: await runtime.stop() @@ -1092,8 +1447,11 @@ async def responses(_client) -> AsyncIterator[ResultMessage]: assert caught.value.code == "claude_relay_cleanup_failed" assert "raw plugin cleanup failure" not in str(caught.value) mock_stop.assert_called_once_with(process) - mock_rmtree.assert_called_once_with(relay.plugin_path) + assert mock_rmtree.call_count == 2 + mock_rmtree.assert_any_call(relay.plugin_path) + mock_rmtree.assert_any_call(mcp_config_root) assert relay.plugin_path.exists() + assert not mcp_config_root.exists() @pytest.mark.parametrize( diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index d47df4ae6..77d5d48a9 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -184,7 +184,48 @@ def mock_codex_fixture(monkeypatch): mock_codex.next_thread_id = "thread-123" mock_codex.next_result = None mock_codex.next_thread = None - mock_codex.skill_request = AsyncMock() + mock_codex.mcp_auth_statuses = {} + mock_codex.mcp_login_success = True + mock_codex.mcp_login_error = None + mock_codex.oauth_authorization_url = "https://auth.example.test/authorize" + mock_codex.login_params = None + + async def protocol_request(method, params, *, response_model): + if method == "skills/extraRoots/set": + return None + if method == "mcpServerStatus/list": + return response_model( + data=[ + { + "name": name, + "authStatus": status, + "resourceTemplates": [], + "resources": [], + "tools": {}, + } + for name, status in mock_codex.mcp_auth_statuses.items() + ] + ) + if method == "mcpServer/oauth/login": + mock_codex.login_params = params + return response_model(authorizationUrl=mock_codex.oauth_authorization_url) + raise AssertionError(f"unexpected Codex protocol request: {method}") + + async def next_notification(): + params = mock_codex.login_params + assert params is not None + return SimpleNamespace( + method="mcpServer/oauthLogin/completed", + payload=adapter.McpServerOauthLoginCompletedNotification( + error=mock_codex.mcp_login_error, + name=params["name"], + success=mock_codex.mcp_login_success, + threadId=params["threadId"], + ), + ) + + mock_codex.skill_request = AsyncMock(side_effect=protocol_request) + mock_codex.next_notification = AsyncMock(side_effect=next_notification) mock_codex.close_error = None def build_client(*, config): @@ -192,14 +233,23 @@ def build_client(*, config): mock_client.config = config mock_client.closed = False mock_client.thread = None - mock_client._client = SimpleNamespace(request=mock_codex.skill_request) + mock_client._client = SimpleNamespace( + request=mock_codex.skill_request, + next_notification=mock_codex.next_notification, + ) async def close(): if mock_codex.close_error is not None: raise mock_codex.close_error mock_client.closed = True - async def thread_start(**_kwargs): + async def thread_start(**kwargs): + config = kwargs.get("config") or {} + for name, server in config.get("mcp_servers", {}).items(): + if server.get("auth") == "oauth": + mock_codex.mcp_auth_statuses.setdefault( + name, adapter.McpAuthStatus.o_auth + ) mock_client.thread = ( mock_codex.next_thread if mock_codex.next_thread is not None @@ -222,7 +272,9 @@ def test_single_invocation_uses_native_thread_and_turn_contract( ): os.environ["CODEX_HOME"] = str(tmp_path / "codex-home") os.environ["CODEX_INTERNAL_ORIGINATOR_OVERRIDE"] = "parent-codex" + os.environ["DBUS_SESSION_BUS_ADDRESS"] = "unix:path=/run/user/1000/bus" os.environ["FABRIC_UNRELATED_SECRET"] = "do-not-forward" + os.environ["XDG_RUNTIME_DIR"] = "/run/user/1000" codex_payload["runtime_context"]["environment"]["env"] = { "CODEX_EXPLICIT": "forward-me" } @@ -249,7 +301,11 @@ def test_single_invocation_uses_native_thread_and_turn_contract( assert client.config.env["CODEX_HOME"] == str(tmp_path / "codex-home") assert client.config.env["CODEX_EXPLICIT"] == "forward-me" assert client.config.env["CODEX_INTERNAL_ORIGINATOR_OVERRIDE"] == "codex_python_sdk" + assert ( + client.config.env["DBUS_SESSION_BUS_ADDRESS"] == "unix:path=/run/user/1000/bus" + ) assert client.config.env["FABRIC_UNRELATED_SECRET"] == "" + assert client.config.env["XDG_RUNTIME_DIR"] == "/run/user/1000" start = client.thread_start.await_args.kwargs assert start["model"] == "gpt-5.4" assert start["model_provider"] == "openai" @@ -320,6 +376,7 @@ async def scenario() -> adapter.lifecycle.LifecycleError: def test_sdk_maps_native_mcp_servers_into_thread_config(codex_payload, mock_codex): os.environ["FABRIC_TEST_MCP_URL"] = "https://mcp.example.test/mcp" + os.environ["FABRIC_TEST_MCP_HEADER"] = "fabric" codex_payload["capability_plan"] = { "native": { "mcp_servers": { @@ -339,6 +396,12 @@ def test_sdk_maps_native_mcp_servers_into_thread_config(codex_payload, mock_code "remote": { "transport": "streamable-http", "url": "${FABRIC_TEST_MCP_URL}", + "custom_headers": {"X-Tenant": "${FABRIC_TEST_MCP_HEADER}"}, + "authentication": { + "type": "oauth2", + "scopes": ["read", "write"], + "redirect_uri": "http://127.0.0.1:8765/callback", + }, }, } } @@ -354,6 +417,9 @@ def test_sdk_maps_native_mcp_servers_into_thread_config(codex_payload, mock_code assert config["mcp_servers"] == { "remote": { "url": "https://mcp.example.test/mcp", + "http_headers": {"X-Tenant": "fabric"}, + "auth": "oauth", + "scopes": ["read", "write"], "required": True, }, "repo": { @@ -369,6 +435,154 @@ def test_sdk_maps_native_mcp_servers_into_thread_config(codex_payload, mock_code "env": {"REPO_MCP_MODE": "test"}, }, } + assert config["mcp_oauth_callback_url"] == "http://127.0.0.1:8765/callback" + + +def test_codex_rejects_mcp_oauth_client_secret(codex_payload): + codex_payload["capability_plan"] = { + "native": { + "mcp_servers": { + "remote": { + "transport": "streamable-http", + "url": "https://mcp.example.test/mcp", + "authentication": { + "type": "oauth2", + "client_id": "fabric-client", + "client_secret_env": "FABRIC_MCP_CLIENT_SECRET", + }, + } + } + } + } + + with pytest.raises(adapter.AdapterConfigError, match="client_secret_env"): + adapter.thread_config(codex_payload, None) + + +def test_codex_rejects_mcp_oauth_client_id(codex_payload): + codex_payload["capability_plan"] = { + "native": { + "mcp_servers": { + "remote": { + "transport": "streamable-http", + "url": "https://mcp.example.test/mcp", + "authentication": { + "type": "oauth2", + "client_id": "fabric-client", + }, + } + } + } + } + + with pytest.raises(adapter.AdapterConfigError, match="client_id"): + adapter.thread_config(codex_payload, None) + + +@pytest.mark.parametrize( + ("invocation_timeout", "oauth_timeout", "expected_timeout"), + [(30, 12, 12), (5, 12, 5)], +) +def test_codex_logs_into_mcp_server_before_first_turn( + codex_payload, + mock_codex, + monkeypatch, + invocation_timeout, + oauth_timeout, + expected_timeout, +): + codex_payload["config"]["runtime"]["timeout_seconds"] = invocation_timeout + codex_payload["capability_plan"] = { + "native": { + "mcp_servers": { + "remote": { + "transport": "streamable-http", + "url": "https://mcp.example.test/mcp", + "authentication": { + "type": "oauth2", + "scopes": ["read", "write"], + "authorization_timeout_seconds": oauth_timeout, + }, + } + } + } + } + mock_codex.mcp_auth_statuses["remote"] = adapter.McpAuthStatus.not_logged_in + open_browser = AsyncMock(return_value=True) + monkeypatch.setattr(adapter.mcp_auth, "open_authorization_url", open_browser) + + output = invoke_once(codex_payload) + + assert output["completed"] is True + requests = mock_codex.skill_request.await_args_list + assert [call.args[0] for call in requests] == [ + "mcpServerStatus/list", + "mcpServer/oauth/login", + ] + assert requests[1].args[1] == { + "name": "remote", + "scopes": ["read", "write"], + "threadId": "thread-123", + "timeoutSecs": expected_timeout, + } + open_browser.assert_awaited_once_with(mock_codex.oauth_authorization_url) + mock_codex.next_notification.assert_awaited_once_with() + mock_codex.instances[0].thread.turn.assert_awaited_once() + + +def test_codex_reports_failed_mcp_oauth_login_before_turn( + codex_payload, mock_codex, monkeypatch +): + codex_payload["capability_plan"] = { + "native": { + "mcp_servers": { + "remote": { + "transport": "streamable-http", + "url": "https://mcp.example.test/mcp", + "authentication": {"type": "oauth2"}, + } + } + } + } + mock_codex.mcp_auth_statuses["remote"] = adapter.McpAuthStatus.not_logged_in + mock_codex.mcp_login_success = False + mock_codex.mcp_login_error = "authorization denied" + monkeypatch.setattr( + adapter.mcp_auth, + "open_authorization_url", + AsyncMock(return_value=True), + ) + + output = invoke_once(codex_payload) + + assert output["failed"] is True + assert output["error"]["code"] == "codex_mcp_authentication_failed" + assert output["error"]["message"] == ( + "Codex MCP OAuth login failed for server 'remote'" + ) + mock_codex.instances[0].thread.turn.assert_not_awaited() + + +def test_codex_rejects_mcp_service_account_authentication(codex_payload): + codex_payload["capability_plan"] = { + "native": { + "mcp_servers": { + "remote": { + "transport": "streamable-http", + "url": "https://mcp.example.test/mcp", + "authentication": { + "type": "service_account", + "client_id": "fabric-client", + "client_secret_env": "FABRIC_MCP_CLIENT_SECRET", + "token_url": "https://auth.example.test/token", + }, + } + } + } + } + + with pytest.raises(adapter.AdapterConfigError, match="service_account"): + adapter.thread_config(codex_payload, None) def test_sdk_registers_native_skill_roots(codex_payload, mock_codex, tmp_path): diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index 7036a2698..c1cc109d4 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -1096,6 +1096,7 @@ def boom(**_kwargs): async def test_mcp_servers_become_adapter_tools( tmp_path, make_payload, monkeypatch, fake_sdks ): + os.environ["FABRIC_TEST_MCP_HEADER"] = "fabric" tool_read = MagicMock() tool_read.name = "read_file" tool_write = MagicMock() @@ -1112,13 +1113,31 @@ async def test_mcp_servers_become_adapter_tools( types.ModuleType("langchain_mcp_adapters"), ) monkeypatch.setitem(sys.modules, "langchain_mcp_adapters.client", client_mod) + mock_oauth = MagicMock(name="oauth") + cleanup_calls = [] + mock_callback = MagicMock(name="oauth_callback") + mock_callback.close_reserved_socket.side_effect = lambda: cleanup_calls.append( + "close_reserved_socket" + ) + mock_callback.close = AsyncMock(side_effect=lambda: cleanup_calls.append("close")) + mock_oauth._fabric_oauth_callback = mock_callback + create_oauth = MagicMock(return_value=mock_oauth) + monkeypatch.setattr(adapter.mcp_auth, "create_mcp_oauth_provider", create_oauth) payload = make_payload(tmp_path) # McpServerPlan carries the URL/command in ``url``. payload["capability_plan"] = { "native": { "mcp_servers": { - "fs": {"transport": "streamable-http", "url": "http://localhost:9/mcp"}, + "fs": { + "transport": "streamable-http", + "url": "http://localhost:9/mcp", + "custom_headers": {"X-Tenant": "${FABRIC_TEST_MCP_HEADER}"}, + "authentication": { + "type": "oauth2", + "client_id": "fabric-client", + }, + }, "local": { "transport": "stdio", "url": "my-server", @@ -1132,8 +1151,24 @@ async def test_mcp_servers_become_adapter_tools( output = await invoke_once(payload) assert output["failed"] is False + create_oauth.assert_called_once_with( + "fs", + "http://localhost:9/mcp", + adapter.mcp_auth.McpOAuth2Config( + client_id="fabric-client", + client_secret_env=None, + scopes=(), + redirect_uri=None, + ), + client_name="NeMo Fabric Deep Agents", + ) assert mock_client_cls.call_args.args[0] == { - "fs": {"transport": "streamable_http", "url": "http://localhost:9/mcp"}, + "fs": { + "transport": "streamable_http", + "url": "http://localhost:9/mcp", + "headers": {"X-Tenant": "fabric"}, + "auth": mock_oauth, + }, "local": { "transport": "stdio", "command": "my-server", @@ -1143,6 +1178,63 @@ async def test_mcp_servers_become_adapter_tools( } tool_names = [tool.name for tool in fake_sdks["create_kwargs"]["tools"]] assert tool_names == ["read_file", "write_file"] + mock_callback.close_reserved_socket.assert_called_once_with() + mock_callback.close.assert_awaited_once_with() + assert cleanup_calls == ["close_reserved_socket", "close"] + + +async def test_mcp_oauth_callback_closed_on_startup_failure( + tmp_path, make_payload, monkeypatch, fake_sdks +): + callback = MagicMock(name="oauth_callback") + callback.close = AsyncMock() + + async def fail_resolve_tools(_payload, oauth_callbacks=None): + oauth_callbacks.append(callback) + raise RuntimeError("MCP startup failed") + + monkeypatch.setattr(adapter, "resolve_tools", fail_resolve_tools) + + runtime = adapter.DeepAgentsRuntime() + with pytest.raises(RuntimeError, match="MCP startup failed"): + await runtime.start(lifecycle_start_payload(make_payload(tmp_path))) + + callback.close_reserved_socket.assert_called_once_with() + callback.close.assert_awaited_once_with() + + +def test_deepagents_maps_service_account_authentication(monkeypatch): + mock_auth = MagicMock(name="service_account_auth") + create_auth = MagicMock(return_value=mock_auth) + monkeypatch.setattr( + adapter.mcp_auth, "create_mcp_service_account_auth", create_auth + ) + + connection = adapter._mcp_connection( + "automation", + { + "transport": "streamable-http", + "url": "https://mcp.example.test/mcp", + "authentication": { + "type": "service_account", + "client_id": "fabric-client", + "client_secret_env": "FABRIC_MCP_CLIENT_SECRET", + "token_url": "https://auth.example.test/token", + "scopes": ["mcp:invoke"], + }, + }, + ) + + create_auth.assert_called_once_with( + "automation", + adapter.mcp_auth.McpServiceAccountConfig( + client_id="fabric-client", + client_secret_env="FABRIC_MCP_CLIENT_SECRET", + token_url="https://auth.example.test/token", + scopes=("mcp:invoke",), + ), + ) + assert connection["auth"] is mock_auth @pytest.mark.usefixtures("use_real_langgraph") diff --git a/tests/adapters/test_hermes_adapter.py b/tests/adapters/test_hermes_adapter.py index 33e684db0..f796ba8f2 100644 --- a/tests/adapters/test_hermes_adapter.py +++ b/tests/adapters/test_hermes_adapter.py @@ -13,6 +13,7 @@ import sys import threading import tomllib +from io import StringIO from pathlib import Path from types import ModuleType from unittest.mock import MagicMock @@ -562,6 +563,69 @@ def test_build_hermes_config_maps_stdio_mcp_args_and_env_from_agent_config( } +def test_hermes_maps_http_mcp_headers_and_oauth(): + os.environ["FABRIC_MCP_CLIENT_SECRET"] = "oauth-secret" + + config = adapter.hermes_mcp_server_config( + AgentMcpServerConfig( + transport="sse", + url="https://mcp.example.test/sse", + custom_headers={"X-Tenant": "fabric"}, + authentication={ + "type": "oauth2", + "client_id": "fabric-client", + "client_secret_env": "FABRIC_MCP_CLIENT_SECRET", + "scopes": ["read", "write"], + "redirect_uri": "http://127.0.0.1:8765/callback", + }, + ) + ) + + assert config == { + "enabled": True, + "url": "https://mcp.example.test/sse", + "transport": "sse", + "headers": {"X-Tenant": "fabric"}, + "auth": "oauth", + "oauth": { + "client_id": "fabric-client", + "client_secret": "${FABRIC_MCP_CLIENT_SECRET}", + "scope": "read write", + "redirect_uri": "http://127.0.0.1:8765/callback", + }, + } + + +def test_hermes_rejects_mcp_service_account_authentication(): + with pytest.raises(ValueError, match="service_account"): + adapter.hermes_mcp_server_config( + AgentMcpServerConfig( + transport="streamable-http", + url="https://mcp.example.test/mcp", + authentication={ + "type": "service_account", + "client_id": "fabric-client", + "client_secret_env": "FABRIC_MCP_CLIENT_SECRET", + "token_url": "https://auth.example.test/token", + }, + ) + ) + + +def test_hermes_rejects_unsupported_mcp_oauth_policy(): + with pytest.raises(ValueError, match="authorization_timeout_seconds"): + adapter.hermes_mcp_server_config( + AgentMcpServerConfig( + transport="streamable-http", + url="https://mcp.example.test/mcp", + authentication={ + "type": "oauth2", + "authorization_timeout_seconds": 30, + }, + ) + ) + + async def test_runtime_start_discovers_mcp_tools_when_configured( monkeypatch, tmp_path: Path, @@ -654,6 +718,119 @@ def shutdown_mcp_servers() -> None: assert caught.value.code == "hermes_runtime_stop_failed" +async def test_runtime_authenticates_oauth_mcp_servers_before_invoke(monkeypatch): + thread_calls = [] + + async def to_thread(function, *args, **kwargs): + thread_calls.append((function, args, kwargs)) + return function(*args, **kwargs) + + monkeypatch.setattr(adapter.asyncio, "to_thread", to_thread) + force_interactive_oauth = MagicMock() + oauth_stdin_reads: list[str] = [] + discover_mcp_tools = MagicMock( + side_effect=lambda: oauth_stdin_reads.append(sys.stdin.readline()) + ) + get_mcp_status = MagicMock( + side_effect=[ + [ + { + "name": "confluence", + "connected": False, + "status": "failed", + } + ], + [ + { + "name": "confluence", + "connected": True, + "status": "connected", + } + ], + ] + ) + refresh_agent_mcp_tools = MagicMock() + + tools_oauth = ModuleType("tools.mcp_oauth") + tools_oauth.force_interactive_oauth = ( # type: ignore[attr-defined] + force_interactive_oauth + ) + tools_mcp = ModuleType("tools.mcp_tool") + tools_mcp.discover_mcp_tools = discover_mcp_tools # type: ignore[attr-defined] + tools_mcp.get_mcp_status = get_mcp_status # type: ignore[attr-defined] + tools_mcp.refresh_agent_mcp_tools = ( # type: ignore[attr-defined] + refresh_agent_mcp_tools + ) + monkeypatch.setitem(sys.modules, "tools.mcp_oauth", tools_oauth) + monkeypatch.setitem(sys.modules, "tools.mcp_tool", tools_mcp) + + runtime = adapter.HermesRuntime() + runtime._agent = MagicMock() + runtime._hermes_config = { + "mcp_servers": { + "confluence": {"auth": "oauth"}, + "time": {"transport": "stdio"}, + } + } + lifecycle_stdin = StringIO('{"operation":"stop"}\n') + monkeypatch.setattr(sys, "stdin", lifecycle_stdin) + + await runtime._authenticate_mcp_servers() + await runtime._authenticate_mcp_servers() + + force_interactive_oauth.assert_called_once_with() + discover_mcp_tools.assert_called_once_with() + assert get_mcp_status.call_count == 2 + refresh_agent_mcp_tools.assert_called_once_with( + runtime._agent, + quiet_mode=True, + ) + assert oauth_stdin_reads == [""] + assert lifecycle_stdin.readline() == '{"operation":"stop"}\n' + assert sys.stdin is lifecycle_stdin + assert runtime._mcp_authentication_checked is True + assert len(thread_calls) == 4 + assert thread_calls[0] == (get_mcp_status, (), {}) + assert thread_calls[1][0].__name__ == "authenticate" + assert thread_calls[2] == (get_mcp_status, (), {}) + assert thread_calls[3] == ( + refresh_agent_mcp_tools, + (runtime._agent,), + {"quiet_mode": True}, + ) + + +async def test_runtime_reports_failed_oauth_mcp_authentication(monkeypatch): + tools_oauth = ModuleType("tools.mcp_oauth") + tools_oauth.force_interactive_oauth = MagicMock() # type: ignore[attr-defined] + tools_mcp = ModuleType("tools.mcp_tool") + tools_mcp.discover_mcp_tools = MagicMock() # type: ignore[attr-defined] + tools_mcp.get_mcp_status = MagicMock( # type: ignore[attr-defined] + return_value=[ + { + "name": "confluence", + "connected": False, + "status": "failed", + } + ] + ) + tools_mcp.refresh_agent_mcp_tools = MagicMock() # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "tools.mcp_oauth", tools_oauth) + monkeypatch.setitem(sys.modules, "tools.mcp_tool", tools_mcp) + + runtime = adapter.HermesRuntime() + runtime._agent = MagicMock() + runtime._hermes_config = {"mcp_servers": {"confluence": {"auth": "oauth"}}} + + with pytest.raises(adapter.lifecycle.LifecycleError) as caught: + await runtime._authenticate_mcp_servers() + + assert caught.value.code == "hermes_mcp_authentication_failed" + assert caught.value.metadata == {"servers": ["confluence"]} + tools_mcp.refresh_agent_mcp_tools.assert_not_called() # type: ignore[attr-defined] + assert runtime._mcp_authentication_checked is False + + def test_write_hermes_config_writes_file(tmp_path: Path): agent_config = _agent_config( { diff --git a/tests/python/test_native_sdk.py b/tests/python/test_native_sdk.py index 8b4339917..f79212f73 100644 --- a/tests/python/test_native_sdk.py +++ b/tests/python/test_native_sdk.py @@ -135,6 +135,12 @@ async def smoke(client: Fabric, fixture_agent: Path) -> None: "transport": "streamable-http", "url": "${GITHUB_MCP_URL}", "exposure": "harness_native", + "custom_headers": {"X-Tenant": "fabric"}, + "authentication": { + "type": "oauth2", + "client_id": "fabric-client", + "scopes": ["repo"], + }, } } }, @@ -154,6 +160,13 @@ async def smoke(client: Fabric, fixture_agent: Path) -> None: assert typed_plan["agent_name"] == "typed-hermes-shim-agent" assert typed_plan["adapter_descriptor"]["source"] == "local" assert typed_plan["telemetry_plan"]["relay_enabled"] is True + native_mcp = typed_plan["capability_plan"]["native"]["mcp_servers"]["github"] + assert native_mcp["custom_headers"] == {"X-Tenant": "fabric"} + assert native_mcp["authentication"] == { + "type": "oauth2", + "client_id": "fabric-client", + "scopes": ["repo"], + } resolved_config = typed_plan.config.to_mapping() assert resolved_config["harness"]["adapter_id"] == "test.fabric.hermes_shim" assert "settings" not in resolved_config["harness"] diff --git a/tests/python/test_sdk_contract.py b/tests/python/test_sdk_contract.py index 66b939021..18a1f77f4 100644 --- a/tests/python/test_sdk_contract.py +++ b/tests/python/test_sdk_contract.py @@ -29,6 +29,7 @@ from nemo_fabric import HarnessConfig from nemo_fabric import InstructionConfig from nemo_fabric import InstructionsConfig +from nemo_fabric import McpAuthenticationConfig from nemo_fabric import McpConfig from nemo_fabric import McpServerConfig from nemo_fabric import MetadataConfig @@ -200,6 +201,12 @@ def test_typed_config_authoring_helpers_emit_schema_shape(): url="${GITHUB_MCP_URL}", args=["--read-only"], env={"GITHUB_TOKEN": "${GITHUB_TOKEN}"}, + authentication=McpAuthenticationConfig( + type="oauth2", + client_id="fabric-client", + scopes=["repo"], + ), + custom_headers={"X-Tenant": "fabric"}, exposure="fabric_managed", allowed_tools=["issues.read", "pull_requests.read"], blocked_tools=["issues.delete"], @@ -246,6 +253,12 @@ def test_typed_config_authoring_helpers_emit_schema_shape(): "url": "${GITHUB_MCP_URL}", "args": ["--read-only"], "env": {"GITHUB_TOKEN": "${GITHUB_TOKEN}"}, + "authentication": { + "type": "oauth2", + "client_id": "fabric-client", + "scopes": ["repo"], + }, + "custom_headers": {"X-Tenant": "fabric"}, "exposure": "fabric_managed", "allowed_tools": ["issues.read", "pull_requests.read"], "blocked_tools": ["issues.delete"], @@ -316,6 +329,155 @@ def test_mcp_server_tool_policy_preserves_empty_allowlist(): assert server.to_mapping() == expected +def test_mcp_server_rejects_unknown_transport(): + with pytest.raises(ValidationError, match="transport"): + McpServerConfig(transport="websocket", url="https://mcp.example.test") + + server = McpServerConfig( + transport="streamable-http", url="https://mcp.example.test" + ) + with pytest.raises(ValidationError, match="transport"): + server.transport = "websocket" # type: ignore[assignment] + + +def test_mcp_server_serializes_oauth2_authentication_and_custom_headers(): + server = McpServerConfig( + transport="streamable-http", + url="https://mcp.example.test/jira", + custom_headers={"X-Tenant": "fabric"}, + authentication={ + "type": "oauth2", + "client_id": "fabric-client", + "client_secret_env": "MCP_CLIENT_SECRET", + "scopes": ["read:jira", "write:jira"], + "redirect_uri": "http://127.0.0.1:8765/callback", + "enable_dynamic_registration": False, + "client_name": "NeMo Fabric", + "token_endpoint_auth_method": "client_secret_post", + "authorization_timeout_seconds": 120, + }, + ) + + assert isinstance(server.authentication, McpAuthenticationConfig) + assert server.custom_headers == {"X-Tenant": "fabric"} + assert "custom_headers" not in server.extra_fields + assert server.to_mapping() == { + "transport": "streamable-http", + "url": "https://mcp.example.test/jira", + "authentication": { + "type": "oauth2", + "client_id": "fabric-client", + "client_secret_env": "MCP_CLIENT_SECRET", + "scopes": ["read:jira", "write:jira"], + "redirect_uri": "http://127.0.0.1:8765/callback", + "enable_dynamic_registration": False, + "client_name": "NeMo Fabric", + "token_endpoint_auth_method": "client_secret_post", + "authorization_timeout_seconds": 120, + }, + "custom_headers": {"X-Tenant": "fabric"}, + "exposure": "harness_native", + } + + +def test_mcp_server_serializes_service_account_authentication(): + server = McpServerConfig( + transport="streamable-http", + url="https://mcp.example.test/automation", + authentication=McpAuthenticationConfig( + type="service_account", + client_id="fabric-client", + client_secret_env="MCP_CLIENT_SECRET", + token_url="https://auth.example.test/token", + scopes=["mcp:invoke"], + token_endpoint_auth_method="client_secret_basic", + token_cache_buffer_seconds=60, + ), + ) + + assert server.to_mapping()["authentication"] == { + "type": "service_account", + "client_id": "fabric-client", + "client_secret_env": "MCP_CLIENT_SECRET", + "token_url": "https://auth.example.test/token", + "scopes": ["mcp:invoke"], + "token_endpoint_auth_method": "client_secret_basic", + "token_cache_buffer_seconds": 60, + } + + +def test_mcp_oauth_allows_dynamic_registration_to_supply_client_secret(): + authentication = McpAuthenticationConfig( + type="oauth2", + token_endpoint_auth_method="client_secret_post", + ) + + assert authentication.client_id is None + assert authentication.client_secret_env is None + assert authentication.enable_dynamic_registration is True + + +@pytest.mark.parametrize( + "authentication", + [ + {"type": "oauth2", "enable_dynamic_registration": False}, + { + "type": "service_account", + "client_id": "fabric-client", + "client_secret_env": "MCP_CLIENT_SECRET", + }, + { + "type": "service_account", + "client_id": "fabric-client", + "client_secret_env": "MCP_CLIENT_SECRET", + "token_url": "https://auth.example.test/token", + "token_endpoint_auth_method": "none", + }, + ], +) +def test_mcp_authentication_rejects_invalid_policy(authentication): + with pytest.raises(ValidationError): + McpAuthenticationConfig.model_validate(authentication) + + +def test_mcp_config_add_server_preserves_legacy_mcp_extra_fields(): + config = McpConfig().add_server( + "docs", + transport="streamable-http", + url="https://mcp.example.test", + extra_fields={ + "authentication": {"type": "oauth2"}, + "custom_headers": {"X-Tenant": "fabric"}, + }, + ) + + assert config.to_mapping()["servers"]["docs"]["authentication"] == { + "type": "oauth2" + } + assert config.to_mapping()["servers"]["docs"]["custom_headers"] == { + "X-Tenant": "fabric" + } + + +def test_mcp_config_add_server_accepts_custom_headers(): + config = McpConfig().add_server( + "docs", + transport="streamable-http", + url="https://mcp.example.test", + custom_headers={"X-Tenant": "fabric"}, + ) + + assert config.servers["docs"].custom_headers == {"X-Tenant": "fabric"} + assert config.to_mapping()["servers"]["docs"]["custom_headers"] == { + "X-Tenant": "fabric" + } + + +def test_mcp_authentication_rejects_unsupported_type(): + with pytest.raises(ValidationError, match="oauth2"): + McpAuthenticationConfig(type="bearer") # type: ignore[arg-type] + + @pytest.mark.parametrize("field", ["allowed_tools", "blocked_tools"]) def test_mcp_config_rejects_string_tool_policy(field: str): with pytest.raises( @@ -449,6 +611,7 @@ def test_run_plan_config_add_mcp_server_emits_tool_filters(): "docs", transport="streamable-http", url="https://mcp.example.test", + authentication={"type": "oauth2"}, allowed_tools=["search"], blocked_tools=["delete"], ) @@ -457,6 +620,7 @@ def test_run_plan_config_add_mcp_server_emits_tool_filters(): "transport": "streamable-http", "url": "https://mcp.example.test", "exposure": "harness_native", + "authentication": {"type": "oauth2"}, "allowed_tools": ["search"], "blocked_tools": ["delete"], } diff --git a/uv.lock b/uv.lock index d03b93a43..ee29faa9b 100644 --- a/uv.lock +++ b/uv.lock @@ -2316,6 +2316,20 @@ name = "nemo-fabric-adapters-common" version = "0.2.0" source = { editable = "adapters/common" } +[package.optional-dependencies] +mcp-oauth = [ + { name = "httpx" }, + { name = "mcp", version = "1.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", marker = "extra == 'mcp-oauth'", specifier = ">=0.27,<1" }, + { name = "mcp", marker = "extra == 'mcp-oauth'", specifier = ">=1.26,<1.29" }, +] +provides-extras = ["mcp-oauth"] + [[package]] name = "nemo-fabric-adapters-deepagents" version = "0.2.0" @@ -2324,7 +2338,7 @@ dependencies = [ { name = "langchain-mcp-adapters" }, { name = "langchain-openai" }, { name = "langgraph-checkpoint-sqlite" }, - { name = "nemo-fabric-adapters-common" }, + { name = "nemo-fabric-adapters-common", extra = ["mcp-oauth"] }, ] [package.optional-dependencies] @@ -2348,7 +2362,7 @@ requires-dist = [ { name = "langgraph", marker = "extra == 'full'", specifier = ">=1.2,<2.0" }, { name = "langgraph", marker = "extra == 'harness'", specifier = ">=1.2,<2.0" }, { name = "langgraph-checkpoint-sqlite", specifier = ">=3.0,<4.0" }, - { name = "nemo-fabric-adapters-common", editable = "adapters/common" }, + { name = "nemo-fabric-adapters-common", extras = ["mcp-oauth"], editable = "adapters/common" }, { name = "nemo-relay", marker = "extra == 'relay'", specifier = ">=0.6.0,<0.7" }, { name = "nemo-relay", extras = ["deepagents"], marker = "extra == 'full'", specifier = ">=0.6.0,<0.7" }, ]