feat: Add secrets support for MCP config variable expansion#2873
feat: Add secrets support for MCP config variable expansion#2873jpshackelford merged 18 commits intomainfrom
Conversation
Python API breakage checks — ✅ PASSEDResult: ✅ PASSED |
REST API breakage checks (OpenAPI) — ✅ PASSEDResult: ✅ PASSED |
Coverage Report •
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
all-hands-bot
left a comment
There was a problem hiding this comment.
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]
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.
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>
all-hands-bot
left a comment
There was a problem hiding this comment.
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.
[RISK ASSESSMENT]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:
Recommendation: Run lightweight evaluations before merging to ensure no unexpected benchmark impact. Consider testing with:
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. 👍 |
all-hands-bot
left a comment
There was a problem hiding this comment.
✅ 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:
- Secrets are now expanded:
${MCP_SECRET_TOKEN}→"my-actual-secret"✅ - Double-expansion bug is fixed:
${SECRET_TOKEN:-fallback}uses the secret value, not the default ✅ - 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=Falseto preserve placeholders - End-to-end conversation-level secrets expansion
Issues Found
None.
|
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. |
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>
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>
|
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. |
neubig
left a comment
There was a problem hiding this comment.
Looks like nice functionality to have! Just a few comments.
5c48e99 to
22172db
Compare
|
@neubig Thanks very much for the quick review! |
|
@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:
Usage: Is this acceptable to you? |
|
The callback pattern ( One thing worth verifying: after The |
tofarr
left a comment
There was a problem hiding this comment.
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.
|
I had an agent work on this same issue and it came up with this PR: #2900 |
|
@tofarr I've incorporated the cipher-based encryption pattern you described into this PR. Changes MadeThe
Implementation
This ensures:
TestsAdded/updated tests covering:
All tests pass: This comment was posted by an AI agent (OpenHands) on behalf of @jpshackelford. |
|
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 This comment was posted by an AI agent (OpenHands) on behalf of @jpshackelford. |
…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 reverts commit 27bda3b.
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>
d716c9d to
fc7613e
Compare
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>
|
Summary
This PR fixes #2872 by letting per-conversation secrets participate in MCP config variable expansion for plugin
.mcp.jsonfiles, and it also addresses two follow-up issues found during review:${VAR:-default}placeholders were being expanded too early during plugin loading, so later secret injection could not override the default.Problem
Before this PR:
expand_mcp_variables()only used the explicit variables dict (for exampleSKILL_ROOT) andos.environ.POST /api/conversations/{id}/secretslive inSecretRegistry, so they were invisible to MCP config expansion.${VAR:-default}before conversation secrets were available.agent.mcp_config, and that field could be serialized into persisted state, WebSocket state updates, and API responses.Solution
Use single-secret lookup instead of bulk export
SecretRegistrynow exposesget_secret_value(name).expand_mcp_variables()andload_mcp_config()acceptget_secret: Callable[[str], str | None].SKILL_ROOT)${VAR:-default}when default expansion is enabledDefer default expansion until conversation-time
.mcp.jsonwithexpand_defaults=False.SKILL_ROOTis still expanded immediately, but${VAR}and${VAR:-default}placeholders are preserved.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 withself._state.secret_registry.get_secret_value.Prevent plaintext secret leakage after expansion
AgentBase.mcp_configis now excluded from serialization (exclude=True).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:
The runtime behavior is now:
Bearer ${MCP_SECRET_TOKEN:-fallback-token}Bearer my-secret-valueBearer fallback-tokenSecurity behavior
Expanded MCP config may contain plaintext secrets at runtime, so
mcp_configis intentionally kept in memory only.That means:
Agentpayloads no longer includemcp_configCompatibility note
This PR intentionally changes serialization behavior:
Agent.model_dump()/ConversationState.model_dump()no longer includemcp_config.That is a security hardening change. Runtime MCP behavior is preserved, but any client that depended on serialized
mcp_configbeing present will need to stop relying on that field.Testing
Added or updated tests for:
SecretRegistry.get_secret_value()callback behaviorexpand_mcp_variables()resolution order and defaultsload_mcp_config()with secret lookup${VAR}and${VAR:-default}placeholders until conversation-timeLocalConversationsecret expansion, default fallback, and merged MCP behaviorEvidence
uv run pytest tests/sdk/skills/test_mcp_config_expansion.py tests/sdk/plugin/test_plugin_loading.py tests/sdk/plugin/test_plugin_loader.py tests/sdk/conversation/test_local_conversation_plugins.py tests/sdk/conversation/test_mcp_secrets_serialization_leak.py tests/sdk/conversation/test_secrets_manager.py tests/sdk/agent/test_agent_serialization.py -k 'mcp or secret'46 passed, 78 deselectedOpenHands/OpenHands#14009, with example scripts here:Changes
secret_registry.pyget_secret_value(name)for callback-based lookupskills/utils.pyget_secretcallback andexpand_defaultscontrol to MCP expansionplugin/plugin.pyplugin/loader.pylocal_conversation.pyagent/base.pymcp_configfrom serialization to prevent secret leakagetest_secrets_manager.pytest_mcp_config_expansion.pytest_plugin_loading.pytest_local_conversation_plugins.pytest_mcp_secrets_serialization_leak.pytest_agent_serialization.pymcp_configis excluded from serialized agent payloadsFixes #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
eclipse-temurin:17-jdknikolaik/python-nodejs:python3.13-nodejs22-slimgolang:1.21-bookwormPull (multi-arch manifest)
# Each variant is a multi-arch manifest supporting both amd64 and arm64 docker pull ghcr.io/openhands/agent-server:2ecddab-pythonRun
All tags pushed for this build
About Multi-Architecture Support
2ecddab-python) is a multi-arch manifest supporting both amd64 and arm642ecddab-python-amd64) are also available if needed