Skip to content

feat(agent-server): per-server CRUD for MCP servers#3235

Open
rbren wants to merge 1 commit into
mainfrom
feature/mcp-server-crud
Open

feat(agent-server): per-server CRUD for MCP servers#3235
rbren wants to merge 1 commit into
mainfrom
feature/mcp-server-crud

Conversation

@rbren
Copy link
Copy Markdown
Contributor

@rbren rbren commented May 12, 2026

This PR was opened by an AI agent (OpenHands) on behalf of the user.

Follow-up to #3234 (typed settings update endpoints) — that PR called out the MCP-server-as-blob problem; this PR fixes it. The two are independent and don't share code; the small api.py exc.headers propagation fix appears in both with identical content (trivial merge resolution either way).

Why mcp_config is the worst-fit case for the settings PATCH

MCP servers live as a dict under agent_settings.mcp_config.mcpServers. Editing one through the global settings PATCH means:

  1. Read-modify-write of the whole collection. Even with deep-merge semantics, to add or remove a server atomically you have to PATCH the full new mcpServers mapping — which forces the client to ship every other server's encrypted env/headers ciphertexts that they had no business round-tripping.
  2. Cross-server concurrency at the wrong granularity. Two clients each adding a different server race, and one of them has to retry, having done no work that conflicts with the other.
  3. Discoverability. mcp_config is documented in OpenAPI as one shape; adding/removing/editing a server is undocumented and indirect.
  4. env / headers are dicts of secrets. Every byte that doesn't need to leave the server shouldn't.

What this PR adds

A dedicated router that treats each MCP server as its own named resource:

Verb Path Purpose
GET /api/settings/mcp-servers List summaries (name, transport kind, description, icon — no secrets).
GET /api/settings/mcp-servers/{name} Read one (honours X-Expose-Secrets like /api/settings).
PUT /api/settings/mcp-servers/{name} Upsert. 201 on create, 200 on update.
PATCH /api/settings/mcp-servers/{name} Partial update of an existing server. 404 if absent.
DELETE /api/settings/mcp-servers/{name} Remove one. 404 if absent.

Body for PUT/PATCH is the raw fastmcp server config dict (the value of MCPConfig.mcpServers[name]). fastmcp's own validators discriminate stdio vs remote vs the transforming variants based on command/url presence; this router passes through. Cross-server invariants from MCPConfig re-fire on every write because each mutation re-validates the full OpenHandsAgentSettings after splicing.

Per-server optimistic concurrency

  • ETag on every read of /{name}. Computed over a plaintext-canonical JSON projection of just that server's config — not on-disk bytes, since Fernet nonces would make identical state hash differently every save and defeat idempotency.
  • If-Match: <etag> on PUT/PATCH/DELETE → 412 (with current ETag echoed in the header) if stale.
  • If-Match: * → must exist (update-only PUT).
  • If-None-Match: * → must not exist (create-only PUT).
  • If-None-Match: <etag> → 412 if it matches current (symmetric to If-Match).

The headline win: concurrent edits to different servers don't conflict at all. Same-server conflicts get a clean 412 with the new state's ETag, so the client can rebase and retry.

X-Expose-Secrets on GET

Same three modes as GET /api/settings:

  • absent → env/headers values are [REDACTED]
  • encrypted → Fernet ciphertext
  • plaintext → raw values

Logged at WARNING level when plaintext is requested, just like the existing settings endpoint.

ACP variant handling

ACPAgentSettings has no mcp_config field. Choices for each verb:

  • GET /api/settings/mcp-servers200 {"servers": []} (list is non-modifying; permissive shape so polling clients stay simple)
  • GET /api/settings/mcp-servers/{name}404
  • PUT/PATCH/DELETE409 Conflict with a message pointing the caller at PATCH /api/settings to switch agent variant.

Persistence

All writes go through the existing file-locked store.update(), so they remain atomic and serialise with each other and with the global PATCH /api/settings. No new persistence backend, no parallel lock. Each write rebuilds the full OpenHandsAgentSettings post-splice and re-validates, so encryption-on-save / decryption-on-load all run through the existing tested paths.

Bonus fix in api.py

The shared HTTPException handler was constructing the JSONResponse from status_code + detail but dropping exc.headers, so any handler-set response header on an error (e.g. our 412 ETag, or WWW-Authenticate on a 401) was silently lost. Three-line propagation fix; identical to the same fix in #3234 so either merge order is trivial to resolve.

SDK additions

New public types in openhands.sdk.settings:

  • MCPServerSummary — list-view shape (no secrets).
  • MCPServerListResponse — wraps the list.
  • MCPServerResponse — single-server read/write response: {name, config: dict[str, Any]}. config is dict[str, Any] (not a typed model) so the server controls secret serialization via context the same way SettingsResponse.agent_settings does.

Tests

tests/agent_server/test_mcp_servers_router.py — 32 tests covering:

  • List / read / upsert / patch / delete happy paths.
  • ETag stability across identical resaves (the core idempotency property), changes on real change.
  • If-Match matching / stale, If-None-Match: * create-only, If-Match: * update-only, stale 412 returns current ETag.
  • Concurrent edits to different servers don't conflict; concurrent edits to the same server: one 200, one 412.
  • PATCH preserves unsent fields; 404 if server doesn't exist; 422 on invalid type.
  • DELETE returns 204, 404 on missing, 412 on stale If-Match, doesn't disturb other servers.
  • ACP variant: list empty, GET 404, PUT 409, DELETE 409.
  • X-Expose-Secrets honoured: default redacted, plaintext raw, encrypted Fernet token.
  • Splice-locally property: editing server B doesn't require the client to round-trip server A's env.
  • Name validator: allows [A-Za-z0-9_.@:-]{1,128}; slashes rejected (URL path segment).

Compatibility

  • Strictly additive. No existing endpoints, schemas, or persistence shapes change.
  • The global PATCH /api/settings continues to work for mcp_config exactly as today; clients can migrate incrementally.
  • The api.py exception-handler fix is a tiny improvement that only adds headers from HTTPException(headers=...) that route handlers explicitly set; it never alters the body, status code, or removes anything.

Test summary

  • tests/agent_server/test_mcp_servers_router.py: 32 / 32 pass.
  • tests/agent_server/test_settings_router.py: 28 / 28 pass (unchanged).
  • tests/sdk/test_settings.py + tests/sdk/settings/*: 87 / 87 pass (unchanged).

Out of scope (further follow-ups)

  • List-level ETag. Per-server ETag is sufficient for the mutation flow; a list-level ETag would help bulk-fetch clients avoid re-walking unchanged lists, but it's a cache-validation feature rather than a correctness one.
  • Tools sub-collection. mcp_config.mcpServers[name].tools has its own per-tool transformation config — if that becomes a hot edit target, a /mcp-servers/{name}/tools/{tool} sub-router would apply the same logic at the next level down.
  • Single-server ETag bound to global settings ETag. Currently per-server ETags float independently of the global settings ETag; for clients that want to ensure their MCP edit didn't race against a concurrent PATCH /api/settings mutating something else, we could expose a compound ETag. Not needed for the per-server flow.

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:d75b1fe-python

Run

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

All tags pushed for this build

ghcr.io/openhands/agent-server:d75b1fe-golang-amd64
ghcr.io/openhands/agent-server:d75b1fe2ff75ad4c180433e43e45f2bd1f4bbe00-golang-amd64
ghcr.io/openhands/agent-server:feature-mcp-server-crud-golang-amd64
ghcr.io/openhands/agent-server:d75b1fe-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:d75b1fe-golang-arm64
ghcr.io/openhands/agent-server:d75b1fe2ff75ad4c180433e43e45f2bd1f4bbe00-golang-arm64
ghcr.io/openhands/agent-server:feature-mcp-server-crud-golang-arm64
ghcr.io/openhands/agent-server:d75b1fe-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:d75b1fe-java-amd64
ghcr.io/openhands/agent-server:d75b1fe2ff75ad4c180433e43e45f2bd1f4bbe00-java-amd64
ghcr.io/openhands/agent-server:feature-mcp-server-crud-java-amd64
ghcr.io/openhands/agent-server:d75b1fe-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:d75b1fe-java-arm64
ghcr.io/openhands/agent-server:d75b1fe2ff75ad4c180433e43e45f2bd1f4bbe00-java-arm64
ghcr.io/openhands/agent-server:feature-mcp-server-crud-java-arm64
ghcr.io/openhands/agent-server:d75b1fe-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:d75b1fe-python-amd64
ghcr.io/openhands/agent-server:d75b1fe2ff75ad4c180433e43e45f2bd1f4bbe00-python-amd64
ghcr.io/openhands/agent-server:feature-mcp-server-crud-python-amd64
ghcr.io/openhands/agent-server:d75b1fe-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:d75b1fe-python-arm64
ghcr.io/openhands/agent-server:d75b1fe2ff75ad4c180433e43e45f2bd1f4bbe00-python-arm64
ghcr.io/openhands/agent-server:feature-mcp-server-crud-python-arm64
ghcr.io/openhands/agent-server:d75b1fe-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:d75b1fe-golang
ghcr.io/openhands/agent-server:d75b1fe2ff75ad4c180433e43e45f2bd1f4bbe00-golang
ghcr.io/openhands/agent-server:feature-mcp-server-crud-golang
ghcr.io/openhands/agent-server:d75b1fe-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:d75b1fe-java
ghcr.io/openhands/agent-server:d75b1fe2ff75ad4c180433e43e45f2bd1f4bbe00-java
ghcr.io/openhands/agent-server:feature-mcp-server-crud-java
ghcr.io/openhands/agent-server:d75b1fe-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:d75b1fe-python
ghcr.io/openhands/agent-server:d75b1fe2ff75ad4c180433e43e45f2bd1f4bbe00-python
ghcr.io/openhands/agent-server:feature-mcp-server-crud-python
ghcr.io/openhands/agent-server:d75b1fe-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., d75b1fe-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., d75b1fe-python-amd64) are also available if needed

Adds a dedicated router for managing MCP servers as a true REST collection
rather than a sub-blob of the global settings PATCH:

  GET    /api/settings/mcp-servers
  GET    /api/settings/mcp-servers/{name}
  PUT    /api/settings/mcp-servers/{name}
  PATCH  /api/settings/mcp-servers/{name}
  DELETE /api/settings/mcp-servers/{name}

Each server is treated as its own named resource:

* Per-server ETag, computed over a plaintext-canonical projection of
  the server's config (stable across Fernet resaves of identical
  content). Mutations honour If-Match / If-None-Match: * for
  optimistic concurrency at the right granularity — concurrent edits
  to different servers no longer collide at all.
* X-Expose-Secrets is honoured on GET /{name} just like the global
  /api/settings, with the same redacted / encrypted / plaintext modes.
* Writes still go through the same file-locked store.update() as
  /api/settings, so they remain atomic and serialise with each other
  and with the legacy global PATCH.
* ACP variant has no mcp_config field: list returns empty (200), single-
  item GET returns 404, mutations return 409 with a message pointing
  the caller at the variant-switching settings endpoint.

Also exposes the new request/response shapes from the SDK
(MCPServerSummary, MCPServerListResponse, MCPServerResponse) so
clients can generate typed bindings.

Bonus fix in api.py: the shared HTTPException handler was building
the JSONResponse from status_code + detail but dropping exc.headers,
so the ETag header on a 412 (or WWW-Authenticate on a 401) was lost.
Propagate it.

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

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

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.

🟢 Good taste - Excellent design and implementation

[ARCHITECTURE WINS]

  • Per-server ETag enables concurrent edits to different servers without conflicts - solves the fundamental "read-modify-write the whole collection" limitation of global settings PATCH
  • Plaintext-canonical ETag computation avoids Fernet nonce issues (idempotent resaves)
  • Clean separation of concerns: secret serialization via model context, redaction as response transform
  • Comprehensive test coverage (11 scenarios including the critical "splice locally" property)

[RISK ASSESSMENT]
🟢 LOW RISK

  • New API surface, zero changes to existing endpoints or persistence format
  • All writes still go through file-locked store.update() (serialized with global PATCH)
  • Properly tested: CRUD, ETag semantics, optimistic concurrency, secret exposure modes, ACP variant, name validation
  • Exception handler change (propagating headers) is safe and necessary for ETag-on-412

VERDICT: ✅ Worth merging - Excellent module docstring explains motivation clearly; implementation is clean and well-tested.

Minor note: PR description says "placeholder" - consider adding a summary before merge.

@enyst
Copy link
Copy Markdown
Collaborator

enyst commented May 12, 2026

@OpenHands read the PR linked in this PR's description too for context. And /codereview-roasted this PR. Respond directly on github.

@openhands-ai
Copy link
Copy Markdown

openhands-ai Bot commented May 12, 2026

I'm on it! enyst can track my progress at all-hands.dev

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

All MCP server CRUD endpoints work correctly with per-server optimistic concurrency, avoiding the need to round-trip all servers' encrypted secrets.

Does this PR achieve its stated goal?

Yes. The PR successfully delivers fine-grained CRUD API endpoints for individual MCP servers with proper optimistic concurrency control. I verified by actually running the agent-server and making HTTP requests that: (1) all CRUD operations work end-to-end, (2) ETags are stable across identical resaves but change when content changes, (3) If-Match/If-None-Match preconditions enforce optimistic concurrency, (4) X-Expose-Secrets modulates secret exposure correctly, (5) ACP variant returns appropriate errors, and (6) editing one server preserves other servers' secrets without client round-tripping.

Phase Result
Environment Setup ✅ Built with make build, all dependencies installed
CI Status ✅ agent-server-tests passed (1m10s), pre-commit passed, core checks green
Functional Verification ✅ All CRUD endpoints verified with real HTTP requests
Functional Verification

Test 1: Basic CRUD Operations

Setup: Started agent-server on port 8000 with persistence enabled.

Ran comprehensive CRUD test script:

./qa_mcp_crud.sh

Results:

  • ✓ LIST empty collection: GET /api/settings/mcp-servers → 200 with {"servers":[]}
  • ✓ CREATE via PUT: PUT /api/settings/mcp-servers/test-server → 201
  • ✓ READ with default redaction: secrets masked as [REDACTED]
  • ✓ READ with X-Expose-Secrets: plaintext: raw secret values returned
  • ✓ PATCH partial update: description updated, other fields preserved
  • ✓ DELETE: 204 No Content, subsequent GET returns 404
  • ✓ CREATE second server: concurrent additions don't conflict
  • ✓ LIST all servers: both servers appear with correct summaries

This confirms all five CRUD endpoints (GET list, GET one, PUT, PATCH, DELETE) work correctly.


Test 2: ETag Stability (Plaintext-Canonical)

Goal: Verify ETags are computed from plaintext-canonical form, not on-disk ciphertext.

Step 1 — Create server and capture ETag:

curl -X PUT -H 'Content-Type: application/json' \
  -d '{"command":"python","args":["server.py"],"env":{"API_KEY":"test-key-123"}}' \
  http://127.0.0.1:8000/api/settings/mcp-servers/etag-test
curl -v http://127.0.0.1:8000/api/settings/mcp-servers/etag-test 2>&1 | grep etag

Output: ETag: "12dbfe5db8c6e78d9b1a848a0699b86c"

Step 2 — Re-PUT identical config (forces re-encryption with new Fernet nonce):

curl -X PUT -H 'Content-Type: application/json' \
  -d '{"command":"python","args":["server.py"],"env":{"API_KEY":"test-key-123"}}' \
  http://127.0.0.1:8000/api/settings/mcp-servers/etag-test

Step 3 — Get ETag again:
Output: ETag: "12dbfe5db8c6e78d9b1a848a0699b86c" (identical)

This confirms ETag is stable despite on-disk ciphertext changing (different Fernet nonce).

Step 4 — Change content and verify ETag changes:

curl -X PUT -H 'Content-Type: application/json' \
  -d '{"command":"python","args":["server.py"],"env":{"API_KEY":"different-key-456"}}' \
  http://127.0.0.1:8000/api/settings/mcp-servers/etag-test

Output: ETag: "5906348764584012abf2d280128a3afd" (different)

This confirms ETag changes when content changes.


Test 3: Optimistic Concurrency (If-Match / If-None-Match)

Scenario A: Stale If-Match rejected with 412

curl -X PUT -H 'If-Match: "stale-etag-xyz"' \
  -d '{"command":"python","args":["app.py"]}' \
  http://127.0.0.1:8000/api/settings/mcp-servers/test-server

Output: 412 Precondition Failed with current ETag in response headers: "edc3458be423398630349d9814f5bb2d"

This confirms clients get the current ETag in 412 responses so they can retry with correct value.

Scenario B: If-None-Match: * on existing server rejected

curl -X PUT -H 'If-None-Match: *' \
  -d '{"command":"node","args":["app.js"]}' \
  http://127.0.0.1:8000/api/settings/mcp-servers/test-server

Output: 412 Precondition Failed with message: "If-None-Match: * requires the resource to not exist"

This confirms create-only semantics work.

Scenario C: Concurrent edits to different servers don't conflict

  • Created server-a with If-None-Match: * → 201
  • Created server-b with If-None-Match: * → 201 (no conflict)
  • Both servers appear in GET /api/settings/mcp-servers

This confirms per-server granularity eliminates false conflicts.


Test 4: X-Expose-Secrets Header Modes

Created server with secrets:

{"command":"curl","env":{"AUTH_TOKEN":"super-secret-token"},"headers":{"Authorization":"Bearer secret-bearer"}}

Mode 1 — Default (no header):

curl http://127.0.0.1:8000/api/settings/mcp-servers/expose-test

Result: "AUTH_TOKEN": "[REDACTED]"

Mode 2 — X-Expose-Secrets: plaintext:

curl -H 'X-Expose-Secrets: plaintext' http://127.0.0.1:8000/api/settings/mcp-servers/expose-test

Result: "AUTH_TOKEN": "super-secret-token"

Mode 3 — X-Expose-Secrets: encrypted:

curl -H 'X-Expose-Secrets: encrypted' http://127.0.0.1:8000/api/settings/mcp-servers/expose-test

Result: "AUTH_TOKEN": "gAAAAABqA5FOlf9HTswqxihPS4DfedJhcdGYffT9pkc7rCimVF..." (Fernet ciphertext) ✓

This confirms all three exposure modes work correctly.


Test 5: Server Isolation (Key Value Proposition)

Goal: Verify editing server A preserves server B's secrets without client round-tripping B's data.

Setup:

curl -X PUT -d '{"command":"echo","args":["alpha"],"env":{"SECRET_ALPHA":"alpha-secret-123"}}' \
  http://127.0.0.1:8000/api/settings/mcp-servers/server-alpha
curl -X PUT -d '{"command":"echo","args":["beta"],"env":{"SECRET_BETA":"beta-secret-456"}}' \
  http://127.0.0.1:8000/api/settings/mcp-servers/server-beta

Captured server-beta's secret:

curl -H 'X-Expose-Secrets: plaintext' http://127.0.0.1:8000/api/settings/mcp-servers/server-beta

Result: "SECRET_BETA": "beta-secret-456"

Edited server-alpha (request does NOT include server-beta's data):

curl -X PATCH -d '{"description":"Updated alpha server"}' \
  http://127.0.0.1:8000/api/settings/mcp-servers/server-alpha

Verified server-beta's secret unchanged:

curl -H 'X-Expose-Secrets: plaintext' http://127.0.0.1:8000/api/settings/mcp-servers/server-beta

Result: "SECRET_BETA": "beta-secret-456" (identical) ✓

This confirms the per-server CRUD avoids round-tripping other servers' secrets through the client.


Test 6: ACP Variant Handling

Switched to ACP variant:

curl -X PATCH -d '{"agent_settings_diff":{"agent_kind":"acp","acp_server":"claude-code","llm":{"model":"claude-sonnet-4-20250514"}}}' \
  http://127.0.0.1:8000/api/settings

LIST on ACP variant:

curl http://127.0.0.1:8000/api/settings/mcp-servers

Result: 200 OK with {"servers":[]} ✓ (permissive for polling clients)

GET on ACP variant:

curl http://127.0.0.1:8000/api/settings/mcp-servers/test-server

Result: 404 Not Found

PUT on ACP variant:

curl -X PUT -d '{"command":"echo","args":["test"]}' \
  http://127.0.0.1:8000/api/settings/mcp-servers/new-server

Result: 409 Conflict with message: "MCP servers are not supported by the current agent variant ('acp'). Switch to an OpenHands-variant agent via PATCH /api/settings before managing MCP servers."

This confirms the router handles ACP variant correctly with appropriate status codes and helpful error messages.


Test 7: HTTP Exception Handler Propagates Headers

Verified 412 responses include ETag header (from api.py changes to propagate exc.headers):

curl -v -X PUT -H 'If-Match: "stale-etag-xyz"' \
  -d '{"command":"python","args":["app.py"]}' \
  http://127.0.0.1:8000/api/settings/mcp-servers/test-server

Response headers include: etag: "edc3458be423398630349d9814f5bb2d"

This confirms the HTTP exception handler changes work correctly — clients receive the current ETag in 412 responses so they can retry with the correct value.

Issues Found

None.

Copy link
Copy Markdown
Collaborator

@enyst enyst left a comment

Choose a reason for hiding this comment

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

Read #3234 for context. The per-server resource shape is the right direction, and the ETag/error-header plumbing is solid. But there’s one real data-loss bug here that keeps this off approval.

🔴 Needs improvement

[CRITICAL ISSUES]

  • openhands-agent-server/openhands/agent_server/mcp_servers_router.py:313 Breaking Change / Data Loss: _validate_servers_dict() reconstructs mcp_config as just {"mcpServers": servers}, and the write paths then assign that back onto agent_dump["mcp_config"]. fastmcp.MCPConfig is additionalProperties=True, so top-level keys besides mcpServers are valid and round-trip today. This router silently deletes them on any PUT/PATCH/DELETE.

    I reproduced this locally by seeding settings through the legacy endpoint with:

    {"mcp_config": {"mcpServers": {"a": {"command": "echo"}}, "extraTop": {"a": 1}}}

    then calling PATCH /api/settings/mcp-servers/a, after which extraTop disappeared from persisted settings.

    That’s a bad boundary bug: the new fine-grained API is mutating more than the named resource. The fix should preserve the full existing mcp_config payload and splice only mcpServers, then revalidate that full object.

[TESTING GAPS]

  • tests/agent_server/test_mcp_servers_router.py Missing regression coverage: there’s no test that seeds mcp_config with a sibling top-level key, performs a per-server PUT/PATCH/DELETE, and verifies that sibling data survives the persistence round-trip. Add one; this bug is easy to reintroduce.

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟡 MEDIUM
    The API design is good and the concurrency/secret handling coverage is strong, but the current implementation can silently drop valid persisted MCPConfig data on ordinary writes. That’s user-visible settings loss.

VERDICT:
Needs rework: preserve the full mcp_config object when mutating mcpServers.

KEY INSIGHT:
Once MCPConfig comes from FastMCP, the safe unit of mutation is “full mcp_config, replace only mcpServers” — not “rebuild mcp_config from scratch around mcpServers”.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.


@openhands-ai
Copy link
Copy Markdown

openhands-ai Bot commented May 12, 2026

OpenHands encountered an error: Request timeout after 30 seconds to https://fauiumbpeveovopc.prod-runtime.all-hands.dev/api/conversations/db269517-fa31-4bfc-af9c-0c9901ab8ef0/ask_agent

See the conversation for more information.

@github-actions
Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   api.py2382191%98, 100–105, 107, 109, 111, 145, 157, 172, 178, 427, 430, 434–436, 438, 444
   mcp_servers_router.py2211095%212–213, 265, 290, 504, 506, 586–587, 656–657
openhands-sdk/openhands/sdk/settings
   api_models.py36488%69, 71, 79, 81
TOTAL27190622777% 

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants