diff --git a/.agents/skills/contribute-adapter/SKILL.md b/.agents/skills/contribute-adapter/SKILL.md index 6a4ec639e..7f764558d 100644 --- a/.agents/skills/contribute-adapter/SKILL.md +++ b/.agents/skills/contribute-adapter/SKILL.md @@ -68,7 +68,7 @@ uv sync --group adapter-tests uv run --no-sync pytest tests/adapters/test_*.py just test-python just lock-python && just wheels -cargo run -p nemo-fabric-core --example generate-schemas -- schemas +just schemas cargo fmt --all -- --check && just test-rust just docs uv run pre-commit run --all-files --show-diff-on-failure diff --git a/adapter-contract/src/nemo_fabric_adapter_contract/codec.py b/adapter-contract/src/nemo_fabric_adapter_contract/codec.py index 7d75d7084..43f321270 100644 --- a/adapter-contract/src/nemo_fabric_adapter_contract/codec.py +++ b/adapter-contract/src/nemo_fabric_adapter_contract/codec.py @@ -164,6 +164,8 @@ def encode_dataclass(instance: Any) -> dict[str, Any]: continue if item.metadata.get("omit_empty") and not value: continue + if "omit_default" in item.metadata and value == item.metadata["omit_default"]: + continue result[item.name] = _encode_value(value, path=(item.name,)) return result @@ -187,6 +189,16 @@ def _decode_value( if type(None) in arguments and value is None: return None options = tuple(option for option in arguments if option is not type(None)) + if isinstance(value, Mapping) and "type" in value: + tagged_options = [ + option + for option in options + if isinstance(option, type) + and get_origin(_resolved_type_hints(option).get("type")) is Literal + and value["type"] in get_args(_resolved_type_hints(option)["type"]) + ] + if len(tagged_options) == 1: + return _decode_value(tagged_options[0], value, path=path) errors = [] for option in options: try: diff --git a/adapter-contract/src/nemo_fabric_adapter_contract/models.py b/adapter-contract/src/nemo_fabric_adapter_contract/models.py index b84905197..9fe8a8afd 100644 --- a/adapter-contract/src/nemo_fabric_adapter_contract/models.py +++ b/adapter-contract/src/nemo_fabric_adapter_contract/models.py @@ -41,6 +41,10 @@ def _empty_list(): return field(default_factory=list, metadata={"omit_empty": True}) +def _default(value: Any): + return field(default=value, metadata={"omit_default": value}) + + def _json_value_field(*, default: Any = MISSING, omit_empty: bool = False): metadata = {"json": True} if omit_empty: @@ -210,6 +214,108 @@ def _validate_tool_names(value: list[str] | None, field_name: str, label: str) - ) +class OAuthTokenEndpointAuthMethod(StrEnum): + """OAuth client authentication method used at the token endpoint.""" + + NONE = "none" + CLIENT_SECRET_POST = "client_secret_post" + CLIENT_SECRET_BASIC = "client_secret_basic" + + +@dataclass(slots=True, kw_only=True) +class McpOAuth2Config(ContractModel): + """OAuth 2.0 authorization-code authentication for an MCP server.""" + + type: Literal["oauth2"] + client_id: str | None = _optional() + client_secret_env: str | None = _optional() + scopes: list[str] = _empty_list() + redirect_uri: str | None = _optional() + enable_dynamic_registration: bool = _default(True) + client_name: str | None = _optional() + token_endpoint_auth_method: OAuthTokenEndpointAuthMethod | None = _optional() + authorization_timeout_seconds: int = _default(300) + + @property + def scope(self) -> str | None: + """Return scopes in the space-delimited form expected by OAuth clients.""" + + value = " ".join(self.scopes) + return value or None + + def _validate(self) -> None: + for name in ("client_id", "client_secret_env", "redirect_uri", "client_name"): + if (value := getattr(self, name)) is not None: + _nonblank(value, name) + _validate_tool_names(self.scopes, "scopes", "authentication scope") + if self.client_secret_env is not None and self.client_id is None: + raise ContractValidationError( + "requires client_id", path=("client_secret_env",) + ) + if not self.enable_dynamic_registration and self.client_id is None: + raise ContractValidationError( + "is required when dynamic registration is disabled", + path=("client_id",), + ) + if ( + self.token_endpoint_auth_method + in { + OAuthTokenEndpointAuthMethod.CLIENT_SECRET_BASIC, + OAuthTokenEndpointAuthMethod.CLIENT_SECRET_POST, + } + and self.client_id is not None + and self.client_secret_env is None + ): + raise ContractValidationError( + "requires client_secret_env for a pre-registered client", + path=("token_endpoint_auth_method",), + ) + if ( + self.token_endpoint_auth_method is OAuthTokenEndpointAuthMethod.NONE + and self.client_secret_env is not None + ): + raise ContractValidationError( + "'none' cannot be combined with client_secret_env", + path=("token_endpoint_auth_method",), + ) + if not 1 <= self.authorization_timeout_seconds <= (1 << 64) - 1: + raise ContractValidationError( + f"must be greater than zero and less than or equal to {(1 << 64) - 1}", + path=("authorization_timeout_seconds",), + ) + + +@dataclass(slots=True, kw_only=True) +class McpServiceAccountConfig(ContractModel): + """OAuth 2.0 client-credentials authentication for an MCP server.""" + + type: Literal["service_account"] + client_id: str + client_secret_env: str + token_url: str + scopes: list[str] = _empty_list() + token_endpoint_auth_method: OAuthTokenEndpointAuthMethod | None = _optional() + token_cache_buffer_seconds: int = _default(300) + + def _validate(self) -> None: + for name in ("client_id", "client_secret_env", "token_url"): + _nonblank(getattr(self, name), name) + _validate_tool_names(self.scopes, "scopes", "authentication scope") + if self.token_endpoint_auth_method is OAuthTokenEndpointAuthMethod.NONE: + raise ContractValidationError( + "service_account requires client_secret_basic or client_secret_post", + path=("token_endpoint_auth_method",), + ) + _bounded_int( + self.token_cache_buffer_seconds, + "token_cache_buffer_seconds", + (1 << 64) - 1, + ) + + +McpAuthenticationConfig = McpOAuth2Config | McpServiceAccountConfig + + @dataclass(slots=True, kw_only=True) class AgentMcpServerConfig(AgentContractBlock): """One MCP server routed to the adapter target.""" @@ -218,12 +324,18 @@ class AgentMcpServerConfig(AgentContractBlock): url: str args: list[str] = _empty_list() env: dict[str, str] = _empty_dict() + authentication: McpAuthenticationConfig | None = _optional() + custom_headers: dict[str, str] = _empty_dict() allowed_tools: list[str] | None = _optional() blocked_tools: list[str] = _empty_list() def _validate(self) -> None: _nonblank(self.transport, "transport") _nonblank(self.url, "url") + if self.transport != "stdio" and self.env: + raise ContractValidationError( + "env is only valid for stdio transport", path=("env",) + ) _validate_tool_names(self.allowed_tools, "allowed_tools", "MCP tool") _validate_tool_names(self.blocked_tools, "blocked_tools", "MCP tool") if self.allowed_tools is not None: diff --git a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py index 706cc571d..b2caf092c 100644 --- a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py +++ b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py @@ -10,6 +10,7 @@ import logging import math import os +import re import shutil import subprocess from dataclasses import asdict @@ -87,6 +88,11 @@ "https_proxy", "no_proxy", } +MCP_HEADER_ENVIRONMENT_VARIABLE = re.compile( + r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}" + r"|\$([A-Za-z_][A-Za-z0-9_]*)" + r"|%([A-Za-z_][A-Za-z0-9_]*)%" +) @dataclass(frozen=True) @@ -291,16 +297,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,9 +323,38 @@ 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: + common_utils.validate_http_headers(name, headers) + result[name]["headers"] = headers + except ValueError as error: + raise AdapterConfigError( + "claude_invalid_configuration", str(error) + ) from error + + auth = server.get("authentication") + if auth is not None: + raise AdapterConfigError( + "claude_invalid_configuration", + f"MCP server {name!r} {auth.get('type')!r} authentication is not supported by Claude", + ) return result +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 _stage_mcp_config(payload: dict[str, Any]) -> ClaudeMcpSettings | None: # Dictionary-valued ClaudeAgentOptions.mcp_servers are JSON-serialized by # claude-agent-sdk into the literal `--mcp-config` command-line argument, @@ -342,33 +370,61 @@ 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 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 ) - if not isinstance(value, str): - raise AdapterConfigError( - "claude_invalid_configuration", - f"MCP server {server_name} env values must be strings", + server["env"] = projected_environment + + if headers := server.get("headers"): + projected_headers: dict[str, str] = {} + for header_name, header_value in headers.items(): + + def project_header_reference(match: re.Match[str]) -> str: + variable_name = next( + group for group in match.groups() if group is not None + ) + if variable_name in os.environ: + value = os.environ[variable_name] + else: + return match.group(0) + return project_environment_value( + server_name, f"header:{header_name}:{variable_name}", value + ) + + projected_headers[header_name] = MCP_HEADER_ENVIRONMENT_VARIABLE.sub( + project_header_reference, header_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["headers"] = projected_headers config_root = ( _artifact_root(payload) @@ -402,8 +458,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") @@ -679,6 +734,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 @@ -794,6 +853,7 @@ def child_environment( values.update( {name: os.environ[name] for name in INHERITED_ENV_NAMES if name in os.environ} ) + model = _selected_model_config(payload) api_key_env = model.get("api_key_env") if isinstance(api_key_env, str) and api_key_env in os.environ: @@ -947,8 +1007,10 @@ async def start(self, payload: dict[str, Any]) -> None: self._relay = relay self._gateway_process = _start_relay_gateway(payload, relay) options = build_options(payload, relay=relay) + if isinstance(options.mcp_servers, Path): self._mcp_config_path = options.mcp_servers + client = ClaudeSDKClient(options) await client.connect() except ClaudeAdapterError as error: @@ -990,11 +1052,15 @@ 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) except ClaudeAdapterError as error: output = adapter_failure(error) + except ClaudeSDKError as error: + output = sdk_failure(error) else: relay = self._relay atif_before = ( @@ -1007,7 +1073,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") @@ -1042,14 +1108,14 @@ async def _run_query( 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): diff --git a/adapters/codex/pyproject.toml b/adapters/codex/pyproject.toml index f3fb1a59a..3fb114738 100644 --- a/adapters/codex/pyproject.toml +++ b/adapters/codex/pyproject.toml @@ -26,6 +26,7 @@ readme = "pypi.md" requires-python = ">=3.11" dependencies = [ "nemo-fabric-adapters-common == 0.2.0", + "nemo-fabric-adapter-contract == 0.2.0", "tomli-w~=1.2", ] @@ -51,4 +52,5 @@ include = ["nemo_fabric_adapters.codex*"] "share/nemo-fabric/adapters/codex" = ["fabric-adapter.json"] [tool.uv.sources] +nemo-fabric-adapter-contract = { path = "../../adapter-contract", editable = true } nemo-fabric-adapters-common = { path = "../common", editable = true } diff --git a/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py b/adapters/codex/src/nemo_fabric_adapters/codex/adapter.py index e6d368f60..b86ecdf9b 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 +import webbrowser from dataclasses import asdict, dataclass, is_dataclass from enum import Enum from pathlib import Path @@ -26,9 +27,19 @@ 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 +from nemo_fabric_adapter_contract.models import AgentMcpServerConfig +from nemo_fabric_adapter_contract.models import McpAuthenticationConfig +from nemo_fabric_adapter_contract.models import McpOAuth2Config +from nemo_fabric_adapter_contract.models import McpServiceAccountConfig import nemo_fabric_adapters.common.relay_gateway as relay_gateway import nemo_fabric_adapters.common.relay_hooks as relay_hooks import nemo_fabric_adapters.common.relay_artifacts as relay_artifacts @@ -47,11 +58,20 @@ "auto_review": ApprovalMode.auto_review, "deny_all": ApprovalMode.deny_all, } + + +async def _open_authorization_url(authorization_url: str) -> bool: + """Open a Codex OAuth authorization URL without blocking the event loop.""" + + return await asyncio.to_thread(webbrowser.open, authorization_url) + + INHERITED_ENV_NAMES = { "APPDATA", "CODEX_HOME", "CODEX_SQLITE_HOME", "COMSPEC", + "DBUS_SESSION_BUS_ADDRESS", "HOME", "HTTP_PROXY", "HTTPS_PROXY", @@ -74,6 +94,7 @@ "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", "http_proxy", "https_proxy", "no_proxy", @@ -152,55 +173,100 @@ def _native_capabilities(payload: dict[str, Any]) -> dict[str, Any]: return _mapping(plan.get("native"), name="capability_plan.native") -def _native_mcp_servers(payload: dict[str, Any]) -> dict[str, dict[str, Any]]: +def _native_mcp_server_specs( + payload: dict[str, Any], +) -> dict[str, AgentMcpServerConfig]: servers = _mapping( _native_capabilities(payload).get("mcp_servers"), name="native MCP servers", ) - result: dict[str, dict[str, Any]] = {} + result: dict[str, AgentMcpServerConfig] = {} for name, raw in sorted(servers.items()): - if not isinstance(name, str) or not name: - raise AdapterConfigError( - "codex_invalid_configuration", - "MCP server names must be non-empty strings", - ) - server = _mapping(raw, name=f"MCP server {name}") - transport = server.get("transport") - if not isinstance(transport, str) or not transport: - raise AdapterConfigError( - "codex_invalid_configuration", - f"MCP server {name} transport is required", - ) - target = server.get("url") - if not isinstance(target, str) or not target: - raise AdapterConfigError( - "codex_invalid_configuration", - f"MCP server {name} URL is required", - ) - target = os.path.expandvars(target).strip() - if not target: - raise AdapterConfigError( - "codex_invalid_configuration", - f"MCP server {name} URL is required", - ) - normalized_transport = transport.strip().lower().replace("_", "-") + server = dict(_mapping(raw, name=f"MCP server {name}")) + server.pop("exposure", None) + result[name] = AgentMcpServerConfig.from_mapping(server) + return result + + +def _native_mcp_servers(payload: dict[str, Any]) -> dict[str, dict[str, Any]]: + result: dict[str, dict[str, Any]] = {} + for name, server in _native_mcp_server_specs(payload).items(): + target = os.path.expandvars(server.url).strip() + normalized_transport = server.transport.strip().lower().replace("_", "-") if normalized_transport == "stdio": result[name] = { "command": target, - "args": common_utils.normalize_list(server.get("args")), + "args": server.args, } - if env := server.get("env"): + if env := server.env: result[name]["env"] = env elif normalized_transport in {"http", "streamable-http"}: result[name] = {"url": target} else: raise AdapterConfigError( "codex_invalid_configuration", - f"unsupported Codex MCP transport: {transport}", + f"unsupported Codex MCP transport: {server.transport}", ) + if headers := server.custom_headers: + try: + headers = common_utils.expand_http_headers(name, headers) + except ValueError as error: + raise AdapterConfigError( + "codex_invalid_configuration", str(error) + ) from error + result[name]["http_headers"] = headers + if authentication := server.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: McpAuthenticationConfig) -> McpOAuth2Config: + if isinstance(value, McpServiceAccountConfig): + raise AdapterConfigError( + "codex_invalid_configuration", + f"MCP server {name!r} service_account authentication is not supported by Codex", + ) + return value + + +def _mcp_oauth_callback_url(payload: dict[str, Any]) -> str | None: + values = { + oauth.redirect_uri + for name, server in _native_mcp_server_specs(payload).items() + if (authentication := server.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 +326,177 @@ async def _register_skill_roots(codex: AsyncCodex, skill_paths: list[Path]) -> N ) +def _mcp_oauth_servers( + payload: dict[str, Any], +) -> dict[str, McpOAuth2Config]: + result: dict[str, McpOAuth2Config] = {} + for name, server in _native_mcp_server_specs(payload).items(): + authentication = server.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, timeout: float +) -> dict[str, McpAuthStatus]: + statuses: dict[str, McpAuthStatus] = {} + seen_cursors: set[str] = set() + cursor: str | None = None + try: + async with asyncio.timeout(timeout): + while True: + params: dict[str, Any] = { + "detail": "toolsAndAuthOnly", + "threadId": thread_id, + } + if cursor is not None: + params["cursor"] = cursor + try: + response = await client.request( + "mcpServerStatus/list", + params, + response_model=ListMcpServerStatusResponse, + ) + except TimeoutError as error: + raise AdapterConfigError( + "codex_mcp_authentication_failed", + "Codex MCP status listing request timed out", + ) from error + statuses.update( + {server.name: server.auth_status for server in response.data} + ) + cursor = response.next_cursor + if cursor is None: + return statuses + if cursor in seen_cursors: + raise AdapterConfigError( + "codex_mcp_authentication_failed", + "Codex MCP status listing returned a repeated cursor", + ) + seen_cursors.add(cursor) + except TimeoutError as error: + raise AdapterConfigError( + "codex_mcp_authentication_failed", + "Codex MCP status listing timed out", + ) from error + + +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 _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, + timeout=invocation_timeout_seconds, + ) + 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", + ) + if status is None: + raise AdapterConfigError( + "codex_mcp_authentication_failed", + f"Codex did not report a status for MCP server {name!r}", + ) + 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 +857,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 +1232,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 +1311,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 +1372,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..c22b1f754 100644 --- a/adapters/codex/uv.lock +++ b/adapters/codex/uv.lock @@ -11,11 +11,21 @@ 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 = "nemo-fabric-adapter-contract" +version = "0.2.0" +source = { editable = "../../adapter-contract" } + +[package.metadata] +requires-dist = [{ name = "pydantic", marker = "extra == 'pydantic'", specifier = ">=2.12,<3" }] +provides-extras = ["pydantic"] + [[package]] name = "nemo-fabric-adapters-codex" version = "0.2.0" source = { editable = "." } dependencies = [ + { name = "nemo-fabric-adapter-contract" }, { name = "nemo-fabric-adapters-common" }, { name = "tomli-w" }, ] @@ -30,6 +40,7 @@ harness = [ [package.metadata] requires-dist = [ + { name = "nemo-fabric-adapter-contract", editable = "../../adapter-contract" }, { name = "nemo-fabric-adapters-common", editable = "../common" }, { name = "openai-codex", marker = "extra == 'full'", specifier = "==0.144.4" }, { name = "openai-codex", marker = "extra == 'harness'", specifier = "==0.144.4" }, diff --git a/adapters/common/src/nemo_fabric_adapters/common/utils.py b/adapters/common/src/nemo_fabric_adapters/common/utils.py index 9eabc38b1..2d0fc1d1d 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/utils.py +++ b/adapters/common/src/nemo_fabric_adapters/common/utils.py @@ -8,11 +8,86 @@ import glob import json import os +import re import sys from pathlib import Path from typing import Any +_FIELD_NAME = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+") + + +def validate_http_header(server_name: str, name: str, value: str) -> None: + """Validate one HTTP header name and value.""" + + if not isinstance(name, str) or not _FIELD_NAME.fullmatch(name): + raise ValueError( + f"Invalid HTTP header name {name!r} for MCP server {server_name!r}" + ) + + if not isinstance(value, str): + raise TypeError( + f"HTTP header value for {name!r} on MCP server {server_name!r} " + "must be a string" + ) + + if not value or not value.strip(): + raise ValueError( + f"HTTP header value for {name!r} on MCP server {server_name!r} " + "must not be blank" + ) + + try: + encoded = value.encode("latin-1") + except UnicodeEncodeError as error: + raise ValueError( + f"HTTP header value for {name!r} on MCP server {server_name!r} " + "is not Latin-1 encodable" + ) from error + + if value[:1] in (" ", "\t") or value[-1:] in (" ", "\t"): + raise ValueError( + f"HTTP header value for {name!r} on MCP server {server_name!r} " + "has outer whitespace" + ) + + if any((byte < 0x20 and byte != 0x09) or byte == 0x7F for byte in encoded): + raise ValueError( + f"HTTP header value for {name!r} on MCP server {server_name!r} " + "contains a control character" + ) + + +def validate_http_headers(server_name: str, value: dict[str, str]) -> None: + """ + Validate an MCP custom-header mapping. + + Use this method for harnesses that support environment variable expansion + in HTTP headers. For harnesses that don't support environment variable + expansion, use expand_http_headers instead. + """ + + for name, item in value.items(): + validate_http_header(server_name, name, item) + + +def expand_http_headers(server_name: str, value: dict[str, str]) -> dict[str, str]: + """ + Expand environment variables and validate an MCP custom-header mapping. + + Use this method instead of validate_http_headers for harnesses that don't + support environment variable expansion in HTTP headers. + """ + + expanded: dict[str, str] = {} + for name, item in value.items(): + item = os.path.expandvars(item) + validate_http_header(server_name, name, item) + expanded[name] = item + + return expanded + + def current_virtualenv() -> Path | None: """Return the current virtual environment, if Python is running in one.""" diff --git a/adapters/common/uv.lock b/adapters/common/uv.lock index b7d8656f9..c316e5f4f 100644 --- a/adapters/common/uv.lock +++ b/adapters/common/uv.lock @@ -1,6 +1,12 @@ 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 = "nemo-fabric-adapters-common" diff --git a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py index 9be0e0804..68ca550ba 100644 --- a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py +++ b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py @@ -286,7 +286,10 @@ 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], +) -> 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 +315,20 @@ 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: + headers = common_utils.expand_http_headers(name, headers) + except Exception as error: + raise AdapterConfigError(f"{error}.") from error + connection["headers"] = headers + + auth = spec.get("authentication") + if auth is not None: + raise AdapterConfigError( + f"MCP server {name!r} {auth.get('type')!r} authentication is not supported by Deep Agents." + ) + return connection # --- runtime state --------------------------------------------------------- diff --git a/adapters/deepagents/uv.lock b/adapters/deepagents/uv.lock index bc1eef8ec..312f1d6e9 100644 --- a/adapters/deepagents/uv.lock +++ b/adapters/deepagents/uv.lock @@ -885,10 +885,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..4efae7d2a 100644 --- a/adapters/hermes/src/nemo_fabric_adapters/hermes/configuration.py +++ b/adapters/hermes/src/nemo_fabric_adapters/hermes/configuration.py @@ -12,6 +12,8 @@ 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_adapter_contract.models import McpOAuth2Config +from nemo_fabric_adapter_contract.models import McpServiceAccountConfig import nemo_fabric_adapters.common.utils as common_utils @@ -23,6 +25,21 @@ } +def _validate_client_secret( + server_name: str, + config: McpOAuth2Config, +): + """Resolve a Hermes OAuth client secret without retaining or logging it.""" + + if config.client_secret_env is not None: + secret = os.environ.get(config.client_secret_env) + if not secret: + raise ValueError( + f"MCP server {server_name!r} authentication.client_secret_env " + "references an unset environment variable" + ) + + def _settings(config: AgentConfig) -> dict[str, Any]: return config.harness.settings if config.harness is not None else {} @@ -104,7 +121,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 +155,21 @@ 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( + f"MCP server {name!r} authentication is not supported for stdio transport" + ) + if server.custom_headers: + raise ValueError( + f"MCP server {name!r} custom_headers are not supported for stdio transport" + ) return common_utils.without_none( { "enabled": True, @@ -154,7 +179,41 @@ 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: + common_utils.validate_http_headers(name, headers) + result["headers"] = headers + if authentication := server.authentication: + if isinstance(authentication, McpServiceAccountConfig): + raise ValueError( + f"MCP server {name!r} service_account authentication is not supported by Hermes" + ) + 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" + ) + + 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: + _validate_client_secret(name, authentication) + 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/crates/fabric-core/src/agent_config.rs b/crates/fabric-core/src/agent_config.rs index b17a753ae..c5882a20b 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, + McpAuthenticationConfig, }; /// 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,12 @@ pub(crate) fn project_agent_config( ( name.clone(), AgentMcpServerConfig { - transport: server.transport.clone(), + transport: server.transport.as_str().to_string(), url: server.url.clone(), args: server.args.clone(), env: server.env.clone(), + authentication: server.authentication.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..c9d988ef1 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -707,12 +707,130 @@ 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, +} + +impl McpTransport { + /// Return the stable configuration value for this transport. + pub fn as_str(self) -> &'static str { + match self { + Self::Stdio => "stdio", + Self::Sse => "sse", + Self::StreamableHttp => "streamable-http", + } + } +} + +/// 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" + )] + #[schemars(range(min = 1))] + 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 +838,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 +852,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 +1448,137 @@ 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.env.is_empty() { + return invalid_config(format!("{field}.env"), "is only valid for stdio transport"); + } + 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 +2477,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 +2943,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 +2952,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 +3181,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 +3195,161 @@ mod tests { assert!(server.extensions.is_empty()); } + #[test] + fn mcp_env_requires_stdio_transport() { + for transport in [McpTransport::Sse, McpTransport::StreamableHttp] { + let mut config = typed_config("nvidia.fabric.hermes"); + config.mcp = Some(McpConfig { + servers: BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport, + url: "https://mcp.example".to_string(), + args: Vec::new(), + env: BTreeMap::from([("MCP_SECRET".to_string(), "secret".to_string())]), + authentication: None, + custom_headers: BTreeMap::new(), + exposure: McpExposure::HarnessNative, + allowed_tools: None, + blocked_tools: Vec::new(), + extensions: BTreeMap::new(), + }, + )]), + extensions: BTreeMap::new(), + }); + + let error = resolve_run_plan_from_config( + config, + ResolveContext::new("/tmp/fabric-invalid-mcp-env"), + ) + .expect_err("HTTP MCP env must be rejected"); + + assert!(matches!( + error, + FabricError::InvalidConfig { field, .. } + if field == "mcp.servers.docs.env" + )); + } + } + + #[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_transport_as_str_matches_serialized_value() { + for transport in [ + McpTransport::Stdio, + McpTransport::Sse, + McpTransport::StreamableHttp, + ] { + assert_eq!( + serde_json::to_value(transport).expect("serialize MCP transport"), + transport.as_str() + ); + } + } + + #[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, server.authentication); + } + #[test] fn agent_config_projects_only_harness_native_mcp_servers() { let path = repository_root().join("adapters/hermes/fabric-adapter.json"); @@ -2979,6 +3397,127 @@ 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", + "authorization_timeout_seconds": 0 + }), + "authorization_timeout_seconds", + ), + ( + 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 +3907,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 +3957,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 +4009,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 +4048,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 +4094,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/crates/fabric-core/src/schema.rs b/crates/fabric-core/src/schema.rs index 3c9a045ed..b060c497f 100644 --- a/crates/fabric-core/src/schema.rs +++ b/crates/fabric-core/src/schema.rs @@ -307,6 +307,11 @@ mod tests { schema["$defs"]["RuntimeConfig"]["properties"]["timeout_seconds"]["exclusiveMinimum"], 0.0 ); + assert_eq!( + schema["$defs"]["McpAuthenticationConfig"]["oneOf"][0]["properties"]["authorization_timeout_seconds"] + ["minimum"], + 1 + ); } #[test] diff --git a/docs/adapter-contract/normalized-configuration.md b/docs/adapter-contract/normalized-configuration.md index 2cb3128c1..cd4367199 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. | @@ -46,7 +46,9 @@ FabricConfig + adapter descriptor + resolved capability plan Use the generated [`AgentConfig` JSON Schema](https://github.com/NVIDIA/NeMo-Fabric/blob/main/schemas/adapter-contract/agent-config.schema.json) for exact fields and constraints. Python adapters can import matching -dataclasses from `nemo_fabric_adapter_contract.models`. +dataclasses from `nemo_fabric_adapter_contract.models`. MCP authentication is +decoded as `McpOAuth2Config` or `McpServiceAccountConfig` before the adapter +receives `AgentMcpServerConfig`. ## Projection Rules @@ -55,6 +57,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..438bfa9e1 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()` | — | — | +| `env` | `dict[str, str]` | No | `dict()` | — | Environment variables passed to an MCP stdio server process. | +| `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..9c0d38403 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<McpAuthenticationConfig>,\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..e1269d89f --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-mcptransport.mdx @@ -0,0 +1,134 @@ +--- +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. + +## Implementations + +### `impl McpTransport` + +
McpTransport"}} />
+ +#### `as_str` + +
str"}} />
+ +Return the stable configuration value for this 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..7a85149c7 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/docs/sdk/python.mdx b/docs/sdk/python.mdx index 2fa8de2ca..4b75ba806 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -232,6 +232,75 @@ capability_config.enable_relay( ) ``` +### Configure MCP Authentication And Headers + +MCP authentication and custom-header support vary by adapter: + +| Adapter | OAuth 2.0 Authentication | HTTP `custom_headers` | +| --- | --- | --- | +| Claude | Unsupported | Supported | +| Codex | Supported | Supported | +| Deep Agents | Unsupported | Supported | +| Hermes Agent | Supported | Supported | + +Claude and Deep Agents return a configuration error when `authentication` is +set. The bundled adapters do not support `service_account` MCP authentication. + +Configure OAuth 2.0 with `McpAuthenticationConfig`. The adapter and MCP server +complete the authorization flow before the first agent turn. The flow can +require interactive browser authorization. + +```python +from nemo_fabric import McpAuthenticationConfig + +capability_config.add_mcp_server( + "github", + transport="streamable-http", + url="https://mcp.example.com/mcp", + authentication=McpAuthenticationConfig( + type="oauth2", + scopes=["repo:read"], + ), + exposure="harness_native", +) +``` + +Use `custom_headers` to send HTTP headers with an `sse` or `streamable-http` +server. Header names and values must be valid HTTP header text. To provide a +sensitive value, set it in the process environment and use a `${NAME}` +reference in the header value: + +```python +import getpass +import os + +os.environ["MCP_ACCESS_TOKEN"] = getpass.getpass("MCP access token: ") + +capability_config.add_mcp_server( + "private-api", + transport="streamable-http", + url="https://mcp.example.com/mcp", + authentication=None, + custom_headers={ + "Authorization": "Bearer ${MCP_ACCESS_TOKEN}", + "X-Tenant": "example-team", + }, +) +``` + +Set referenced environment variables before calling `run(...)` or +`start_runtime(...)`. Do not use `McpServerConfig.env` for HTTP header +variables; `env` applies only to `stdio` MCP server processes. + + + The Claude adapter stages its MCP configuration in a file for the Claude SDK. + A sensitive value in `custom_headers` can therefore be written to + disk. Store sensitive values in environment variables and use `${NAME}` in + `custom_headers`. The adapter stages the reference and supplies the value + through the scoped child-process environment instead of writing the value to + the MCP configuration file. + + MCP tool policy is scoped to one server and uses the tool names that server advertises. `allowed_tools=None` exposes every discovered tool, while an empty allowlist exposes none. `blocked_tools` is then removed from the allowed or diff --git a/justfile b/justfile index 3dcd67ce8..10480de62 100644 --- a/justfile +++ b/justfile @@ -298,8 +298,12 @@ build-python: --reinstall-package nemo-fabric-runtime fi +# Generate the JSON Schema files from the Rust configuration types. +schemas: + cargo run -p nemo-fabric-core --example generate-schemas -- schemas + # Build all supported language packages. -build-all: build-rust build-python +build-all: build-rust build-python schemas # Create or update the lockfile for every Python project. lock-python: 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..d57c3f496 100644 --- a/python/src/nemo_fabric/models.py +++ b/python/src/nemo_fabric/models.py @@ -292,10 +292,126 @@ def remove_path(self, path: str | Path) -> Self: return self +class McpAuthenticationConfig(FabricBaseModel): + """MCP server authentication configuration.""" + + model_config = ConfigDict(extra="forbid") + + 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": + service_account_fields = self.model_fields_set.intersection( + {"token_url", "token_cache_buffer_seconds"} + ) + if service_account_fields: + name = sorted(service_account_fields)[0] + raise ValueError( + f"{name} is only valid for service_account authentication" + ) + 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_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 + + oauth2_fields = self.model_fields_set.intersection( + { + "redirect_uri", + "enable_dynamic_registration", + "client_name", + "authorization_timeout_seconds", + } + ) + if oauth2_fields: + name = sorted(oauth2_fields)[0] + raise ValueError(f"{name} is only valid for oauth2 authentication") + 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.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=( @@ -307,7 +423,22 @@ class McpServerConfig(FabricBaseModel): exclude_if=lambda value: not value, 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) + env: dict[str, str] = Field( + default_factory=dict, + exclude_if=lambda value: not value, + description="Environment variables passed to an MCP stdio server process.", + ) + 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, @@ -341,6 +472,8 @@ def _validate_tool_names(cls, value: list[str] | None) -> list[str] | None: @model_validator(mode="after") def _validate_tool_policy(self) -> Self: + if self.transport != "stdio" and self.env: + raise ValueError("env is only valid for stdio transport") if self.allowed_tools is not None: overlap = set(self.allowed_tools).intersection(self.blocked_tools) if overlap: @@ -370,6 +503,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 +519,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 +531,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 +874,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 +895,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/SCHEMA.md b/schemas/SCHEMA.md index 5f954dcd4..9faeb341b 100644 --- a/schemas/SCHEMA.md +++ b/schemas/SCHEMA.md @@ -106,7 +106,7 @@ and export them here. Use the core generator to regenerate them after intentional contract changes: ```bash -cargo run -p nemo-fabric-core --example generate-schemas -- schemas +just schemas ``` To add a new schema-backed typed model: diff --git a/schemas/adapter-contract/agent-config.schema.json b/schemas/adapter-contract/agent-config.schema.json index 2aad9230f..49a5adaeb 100644 --- a/schemas/adapter-contract/agent-config.schema.json +++ b/schemas/adapter-contract/agent-config.schema.json @@ -106,6 +106,17 @@ }, "type": "array" }, + "authentication": { + "anyOf": [ + { + "$ref": "#/$defs/McpAuthenticationConfig" + }, + { + "type": "null" + } + ], + "description": "Authentication used by an HTTP MCP server." + }, "blocked_tools": { "description": "MCP tool names blocked after applying the optional allowlist.", "items": { @@ -113,6 +124,13 @@ }, "type": "array" }, + "custom_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "HTTP headers passed to an MCP server.", + "type": "object" + }, "env": { "additionalProperties": { "type": "string" @@ -368,6 +386,154 @@ "type": "string" } ] + }, + "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": 1, + "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" + } + ] + }, + "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" + } + ] } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/schemas/agent.schema.json b/schemas/agent.schema.json index c36304df5..0eb3bab99 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": 1, + "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..5217aab0d 100644 --- a/schemas/run-plan.schema.json +++ b/schemas/run-plan.schema.json @@ -538,6 +538,17 @@ }, "type": "array" }, + "authentication": { + "anyOf": [ + { + "$ref": "#/$defs/McpAuthenticationConfig" + }, + { + "type": "null" + } + ], + "description": "Authentication used by an HTTP MCP server." + }, "blocked_tools": { "description": "MCP tool names blocked after applying the optional allowlist.", "items": { @@ -545,6 +556,13 @@ }, "type": "array" }, + "custom_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "HTTP headers passed to an MCP server.", + "type": "object" + }, "env": { "additionalProperties": { "type": "string" @@ -1297,6 +1315,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": 1, + "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 +1493,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 +1511,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 +1530,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 +1566,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 +1584,13 @@ }, "type": "array" }, + "custom_headers": { + "additionalProperties": { + "type": "string" + }, + "description": "HTTP headers passed to an MCP server.", + "type": "object" + }, "env": { "additionalProperties": { "type": "string" @@ -1421,8 +1603,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 +1618,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 +1705,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..d334c74f6 100644 --- a/skills/nemo-fabric-integrate/references/config-mapping.md +++ b/skills/nemo-fabric-integrate/references/config-mapping.md @@ -24,7 +24,7 @@ Import these from the top-level `nemo_fabric` package: | `RuntimeConfig` | Input/output labels, artifact location, invocation timeout, and harness turn limit. | | `EnvironmentConfig` | Execution environment, workspace, and harness-visible variables. | | `ToolsConfig` / `ToolDefinitionConfig` | Named tool and tool-group definitions plus selection and blocking policy. | -| `McpConfig` / `McpServerConfig` | MCP transport, network URL or stdio executable, separate process arguments, environment, exposure, and optional per-server tool policy. | +| `McpConfig` / `McpServerConfig` | MCP transport, network URL or stdio executable, separate stdio process arguments and environment, exposure, and optional per-server tool policy. | | `SkillConfig` | Skill directories. | | `TelemetryConfig` | Telemetry providers. | | `RelayConfig` and `Relay*Config` | NVIDIA NeMo Relay observability under the top-level `relay` block. | @@ -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/_utils/mock_api_server.py b/tests/_utils/mock_api_server.py index 119e9f3e3..31b0bcacb 100644 --- a/tests/_utils/mock_api_server.py +++ b/tests/_utils/mock_api_server.py @@ -8,7 +8,7 @@ from contextlib import contextmanager from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.responses import JSONResponse, Response, StreamingResponse import uvicorn @@ -32,6 +32,7 @@ def mock_api_server(port: int) -> Iterator[str]: app.state.status_code = 200 app.state.tool_call = None app.state.tool_call_sent = False + app.state.mcp_authorization_headers = [] @app.get("/health") def health() -> dict[str, str]: @@ -70,6 +71,54 @@ async def scenario(request: Request) -> dict[str, object]: "tool_call": app.state.tool_call, } + @app.get("/_mcp_authorization_headers") + def mcp_authorization_headers() -> list[str | None]: + return list(app.state.mcp_authorization_headers) + + @app.post("/mcp") + async def mcp(request: Request): + payload = await request.json() + app.state.mcp_authorization_headers.append(request.headers.get("authorization")) + method = payload.get("method") + if method == "notifications/initialized": + return Response(status_code=202) + if method == "initialize": + result = { + "protocolVersion": payload["params"]["protocolVersion"], + "capabilities": {"tools": {}}, + "serverInfo": {"name": "fabric-header-test", "version": "1.0.0"}, + } + elif method == "tools/list": + result = { + "tools": [ + { + "name": "get_authorization_header", + "description": "Return the Authorization header sent to the MCP server.", + "inputSchema": {"type": "object", "properties": {}}, + } + ] + } + elif method == "tools/call": + result = { + "content": [ + { + "type": "text", + "text": request.headers.get("authorization", ""), + } + ] + } + elif method == "ping": + result = {} + else: + return JSONResponse( + { + "jsonrpc": "2.0", + "id": payload.get("id"), + "error": {"code": -32601, "message": "Method not found"}, + } + ) + return JSONResponse({"jsonrpc": "2.0", "id": payload["id"], "result": result}) + @app.post("/v1/chat/completions") async def chat_completions(request: Request): payload = await request.json() diff --git a/tests/adapter_contract/test_agent_config.py b/tests/adapter_contract/test_agent_config.py index 461e3e5ba..3d87325d3 100644 --- a/tests/adapter_contract/test_agent_config.py +++ b/tests/adapter_contract/test_agent_config.py @@ -26,6 +26,9 @@ from nemo_fabric_adapter_contract.models import AgentToolsConfig from nemo_fabric_adapter_contract.models import AgentWorkflowConfig from nemo_fabric_adapter_contract.models import AgentWorkflowEntrypointConfig +from nemo_fabric_adapter_contract.models import McpOAuth2Config +from nemo_fabric_adapter_contract.models import McpServiceAccountConfig +from nemo_fabric_adapter_contract.models import OAuthTokenEndpointAuthMethod from nemo_fabric_adapter_contract.codec import ContractValidationError from nemo_fabric_adapter_contract.pydantic_support import extension_schema from nemo_fabric_adapter_contract.pydantic_support import set_pydantic_extensions @@ -171,6 +174,135 @@ 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 isinstance(server.authentication, McpOAuth2Config) + 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"}, + } + + +@pytest.mark.parametrize("transport", ["sse", "streamable-http"]) +def test_agent_mcp_server_rejects_env_for_http_transport(transport): + with pytest.raises( + ContractValidationError, match=r"env: env is only valid for stdio transport" + ): + AgentMcpServerConfig.from_mapping( + { + "transport": transport, + "url": "https://mcp.example.test/mcp", + "env": {"MCP_SECRET": "secret"}, + } + ) + + +def test_mcp_oauth2_config_matches_rust_defaults_and_wire_shape(): + authentication = McpOAuth2Config.from_mapping( + { + "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", + "token_endpoint_auth_method": "client_secret_post", + } + ) + + assert authentication.enable_dynamic_registration is True + assert authentication.authorization_timeout_seconds == 300 + assert authentication.scope == "read write" + assert authentication.token_endpoint_auth_method is ( + OAuthTokenEndpointAuthMethod.CLIENT_SECRET_POST + ) + assert authentication.to_mapping() == { + "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", + "token_endpoint_auth_method": "client_secret_post", + } + + +def test_mcp_service_account_config_matches_rust_defaults_and_wire_shape(): + authentication = McpServiceAccountConfig.from_mapping( + { + "type": "service_account", + "client_id": "fabric-client", + "client_secret_env": "FABRIC_MCP_CLIENT_SECRET", + "token_url": "https://auth.example.test/token", + } + ) + + assert authentication.token_cache_buffer_seconds == 300 + assert authentication.to_mapping() == { + "type": "service_account", + "client_id": "fabric-client", + "client_secret_env": "FABRIC_MCP_CLIENT_SECRET", + "token_url": "https://auth.example.test/token", + } + + +@pytest.mark.parametrize( + ("authentication", "path"), + [ + ({"type": "oauth2", "unknown": True}, "authentication"), + ( + {"type": "oauth2", "client_secret_env": "MCP_CLIENT_SECRET"}, + "authentication.client_secret_env", + ), + ( + {"type": "oauth2", "authorization_timeout_seconds": 0}, + "authentication.authorization_timeout_seconds", + ), + ( + { + "type": "service_account", + "client_id": "fabric-client", + "client_secret_env": "MCP_CLIENT_SECRET", + }, + "authentication", + ), + ( + { + "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", + }, + "authentication.token_endpoint_auth_method", + ), + ], +) +def test_agent_mcp_server_rejects_invalid_authentication(authentication, path): + with pytest.raises(ContractValidationError, match=path.replace(".", r"\.")): + AgentMcpServerConfig.from_mapping( + { + "transport": "streamable-http", + "url": "https://mcp.example.test/mcp", + "authentication": authentication, + } + ) + + def test_agent_config_model_tracks_rust_schema_root_fields(): rust_schema = json.loads( (ROOT / "schemas/adapter-contract/agent-config.schema.json").read_text( @@ -195,8 +327,37 @@ def test_agent_config_dataclasses_track_rust_schema_block_fields(): if model not in {AgentConfig, AgentConfigBlock} } - assert set(blocks) == set(rust_schema["$defs"]).difference({"InstructionMode"}) + assert set(blocks) == set(rust_schema["$defs"]).difference( + { + "InstructionMode", + "McpAuthenticationConfig", + "OAuthTokenEndpointAuthMethod", + } + ) for name, model in blocks.items(): assert {item.name for item in fields(model)} == set( rust_schema["$defs"][name]["properties"] ) + + +def test_mcp_authentication_dataclasses_track_rust_schema_variants(): + rust_schema = json.loads( + (ROOT / "schemas/adapter-contract/agent-config.schema.json").read_text( + encoding="utf-8" + ) + ) + variants = rust_schema["$defs"]["McpAuthenticationConfig"]["oneOf"] + variants_by_type = { + variant["properties"]["type"]["const"]: variant for variant in variants + } + + assert {item.name for item in fields(McpOAuth2Config)} == set( + variants_by_type["oauth2"]["properties"] + ) + assert {item.name for item in fields(McpServiceAccountConfig)} == set( + variants_by_type["service_account"]["properties"] + ) + assert {item.value for item in OAuthTokenEndpointAuthMethod} == { + variant["const"] + for variant in rust_schema["$defs"]["OAuthTokenEndpointAuthMethod"]["oneOf"] + } diff --git a/tests/adapters/test_adapaters_common_utils.py b/tests/adapters/test_adapaters_common_utils.py index 817f05b12..75c7b5d70 100644 --- a/tests/adapters/test_adapaters_common_utils.py +++ b/tests/adapters/test_adapaters_common_utils.py @@ -4,6 +4,7 @@ import builtins import json import os +import re import sys import tomllib from io import StringIO @@ -14,6 +15,78 @@ import pytest +@pytest.mark.parametrize( + "name", + [ + "", + " ", + "X Foo", + "X:Foo", + "X-Föö", + "X-Foo\0", + "X-Foo\v", + "X-Foo\r", + "X-Foo\n", + ], +) +def test_validate_http_headers_rejects_invalid_names(name): + with pytest.raises( + ValueError, + match=re.escape(f"Invalid HTTP header name {name!r} for MCP server 'docs'"), + ): + common_utils.validate_http_headers("docs", {name: "bar"}) + + +@pytest.mark.parametrize( + ("value", "message"), + [ + ("", "must not be blank"), + (" \t ", "must not be blank"), + (" value", "outer whitespace"), + ("value\t", "outer whitespace"), + ("bar\0", "control character"), + ("bar\v", "control character"), + ("bar\r", "control character"), + ("bar\nX-Evil: injected", "control character"), + ("bar\x7f", "control character"), + ("Bearer 🔑", "not Latin-1 encodable"), + ], +) +def test_validate_http_headers_rejects_invalid_values(value, message): + with pytest.raises( + ValueError, + match=rf"HTTP header value for 'X-Foo' on MCP server 'docs' .*{message}", + ): + common_utils.validate_http_headers("docs", {"X-Foo": value}) + + +def test_validate_http_headers_accepts_latin_1_and_embedded_tab(): + assert ( + common_utils.validate_http_headers("docs", {"X-Description": "café\tvalue"}) + is None + ) + + +def test_validate_http_headers_rejects_non_string_value(): + with pytest.raises( + TypeError, + match="HTTP header value for 'X-Foo' on MCP server 'docs' must be a string", + ): + common_utils.validate_http_headers("docs", {"X-Foo": None}) + + +def test_expand_http_headers_expands_environment_variables_before_validation(): + os.environ["FABRIC_TEST_HEADER"] = "fabric" + + assert common_utils.expand_http_headers( + "docs", + { + "X-Tenant": "${FABRIC_TEST_HEADER}", + "X-Static": "static", + }, + ) == {"X-Tenant": "fabric", "X-Static": "static"} + + @pytest.mark.parametrize( ("prefix", "base_prefix", "expected"), [ diff --git a/tests/adapters/test_adapter_package_metadata.py b/tests/adapters/test_adapter_package_metadata.py index 3b35130cb..ba45d3a68 100644 --- a/tests/adapters/test_adapter_package_metadata.py +++ b/tests/adapters/test_adapter_package_metadata.py @@ -71,6 +71,7 @@ def load_pyproject(path: str) -> dict: ( "adapters/codex", [ + f"nemo-fabric-adapter-contract == {PACKAGE_VERSION}", f"nemo-fabric-adapters-common == {PACKAGE_VERSION}", "tomli-w~=1.2", ], diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index 4ecbdc8da..f126587ec 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -264,6 +264,68 @@ def test_build_options_maps_normalized_capabilities_and_claude_settings(claude_p assert "ANTHROPIC_BASE_URL" not in options.env +@pytest.mark.parametrize("authentication_type", ["oauth2", "service_account"]) +def test_claude_rejects_mcp_authentication(claude_payload, authentication_type): + server = claude_payload["capability_plan"]["native"]["mcp_servers"]["docs"] + server["authentication"] = {"type": authentication_type} + + with pytest.raises( + adapter.AdapterConfigError, match="not supported by Claude" + ) as caught: + adapter.build_options(claude_payload) + + assert caught.value.code == "claude_invalid_configuration" + + +def test_claude_maps_mcp_custom_headers(claude_payload): + server = claude_payload["capability_plan"]["native"]["mcp_servers"]["docs"] + server["custom_headers"] = { + "X-Tenant": "${FABRIC_TEST_MCP_HEADER}", + "X-Windows": "%FABRIC_TEST_WINDOWS_HEADER%", + } + os.environ["FABRIC_TEST_MCP_HEADER"] = "fabric" + os.environ["FABRIC_TEST_WINDOWS_HEADER"] = "windows" + + options = adapter.build_options(claude_payload) + mcp_servers = json.loads(options.mcp_servers.read_text(encoding="utf-8"))[ + "mcpServers" + ] + + tenant_reference = mcp_servers["docs"]["headers"]["X-Tenant"] + windows_reference = mcp_servers["docs"]["headers"]["X-Windows"] + assert tenant_reference.startswith("${NEMO_FABRIC_CLAUDE_MCP_") + assert windows_reference.startswith("${NEMO_FABRIC_CLAUDE_MCP_") + assert options.env[tenant_reference[2:-1]] == "fabric" + assert options.env[windows_reference[2:-1]] == "windows" + assert options.env["FABRIC_TEST_MCP_HEADER"] == "" + assert options.env["FABRIC_TEST_WINDOWS_HEADER"] == "" + + +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) + run_query = AsyncMock(return_value={"completed": False}) + 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 = remaining_timeout.call_args.args[0] + 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 +790,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 +1128,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 +1164,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 +1174,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..b81dd4f85 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -91,6 +91,12 @@ def codex_payload_fixture(tmp_path): } +def configure_mcp(payload, servers): + capability_plan = payload.setdefault("capability_plan", {}) + native = capability_plan.setdefault("native", {}) + native["mcp_servers"] = servers + + def successful_result(response="done"): return SimpleNamespace( id="turn-1", @@ -184,7 +190,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 +239,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 +278,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 +307,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,29 +382,42 @@ 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" - codex_payload["capability_plan"] = { - "native": { - "mcp_servers": { - "repo": { - "transport": "stdio", - "url": "python", - "args": [ - "-m", - "repo_mcp", - "--root", - ".", - "--config", - "repo config.json", - ], - "env": {"REPO_MCP_MODE": "test"}, + os.environ["FABRIC_TEST_MCP_HEADER"] = "fabric" + os.environ["FABRIC_TEST_WINDOWS_HEADER"] = "windows" + os.environ["FABRIC_TEST_UNBRACED_HEADER"] = "unbraced" + configure_mcp( + codex_payload, + { + "repo": { + "transport": "stdio", + "url": "python", + "args": [ + "-m", + "repo_mcp", + "--root", + ".", + "--config", + "repo config.json", + ], + "env": {"REPO_MCP_MODE": "test"}, + }, + "remote": { + "transport": "streamable-http", + "url": "${FABRIC_TEST_MCP_URL}", + "custom_headers": { + "X-Tenant": "${FABRIC_TEST_MCP_HEADER}", + "X-Windows": "%FABRIC_TEST_WINDOWS_HEADER%", + "X-Unbraced": "$FABRIC_TEST_UNBRACED_HEADER", + "X-Static": "static", }, - "remote": { - "transport": "streamable-http", - "url": "${FABRIC_TEST_MCP_URL}", + "authentication": { + "type": "oauth2", + "scopes": ["read", "write"], + "redirect_uri": "http://127.0.0.1:8765/callback", }, - } - } - } + }, + }, + ) codex_payload["config"]["harness"]["settings"]["config_overrides"][ "mcp_servers.remote.required" ] = True @@ -354,6 +429,18 @@ 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", + "X-Unbraced": "unbraced", + "X-Windows": ( + "windows" + if os.name == "nt" + else "%FABRIC_TEST_WINDOWS_HEADER%" + ), + "X-Static": "static", + }, + "auth": "oauth", + "scopes": ["read", "write"], "required": True, }, "repo": { @@ -369,6 +456,273 @@ def test_sdk_maps_native_mcp_servers_into_thread_config(codex_payload, mock_code "env": {"REPO_MCP_MODE": "test"}, }, } + assert mock_codex.instances[0].config.env["FABRIC_TEST_MCP_HEADER"] == "" + assert mock_codex.instances[0].config.env["FABRIC_TEST_WINDOWS_HEADER"] == "" + assert mock_codex.instances[0].config.env["FABRIC_TEST_UNBRACED_HEADER"] == "" + assert config["mcp_oauth_callback_url"] == "http://127.0.0.1:8765/callback" + + +def test_codex_preserves_prefixed_environment_reference_as_static_header( + codex_payload, +): + configure_mcp( + codex_payload, + { + "remote": { + "transport": "streamable-http", + "url": "https://mcp.example.test/mcp", + "custom_headers": {"Authorization": "Bearer ${MCP_TOKEN}"}, + } + }, + ) + + assert adapter._native_mcp_servers(codex_payload)["remote"] == { + "url": "https://mcp.example.test/mcp", + "http_headers": {"Authorization": "Bearer ${MCP_TOKEN}"}, + } + + +def test_codex_rejects_mcp_oauth_client_secret(codex_payload): + configure_mcp( + codex_payload, + { + "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): + configure_mcp( + codex_payload, + { + "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) + + +async def test_mcp_auth_statuses_paginates_until_cursor_is_none(): + client = MagicMock() + client.request = AsyncMock( + side_effect=[ + SimpleNamespace( + data=[ + SimpleNamespace( + name="first", auth_status=adapter.McpAuthStatus.o_auth + ) + ], + next_cursor="page-2", + ), + SimpleNamespace( + data=[ + SimpleNamespace( + name="second", + auth_status=adapter.McpAuthStatus.not_logged_in, + ) + ], + next_cursor=None, + ), + ] + ) + + statuses = await adapter._mcp_auth_statuses( + client, thread_id="thread-123", timeout=1 + ) + + assert statuses == { + "first": adapter.McpAuthStatus.o_auth, + "second": adapter.McpAuthStatus.not_logged_in, + } + assert client.request.await_args_list[0].args[1] == { + "detail": "toolsAndAuthOnly", + "threadId": "thread-123", + } + assert client.request.await_args_list[1].args[1] == { + "detail": "toolsAndAuthOnly", + "threadId": "thread-123", + "cursor": "page-2", + } + + +async def test_mcp_auth_statuses_rejects_repeated_cursor(): + client = MagicMock() + client.request = AsyncMock( + side_effect=[ + SimpleNamespace(data=[], next_cursor="repeated"), + SimpleNamespace(data=[], next_cursor="repeated"), + ] + ) + + with pytest.raises( + adapter.AdapterConfigError, match="returned a repeated cursor" + ) as caught: + await adapter._mcp_auth_statuses(client, thread_id="thread-123", timeout=1) + + assert caught.value.code == "codex_mcp_authentication_failed" + assert client.request.await_count == 2 + + +async def test_mcp_auth_statuses_respects_invocation_timeout(): + async def block_request(*args, **kwargs): + await asyncio.Event().wait() + + client = MagicMock() + client.request = AsyncMock(side_effect=block_request) + + with pytest.raises( + adapter.AdapterConfigError, match="status listing timed out" + ) as caught: + await adapter._mcp_auth_statuses(client, thread_id="thread-123", timeout=0.01) + + assert caught.value.code == "codex_mcp_authentication_failed" + + +async def test_mcp_auth_statuses_distinguishes_request_timeout(): + request_timeout = TimeoutError("request transport timed out") + client = MagicMock() + client.request = AsyncMock(side_effect=request_timeout) + + with pytest.raises( + adapter.AdapterConfigError, match="status listing request timed out" + ) as caught: + await adapter._mcp_auth_statuses(client, thread_id="thread-123", timeout=1) + + assert caught.value.code == "codex_mcp_authentication_failed" + assert caught.value.__cause__ is request_timeout + + +@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 + configure_mcp( + codex_payload, + { + "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, "_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 +): + configure_mcp( + codex_payload, + { + "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, + "_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() + + +@pytest.mark.parametrize("opened", [True, False]) +async def test_codex_opens_mcp_authorization_url_without_blocking(monkeypatch, opened): + open_browser = MagicMock(return_value=opened) + monkeypatch.setattr(adapter.webbrowser, "open", open_browser) + to_thread = AsyncMock(return_value=opened) + monkeypatch.setattr(adapter.asyncio, "to_thread", to_thread) + + assert await adapter._open_authorization_url("https://auth.example.test") is opened + to_thread.assert_awaited_once_with(open_browser, "https://auth.example.test") + + +def test_codex_rejects_mcp_service_account_authentication(codex_payload): + configure_mcp( + codex_payload, + { + "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): @@ -422,13 +776,10 @@ def test_sdk_closes_when_skill_registration_is_unavailable( @pytest.mark.parametrize("transport", ["sse", "carrier-pigeon"]) def test_sdk_rejects_unsupported_mcp_transport(codex_payload, mock_codex, transport): - codex_payload["capability_plan"] = { - "native": { - "mcp_servers": { - "bad": {"transport": transport, "url": "https://mcp.example.test"} - } - } - } + configure_mcp( + codex_payload, + {"bad": {"transport": transport, "url": "https://mcp.example.test"}}, + ) error = runtime_start_error(codex_payload) @@ -531,14 +882,17 @@ async def test_persistent_runtime_registers_skills_once_and_maps_mcp( codex_payload["capability_plan"] = { "native": { "skill_paths": ["skills/review"], - "mcp_servers": { - "review": { - "transport": "streamable-http", - "url": "https://mcp.example.test/review", - } - }, } } + configure_mcp( + codex_payload, + { + "review": { + "transport": "streamable-http", + "url": "https://mcp.example.test/review", + } + }, + ) start_payload = dict(codex_payload) start_payload.pop("request") runtime = adapter.CodexRuntime() diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index 7036a2698..ecb070f53 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -1112,13 +1112,17 @@ async def test_mcp_servers_become_adapter_tools( types.ModuleType("langchain_mcp_adapters"), ) monkeypatch.setitem(sys.modules, "langchain_mcp_adapters.client", client_mod) - payload = make_payload(tmp_path) + os.environ["FABRIC_TEST_MCP_HEADER"] = "fabric" # 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}"}, + }, "local": { "transport": "stdio", "url": "my-server", @@ -1133,7 +1137,11 @@ async def test_mcp_servers_become_adapter_tools( assert output["failed"] is False 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"}, + }, "local": { "transport": "stdio", "command": "my-server", @@ -1145,6 +1153,21 @@ async def test_mcp_servers_become_adapter_tools( assert tool_names == ["read_file", "write_file"] +@pytest.mark.parametrize("authentication_type", ["oauth2", "service_account"]) +def test_deepagents_rejects_mcp_authentication(authentication_type): + with pytest.raises( + adapter.AdapterConfigError, match="not supported by Deep Agents" + ): + adapter._mcp_connection( + "automation", + { + "transport": "streamable-http", + "url": "https://mcp.example.test/mcp", + "authentication": {"type": authentication_type}, + }, + ) + + @pytest.mark.usefixtures("use_real_langgraph") async def test_tool_policy_middleware_enforces_enabled_and_blocked_tools(): pytest.importorskip("langchain.agents.middleware") diff --git a/tests/adapters/test_hermes_adapter.py b/tests/adapters/test_hermes_adapter.py index 33e684db0..1e5eeea01 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 @@ -282,9 +283,7 @@ def stop_after_staging(*_args, **kwargs): "config": _agent_config( { "harness": {"settings": {}}, - "models": { - "default": {"provider": "nvidia", "model": "test-model"} - }, + "models": {"default": {"provider": "nvidia", "model": "test-model"}}, } ), "runtime_context": _runtime_context( @@ -562,6 +561,91 @@ 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_MCP_HEADER}"}, + 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_MCP_HEADER}"}, + "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_unset_mcp_oauth_client_secret(): + with pytest.raises(ValueError, match="unset environment variable"): + adapter.hermes_mcp_server_config( + AgentMcpServerConfig( + transport="sse", + url="https://mcp.example.test/sse", + authentication={ + "type": "oauth2", + "client_id": "fabric-client", + "client_secret_env": "FABRIC_MCP_CLIENT_SECRET", + }, + ) + ) + + +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_accepts_configured_mcp_oauth_timeout(): + config = adapter.hermes_mcp_server_config( + AgentMcpServerConfig( + transport="streamable-http", + url="https://mcp.example.test/mcp", + authentication={ + "type": "oauth2", + "authorization_timeout_seconds": 30, + }, + ) + ) + + assert config == { + "enabled": True, + "url": "https://mcp.example.test/mcp", + "transport": "streamable-http", + "auth": "oauth", + "oauth": {}, + } + + async def test_runtime_start_discovers_mcp_tools_when_configured( monkeypatch, tmp_path: Path, @@ -654,6 +738,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( { @@ -790,10 +987,13 @@ def stop_after_environment_setup(*_args, **_kwargs): def test_artifact_root_resolves_relative_to_base_dir(tmp_path: Path): - assert adapter._artifact_root( - _runtime_context(artifact_root="run-artifacts"), - str(tmp_path), - ) == (tmp_path / "run-artifacts").resolve() + assert ( + adapter._artifact_root( + _runtime_context(artifact_root="run-artifacts"), + str(tmp_path), + ) + == (tmp_path / "run-artifacts").resolve() + ) async def test_persistent_runtime_reuses_hermes_agent_session_and_history( diff --git a/tests/e2e/test_claude.py b/tests/e2e/test_claude.py index 8ed325392..0d684db29 100644 --- a/tests/e2e/test_claude.py +++ b/tests/e2e/test_claude.py @@ -177,6 +177,43 @@ async def test_fabric_session_reuses_persistent_claude_runtime(tmp_path): assert not any(artifact.kind == "stderr" for artifact in second.artifacts.artifacts) +async def test_env_secrets_in_headers(api_server, tmp_path): + os.environ["MY_KEY"] = "XYZ" + tool_name = "mcp__headers__get_authorization_header" + scenario_response = requests.post( + f"{api_server}/_scenario", + json={"tool_call": {"name": tool_name, "arguments": {}}}, + timeout=5, + ) + scenario_response.raise_for_status() + + config = fabric_config(tmp_path) + config.models["default"].provider = "fabric-test" + config.models["default"].model = "fabric-echo" + config.models["default"].api_key_env = "FABRIC_TEST_API_KEY" + config.models["default"].base_url = f"{api_server}/v1" + config.environment.env["FABRIC_TEST_API_KEY"] = "test" + config.tools = ToolsConfig(enabled=[tool_name]) + config.add_mcp_server( + "headers", + transport="streamable-http", + url=f"{api_server}/mcp", + authentication=None, + custom_headers={"Authorization": "Bearer ${MY_KEY}"}, + ) + + result = await Fabric().run( + config, + base_dir=tmp_path, + input="Use the MCP tool to return its Authorization header.", + ) + + assert result["status"] == "succeeded", result.to_mapping() + response = requests.get(f"{api_server}/_mcp_authorization_headers", timeout=5) + response.raise_for_status() + assert set(response.json()) == {"Bearer XYZ"} + + @pytest.mark.parametrize("enabled", [True, False]) async def test_mcp_stdio_transport(api_server, tmp_path, enabled): tool_name = "mcp__mcp_server_time__get_current_time" diff --git a/tests/e2e/test_codex.py b/tests/e2e/test_codex.py index 3d1809c48..e91bf3f39 100644 --- a/tests/e2e/test_codex.py +++ b/tests/e2e/test_codex.py @@ -20,6 +20,53 @@ from _utils.utils import assert_semantic_relay_artifacts +async def test_env_secrets_in_headers(api_server, tmp_path): + from examples.code_review_agent import codex_config + from nemo_fabric import Fabric + + os.environ["MY_KEY"] = "Bearer XYZ" + scenario_response = requests.post( + f"{api_server}/_scenario", + json={ + "tool_call": { + "name": "get_authorization_header", + "namespace": "mcp__headers", + "arguments": {}, + } + }, + timeout=5, + ) + scenario_response.raise_for_status() + + config = codex_config() + config.models["default"].provider = "fabric-test" + config.models["default"].model = "fabric-echo" + config.models["default"].api_key_env = "FABRIC_TEST_API_KEY" + config.models["default"].base_url = f"{api_server}/v1" + config.environment.workspace = tmp_path + config.environment.artifacts = tmp_path / "artifacts" + config.environment.env["FABRIC_TEST_API_KEY"] = "test" + config.runtime.artifacts = tmp_path / "artifacts" + config.add_mcp_server( + "headers", + transport="streamable-http", + url=f"{api_server}/mcp", + authentication=None, + custom_headers={"Authorization": "${MY_KEY}"}, + ) + + result = await Fabric().run( + config, + base_dir=tmp_path, + input="Use the MCP tool to return its Authorization header.", + ) + + assert result["status"] == "succeeded", result.to_mapping() + response = requests.get(f"{api_server}/_mcp_authorization_headers", timeout=5) + response.raise_for_status() + assert set(response.json()) == {"Bearer XYZ"} + + @pytest.mark.parametrize("enabled", [True, False]) async def test_mcp_stdio_transport(api_server, tmp_path, enabled): from examples.code_review_agent import codex_config diff --git a/tests/e2e/test_deepagents.py b/tests/e2e/test_deepagents.py index cb5e7531f..b59c1e794 100644 --- a/tests/e2e/test_deepagents.py +++ b/tests/e2e/test_deepagents.py @@ -105,6 +105,57 @@ async def test_deepagents_persistent_host_with_relay_and_mock_model( }, turn.to_mapping() +@pytest.mark.usefixtures("mock_nvidia_api_key") +async def test_env_secrets_in_headers(api_server, tmp_path): + pytest.importorskip("deepagents") + from examples.code_review_agent import deepagents_config + from nemo_fabric import EnvironmentConfig, Fabric, RuntimeConfig + + os.environ["MY_KEY"] = "XYZ" + scenario_response = requests.post( + f"{api_server}/_scenario", + json={ + "tool_call": { + "name": "get_authorization_header", + "arguments": {}, + } + }, + timeout=5, + ) + scenario_response.raise_for_status() + + config = deepagents_config() + config.models["default"].base_url = f"{api_server}/v1" + config.environment = EnvironmentConfig( + provider="local", + workspace=tmp_path, + artifacts=tmp_path / "artifacts", + ) + config.runtime = RuntimeConfig( + input_schema="chat", + output_schema="message", + artifacts=tmp_path / "artifacts", + ) + config.add_mcp_server( + "headers", + transport="streamable-http", + url=f"{api_server}/mcp", + authentication=None, + custom_headers={"Authorization": "Bearer ${MY_KEY}"}, + ) + + result = await Fabric().run( + config, + base_dir=tmp_path, + input="Use the MCP tool to return its Authorization header.", + ) + + assert result["status"] == "succeeded", result.to_mapping() + response = requests.get(f"{api_server}/_mcp_authorization_headers", timeout=5) + response.raise_for_status() + assert set(response.json()) == {"Bearer XYZ"} + + @pytest.mark.usefixtures("mock_nvidia_api_key") @pytest.mark.parametrize("enabled", [True, False]) async def test_mcp_stdio_transport(api_server, tmp_path, enabled): diff --git a/tests/e2e/test_hermes_e2e.py b/tests/e2e/test_hermes_e2e.py index 6a630bde4..e45419d7e 100644 --- a/tests/e2e/test_hermes_e2e.py +++ b/tests/e2e/test_hermes_e2e.py @@ -104,6 +104,51 @@ async def test_hermes_persistent_host_with_relay( assert sum(record["name"] == "hermes.session.end" for record in atof_records) == 2 +@pytest.mark.usefixtures("mock_nvidia_api_key") +async def test_env_secrets_in_headers( + code_review_agent_dir: Path, + api_server: str, +): + os.environ["ADAPTER_PYTHON"] = sys.executable + os.environ["MY_KEY"] = "XYZ" + tool_name = "mcp__headers__get_authorization_header" + if Version(distribution_version("hermes-agent")) < Version("0.20"): + tool_call = {"name": tool_name, "arguments": {}} + else: + tool_call = { + "name": "tool_call", + "arguments": {"name": tool_name, "arguments": {}}, + } + scenario_response = requests.post( + f"{api_server}/_scenario", + json={"tool_call": tool_call}, + timeout=5, + ) + scenario_response.raise_for_status() + + config = hermes_config() + config.models["default"].base_url = f"{api_server}/v1" + config.tools.enabled = None + config.add_mcp_server( + "headers", + transport="streamable-http", + url=f"{api_server}/mcp", + authentication=None, + custom_headers={"Authorization": "Bearer ${MY_KEY}"}, + ) + + result = await Fabric().run( + config, + base_dir=code_review_agent_dir, + input="Use the MCP tool to return its Authorization header.", + ) + + assert result["status"] == "succeeded", result.to_mapping() + response = requests.get(f"{api_server}/_mcp_authorization_headers", timeout=5) + response.raise_for_status() + assert set(response.json()) == {"Bearer XYZ"} + + @pytest.mark.usefixtures("mock_nvidia_api_key", "nemo_relay") @pytest.mark.parametrize("enabled", [True, False]) async def test_mcp_stdio_transport( 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..fe31a92dc 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 @@ -199,7 +200,12 @@ def test_typed_config_authoring_helpers_emit_schema_shape(): transport="streamable-http", 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"], @@ -245,7 +251,12 @@ def test_typed_config_authoring_helpers_emit_schema_shape(): "transport": "streamable-http", "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 +327,211 @@ 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] + + +@pytest.mark.parametrize("transport", ["sse", "streamable-http"]) +def test_mcp_server_rejects_env_for_http_transport(transport): + with pytest.raises(ValidationError, match="env is only valid for stdio transport"): + McpServerConfig( + transport=transport, + url="https://mcp.example.test", + env={"MCP_SECRET": "secret"}, + ) + + +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) + + +@pytest.mark.parametrize( + "authentication", + [ + {"type": "oauth2", "unknown": True}, + { + "type": "service_account", + "client_id": "fabric-client", + "client_secret_env": "MCP_CLIENT_SECRET", + "token_url": "https://auth.example.test/token", + "unknown": True, + }, + ], +) +def test_mcp_authentication_rejects_unknown_variant_fields(authentication): + with pytest.raises(ValidationError, match="unknown"): + McpAuthenticationConfig(**authentication) + + +@pytest.mark.parametrize( + ("authentication_type", "field", "value"), + [ + ("oauth2", "token_url", None), + ("oauth2", "token_cache_buffer_seconds", 300), + ("service_account", "redirect_uri", None), + ("service_account", "enable_dynamic_registration", True), + ("service_account", "client_name", None), + ("service_account", "authorization_timeout_seconds", 300), + ], +) +def test_mcp_authentication_rejects_explicit_cross_variant_fields( + authentication_type, field, value +): + authentication = {"type": authentication_type, field: value} + if authentication_type == "service_account": + authentication.update( + { + "client_id": "fabric-client", + "client_secret_env": "MCP_CLIENT_SECRET", + "token_url": "https://auth.example.test/token", + } + ) + + with pytest.raises(ValidationError, match=field): + McpAuthenticationConfig(**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 +665,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 +674,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..5114d0571 100644 --- a/uv.lock +++ b/uv.lock @@ -2293,6 +2293,7 @@ name = "nemo-fabric-adapters-codex" version = "0.2.0" source = { editable = "adapters/codex" } dependencies = [ + { name = "nemo-fabric-adapter-contract" }, { name = "nemo-fabric-adapters-common" }, { name = "tomli-w" }, ] @@ -2304,6 +2305,7 @@ harness = [ [package.metadata] requires-dist = [ + { name = "nemo-fabric-adapter-contract", editable = "adapter-contract" }, { name = "nemo-fabric-adapters-common", editable = "adapters/common" }, { name = "openai-codex", marker = "extra == 'full'", specifier = "==0.144.4" }, { name = "openai-codex", marker = "extra == 'harness'", specifier = "==0.144.4" },