Skip to content

feat(mcp): add organization-scoped MCP server listings with API, permission and launch merge (OHE-3861) - #1121

Merged
hieptl merged 3 commits into
mainfrom
hiep/ohe-3861-org-mcp-org-mcp-server-storage-api-permission-and-launch
Nov 2, 2026
Merged

hieptl merged 3 commits into
mainfrom
hiep/ohe-3861-org-mcp-org-mcp-server-storage-api-permission-and-launch

Conversation

@hieptl

@hieptl hieptl commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

HUMAN:

  • A human has tested these changes.

AGENT:


Why

MCP servers exist only per member today: OrgMember.mcp_config is the single store, the launch path only ever sees the member's own servers, and organization Agent Profiles may not reference MCP servers at all. OHE-3091 asks for organization-scoped MCP servers: an admin defines a listing once (for example a company Bifrost or a shared Datadog server) and every member's conversations get it, either with one shared credential the admin supplies (organization-managed) or with each member's own identity (user-managed, the default). This PR is the backend half; the Settings > MCP page and the Agent Profile editors follow in separate PRs behind the enable_org_mcp_servers flag from #1120.

Summary

  • Storage and API. New org.mcp_servers EncryptedJSON column (migration 207, nullable, no backfill) holding {servers: {<name>: {managed_by: 'org' | 'user', server: <MCPServer>}}}, a store that mutates under the org row lock, the MANAGE_ORG_MCP permission (owner/admin), and GET / POST / PUT /{name} / DELETE /{name} under /api/organizations/{org_id}/mcp-servers. Reads mask every credential and list the ${NAME} secrets each listing references, since the reference itself sits inside the masked credential. Writes accept remote servers only, reject OAuth credentials and the reserved name default, require an organization-managed listing's ${NAME} references to be org-shared secrets (a member's personal secret of the same name would otherwise shadow them), and return 409 when deleting a listing, or handing it over to users, while an organization Agent Profile still references it. A user-managed listing stores ${SECRET_NAME} where the credential goes; nothing per member is stored.
  • Launch. Only the resolved launch view (SaasSettingsStore.load(resolve_agent_profile=True)) merges the listings under the member's own servers, before profile resolution, so launches without a profile get them and personal and org mcp_server_refs can name them; a member's server keeps the bare name on a collision. Plain settings loads never see them, so a settings round-trip can never write them into a member's row. Conversation start skips a user-managed listing whose required ${NAME} reference this member's secrets cannot satisfy (the sandbox keeps such a reference literally and the server would fail to authenticate). The profile materialize dry run resolves against the same map.
  • Agent Profiles and the connection test. scope=org saves may reference the organization-managed listings (instead of none; null stays accepted for the legacy seeded default), and POST /api/v1/mcp/test restores a redacted credential from an organization-managed listing of the same name when the caller's personal settings hold no such server.

Issue Number

OHE-3861 (sub-ticket of OHE-3091)

How to Test

uv run alembic heads   # 207 (head)
uv run pytest tests/unit/server/routes/test_org_mcp_servers.py tests/unit/storage/test_org_mcp_servers_store.py \
  tests/unit/test_org_secrets_permissions.py tests/unit/app_server/test_mcp_test_router.py \
  tests/unit/app_server/test_live_status_app_conversation_service.py tests/unit/test_agent_profiles.py \
  tests/unit/test_saas_settings_store.py

583 tests pass (Docker is needed for the Postgres-backed store and launch tests). New coverage: store CRUD, encryption at rest and masking, the referential guard; the route permission split, validation and error mapping, and that a 422 never echoes a submitted credential; the launch merge with member-wins precedence, the plain-load isolation, store() refusing a launch view; org-profile saves accepting organization-managed names and rejecting user-managed or unknown ones; materialize resolving an org reference; the conversation-start skip of unconnected listings on both the OpenHands and ACP paths; and the test route's org fallback.

API smoke test as an admin: POST /api/organizations/{org_id}/mcp-servers with {"name": "bifrost-internal", "managed_by": "user", "server": {"url": "https://bifrost.acme.com/mcp", "transport": "http", "auth": {"strategy": "bearer", "value": "${BIFROST_TOKEN}"}}}, then GET as a member returns it masked with secret_names: ["BIFROST_TOKEN"]; a member POST returns 403. A member without a personal BIFROST_TOKEN secret starts a conversation without the server (a warning is logged), with the secret the server is attached and the reference expands in the sandbox.

Video/Screenshots

None: backend only.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

  • Migration 207 adds a nullable column; installs that never define a listing behave exactly as today (an empty map is a no-op everywhere, and "subset of an empty set" equals the previous "must be empty" rule for org profiles).
  • The API is not behind the enable_org_mcp_servers flag, as the agent-profiles API is not; the flag gates the UI only.
  • Out of scope, on purpose: OAuth for shared listings (the sandbox's refreshed-token write-back only searches the sandbox owner's personal servers), stdio listings, renaming a listing (profiles reference the name), showing the listings in Canvas, and /api/v1/mcp/servers/{name}/call-tool, which keeps answering 404 for org listings.
  • An organization-managed listing that references an org-shared secret with ${NAME} is accepted, but an inline token is the primary path.

Enterprise server image for this PR:

ghcr.io/openhands/enterprise-server:sha-20fccfb

…ission and launch merge (OHE-3861)

An org admin defines MCP server listings once and every member's
conversation launches with them. A listing is organization-managed
(the admin's shared credential rides inline, encrypted at rest) or
user-managed (the config references a personal secret with
${SECRET_NAME} and each member connects by creating that secret).

- org.mcp_servers (EncryptedJSON, migration 207), a store that mutates
  under the org row lock, and MANAGE_ORG_MCP for owners and admins.
- GET/POST/PUT/DELETE /api/organizations/{org_id}/mcp-servers: reads
  mask every credential and list the referenced secret names; writes
  accept remote servers only, reject OAuth and the reserved name
  "default", require organization-managed ${NAME} references to be
  org-shared secrets, and refuse to delete or hand over a listing an
  org Agent Profile still references.
- The resolved launch view merges the listings under the member's own
  servers (a member's server keeps its name), so personal and org
  Agent Profiles can reference them; plain settings loads never see
  them. Conversation start skips user-managed listings whose required
  secret the member has not created.
- Org Agent Profiles may reference organization-managed listings
  instead of none; the MCP connection test restores a masked credential
  from an organization-managed listing of the same name.
@github-actions github-actions Bot added the type: feat A new feature label Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

⚠️ This PR contains migrations. Please synchronize before merging to prevent conflicts.

@tofarr tofarr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🍰

hieptl and others added 2 commits November 2, 2026 21:28
…861)

Main took revision 207 for the startup title generation migration (#1119),
so this branch had two 207 revisions and two alembic heads. Move the org
MCP servers migration to 208 on top of it.

Seed the org row in the agent profile upgrade test with a Core insert. The
ORM insert writes NULL into every non-JSON org column it maps, and at the
older starting revisions the new mcp_servers column does not exist yet.
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  migrations/versions
  208_add_org_mcp_servers.py 35, 39
  openhands/app_server/app_conversation
  live_status_app_conversation_service.py 2613, 3603
  openhands/app_server/mcp
  mcp_secret_references.py 43-45
  mcp_test_router.py 348, 352-353, 360-381, 394-402, 473-478
  openhands/app_server/settings
  settings_models.py
  server/auth
  authorization.py
  server/routes
  agent_profiles.py 452, 471-472
  org_mcp_servers.py 55-61, 112, 135-181, 197-203, 233-248, 268-301, 314-328
  storage
  agent_profile_resolution.py
  org.py
  org_mcp_servers.py 65, 71-72
  org_mcp_servers_store.py
  saas_settings_store.py
Project Total  

This report was generated by python-coverage-comment-action

@hieptl
hieptl merged commit a40fec1 into main Nov 2, 2026
27 of 29 checks passed
@hieptl
hieptl deleted the hiep/ohe-3861-org-mcp-org-mcp-server-storage-api-permission-and-launch branch November 2, 2026 15:37
@openhands-release-bot openhands-release-bot Bot added the released: 1.83.0 Shipped in 1.83.0 label Nov 2, 2026
@openhands-release-bot

Copy link
Copy Markdown
Contributor

🚀 Released in 1.83.0.

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

Labels

released: 1.83.0 Shipped in 1.83.0 type: feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants