Skip to content

feat: Add secrets support for MCP config variable expansion#2873

Merged
jpshackelford merged 18 commits intomainfrom
fix/mcp-secrets-expansion
Apr 23, 2026
Merged

feat: Add secrets support for MCP config variable expansion#2873
jpshackelford merged 18 commits intomainfrom
fix/mcp-secrets-expansion

Conversation

@jpshackelford
Copy link
Copy Markdown
Contributor

@jpshackelford jpshackelford commented Apr 17, 2026

Summary

This PR fixes #2872 by letting per-conversation secrets participate in MCP config variable expansion for plugin .mcp.json files, and it also addresses two follow-up issues found during review:

  1. ${VAR:-default} placeholders were being expanded too early during plugin loading, so later secret injection could not override the default.
  2. Once MCP config placeholders were expanded to plaintext secrets, those values could leak through agent / conversation serialization.

Problem

Before this PR:

  • expand_mcp_variables() only used the explicit variables dict (for example SKILL_ROOT) and os.environ.
  • Per-conversation secrets injected through POST /api/conversations/{id}/secrets live in SecretRegistry, so they were invisible to MCP config expansion.
  • Plugin loading eagerly applied defaults from ${VAR:-default} before conversation secrets were available.
  • Expanded MCP config was stored on agent.mcp_config, and that field could be serialized into persisted state, WebSocket state updates, and API responses.

Solution

  1. Use single-secret lookup instead of bulk export

    • SecretRegistry now exposes get_secret_value(name).
    • expand_mcp_variables() and load_mcp_config() accept get_secret: Callable[[str], str | None].
    • Resolution order is now:
      1. explicit variables (for example SKILL_ROOT)
      2. per-conversation secrets via callback
      3. environment variables
      4. default value from ${VAR:-default} when default expansion is enabled
  2. Defer default expansion until conversation-time

    • Plugin loading now parses .mcp.json with expand_defaults=False.
    • SKILL_ROOT is still expanded immediately, but ${VAR} and ${VAR:-default} placeholders are preserved.
    • Final expansion happens after plugin configs are merged and conversation secrets are available, which fixes the double-expansion bug.
  3. Expand merged MCP config at the right layer

    • load_plugins() accepts the secret lookup callback and expands the merged MCP config.
    • LocalConversation._ensure_plugins_loaded() expands the final merged MCP config with self._state.secret_registry.get_secret_value.
  4. Prevent plaintext secret leakage after expansion

    • AgentBase.mcp_config is now excluded from serialization (exclude=True).
    • Expanded MCP config remains available in memory for MCP tool creation, but it is no longer written into persisted conversation state, WebSocket state updates, or API responses.

Behavior

Secret expansion now works

Given plugin MCP config:

{
  "mcpServers": {
    "my-server": {
      "url": "https://example.com/mcp",
      "headers": {
        "Authorization": "Bearer ${MCP_SECRET_TOKEN:-fallback-token}"
      }
    }
  }
}

And a per-conversation secret injected via:

POST /api/conversations/{id}/secrets
{"secrets": {"MCP_SECRET_TOKEN": "my-secret-value"}}

The runtime behavior is now:

  • plugin load preserves Bearer ${MCP_SECRET_TOKEN:-fallback-token}
  • conversation plugin initialization resolves it to Bearer my-secret-value
  • if no secret is injected, it falls back to Bearer fallback-token

Security behavior

Expanded MCP config may contain plaintext secrets at runtime, so mcp_config is intentionally kept in memory only.

That means:

  • MCP tools still receive the expanded config at runtime
  • serialized Agent payloads no longer include mcp_config
  • persisted conversation state no longer stores expanded MCP secrets
  • WebSocket / API state payloads no longer expose expanded MCP secrets

Compatibility note

This PR intentionally changes serialization behavior: Agent.model_dump() / ConversationState.model_dump() no longer include mcp_config.

That is a security hardening change. Runtime MCP behavior is preserved, but any client that depended on serialized mcp_config being present will need to stop relying on that field.

Testing

Added or updated tests for:

  • SecretRegistry.get_secret_value() callback behavior
  • expand_mcp_variables() resolution order and defaults
  • load_mcp_config() with secret lookup
  • plugin loading preserving ${VAR} and ${VAR:-default} placeholders until conversation-time
  • LocalConversation secret expansion, default fallback, and merged MCP behavior
  • serialization leak prevention across agent dumps, persisted state, WebSocket state updates, and API-style state dumps

Evidence

Changes

File Change
secret_registry.py Add get_secret_value(name) for callback-based lookup
skills/utils.py Add get_secret callback and expand_defaults control to MCP expansion
plugin/plugin.py Preserve unresolved placeholders during plugin load to avoid double-expansion
plugin/loader.py Accept secret lookup callback and expand merged MCP config
local_conversation.py Expand merged MCP config after conversation secrets are available
agent/base.py Exclude mcp_config from serialization to prevent secret leakage
test_secrets_manager.py Add tests for single-secret lookup callback behavior
test_mcp_config_expansion.py Add MCP expansion tests covering secrets, defaults, and precedence
test_plugin_loading.py Add regression tests for deferred placeholder expansion
test_local_conversation_plugins.py Add conversation-level MCP secret expansion tests
test_mcp_secrets_serialization_leak.py Add serialization leak regression tests
test_agent_serialization.py Assert mcp_config is excluded from serialized agent payloads

Fixes #2872


This PR was created/updated by an AI assistant (OpenHands) on behalf of the user.

@jpshackelford can click here to continue refining the PR


Agent Server images for this PR

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:2ecddab-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-2ecddab-python \
  ghcr.io/openhands/agent-server:2ecddab-python

All tags pushed for this build

ghcr.io/openhands/agent-server:2ecddab-golang-amd64
ghcr.io/openhands/agent-server:2ecddab-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:2ecddab-golang-arm64
ghcr.io/openhands/agent-server:2ecddab-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:2ecddab-java-amd64
ghcr.io/openhands/agent-server:2ecddab-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:2ecddab-java-arm64
ghcr.io/openhands/agent-server:2ecddab-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:2ecddab-python-amd64
ghcr.io/openhands/agent-server:2ecddab-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:2ecddab-python-arm64
ghcr.io/openhands/agent-server:2ecddab-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:2ecddab-golang
ghcr.io/openhands/agent-server:2ecddab-java
ghcr.io/openhands/agent-server:2ecddab-python

About Multi-Architecture Support

  • Each variant tag (e.g., 2ecddab-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 2ecddab-python-amd64) are also available if needed

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 17, 2026

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 17, 2026

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 17, 2026

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/conversation
   secret_registry.py75790%109, 166, 170, 177, 179, 182, 191
openhands-sdk/openhands/sdk/conversation/impl
   local_conversation.py4362594%295, 300, 429, 475, 512, 528, 593, 785–786, 789, 949, 957, 959, 963–964, 975, 977–979, 1004, 1199, 1203, 1273, 1280–1281
openhands-sdk/openhands/sdk/plugin
   loader.py29293%108, 111
   plugin.py2101692%424–425, 432–433, 450–451, 454–456, 485–487, 503–504, 522–523
openhands-sdk/openhands/sdk/skills
   utils.py1561789%48, 65, 162–163, 166, 183–184, 239, 252, 256, 284, 305, 355–357, 430–431
openhands-sdk/openhands/sdk/subagent
   schema.py119794%58, 71, 90, 105, 135, 231, 234
TOTAL24160561276% 

@jpshackelford jpshackelford added the customer-support This item has been reported by a customer and is being tracked as a support ticket. label Apr 17, 2026
@jpshackelford jpshackelford marked this pull request as ready for review April 17, 2026 21:29
@jpshackelford jpshackelford marked this pull request as draft April 17, 2026 21:29
Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taste Rating: 🟢 Good taste - Well-implemented solution to a real problem

Code Quality: Clean implementation with comprehensive test coverage. The resolution order (variables > secrets > env > default) is clearly documented and correctly implemented.

[EVALUATION IMPACT NOTE]
This PR modifies how MCP configs are processed when per-conversation secrets are present. While backward compatible and well-tested, it could theoretically affect tool behavior in benchmarks. Recommend running lightweight evals to verify no unexpected impact before merging.

[RISK ASSESSMENT]
⚠️ Risk: 🟡 MEDIUM - Handles secrets (security-sensitive) and modifies MCP config processing. Changes are backward compatible and opt-in (only affects users who inject secrets via API). Risk mitigated by comprehensive tests and graceful error handling.

VERDICT: ✅ Worth merging - Core logic is sound, solves a real problem (#2872). One minor style improvement suggested inline.

KEY INSIGHT: The get_all_secrets() method with exception handling ensures one failing secret doesn't break the entire MCP config expansion - good defensive programming.

Comment thread openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py Outdated
all-hands-bot

This comment was marked as outdated.

jpshackelford pushed a commit that referenced this pull request Apr 18, 2026
This commit fixes a critical bug where per-conversation secrets with
default values (e.g., ${VAR:-default}) were not being correctly
expanded in MCP configurations.

Root Cause:
MCP configs were being expanded TWICE:
1. First in plugin.py::_load_mcp_config() during plugin loading (no secrets)
2. Second in local_conversation.py::_ensure_plugins_loaded() (with secrets)

The first expansion replaced ${VAR:-default} with 'default' because no
secrets were available, leaving no placeholder for the second pass.

Fix:
- Add 'expand_defaults' parameter to expand_mcp_variables() and
  load_mcp_config() to control whether defaults are applied
- In plugin.py::_load_mcp_config(), use expand_defaults=False to preserve
  placeholders like ${VAR:-default} for later expansion
- In local_conversation.py::_ensure_plugins_loaded(), expand with
  expand_defaults=True (default) to apply both secrets and defaults
- Move inline import of expand_mcp_variables to top of file per repo style

Tests:
- Add unit tests for plugin MCP config loading with deferred expansion
- Add integration tests verifying secrets override defaults
- Add integration test verifying defaults are used when no secret exists

Addresses review feedback from PR #2873.

Co-authored-by: openhands <openhands@all-hands.dev>
@jpshackelford jpshackelford marked this pull request as ready for review April 18, 2026 00:19
Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taste Rating: 🟡 Acceptable - Solid fix with comprehensive tests, minor suggestions for robustness

The core solution is well-designed: using expand_defaults to control expansion phases prevents double-expansion, and the integration tests thoroughly cover critical scenarios. The resolution order (variables → secrets → env → defaults) is clearly documented.

A few suggestions for improvement around edge cases and consistency.

Comment thread openhands-sdk/openhands/sdk/conversation/secret_registry.py Outdated
Comment thread openhands-sdk/openhands/sdk/plugin/loader.py Outdated
Comment thread openhands-sdk/openhands/sdk/plugin/loader.py
Comment thread openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py Outdated
@all-hands-bot
Copy link
Copy Markdown
Collaborator

[RISK ASSESSMENT]

⚠️ Risk Level: 🟡 MEDIUM

Analysis: This PR modifies MCP config processing and variable expansion during agent initialization. While the implementation is backward compatible and well-tested, it changes how MCP tools are configured, which could theoretically affect benchmark/evaluation performance if benchmarks use MCP servers with variable expansion.

Risk Factors:

  • ✅ Backward compatible (no breaking changes)
  • ✅ Comprehensive test coverage (unit + integration)
  • ⚠️ Modifies agent initialization behavior (MCP config expansion)
  • ⚠️ Adds new expansion phase in conversation setup
  • ⚠️ Could affect tool availability if MCP configs use secrets

Recommendation: Run lightweight evaluations before merging to ensure no unexpected benchmark impact. Consider testing with:

  • Benchmarks that use MCP tools
  • Scenarios with and without per-conversation secrets
  • Verify tool initialization timing remains consistent

Per repository guidelines, PRs that modify agent behavior require eval verification before approval.


Overall: The implementation is solid and the fix addresses a real issue (#2872). Once evals confirm no negative impact, this is ready to merge. 👍

Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ QA Report: PASS

Per-conversation secrets now correctly expand MCP config variables, fixing both the missing expansion and the double-expansion bugs.

Does this PR achieve its stated goal?

Yes. The PR successfully enables per-conversation secrets (injected via REST API) to be used for MCP config variable expansion (${VAR} syntax). I verified:

  1. Secrets are now expanded: ${MCP_SECRET_TOKEN}"my-actual-secret"
  2. Double-expansion bug is fixed: ${SECRET_TOKEN:-fallback} uses the secret value, not the default ✅
  3. Defaults work when no secret: ${VAR:-default}"default" when secret not provided ✅
Phase Result
Environment Setup ✅ uv environment ready, dependencies installed
CI & Tests ✅ All CI checks pass (sdk-tests, tools-tests, build, pre-commit)
Functional Verification ✅ Verified both bugs fixed with before/after testing
Functional Verification

Test 1: Basic Secrets Expansion (Issue #2872)

Step 1 — Reproduce the bug (main branch):

Created test plugin with MCP config:

{
  "mcpServers": {
    "test-server": {
      "url": "https://example.com/mcp",
      "headers": {
        "Authorization": "Bearer ${MCP_SECRET_TOKEN}"
      }
    }
  }
}

Injected secret via conversation.update_secrets({"MCP_SECRET_TOKEN": "my-actual-secret"})

Ran verification script:

Authorization header: Bearer ${MCP_SECRET_TOKEN}
❌ BUG: Secret was NOT expanded (placeholder remains)

This confirms the bug exists: secrets from SecretRegistry were not used during MCP config expansion.

Step 2 — Apply the PR's changes:

Checked out PR branch pr-2873 (commit 055e09f)

Step 3 — Re-run verification with the fix:

Authorization header: Bearer my-actual-secret
✅ SUCCESS: Secret was correctly expanded!

The placeholder is now correctly replaced with the secret value from SecretRegistry.


Test 2: Double-Expansion Bug (Variables with Defaults)

Step 1 — Reproduce the double-expansion bug (main branch):

Created test plugin with MCP config using default syntax:

{
  "mcpServers": {
    "test-server": {
      "headers": {
        "Authorization": "Bearer ${SECRET_TOKEN:-fallback-token}"
      }
    }
  }
}

Injected secret: {"SECRET_TOKEN": "my-actual-secret"}

Ran verification script:

Authorization header: Bearer fallback-token
❌ BUG: Default was used instead of secret (double-expansion bug)

This confirms the double-expansion bug: the default value was applied during plugin loading (first expansion), so the secret could not replace it later (second expansion).

Step 2 — Apply the PR's changes:

Checked out PR branch pr-2873

Step 3 — Re-run verification with the fix:

Authorization header: Bearer my-actual-secret
✅ SUCCESS: Secret was used (not default)!

The secret value is now correctly used instead of the default, confirming the double-expansion bug is fixed.


Test 3: Comprehensive Unit Tests

Ran all new unit tests added by the PR:

tests/sdk/conversation/test_secrets_manager.py::test_get_all_secrets_static_values PASSED
tests/sdk/conversation/test_secrets_manager.py::test_get_all_secrets_callable_values PASSED
tests/sdk/conversation/test_secrets_manager.py::test_get_all_secrets_handles_exceptions PASSED
tests/sdk/conversation/test_secrets_manager.py::test_get_all_secrets_empty_registry PASSED

tests/sdk/skills/test_mcp_config_expansion.py - 14 tests PASSED
  - test_expand_secrets
  - test_variable_resolution_order
  - test_secrets_take_precedence_over_env
  - test_default_not_used_when_secret_exists
  - etc.

tests/sdk/conversation/test_local_conversation_plugins.py::TestPluginMcpSecretsExpansion - 3 tests PASSED
  - test_plugin_mcp_secrets_without_defaults
  - test_plugin_mcp_secrets_with_defaults
  - test_plugin_mcp_secrets_fallback_to_default_when_no_secret

tests/sdk/plugin/test_plugin_loading.py::TestPluginMcpConfigLoading - 3 tests PASSED
  - test_plugin_mcp_config_preserves_unexpanded_variables
  - test_plugin_mcp_config_preserves_variables_with_defaults
  - test_plugin_mcp_skill_root_is_expanded

All tests pass, covering:

  • SecretRegistry.get_all_secrets() method
  • Variable expansion resolution order (variables → secrets → env → defaults)
  • Secrets taking precedence over environment variables
  • Plugin loading with expand_defaults=False to preserve placeholders
  • End-to-end conversation-level secrets expansion

Issues Found

None.

@jpshackelford
Copy link
Copy Markdown
Contributor Author

This is the SDK part of the solution. The enterprise-server part is in OpenHands/OpenHands#14009. See associated issues for both PRs for more context.

jpshackelford pushed a commit to jpshackelford/openhands-agent-sdk-examples that referenced this pull request Apr 18, 2026
Adds comprehensive examples demonstrating how to pass per-conversation
secrets to OpenHands agents:

SDK Level (01_standalone_sdk/04_secrets_sdk_level.py):
- Pass secrets via conversation.send_message(secrets={...})
- For local/standalone agent usage

API Level (02_cloud_api/01_secrets_api_level.py):
- Pass secrets via POST /v1/app-conversations with 'secrets' field
- For OpenHands Cloud integration

Also updates README with:
- Documentation for both secret injection methods
- Comparison table of SDK vs API approaches
- New 02_cloud_api/ section for Cloud API examples

Related to:
- OpenHands/OpenHands#14007 (API-level secrets)
- OpenHands/software-agent-sdk#2873 (SDK-level secrets)

Co-authored-by: openhands <openhands@all-hands.dev>
jpshackelford pushed a commit to jpshackelford/oh-examples that referenced this pull request Apr 18, 2026
Adds test_secrets_at_start.py demonstrating the new approach where secrets
are passed directly in the AppConversationStartRequest body, rather than
injected via a separate Agent Server API call.

Advantages of the new approach:
- Single request - simpler API
- Secrets available immediately when agent starts
- No race condition - guaranteed to be set before agent runs
- Secrets are merged with vault secrets (API secrets take precedence)

Also updates README.md to document both approaches with a comparison table.

Related PRs:
- OpenHands/OpenHands#14009 (app server)
- OpenHands/software-agent-sdk#2873 (agent server)

Co-authored-by: openhands <openhands@all-hands.dev>
@jpshackelford
Copy link
Copy Markdown
Contributor Author

jpshackelford commented Apr 18, 2026

I ran and end-to-end test with this change and OpenHands/OpenHands#14009 in staging posting results to the OpenHands/OpenHands#14009 PR. Example scripts for testing are here: https://github.com/jpshackelford/oh-examples/tree/main/per-conversation-secrets.

Copy link
Copy Markdown
Contributor

@neubig neubig left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like nice functionality to have! Just a few comments.

Comment thread openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py Outdated
Comment thread openhands-sdk/openhands/sdk/conversation/secret_registry.py Outdated
Comment thread openhands-sdk/openhands/sdk/plugin/loader.py Outdated
Comment thread openhands-sdk/openhands/sdk/skills/utils.py Outdated
@jpshackelford jpshackelford force-pushed the fix/mcp-secrets-expansion branch 3 times, most recently from 5c48e99 to 22172db Compare April 18, 2026 12:07
@jpshackelford
Copy link
Copy Markdown
Contributor Author

@neubig Thanks very much for the quick review!

@jpshackelford jpshackelford requested a review from neubig April 18, 2026 12:09
@jpshackelford jpshackelford changed the title feat: Add per-conversation secrets support for MCP config variable expansion feat: Add secrets support for MCP config variable expansion Apr 18, 2026
Copy link
Copy Markdown
Contributor Author

jpshackelford commented Apr 18, 2026

@neubig As discussed in Slack, adding a method to expose all secrets in plaintext is a bad pattern even if it seems okay for the current implementation of the secrets store and our use of it now. At the same time, I'd like to keep coupling low for testability and avoid circular dependencies, so I implemented a callback pattern instead.

Changes:

  • Replaced get_all_secrets() with get_secret_value(name) for single lookups
  • expand_mcp_variables() now accepts get_secret: Callable[[str], str | None]
  • Secrets are retrieved lazily, only when referenced in the config

Usage: expand_mcp_variables(config, vars, get_secret=registry.get_secret_value)
Testing: expand_mcp_variables(config, vars, get_secret={"KEY": "val"}.get)

Is this acceptable to you?

@desiorac
Copy link
Copy Markdown

The callback pattern (get_secret: Callable[[str], str | None]) over get_all_secrets() is the right call — bulk export would have made secret sprawl hard to audit.

One thing worth verifying: after expand_mcp_variables() resolves placeholders, the expanded config (now containing plaintext secrets) gets written into self.agent via model_copy() and then into self._state.agent. If ConversationState is serialized to disk or emitted in observation outputs, secrets end up in plaintext outside the registry. Concrete check: grep for any .model_dump() / .dict() / json() call on the agent or state objects downstream of _ensure_plugins_loaded() and verify mcp_config is excluded or redacted.

The expand_defaults=False approach for the double-expansion bug is clean — preserving placeholders through the plugin-load pass is the right abstraction boundary.

Copy link
Copy Markdown
Collaborator

@tofarr tofarr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem I see with this PR is that it redacts in all cases - so it would mean that these values are not saved when storing to disk - this would mean that the agent servers choke on resume. (Or at least that MCP operations do) What we actually want is the same sort of behavior we have in the StaticSecret class:

  • encrypt values when storing to disk (In this case we have provided a cipher in the context)
  • redact when writing externally (We have not provided any context / the context has no special parameters)
  • Unless expose_secrets=true - in this case we write secrets in plaintext.

@tofarr
Copy link
Copy Markdown
Collaborator

tofarr commented Apr 21, 2026

I had an agent work on this same issue and it came up with this PR: #2900

Copy link
Copy Markdown
Contributor Author

@tofarr I've incorporated the cipher-based encryption pattern you described into this PR.

Changes Made

The mcp_config field now follows the same serialization pattern as StaticSecret and other secrets in the SDK:

Serialization Context mcp_config Behavior
No context mcp_config: null (fully redacted)
{"expose_secrets": True} mcp_config: {...} (plaintext)
{"cipher": cipher} encrypted_mcp_config: "<encrypted>"

Implementation

  1. @model_validator(mode="before") - Decrypts encrypted_mcp_config on load when cipher is in context
  2. @field_serializer("mcp_config") - Returns None when redacting, plaintext when expose_secrets=True, or None (encryption handled by model_serializer) when cipher present
  3. @model_serializer(mode="wrap") - Encrypts entire mcp_config as JSON blob when cipher is in context, stores as encrypted_mcp_config

This ensures:

  • API/WebSocket responses: mcp_config is redacted (no secrets leak)
  • Disk persistence (with cipher): mcp_config is encrypted and can be restored on conversation resume
  • Runtime (in-memory): Full plaintext available for MCP tool use

Tests

Added/updated tests covering:

  • Redaction by default (no context)
  • Exposure with expose_secrets=True
  • Encryption with cipher
  • Full roundtrip (encrypt → decrypt)
  • Backward compatibility with plaintext mcp_config
  • Empty mcp_config handling

All tests pass:

uv run pytest tests/sdk/agent/test_agent_serialization.py tests/sdk/conversation/test_mcp_secrets_serialization_leak.py tests/sdk/conversation/local/test_state_serialization.py -v
# 61 passed

This comment was posted by an AI agent (OpenHands) on behalf of @jpshackelford.

Copy link
Copy Markdown
Contributor Author

Note: The "Check OpenAPI Schema" and "REST API (OpenAPI)" CI failures are pre-existing issues unrelated to this PR. I verified by testing against the commit before my changes:

git stash  # stash my changes
uv run python -c "from openhands.agent_server.api import api; api.openapi()"
# KeyError: 'properties'

The issue is in DiscriminatedUnionMixin.__get_pydantic_json_schema__ where it assumes schema["properties"] exists, but this fails for certain nested model combinations. This is a separate bug.


This comment was posted by an AI agent (OpenHands) on behalf of @jpshackelford.

Copy link
Copy Markdown
Collaborator

@tofarr tofarr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🍰

openhands-agent and others added 17 commits April 21, 2026 23:53
…pansion

This change allows per-conversation secrets injected via the REST API
to be used for MCP config variable expansion (${VAR} syntax).

Changes:
- Add get_all_secrets() method to SecretRegistry to export all secrets
  as a dictionary for MCP config expansion
- Update expand_mcp_variables() to accept optional secrets parameter
  with variable resolution order: variables > secrets > env > default
- Update load_mcp_config() to pass secrets through to expansion
- Update load_plugins() to accept and use secrets for MCP expansion
- Update LocalConversation._ensure_plugins_loaded() to expand MCP
  config with secrets after merging plugin configs

Fixes #2872

Co-authored-by: openhands <openhands@all-hands.dev>
This commit fixes a critical bug where per-conversation secrets with
default values (e.g., ${VAR:-default}) were not being correctly
expanded in MCP configurations.

Root Cause:
MCP configs were being expanded TWICE:
1. First in plugin.py::_load_mcp_config() during plugin loading (no secrets)
2. Second in local_conversation.py::_ensure_plugins_loaded() (with secrets)

The first expansion replaced ${VAR:-default} with 'default' because no
secrets were available, leaving no placeholder for the second pass.

Fix:
- Add 'expand_defaults' parameter to expand_mcp_variables() and
  load_mcp_config() to control whether defaults are applied
- In plugin.py::_load_mcp_config(), use expand_defaults=False to preserve
  placeholders like ${VAR:-default} for later expansion
- In local_conversation.py::_ensure_plugins_loaded(), expand with
  expand_defaults=True (default) to apply both secrets and defaults
- Move inline import of expand_mcp_variables to top of file per repo style

Tests:
- Add unit tests for plugin MCP config loading with deferred expansion
- Add integration tests verifying secrets override defaults
- Add integration test verifying defaults are used when no secret exists

Addresses review feedback from PR #2873.

Co-authored-by: openhands <openhands@all-hands.dev>
- Fix empty string check in get_all_secrets() to use 'is not None'
  instead of truthy check (allows empty string secrets)
- Add explicit expand_defaults=True in loader.py for clarity
- Improve logging to indicate when defaults are applied without secrets

Co-authored-by: openhands <openhands@all-hands.dev>
Simplify _ensure_plugins_loaded by removing the agent_update_needed
optimization flag. As noted by @neubig, this flag could be a source
of errors if future code changes forget to set it to True when
updates are needed. The efficiency gain (avoiding one model_copy call)
is minimal and not worth the maintenance risk.

The agent is now always updated with merged content, which ensures
no code paths accidentally skip the update.
Refactor _ensure_plugins_loaded to use explicit has_mcp_config flag
and direct self._plugin_specs checks instead of the agent_update_needed
flag. This provides clearer intent while maintaining correctness and
proper type safety.

The agent update is now conditionally executed only when there are
plugins or MCP config to process, avoiding unnecessary model_copy
calls that could interfere with agent initialization.

Also adds comments explaining the keyword-only * syntax (PEP 3102)
in expand_mcp_variables and load_mcp_config functions to prevent
ambiguous positional boolean arguments.

Per review feedback from @neubig.
Replace get_all_secrets() with get_secret_value() and use a callback
pattern in expand_mcp_variables(). This addresses the concern about
exposing all secrets as a plain-text dict, which could encourage
patterns that accidentally log, serialize, or pass secrets where not
all are needed.

The callback approach:
- Retrieves secrets lazily, one at a time, only when referenced
- Keeps expand_mcp_variables() decoupled from SecretRegistry
- Makes testing straightforward (pass dict.get or a lambda)

Co-authored-by: openhands <openhands@all-hands.dev>
Address security concern from PR review: after expand_mcp_variables()
resolves placeholders, mcp_config may contain plaintext secrets (API
tokens, etc.) that would leak through:
- Persistence (base_state.json)
- WebSocket events (ConversationStateUpdateEvent)
- API responses (state.model_dump)

Fix: Add exclude=True to mcp_config field. This is safe because:
1. MCP tools are created dynamically at runtime from mcp_config
2. On resume, the user must re-provide mcp_config with the agent
3. Tool verification only checks the tools spec list, not MCP tools

Added comprehensive tests for secret leakage scenarios and updated
existing serialization test to reflect the new security behavior.

See: #2873 (comment)

Co-authored-by: openhands <openhands@all-hands.dev>
- Use uuid.uuid4() directly instead of str(uuid.uuid4()) for id param
- Mark unused 'state' variables with _ prefix to satisfy lint rules

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
This commit addresses 4 review comments from PR #2873:

1. Unify MCP expansion implementations (most impactful):
   - Removed _resolve_env_vars() and _resolve_env_vars_deep() from subagent/schema.py
   - AgentDefinition now preserves ${VAR} placeholders in mcp_servers
   - Variable expansion deferred to runtime via expand_mcp_variables()
   - This ensures subagent MCP configs get per-conversation secrets

2. API redaction instead of exclude=True:
   - Replaced exclude=True on mcp_config field with a field_serializer
   - Uses existing sanitize_config() to redact env/headers during serialization
   - Keeps output shape stable (mcp_config present but secrets redacted)
   - Maintains backward compatibility for API consumers

3. Exception handling in get_secret_value:
   - Narrowed catch-all exception to categorize errors:
     - OSError/TimeoutError: transient network issues
     - ValueError/KeyError/TypeError: configuration errors
     - Exception: unexpected (logged with full details)
   - Better diagnostics for debugging secret retrieval failures

4. Track secrets for output masking:
   - get_secret_value now adds retrieved secrets to _exported_values
   - Secrets fetched for MCP expansion now get masked in command output
   - Fixes bypass where MCP-expanded secrets weren't being masked

Co-authored-by: openhands <openhands@all-hands.dev>
Add explicit assertions that mcp_servers is not None before subscripting
to satisfy pyright's reportOptionalSubscript check.
Co-authored-by: openhands <openhands@all-hands.dev>
Implements the standard SDK secret handling pattern for mcp_config:
- If cipher is provided: encrypt entire mcp_config as JSON blob
- If expose_secrets=True: return plaintext (for runtime use)
- Otherwise: redact (return None) for API/WebSocket safety

This ensures mcp_config secrets can be preserved across conversation
resume in OpenHands Enterprise, where conversations may be shutdown
and restored.

Changes:
- Add model_validator to decrypt encrypted_mcp_config on load
- Add field_serializer with cipher/expose_secrets context handling
- Add model_serializer to encrypt mcp_config when cipher present
- Update tests to reflect new behavior patterns

Addresses feedback from @tofarr on the cipher-based persistence pattern.

Co-authored-by: openhands <openhands@all-hands.dev>
The mcp_config field had exclude=True which prevented the model_serializer
from handling it. Removing this allows the serializer to properly:
- Expose mcp_config when expose_secrets=True
- Encrypt mcp_config when cipher is provided
- Redact (omit) mcp_config by default

Also fixed test_agent_mcp_config_empty_not_encrypted to expect empty
mcp_config to be omitted entirely (matching Tim's implementation).

Co-authored-by: openhands <openhands@all-hands.dev>
@jpshackelford jpshackelford force-pushed the fix/mcp-secrets-expansion branch from d716c9d to fc7613e Compare April 21, 2026 23:58
The rebase included an old commit that reverted the version bump.
Restoring to match main's current version.

Co-authored-by: openhands <openhands@all-hands.dev>
@jpshackelford
Copy link
Copy Markdown
Contributor Author

This PR enables per-conversation secrets to participate in MCP config variable
expansion for plugin `.mcp.json` files, and prevents expanded secrets from
leaking through serialization.

## Problem

Before this PR:
- `expand_mcp_variables()` only used explicit variables and `os.environ`
- Per-conversation secrets from `POST /api/conversations/{id}/secrets` were
  invisible to MCP config expansion
- Plugin loading eagerly applied defaults from `${VAR:-default}` before
  conversation secrets were available
- Expanded MCP config could leak through agent/conversation serialization

## Solution

1. **Callback-based secret lookup**
   - `SecretRegistry.get_secret_value(name)` for single-secret lookup
   - `expand_mcp_variables()` and `load_mcp_config()` accept `get_secret` callback
   - Resolution order: explicit variables → secrets → env vars → defaults

2. **Deferred default expansion**
   - Plugin loading parses `.mcp.json` with `expand_defaults=False`
   - `SKILL_ROOT` expanded immediately, `${VAR:-default}` preserved
   - Final expansion at conversation-time when secrets are available

3. **Conversation-level MCP expansion**
   - `LocalConversation._ensure_plugins_loaded()` expands merged MCP config
     with `self._state.secret_registry.get_secret_value`

4. **Prevent secret leakage via model_serializer**
   - Default: mcp_config is redacted (omitted from serialization)
   - With `cipher` context: mcp_config is encrypted for secure persistence
   - With `expose_secrets=True`: mcp_config exposed for debugging

Fixes #2872

Co-authored-by: enyst <engel.nyst@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>

@jpshackelford jpshackelford merged commit 3e17806 into main Apr 23, 2026
30 checks passed
@jpshackelford jpshackelford deleted the fix/mcp-secrets-expansion branch April 23, 2026 15:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

customer-support This item has been reported by a customer and is being tracked as a support ticket.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Per-conversation secrets not available for MCP config variable expansion

8 participants