Skip to content

🛡️ feat: Lock YAML-Defined MCP Servers In Admin Panel#63

Open
dustinhealy wants to merge 12 commits into
mainfrom
fix/mcp-per-entry-save
Open

🛡️ feat: Lock YAML-Defined MCP Servers In Admin Panel#63
dustinhealy wants to merge 12 commits into
mainfrom
fix/mcp-per-entry-save

Conversation

@dustinhealy
Copy link
Copy Markdown
Contributor

@dustinhealy dustinhealy commented May 23, 2026

Summary

Admins editing MCP server entries from the panel could rename or delete YAML-defined servers, creating duplicate entries (because the YAML still declared the original) and breaking the silent-drop fix that just shipped to LibreChat. They could also lose nested data on rename, and single-field resets were silently dropped because the per-entry write strategy collided with the global Save bar's baseline-equality dirty pruning.

What was happening

The renderer wrote MCP edits as whole-entry blobs at mcpServers.<name>, which (a) broke single-field reset because the path didn't match how flatBaseline stores leaves, (b) made rename orphan cleanup miss descendant edits, and (c) carried inspector-derived fields like tools and capabilities into the save payload even though the LibreChat registry ignores them on the way back in.

YAML detection used an absence-of-override heuristic that produced false positives on YAML servers with any admin override on cosmetic fields like title or iconPath, re-enabling rename and delete on those.

Fix

Detection now reads the un-merged YAML key set directly from a new getBaseConfigFn channel that calls LibreChat's /api/admin/config/base with ?baseOnly=true (companion PR danny-avila/LibreChat#13173). Identity is locked iff the name appears in that set, regardless of how many fields the admin has since overridden.

For YAML-source servers the rename pencil and trash button are hidden, the transport-type selector is disabled because changing it breaks the Zod union, and inspector-derived fields are hidden from the editor. All other fields including url, command, args, env, stderr, headers, apiKey, and oauth remain editable to support the per-tenant deployment model where YAML provides global defaults and the admin panel is the per-tenant customization surface.

MCP entry edits now write per-leaf paths under mcpServers.<name>.<field> through every code path (create, edit, remove, rename). The rename path enumerates leaves recursively so nested data like headers.Authorization survives rename intact. Dotted, __proto__, constructor, and prototype names are rejected at create and rename time. A custom anyOfSchema wrapper resolves Zod union sub-schemas during per-field validation so a streamable-http server's type field no longer fails union resolution against an unrelated Stdio branch literal.

Testing

bun run test passes 577/577 across 14 suites (18 new in McpServersRenderer.test.tsx covering per-leaf writes, YAML detection, the lock matrix, dotted-name rejection, rename-with-nested-data, remove-with-nested-data, create-then-edit-then-rename, and the yamlBaseKeys-absent fallback).

Manual verification flow:

  1. With companion PR running on LibreChat, restart admin panel dev server.
  2. YAML server kapa with no overrides: rename and trash hidden, transport type disabled, every other field editable.
  3. YAML server kapa with admin title override: identity still locked, url and headers still editable.
  4. Admin-only server testmcp1: fully editable including rename and delete.
  5. Freshly created server: fully editable.
  6. Create with name foo.bar, __proto__, or pre-existing key: rejected with the appropriate banner message.

Companion PR

Requires danny-avila/LibreChat#13173 deployed for the YAML-only signal. The detection falls back to "lock nothing" if yamlMcpKeys is absent (older LibreChat without ?baseOnly support), which is safer than the previous heuristic that produced false positives.


Note

Medium Risk
Changes how MCP and general config edits are batched and persisted (delete/rename semantics, MongoDB unset behavior) and depend on a companion LibreChat baseOnly API for correct YAML locking.

Overview
Locks YAML-defined MCP servers in the admin panel using server names from getBaseConfigFn (?baseOnly=true / yamlMcpKeys), with a safe fallback when that API is missing. YAML entries keep rename/delete and transport type locked while other fields stay editable; inspector-only fields are hidden from the editor.

MCP editing is per-leaf (mcpServers.<name>.<field>) for create, edit, remove, and rename—including nested paths and entry-level $unset—instead of replacing whole server objects. ConfigPage dirty-state logic is tightened so container deletes and overlapping parent/child edits are not dropped before save.

Scope editing disables creating/removing/renaming MCP servers but still allows field overrides. Role/group and validation UX add toasts, stricter server names, and transport required-field checks in the create dialog. Server-side Zod union traversal (anyOfSchema / resolveSubSchema) improves per-field MCP validation; a large McpServersRenderer test suite covers the new behavior.

Reviewed by Cursor Bugbot for commit 7f26300. Bugbot is set up for automated code reviews on this repo. Configure here.

Detects servers that come from the YAML config by reading the un-merged
base config keys threaded from getBaseConfigFn's new yamlMcpKeys signal,
so admin overrides on cosmetic fields like title or iconPath no longer
flip the detection and re-enable rename or delete on YAML-defined
servers.

For YAML-source servers the rename pencil and trash button are hidden
and the transport-type selector is disabled because changing it breaks
the Zod union. All other fields including url, command, args, env,
stderr, headers, apiKey, and oauth remain editable to support the
multi-tenant model where YAML provides global defaults and the admin
panel is the per-tenant customization surface. Inspector-derived
fields are hidden from the editor since admin overrides of those are
silently ignored at the registry layer.

MCP entry edits now write per-leaf paths under mcpServers.<name>.<field>
so single-field reset, baseline-equality dirty detection, and rename
orphan cleanup all work correctly through the existing global Save bar.
The rename path enumerates leaves recursively so nested per-leaf data
like headers and oauth survive rename intact. Dotted server names are
rejected at create and rename time because the path model treats dots
as delimiters, and a custom anyOfSchema wrapper resolves Zod union
sub-schemas during per-field validation without relying on a specific
zod major version.
@dustinhealy dustinhealy force-pushed the fix/mcp-per-entry-save branch from deb88a2 to 2a1ba73 Compare May 23, 2026 02:02
@dustinhealy dustinhealy marked this pull request as ready for review May 23, 2026 02:40
@dustinhealy dustinhealy marked this pull request as draft May 23, 2026 02:41
Replace the inline danger banner in McpServersRenderer with the same
toast channel ConfigPage uses for save errors. ConfigTabContent
forwards an optional onValidationError prop down to record-type
renderers, ConfigPage wires it to showToast, and the renderer fires
toasts instead of holding local error state. Dot, prototype, and
existing-name guards remain to prevent dotted names from corrupting
the per-leaf write path on save.
Drop file-header docblocks, section dividers, and JSDocs that restated
what well-named identifiers already say. Keep only the WHY comments
that explain non-obvious behavior: MongoDB \$unset subtree collapse,
zod v3/v4 cross-version anyOfSchema, container-delete prune bypass,
ref-stable callbacks for memo bail, and YAML-source detection fallback.

171 added comment lines down to 21.
@dustinhealy dustinhealy marked this pull request as ready for review May 23, 2026 03:30
@dustinhealy dustinhealy requested a review from danny-avila May 23, 2026 04:16
@danny-avila
Copy link
Copy Markdown
Contributor

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b3a1cf4b80

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/server/config.ts Outdated
Comment thread src/components/configuration/sections/McpServersRenderer.tsx Outdated
anyOfSchema now keeps its candidate options on _def so resolveSubSchema
can keep walking when a path crosses more than one union segment; the
union walker also recurses into each candidate so records, arrays, and
nested unions resolve through their own case instead of relying on
shape lookup. Without this, validateFieldValue silently returned
success for any leaf reached through a multi-candidate union.

editsByEntry now resolves the entry key by longest-prefix match
against baseRecord keys, so legacy server names that already contain
dots stay attributed to their real entry rather than getting carved
up at the first dot. New names are still blocked from containing dots
at create and rename time.

Regression tests cover the through-union nested-field path in
config.test.ts and the dotted legacy entry editsByEntry plus remove
behavior in McpServersRenderer.test.tsx.
@dustinhealy
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a083be3b2c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/components/configuration/sections/McpServersRenderer.tsx
Comment thread src/components/configuration/sections/McpServersRenderer.tsx
A per-leaf write under a dotted server name (mcpServers.legacy.dotted.url
for entry "legacy.dotted") is indistinguishable on the save side from a
nested mcpServers.legacy.dotted.url path, because the fields PATCH
endpoint parses fieldPath as dot-delimited segments. Pre-existing
dotted entries previously survived because the renderer saved the
whole mcpServers record; per-leaf granularity broke that.

McpEntryRow now flags entryKey.includes('.') as a dotted-legacy entry
and renders it disabled with rename and delete hidden. The entry
remains visible so admins know it exists and can fix it via YAML or
the underlying datastore. entryOnChange and handleWholeEntryChange
short-circuit for dotted keys, and handleRemove and handleRename
add the same guard as defense-in-depth so no programmatic caller
can emit a colliding fieldPath.

Regression tests cover the disabled-input render, missing rename and
delete buttons, and the no-onChange-writes invariant.
@dustinhealy
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2f29f22734

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/components/configuration/ConfigPage.tsx Outdated
Comment thread src/components/configuration/sections/McpServersRenderer.tsx
Comment thread src/components/configuration/ConfigPage.tsx
The record useMemo walked baseRecord first then appended every edited
entry, so changing a single field bumped its row to the bottom of the
list. The merged loop now walks baseRecord and applies the edits
overlay in place, with newly-created entries appended in their
editsByEntry insertion order.

baselineContainerPaths walks the structured config to record every
container path (including orphaned mcpServers.foo with no leaves
under it). The dirty check now treats an entry-path undefined as a
container delete when either the leaf-derived intermediates or the
structured container set knows about it, so an orphaned empty entry
can actually be removed instead of being pruned as a no-op write.

Regression tests cover the in-place ordering for an edited entry,
the bottom-append ordering for a freshly-created entry, and the
existing handleRemove flow continues to assert leaf cleanup for
non-empty entries.
@dustinhealy
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f0f6712b53

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/server/config.ts Outdated
The prune branch in handleFieldChange dropped only the current leaf
path from editedValues when its value returned to baseline, leaving
any ancestor `undefined` write in place. After a delete-then-recreate
sequence where every recreated field happens to match its baseline,
the leftover `mcpServers.<name>: undefined` survived and the record
overlay continued to hide the resurrected entry.

The prune branch now mirrors the dedup loop in the non-prune branch:
on a baseline-match drop, any ancestor entry whose value is
`undefined` is removed too. Non-undefined ancestors are untouched so
explicit subtree overrides keep their semantics.
A LibreChat without the companion baseOnly support returns the same
merged config payload for both /api/admin/config/base and the
?baseOnly=true variant. The previous code treated every key from the
baseOnly response as YAML-defined, which on legacy backends locked
admin-only servers against rename and delete.

getBaseConfigFn now checks dbOverrides.mcpServers first. When admin
overrides on mcpServers exist, the baseOnly response is only treated
as authoritative if it differs from the merged config; when the two
are byte-identical despite known overrides, the backend is legacy and
yamlMcpKeys stays undefined so the renderer falls back to its safer
"lock nothing" path. When no admin overrides exist on mcpServers the
two responses are legitimately identical, so baseOnly is used
unchanged and YAML servers remain locked.
@dustinhealy
Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/components/configuration/ConfigPage.tsx
The non-prune dedup loop only cleared ancestor entries (existing keys
that path descends from). A subsequent parent-level write (e.g. an
edit to mcpServers.bar.headers as a whole) left previously-emitted
per-leaf writes like mcpServers.bar.headers.Authorization in
editedValues. Both reached the field PATCH endpoint as separate
entries and raced order-dependently, letting the stale leaf overwrite
the user's parent edit.

The loop now also removes descendants of the current path so a write
at any subtree level supersedes any deeper entries beneath it.
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 51a2001. Configure here.

Comment thread src/components/configuration/sections/McpServersRenderer.tsx Outdated
Comment thread src/components/configuration/ConfigPage.tsx
handleCreate and handleRename listed onValidationError and localize in
their useCallback dependency arrays. ConfigPage passes onValidationError
as an inline arrow each render, and useLocalize returns a fresh function
reference each render, so the callbacks were recreated on every parent
re-render. That defeated memo(McpEntryRow) because the onRename prop
changed identity every cycle and every row re-rendered even when its
data was untouched.

Mirror the existing editedValuesRef / baseRecordRef / recordRef pattern
with refs for both onValidationError and localize, then drop those
unstable values from the callback dep lists. handleCreate, handleRemove,
and handleRename are now stable as long as onChange and path are stable,
which they are.
@dustinhealy
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dfdde5d18e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/components/configuration/sections/McpServersRenderer.tsx
Per-leaf save granularity bypasses the whole-object Zod validation
that previously caught missing required siblings, so an admin could
pick transport sse and submit without a url; the resulting per-field
PATCH succeeded because type=sse is a valid enum on its own and no
sibling check ran on the server. The persisted entry was invalid for
its transport but stuck around.

CreateMcpServerDialog.handleSubmit now consults REQUIRED_BY_TRANSPORT
for the effective transport (http normalized to streamable-http) and
surfaces a missing-required-field error inline before onSave runs.
The dialog's submit-disabled invariant already covers the always-
required type field; this adds the transport-specific sibling check
so a partial create never reaches the field-PATCH endpoint.
@dustinhealy
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 22e211e671

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +845 to +846
if (!seen.has(entryPath)) {
onChange(entryPath, undefined);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve scope deletions for inherited MCP entries

When editing a role/group scope, deleting a non-YAML MCP server that is inherited from the base config now records mcpServers.<key> as undefined; handleConfirmSave treats that as a reset and calls the scope DELETE endpoint, which only removes an existing scope override. If the entry exists only in the base config, the DELETE is a no-op and the server reappears after save (renames have the same old-key cleanup problem, leaving the inherited entry plus the new copy). This needs a scope-level replacement/tombstone instead of a reset for inherited entries.

Useful? React with 👍 / 👎.

The scope-edit save path treats an undefined field write as a reset
that calls the profile DELETE endpoint, which can only remove an
existing scope override. Deleting an inherited base entry from a
role/group scope therefore left it visible after save because no
scope override existed to remove; renaming had the same problem and
created a parallel scope entry alongside the surviving inherited
original. Per-leaf saves cannot express a scope-level tombstone, so
the proper fix needs backend support that does not exist yet.

Add a scopeMode prop on FieldRendererProps and ConfigTabContentProps,
plumb isEditingScope through ConfigPage so the MCP renderer knows
when it is editing a scope, and treat scopeMode the same as
isYamlSource / isDottedLegacy for the identity lock: the create
button is disabled, an inline note explains the limitation, and
McpEntryRow hides rename and delete on every entry. Field edits
still produce per-leaf scope overrides as expected.

Regression tests cover the create-disabled state and the absence of
rename/delete affordances in scope mode, plus that a field edit
still emits a per-leaf write so the user can still customize values
per scope.
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.

2 participants