🔒 feat: Add On-Behalf-Of (OBO) token exchange support for MCP Servers (#13429)
Some checks failed
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
Publish `librechat-data-provider` to NPM / pack (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / pack (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled

* 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.
This commit is contained in:
jcbartle 2026-06-01 22:36:18 -04:00 committed by GitHub
parent 58662283af
commit 268f095c1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 2622 additions and 104 deletions

View file

@ -7,13 +7,20 @@
*/
const { logger } = require('@librechat/data-schemas');
const {
checkAccess,
MCPErrorCodes,
redactServerSecrets,
redactAllServerSecrets,
isMCPDomainNotAllowedError,
isMCPInspectionFailedError,
} = require('@librechat/api');
const { Constants, MCPServerUserInputSchema } = require('librechat-data-provider');
const {
Constants,
Permissions,
PermissionTypes,
MCPServerUserInputSchema,
MCP_USER_INPUT_FIELDS,
} = require('librechat-data-provider');
const {
resolveConfigServers,
resolveMcpConfigNames,
@ -21,6 +28,7 @@ const {
} = require('~/server/services/MCP');
const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
const { getMCPManager, getMCPServersRegistry } = require('~/config');
const db = require('~/models');
/**
* Handles MCP-specific errors and sends appropriate HTTP responses.
@ -202,6 +210,63 @@ const getMCPServersList = async (req, res) => {
}
};
/**
* Returns true when the request body's parsed config configures OBO. We block
* non-permission holders from creating or updating any DB-stored MCP server
* that mints per-user delegated tokens.
*/
function configHasObo(parsedConfig) {
return (
!!parsedConfig &&
typeof parsedConfig === 'object' &&
'obo' in parsedConfig &&
parsedConfig.obo != null
);
}
/**
* Fields a user without `CONFIGURE_OBO` may modify on an OBO server (allowlist).
* Any field not on this list is locked: changes to it (add, modify, or remove)
* require the permission. Allowlisting is fail-closed when upstream introduces
* a new MCP server config field, it lands in the locked set by default until
* explicitly opted in here. Anything that could redirect the OBO token flow
* (`url`, `proxy`, `headers`), change scopes (`obo`), or reroute auth (`oauth`,
* `apiKey`, `customUserVars`) MUST stay locked.
*/
const OBO_USER_EDITABLE_FIELDS = new Set(['title', 'description', 'iconPath']);
/**
* Returns true when any non-allowlisted user-input field differs between the
* existing server config and the new payload. Treats add, remove, and modify
* as changes (stable JSON compare, with absence on either side counting as a
* change unless both sides are absent). The comparison surface is
* `MCP_USER_INPUT_FIELDS` (schema-derived from `MCPServerUserInputSchema`),
* so new fields on the schema are picked up automatically and stay locked
* by default until added to the allowlist above.
*/
function violatesOboLockdown(existingConfig, newConfig) {
for (const field of MCP_USER_INPUT_FIELDS) {
if (OBO_USER_EDITABLE_FIELDS.has(field)) continue;
const existing = existingConfig?.[field];
const next = newConfig?.[field];
if (existing === undefined && next === undefined) continue;
if (JSON.stringify(existing) !== JSON.stringify(next)) {
return true;
}
}
return false;
}
async function callerCanConfigureObo(req) {
return checkAccess({
req,
user: req.user,
permissionType: PermissionTypes.MCP_SERVERS,
permissions: [Permissions.CONFIGURE_OBO],
getRoleByName: db.getRoleByName,
});
}
/**
* Create MCP server
* @route POST /api/mcp/servers
@ -218,6 +283,14 @@ const createMCPServerController = async (req, res) => {
errors: validation.error.errors,
});
}
if (configHasObo(validation.data) && !(await callerCanConfigureObo(req))) {
logger.warn(
`[createMCPServer] User ${userId} attempted to configure OBO without ${Permissions.CONFIGURE_OBO} permission`,
);
return res
.status(403)
.json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
}
const reservedServerNames = await resolveMcpConfigNames(req);
const result = await getMCPServersRegistry().addServer(
'temp_server_name',
@ -285,6 +358,36 @@ const updateMCPServerController = async (req, res) => {
errors: validation.error.errors,
});
}
/**
* On an existing OBO server, lock down every user-input field except the
* cosmetic allowlist (title, description, iconPath) for callers without
* CONFIGURE_OBO. This closes the OBO redirect vector without it, a user
* with UPDATE could change `url` (or `proxy`/`headers`/`customUserVars`)
* to point OBO-minted tokens at an attacker-controlled endpoint. Adds,
* modifies, and removes are all caught.
*/
const existingConfig = await getMCPServersRegistry().getServerConfig(serverName, userId);
if (configHasObo(existingConfig) && !(await callerCanConfigureObo(req))) {
if (violatesOboLockdown(existingConfig, validation.data)) {
logger.warn(
`[updateMCPServer] User ${userId} attempted to modify a locked field on OBO server '${serverName}' without ${Permissions.CONFIGURE_OBO} permission`,
);
return res
.status(403)
.json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
}
} else if (configHasObo(validation.data) && !(await callerCanConfigureObo(req))) {
// Adding OBO to a non-OBO server (or first-time configuration) still
// requires the permission, even if existing has no OBO.
logger.warn(
`[updateMCPServer] User ${userId} attempted to add OBO to '${serverName}' without ${Permissions.CONFIGURE_OBO} permission`,
);
return res
.status(403)
.json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
}
const parsedConfig = await getMCPServersRegistry().updateServer(
serverName,
validation.data,

View file

@ -154,6 +154,7 @@ describe('MCP Routes', () => {
let app;
let mongoServer;
let mcpRouter;
let currentUser;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
@ -168,7 +169,7 @@ describe('MCP Routes', () => {
app.use(cookieParser());
app.use((req, res, next) => {
req.user = { id: 'test-user-id' };
req.user = currentUser ?? { id: 'test-user-id' };
next();
});
@ -182,9 +183,20 @@ describe('MCP Routes', () => {
beforeEach(() => {
jest.clearAllMocks();
currentUser = undefined;
mockResolveAllMcpConfigs.mockResolvedValue({});
mockResolveMcpConfigNames.mockResolvedValue([]);
mockMCPUseAllowed = true;
/**
* Reset registry method implementations every test. `clearAllMocks` resets
* call records but NOT implementations, so a `.mockRejectedValue(...)` set
* by an earlier test leaks into later ones including the new
* `getServerConfig` lookup in updateMCPServerController.
*/
mockRegistryInstance.getServerConfig.mockReset().mockResolvedValue(undefined);
mockRegistryInstance.addServer.mockReset();
mockRegistryInstance.updateServer.mockReset();
mockRegistryInstance.removeServer.mockReset();
});
describe('GET /:serverName/oauth/initiate', () => {
@ -2340,6 +2352,224 @@ describe('MCP Routes', () => {
expect(response.body).toEqual({ message: 'Database connection failed' });
});
describe('OBO permission gate', () => {
const oboConfig = {
type: 'streamable-http',
url: 'https://mcp-server.example.com/mcp',
title: 'OBO Server',
obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
};
const db = require('~/models');
beforeEach(() => {
currentUser = { id: 'test-user-id', role: 'USER' };
mockRegistryInstance.addServer.mockResolvedValue({
serverName: 'obo-server',
config: oboConfig,
});
});
it('rejects POST with obo body when role lacks CONFIGURE_OBO', async () => {
db.getRoleByName.mockResolvedValue({
name: 'USER',
permissions: {
MCP_SERVERS: {
USE: true,
CREATE: true,
SHARE: false,
SHARE_PUBLIC: false,
CONFIGURE_OBO: false,
},
},
});
const response = await request(app).post('/api/mcp/servers').send({ config: oboConfig });
expect(response.status).toBe(403);
expect(response.body.message).toMatch(/Insufficient permissions to configure OBO/);
expect(mockRegistryInstance.addServer).not.toHaveBeenCalled();
});
it('allows POST with obo body when role has CONFIGURE_OBO', async () => {
db.getRoleByName.mockResolvedValue({
name: 'USER',
permissions: {
MCP_SERVERS: {
USE: true,
CREATE: true,
SHARE: false,
SHARE_PUBLIC: false,
CONFIGURE_OBO: true,
},
},
});
const response = await request(app).post('/api/mcp/servers').send({ config: oboConfig });
expect(response.status).toBe(201);
expect(mockRegistryInstance.addServer).toHaveBeenCalled();
});
it('allows POST without obo body regardless of CONFIGURE_OBO', async () => {
db.getRoleByName.mockResolvedValue({
name: 'USER',
permissions: {
MCP_SERVERS: {
USE: true,
CREATE: true,
CONFIGURE_OBO: false,
},
},
});
const nonOboConfig = {
type: 'streamable-http',
url: 'https://mcp-server.example.com/mcp',
title: 'Plain Server',
};
mockRegistryInstance.addServer.mockResolvedValue({
serverName: 'plain-server',
config: nonOboConfig,
});
const response = await request(app).post('/api/mcp/servers').send({ config: nonOboConfig });
expect(response.status).toBe(201);
expect(db.getRoleByName).not.toHaveBeenCalled();
expect(mockRegistryInstance.addServer).toHaveBeenCalled();
});
it('rejects PATCH with obo body when role lacks CONFIGURE_OBO', async () => {
db.getRoleByName.mockResolvedValue({
name: 'USER',
permissions: {
MCP_SERVERS: {
USE: true,
CREATE: true,
CONFIGURE_OBO: false,
},
},
});
const response = await request(app)
.patch('/api/mcp/servers/obo-server')
.send({ config: oboConfig });
expect(response.status).toBe(403);
expect(response.body.message).toMatch(/Insufficient permissions to configure OBO/);
expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
});
it('allows PATCH without CONFIGURE_OBO when OBO is unchanged', async () => {
// Editor without CONFIGURE_OBO should still be able to edit non-OBO fields
// (title, URL, description) on an OBO server as long as the OBO block is
// re-sent unchanged. Closes the regression where any save of an OBO server
// by such a user was rejected even when OBO itself was not being modified.
db.getRoleByName.mockResolvedValue({
name: 'USER',
permissions: {
MCP_SERVERS: {
USE: true,
CREATE: true,
CONFIGURE_OBO: false,
},
},
});
mockRegistryInstance.getServerConfig.mockResolvedValue({
...oboConfig,
obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
});
mockRegistryInstance.updateServer.mockResolvedValue({
...oboConfig,
title: 'Renamed OBO Server',
});
const response = await request(app)
.patch('/api/mcp/servers/obo-server')
.send({
config: {
...oboConfig,
title: 'Renamed OBO Server',
obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
},
});
expect(response.status).toBe(200);
expect(mockRegistryInstance.updateServer).toHaveBeenCalled();
});
it('rejects PATCH that removes OBO from an existing OBO server without CONFIGURE_OBO', async () => {
// Closes the silent-downgrade vector: a user with UPDATE but not
// CONFIGURE_OBO must not be able to convert an OBO server to non-OBO,
// because doing so de-secures the server end-to-end.
db.getRoleByName.mockResolvedValue({
name: 'USER',
permissions: {
MCP_SERVERS: {
USE: true,
CREATE: true,
CONFIGURE_OBO: false,
},
},
});
mockRegistryInstance.getServerConfig.mockResolvedValue({
...oboConfig,
obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
});
// Submit body that omits the obo field (auth_type changed away from OBO)
const downgradePayload = {
type: 'streamable-http',
url: 'https://mcp-server.example.com/mcp',
title: 'OBO Server',
};
const response = await request(app)
.patch('/api/mcp/servers/obo-server')
.send({ config: downgradePayload });
expect(response.status).toBe(403);
expect(response.body.message).toMatch(/Insufficient permissions to configure OBO/);
expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
});
it('rejects PATCH that redirects the URL of an existing OBO server without CONFIGURE_OBO', async () => {
// Closes the OBO redirect vector — the original trust-boundary concern
// CONFIGURE_OBO was introduced to address. A user with UPDATE but
// without the permission must not be able to point an existing OBO
// server at an attacker-controlled endpoint, which would cause OBO
// tokens minted for other users to be exfiltrated to that endpoint.
// The same allowlist policy also covers `proxy`, `headers`, transport
// type, and auth blocks.
db.getRoleByName.mockResolvedValue({
name: 'USER',
permissions: {
MCP_SERVERS: {
USE: true,
CREATE: true,
CONFIGURE_OBO: false,
},
},
});
mockRegistryInstance.getServerConfig.mockResolvedValue({
...oboConfig,
obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
});
const redirectPayload = {
...oboConfig,
url: 'https://attacker.example.com/mcp',
obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
};
const response = await request(app)
.patch('/api/mcp/servers/obo-server')
.send({ config: redirectPayload });
expect(response.status).toBe(403);
expect(response.body.message).toMatch(/Insufficient permissions to configure OBO/);
expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
});
});
it('should fail closed when config-managed names cannot be resolved', async () => {
const validConfig = {
type: 'sse',

View file

@ -1,77 +1,19 @@
const client = require('openid-client');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
const getLogStores = require('~/cache/getLogStores');
const { exchangeOboToken } = require('./OboTokenService');
/**
* Get Microsoft Graph API token using existing token exchange mechanism
* Get Microsoft Graph API token using the On-Behalf-Of flow.
* Thin wrapper around the generic OBO exchange for Graph-specific error context.
*
* @param {Object} user - User object with OpenID information
* @param {string} accessToken - Federated access token used as OBO assertion
* @param {string} scopes - Graph API scopes for the token
* @param {boolean} fromCache - Whether to try getting token from cache first
* @param {boolean} [fromCache=true] - Whether to try getting token from cache first
* @returns {Promise<Object>} Graph API token response with access_token and expires_in
*/
async function getGraphApiToken(user, accessToken, scopes, fromCache = true) {
try {
if (!user.openidId) {
throw new Error('User must be authenticated via Entra ID to access Microsoft Graph');
}
if (!accessToken) {
throw new Error('Access token is required for token exchange');
}
if (!scopes) {
throw new Error('Graph API scopes are required for token exchange');
}
const config = getOpenIdConfig();
if (!config) {
throw new Error('OpenID configuration not available');
}
const cacheKey = `${user.openidId}:${scopes}`;
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
if (fromCache) {
const cachedToken = await tokensCache.get(cacheKey);
if (cachedToken) {
logger.debug(`[GraphTokenService] Using cached Graph API token for user: ${user.openidId}`);
return cachedToken;
}
}
logger.debug(`[GraphTokenService] Requesting new Graph API token for user: ${user.openidId}`);
logger.debug(`[GraphTokenService] Requested scopes: ${scopes}`);
const grantResponse = await client.genericGrantRequest(
config,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope: scopes,
assertion: accessToken,
requested_token_use: 'on_behalf_of',
},
);
const tokenResponse = {
access_token: grantResponse.access_token,
token_type: 'Bearer',
expires_in: grantResponse.expires_in || 3600,
scope: scopes,
};
await tokensCache.set(
cacheKey,
tokenResponse,
(grantResponse.expires_in || 3600) * 1000, // Convert to milliseconds
);
logger.debug(
`[GraphTokenService] Successfully obtained and cached Graph API token for user: ${user.openidId}`,
);
return tokenResponse;
return await exchangeOboToken(user, accessToken, scopes, fromCache);
} catch (error) {
logger.error(
`[GraphTokenService] Failed to acquire Graph API token for user ${user.openidId}:`,

View file

@ -34,6 +34,8 @@ const {
const db = require('~/models');
const { findToken, createToken, updateToken, deleteTokens } = db;
const { getGraphApiToken } = require('./GraphTokenService');
const { exchangeOboToken } = require('./OboTokenService');
const { createOboTrustChecker } = require('./OboPolicyService');
const { reinitMCPServer } = require('./Tools/mcp');
const { getAppConfig } = require('./Config');
const { getLogStores } = require('~/cache');
@ -738,6 +740,8 @@ function createToolInstance({
oauthStart,
oauthEnd,
graphTokenResolver: getGraphApiToken,
oboTokenResolver: exchangeOboToken,
oboTrustChecker: createOboTrustChecker(),
});
if (isAssistantsEndpoint(provider) && Array.isArray(result)) {

View file

@ -0,0 +1,43 @@
const { isOboConfigStillTrusted } = require('@librechat/api');
const db = require('~/models');
/**
* Checks whether a parsed MCP server config is DB-sourced (user-created) using
* the same `isUserSourced` heuristics as the rest of the MCP layer: an explicit
* `source` is authoritative when present; otherwise `dbId` presence is used.
*/
function isDbSourced({ source, dbId }) {
if (source != null) {
return source === 'user';
}
return !!dbId;
}
/**
* Builds the predicate the MCP runtime calls before performing an OBO token exchange.
*
* YAML/Config-sourced configs (admin-defined) bypass the check admins are
* already trusted at the deployment level. DB-sourced configs (created via the
* UI) are gated on the original author still holding `MCP_SERVERS.CONFIGURE_OBO`,
* so retained configs fail closed when an author's role is downgraded.
*/
function createOboTrustChecker() {
return async ({ source, author, dbId }) => {
if (!isDbSourced({ source, dbId })) {
return true;
}
return isOboConfigStillTrusted({
authorId: author,
getUserRoleByAuthorId: async (userId) => {
const user = await db.findUser({ _id: userId }, 'role');
return user?.role;
},
getRolePermissions: async (roleName) => {
const role = await db.getRoleByName(roleName);
return role?.permissions;
},
});
};
}
module.exports = { createOboTrustChecker };

View file

@ -0,0 +1,194 @@
const client = require('openid-client');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
const getLogStores = require('~/cache/getLogStores');
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
const RETRYABLE_ERROR_CODES = new Set(['ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN', 'ENOTFOUND']);
const OBO_RETRY_DELAY_MS = 300;
/**
* In-flight OBO exchanges keyed by `${openidId}:${scopes}`.
*
* Without coalescing, parallel tool calls that arrive on a cache miss each issue
* their own jwt-bearer request to the IdP. Under fan-out, Entra intermittently
* returns errors that look non-retryable, surfacing as "identity provider
* rejected the OBO token exchange." A user retry then hits the populated cache
* and succeeds, which matches the observed flakiness. Sharing a single upstream
* exchange per key removes the thundering herd.
*/
const inFlightExchanges = new Map();
function getErrorStatus(error) {
return error?.status ?? error?.statusCode ?? error?.response?.status;
}
function getErrorCode(error) {
return typeof error?.code === 'string' ? error.code.toUpperCase() : undefined;
}
function isRetryableOboExchangeError(error) {
const status = getErrorStatus(error);
if (status != null && RETRYABLE_STATUS_CODES.has(status)) {
return true;
}
const code = getErrorCode(error);
if (code != null && RETRYABLE_ERROR_CODES.has(code)) {
return true;
}
const message = String(error?.message ?? '').toLowerCase();
return (
message.includes('timed out') ||
message.includes('timeout') ||
message.includes('econnreset') ||
message.includes('socket hang up') ||
message.includes('temporarily unavailable') ||
message.includes('too many requests') ||
message.includes('service unavailable')
);
}
function tagOboExchangeError(error, retryable) {
if (error && typeof error === 'object') {
error.retryable = retryable;
error.oboFailureReason = 'exchange_failed';
}
return error;
}
async function delay(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function performOboExchange({ user, accessToken, scopes, config, tokensCache, cacheKey }) {
const requestGrant = async () =>
client.genericGrantRequest(config, 'urn:ietf:params:oauth:grant-type:jwt-bearer', {
scope: scopes,
assertion: accessToken,
requested_token_use: 'on_behalf_of',
});
let grantResponse;
try {
grantResponse = await requestGrant();
} catch (error) {
const retryable = isRetryableOboExchangeError(error);
if (!retryable) {
throw tagOboExchangeError(error, false);
}
logger.warn(
`[OboTokenService] Transient OBO exchange failure for user: ${user.openidId}, retrying once`,
error,
);
await delay(OBO_RETRY_DELAY_MS);
try {
grantResponse = await requestGrant();
} catch (retryError) {
throw tagOboExchangeError(retryError, isRetryableOboExchangeError(retryError));
}
}
const tokenResponse = {
access_token: grantResponse.access_token,
token_type: 'Bearer',
expires_in: grantResponse.expires_in || 3600,
scope: scopes,
};
await tokensCache.set(cacheKey, tokenResponse, (grantResponse.expires_in || 3600) * 1000);
logger.debug(
`[OboTokenService] Successfully obtained and cached OBO token for user: ${user.openidId}`,
);
return tokenResponse;
}
/**
* Exchange a user's access token for a downstream-scoped token via the
* OAuth 2.0 On-Behalf-Of (jwt-bearer) grant.
*
* Concurrent callers for the same `${openidId}:${scopes}` key share a single
* upstream exchange (see `inFlightExchanges`) so a fan-out of tool calls right
* after a cache miss does not produce N parallel requests to the IdP.
*
* @param {Object} user - User object with OpenID information
* @param {string} accessToken - Federated access token used as OBO assertion
* @param {string} scopes - Scopes to request for the downstream service
* @param {boolean} [fromCache=true] - When true, read from cache and join any
* in-flight exchange. When false, bypass both and force a fresh exchange.
* @returns {Promise<Object>} Token response with access_token and expires_in
*/
async function exchangeOboToken(user, accessToken, scopes, fromCache = true) {
if (!user.openidId) {
throw new Error('User must be authenticated via OpenID to perform OBO token exchange');
}
if (!accessToken) {
throw new Error('Access token is required for OBO exchange');
}
if (!scopes) {
throw new Error('Scopes are required for OBO exchange');
}
const config = getOpenIdConfig();
if (!config) {
throw new Error('OpenID configuration not available');
}
const cacheKey = `${user.openidId}:${scopes}`;
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
if (fromCache) {
const cachedToken = await tokensCache.get(cacheKey);
if (cachedToken) {
logger.debug(`[OboTokenService] Using cached token for user: ${user.openidId}`);
return cachedToken;
}
const inFlight = inFlightExchanges.get(cacheKey);
if (inFlight) {
logger.debug(`[OboTokenService] Joining in-flight OBO exchange for user: ${user.openidId}`);
return inFlight;
}
}
logger.debug(
`[OboTokenService] Requesting new OBO token for user: ${user.openidId}, scopes: ${scopes}`,
);
const exchangePromise = performOboExchange({
user,
accessToken,
scopes,
config,
tokensCache,
cacheKey,
});
if (fromCache) {
inFlightExchanges.set(cacheKey, exchangePromise);
exchangePromise
.finally(() => {
if (inFlightExchanges.get(cacheKey) === exchangePromise) {
inFlightExchanges.delete(cacheKey);
}
})
.catch(() => {
/* The original rejection is delivered to the awaiting caller; this
* chain exists only to run cleanup, so swallow it here to avoid an
* unhandled-rejection warning on the cleanup promise. */
});
}
return exchangePromise;
}
module.exports = {
exchangeOboToken,
};

View file

@ -0,0 +1,342 @@
jest.mock('~/strategies/openidStrategy');
jest.mock('~/cache/getLogStores');
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
const client = require('openid-client');
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
const getLogStores = require('~/cache/getLogStores');
const { exchangeOboToken } = require('./OboTokenService');
describe('OboTokenService', () => {
let mockTokensCache;
let mockOpenIdConfig;
const mockUser = {
openidId: 'oidc-sub-123',
email: 'test@example.com',
name: 'Test User',
};
beforeEach(() => {
jest.clearAllMocks();
mockTokensCache = {
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue(undefined),
};
getLogStores.mockReturnValue(mockTokensCache);
mockOpenIdConfig = {
client_id: 'test-client-id',
issuer: 'https://login.microsoftonline.com/tenant-id/v2.0',
};
getOpenIdConfig.mockReturnValue(mockOpenIdConfig);
client.genericGrantRequest.mockResolvedValue({
access_token: 'exchanged-obo-token',
expires_in: 3600,
});
});
describe('input validation', () => {
it('should throw when user has no openidId', async () => {
await expect(
exchangeOboToken({ email: 'test@example.com' }, 'access-token', 'api://scope'),
).rejects.toThrow('User must be authenticated via OpenID to perform OBO token exchange');
});
it('should throw when accessToken is missing', async () => {
await expect(exchangeOboToken(mockUser, '', 'api://scope')).rejects.toThrow(
'Access token is required for OBO exchange',
);
});
it('should throw when scopes are missing', async () => {
await expect(exchangeOboToken(mockUser, 'access-token', '')).rejects.toThrow(
'Scopes are required for OBO exchange',
);
});
it('should throw when OpenID config is not available', async () => {
getOpenIdConfig.mockReturnValue(null);
await expect(exchangeOboToken(mockUser, 'access-token', 'api://scope')).rejects.toThrow(
'OpenID configuration not available',
);
});
});
describe('cache behavior', () => {
it('should return cached token when fromCache is true and cache hit', async () => {
const cachedToken = {
access_token: 'cached-obo-token',
token_type: 'Bearer',
expires_in: 1800,
scope: 'api://mcp-server/Scope.Read',
};
mockTokensCache.get.mockResolvedValue(cachedToken);
const result = await exchangeOboToken(
mockUser,
'access-token',
'api://mcp-server/Scope.Read',
true,
);
expect(result).toBe(cachedToken);
expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://mcp-server/Scope.Read');
expect(client.genericGrantRequest).not.toHaveBeenCalled();
});
it('should skip cache when fromCache is false', async () => {
const cachedToken = { access_token: 'cached-obo-token' };
mockTokensCache.get.mockResolvedValue(cachedToken);
const result = await exchangeOboToken(
mockUser,
'access-token',
'api://mcp-server/Scope.Read',
false,
);
expect(mockTokensCache.get).not.toHaveBeenCalled();
expect(client.genericGrantRequest).toHaveBeenCalled();
expect(result.access_token).toBe('exchanged-obo-token');
});
it('should default fromCache to true', async () => {
mockTokensCache.get.mockResolvedValue(null);
await exchangeOboToken(mockUser, 'access-token', 'api://scope');
expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://scope');
});
});
describe('OBO token exchange', () => {
it('should call genericGrantRequest with jwt-bearer grant type', async () => {
await exchangeOboToken(mockUser, 'user-access-token', 'api://mcp-server/Tools.ReadWrite');
expect(client.genericGrantRequest).toHaveBeenCalledWith(
mockOpenIdConfig,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope: 'api://mcp-server/Tools.ReadWrite',
assertion: 'user-access-token',
requested_token_use: 'on_behalf_of',
},
);
});
it('should return token response with correct structure', async () => {
const result = await exchangeOboToken(
mockUser,
'access-token',
'api://mcp-server/Tools.ReadWrite',
);
expect(result).toEqual({
access_token: 'exchanged-obo-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'api://mcp-server/Tools.ReadWrite',
});
});
it('should cache the exchanged token with correct TTL', async () => {
client.genericGrantRequest.mockResolvedValue({
access_token: 'new-obo-token',
expires_in: 1800,
});
await exchangeOboToken(mockUser, 'access-token', 'api://scope');
expect(mockTokensCache.set).toHaveBeenCalledWith(
'oidc-sub-123:api://scope',
{
access_token: 'new-obo-token',
token_type: 'Bearer',
expires_in: 1800,
scope: 'api://scope',
},
1800 * 1000,
);
});
it('should default expires_in to 3600 when not in response', async () => {
client.genericGrantRequest.mockResolvedValue({
access_token: 'no-expiry-token',
});
const result = await exchangeOboToken(mockUser, 'access-token', 'api://scope');
expect(result.expires_in).toBe(3600);
expect(mockTokensCache.set).toHaveBeenCalledWith(
'oidc-sub-123:api://scope',
expect.objectContaining({ expires_in: 3600 }),
3600 * 1000,
);
});
it('should propagate errors from genericGrantRequest', async () => {
client.genericGrantRequest.mockRejectedValue(
new Error('invalid_grant: AADSTS50013: Assertion failed signature validation'),
);
await expect(exchangeOboToken(mockUser, 'bad-token', 'api://scope')).rejects.toThrow(
'invalid_grant: AADSTS50013: Assertion failed signature validation',
);
});
it('should retry once for transient Entra failures and succeed on the second attempt', async () => {
const transientError = Object.assign(new Error('Service unavailable'), { status: 503 });
const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((callback) => {
callback();
return 0;
});
try {
client.genericGrantRequest.mockRejectedValueOnce(transientError).mockResolvedValueOnce({
access_token: 'retried-obo-token',
expires_in: 1800,
});
const result = await exchangeOboToken(mockUser, 'access-token', 'api://scope');
expect(client.genericGrantRequest).toHaveBeenCalledTimes(2);
expect(result).toEqual({
access_token: 'retried-obo-token',
token_type: 'Bearer',
expires_in: 1800,
scope: 'api://scope',
});
} finally {
setTimeoutSpy.mockRestore();
}
});
it('should not retry permanent OBO exchange failures', async () => {
const permanentError = new Error(
'invalid_grant: AADSTS50013: Assertion failed signature validation',
);
client.genericGrantRequest.mockRejectedValue(permanentError);
await expect(exchangeOboToken(mockUser, 'bad-token', 'api://scope')).rejects.toThrow(
'invalid_grant: AADSTS50013: Assertion failed signature validation',
);
expect(client.genericGrantRequest).toHaveBeenCalledTimes(1);
});
});
describe('cache key isolation', () => {
it('should use different cache keys for different scopes', async () => {
await exchangeOboToken(mockUser, 'access-token', 'api://server-a/Scope.A');
await exchangeOboToken(mockUser, 'access-token', 'api://server-b/Scope.B');
expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://server-a/Scope.A');
expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://server-b/Scope.B');
});
it('should use different cache keys for different users', async () => {
const otherUser = { openidId: 'oidc-sub-456', email: 'other@example.com' };
await exchangeOboToken(mockUser, 'access-token', 'api://scope');
await exchangeOboToken(otherUser, 'access-token', 'api://scope');
expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://scope');
expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-456:api://scope');
});
});
describe('single-flight coalescing', () => {
/** Yields long enough for both pending callers to advance past their cache lookup. */
const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolve));
it('coalesces concurrent exchanges for the same key into one IdP call', async () => {
let resolveGrant;
client.genericGrantRequest.mockReturnValueOnce(
new Promise((resolve) => {
resolveGrant = resolve;
}),
);
const callA = exchangeOboToken(mockUser, 'access-token', 'api://shared');
const callB = exchangeOboToken(mockUser, 'access-token', 'api://shared');
await flushMicrotasks();
expect(client.genericGrantRequest).toHaveBeenCalledTimes(1);
resolveGrant({ access_token: 'shared-obo-token', expires_in: 3600 });
const [resultA, resultB] = await Promise.all([callA, callB]);
expect(resultA).toEqual(resultB);
expect(resultA.access_token).toBe('shared-obo-token');
expect(client.genericGrantRequest).toHaveBeenCalledTimes(1);
expect(mockTokensCache.set).toHaveBeenCalledTimes(1);
});
it('does not coalesce exchanges for different keys', async () => {
await Promise.all([
exchangeOboToken(mockUser, 'access-token', 'api://scope-a'),
exchangeOboToken(mockUser, 'access-token', 'api://scope-b'),
]);
expect(client.genericGrantRequest).toHaveBeenCalledTimes(2);
});
it('clears the in-flight slot after a successful exchange', async () => {
await exchangeOboToken(mockUser, 'access-token', 'api://scope');
expect(client.genericGrantRequest).toHaveBeenCalledTimes(1);
await exchangeOboToken(mockUser, 'access-token', 'api://scope');
expect(client.genericGrantRequest).toHaveBeenCalledTimes(2);
});
it('clears the in-flight slot after a failed exchange', async () => {
client.genericGrantRequest
.mockRejectedValueOnce(
new Error('invalid_grant: AADSTS50013: Assertion failed signature validation'),
)
.mockResolvedValueOnce({ access_token: 'fresh-token', expires_in: 3600 });
await expect(exchangeOboToken(mockUser, 'access-token', 'api://scope')).rejects.toThrow(
'invalid_grant',
);
const result = await exchangeOboToken(mockUser, 'access-token', 'api://scope');
expect(result.access_token).toBe('fresh-token');
expect(client.genericGrantRequest).toHaveBeenCalledTimes(2);
});
it('bypasses in-flight coalescing when fromCache is false', async () => {
let resolveFirst;
client.genericGrantRequest
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirst = resolve;
}),
)
.mockResolvedValueOnce({ access_token: 'forced-fresh-token', expires_in: 3600 });
const callA = exchangeOboToken(mockUser, 'access-token', 'api://scope', true);
await flushMicrotasks();
const callB = exchangeOboToken(mockUser, 'access-token', 'api://scope', false);
expect(client.genericGrantRequest).toHaveBeenCalledTimes(2);
resolveFirst({ access_token: 'in-flight-token', expires_in: 3600 });
const [resultA, resultB] = await Promise.all([callA, callB]);
expect(resultA.access_token).toBe('in-flight-token');
expect(resultB.access_token).toBe('forced-fresh-token');
});
});
});

View file

@ -3,6 +3,8 @@ const { getMissingCustomUserVars } = require('@librechat/api');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { getMCPManager, getMCPServersRegistry, getFlowStateManager } = require('~/config');
const { findToken, createToken, updateToken, deleteTokens } = require('~/models');
const { exchangeOboToken } = require('~/server/services/OboTokenService');
const { createOboTrustChecker } = require('~/server/services/OboPolicyService');
const { updateMCPServerTools } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');
@ -133,6 +135,8 @@ async function reinitMCPServer({
customUserVars,
connectionTimeout,
serverConfig,
oboTokenResolver: exchangeOboToken,
oboTrustChecker: createOboTrustChecker(),
});
logger.info(`[MCP Reinitialize] Successfully established connection for ${serverName}`);
@ -166,6 +170,8 @@ async function reinitMCPServer({
customUserVars,
connectionTimeout,
configServers,
oboTokenResolver: exchangeOboToken,
oboTrustChecker: createOboTrustChecker(),
});
if (discoveryResult.tools && discoveryResult.tools.length > 0) {

View file

@ -43,6 +43,12 @@ jest.mock('~/models', () => ({
jest.mock('~/server/services/GraphTokenService', () => ({
getGraphApiToken: jest.fn(),
}));
jest.mock('~/server/services/OboTokenService', () => ({
exchangeOboToken: jest.fn(),
}));
jest.mock('~/server/services/OboPolicyService', () => ({
createOboTrustChecker: jest.fn(() => async () => true),
}));
jest.mock('~/server/services/Tools/mcp', () => ({
reinitMCPServer: jest.fn(),
}));

View file

@ -10,6 +10,10 @@ const permissions: PermissionConfig[] = [
{ permission: Permissions.CREATE, labelKey: 'com_ui_mcp_servers_allow_create' },
{ permission: Permissions.SHARE, labelKey: 'com_ui_mcp_servers_allow_share' },
{ permission: Permissions.SHARE_PUBLIC, labelKey: 'com_ui_mcp_servers_allow_share_public' },
{
permission: Permissions.CONFIGURE_OBO,
labelKey: 'com_ui_mcp_servers_allow_configure_obo',
},
];
const MCPAdminSettings = () => {

View file

@ -1,5 +1,8 @@
import { FormProvider } from 'react-hook-form';
import type { useMCPServerForm } from './hooks/useMCPServerForm';
import { FormProvider, useWatch } from 'react-hook-form';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import { useHasAccess } from '~/hooks';
import type { useMCPServerForm, MCPServerFormData } from './hooks/useMCPServerForm';
import { AuthTypeEnum } from './hooks/useMCPServerForm';
import ConnectionSection from './sections/ConnectionSection';
import BasicInfoSection from './sections/BasicInfoSection';
import TransportSection from './sections/TransportSection';
@ -13,18 +16,40 @@ interface MCPServerFormProps {
export default function MCPServerForm({ formHook }: MCPServerFormProps) {
const { methods, isEditMode, server } = formHook;
const canConfigureObo = useHasAccess({
permissionType: PermissionTypes.MCP_SERVERS,
permission: Permissions.CONFIGURE_OBO,
});
/**
* Lockdown applies when a user without CONFIGURE_OBO opens an OBO server in
* edit mode. Mirrors the backend allowlist policy: every field outside title,
* description, and iconPath is read-only (URL, transport, auth, trust). Any
* other change would let the user redirect OBO tokens to an arbitrary endpoint.
*/
const authType = useWatch<MCPServerFormData, 'auth.auth_type'>({
control: methods.control,
name: 'auth.auth_type',
});
const isOboLockedReadOnly = isEditMode && authType === AuthTypeEnum.OBO && !canConfigureObo;
return (
<FormProvider {...methods}>
<div className="space-y-4 px-1 py-1">
<BasicInfoSection />
<fieldset
disabled={isOboLockedReadOnly}
className="contents space-y-4"
aria-disabled={isOboLockedReadOnly}
>
<ConnectionSection />
<ConnectionSection />
<TransportSection />
<TransportSection />
<AuthSection isEditMode={isEditMode} serverName={server?.serverName} />
<AuthSection isEditMode={isEditMode} serverName={server?.serverName} />
<TrustSection />
<TrustSection />
</fieldset>
</div>
</FormProvider>
);

View file

@ -16,6 +16,7 @@ export enum AuthTypeEnum {
None = 'none',
ServiceHttp = 'service_http',
OAuth = 'oauth',
OBO = 'obo',
}
// Authorization type enum
@ -37,6 +38,7 @@ export interface AuthConfig {
oauth_authorization_url?: string;
oauth_token_url?: string;
oauth_scope?: string;
obo_scopes?: string;
server_id?: string;
}
@ -77,7 +79,9 @@ export function useMCPServerForm({ server, onSuccess, onClose }: UseMCPServerFor
const defaultValues = useMemo<MCPServerFormData>(() => {
if (server) {
let authType = AuthTypeEnum.None;
if (server.config.oauth) {
if ('obo' in server.config && server.config.obo) {
authType = AuthTypeEnum.OBO;
} else if (server.config.oauth) {
authType = AuthTypeEnum.OAuth;
} else if ('apiKey' in server.config && server.config.apiKey) {
authType = AuthTypeEnum.ServiceHttp;
@ -104,6 +108,7 @@ export function useMCPServerForm({ server, onSuccess, onClose }: UseMCPServerFor
oauth_authorization_url: server.config.oauth?.authorization_url || '',
oauth_token_url: server.config.oauth?.token_url || '',
oauth_scope: server.config.oauth?.scope || '',
obo_scopes: 'obo' in server.config && server.config.obo ? server.config.obo.scopes : '',
server_id: server.serverName,
},
trust: true, // Pre-checked for existing servers
@ -127,6 +132,7 @@ export function useMCPServerForm({ server, onSuccess, onClose }: UseMCPServerFor
oauth_authorization_url: '',
oauth_token_url: '',
oauth_scope: '',
obo_scopes: '',
},
trust: false,
};
@ -142,7 +148,6 @@ export function useMCPServerForm({ server, onSuccess, onClose }: UseMCPServerFor
// Watch URL for auto-fill
const watchedUrl = watch('url');
const watchedTitle = watch('title');
// Auto-fill title from URL when title is empty
const handleUrlChange = useCallback(
@ -221,6 +226,10 @@ export function useMCPServerForm({ server, onSuccess, onClose }: UseMCPServerFor
};
}
if (formData.auth.auth_type === AuthTypeEnum.OBO && formData.auth.obo_scopes) {
config.obo = { scopes: formData.auth.obo_scopes };
}
const params: MCPServerCreateParams = { config };
const result = server

View file

@ -1,10 +1,11 @@
import { useMemo, useState } from 'react';
import { Copy, CopyCheck } from 'lucide-react';
import { useFormContext, useWatch } from 'react-hook-form';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import { Label, Input, Checkbox, SecretInput, Radio, useToastContext } from '@librechat/client';
import { AuthTypeEnum, AuthorizationTypeEnum } from '../hooks/useMCPServerForm';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import { useLocalize, useCopyToClipboard, useHasAccess } from '~/hooks';
import { cn } from '~/utils';
interface AuthSectionProps {
@ -23,6 +24,11 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
const [isCopying, setIsCopying] = useState(false);
const canConfigureObo = useHasAccess({
permissionType: PermissionTypes.MCP_SERVERS,
permission: Permissions.CONFIGURE_OBO,
});
const authType = useWatch<MCPServerFormData, 'auth.auth_type'>({
name: 'auth.auth_type',
}) as AuthTypeEnum;
@ -41,14 +47,31 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
const copyLink = useCopyToClipboard({ text: redirectUri });
const authTypeOptions = useMemo(
() => [
/**
* Show OBO as a selectable auth option only when the caller has the permission.
* Edit-mode carve-out: if the form was loaded for a server that already uses OBO
* and the caller has since lost the permission, surface OBO in the radio so the
* current auth state is visible but mark every OBO control disabled (and
* disable the radio itself, so the user can't switch away) since the backend
* also rejects modifying OBO without the permission.
*/
const showOboOption = canConfigureObo || authType === AuthTypeEnum.OBO;
const isOboLockedReadOnly = !canConfigureObo && authType === AuthTypeEnum.OBO;
const authTypeOptions = useMemo(() => {
const options = [
{ value: AuthTypeEnum.None, label: localize('com_ui_no_auth') },
{ value: AuthTypeEnum.ServiceHttp, label: localize('com_ui_api_key') },
{ value: AuthTypeEnum.OAuth, label: 'OAuth' },
],
[localize],
);
];
if (showOboOption) {
options.push({
value: AuthTypeEnum.OBO,
label: localize('com_ui_obo'),
});
}
return options;
}, [localize, showOboOption]);
const headerFormatOptions = useMemo(
() => [
@ -73,6 +96,7 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
value={authType || AuthTypeEnum.None}
onChange={(val) => setValue('auth.auth_type', val as AuthTypeEnum)}
fullWidth
disabled={isOboLockedReadOnly}
aria-labelledby="auth-type-label"
/>
</fieldset>
@ -265,6 +289,48 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
)}
</div>
)}
{/* OBO Fields */}
{authType === AuthTypeEnum.OBO && (
<div className="space-y-3 rounded-lg border border-border-light p-3">
<div className="space-y-1.5">
<Label htmlFor="obo_scopes" className="text-sm font-medium">
{localize('com_ui_obo_scopes')}{' '}
<span aria-hidden="true" className="text-text-secondary">
*
</span>
<span className="sr-only">{localize('com_ui_field_required')}</span>
</Label>
<Input
id="obo_scopes"
placeholder="api://<client-id>/Mcp.Tools.ReadWrite"
disabled={!canConfigureObo}
aria-invalid={errors.auth?.obo_scopes ? 'true' : 'false'}
aria-describedby={
canConfigureObo ? 'obo-scopes-description' : 'obo-scopes-readonly-description'
}
{...register('auth.obo_scopes', {
required: canConfigureObo && authType === AuthTypeEnum.OBO,
})}
className={cn(errors.auth?.obo_scopes && 'border-border-destructive')}
/>
{errors.auth?.obo_scopes && (
<p role="alert" className="text-xs text-text-destructive">
{localize('com_ui_field_required')}
</p>
)}
{canConfigureObo ? (
<p id="obo-scopes-description" className="text-xs text-text-secondary">
{localize('com_ui_obo_scopes_description')}
</p>
) : (
<p id="obo-scopes-readonly-description" className="text-xs text-text-secondary">
{localize('com_ui_obo_readonly_no_permission')}
</p>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -1196,6 +1196,7 @@
"com_ui_mcp_server_url_placeholder": "https://mcp.example.com",
"com_ui_mcp_servers": "MCP Servers",
"com_ui_mcp_servers_allow_create": "Allow users to create MCP servers",
"com_ui_mcp_servers_allow_configure_obo": "Allow users to configure On-Behalf-Of (OBO) on MCP servers",
"com_ui_mcp_servers_allow_share": "Allow users to share MCP servers",
"com_ui_mcp_servers_allow_share_public": "Allow users to share MCP servers publicly",
"com_ui_mcp_servers_allow_use": "Allow users to use MCP servers",
@ -1286,6 +1287,10 @@
"com_ui_none": "None",
"com_ui_not_used": "Not Used",
"com_ui_nothing_found": "Nothing found",
"com_ui_obo": "On-Behalf-Of (OBO)",
"com_ui_obo_scopes": "OBO Scopes",
"com_ui_obo_scopes_description": "Scopes to request for the downstream MCP server via the On-Behalf-Of flow. Requires OpenID Connect authentication (e.g., Entra ID).",
"com_ui_obo_readonly_no_permission": "OBO is configured for this server but your role no longer has permission to modify it. Contact an administrator to make changes.",
"com_ui_oauth": "OAuth",
"com_ui_oauth_connected_to": "Connected to",
"com_ui_oauth_error_callback_failed": "Authentication callback failed. Please try again.",

View file

@ -99,6 +99,7 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.CREATE]: true,
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
[Permissions.CONFIGURE_OBO]: false,
},
[PermissionTypes.REMOTE_AGENTS]: {
[Permissions.USE]: false,
@ -152,6 +153,7 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.CREATE]: true,
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
[Permissions.CONFIGURE_OBO]: true,
},
[PermissionTypes.REMOTE_AGENTS]: {
[Permissions.USE]: true,
@ -269,6 +271,7 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.CREATE]: true,
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
[Permissions.CONFIGURE_OBO]: false,
},
[PermissionTypes.REMOTE_AGENTS]: {
[Permissions.USE]: false,
@ -322,6 +325,7 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.CREATE]: true,
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
[Permissions.CONFIGURE_OBO]: true,
},
[PermissionTypes.REMOTE_AGENTS]: {
[Permissions.USE]: true,
@ -425,6 +429,7 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.CREATE]: false,
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
[Permissions.CONFIGURE_OBO]: false,
},
[PermissionTypes.REMOTE_AGENTS]: {
[Permissions.USE]: false,
@ -478,6 +483,7 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
[Permissions.CONFIGURE_OBO]: true,
},
[PermissionTypes.REMOTE_AGENTS]: {
[Permissions.USE]: true,
@ -594,6 +600,7 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.CREATE]: false,
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
[Permissions.CONFIGURE_OBO]: false,
},
[PermissionTypes.REMOTE_AGENTS]: {
[Permissions.USE]: false,
@ -647,6 +654,7 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
[Permissions.CONFIGURE_OBO]: true,
},
[PermissionTypes.REMOTE_AGENTS]: {
[Permissions.USE]: true,
@ -750,6 +758,7 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.CREATE]: false,
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
[Permissions.CONFIGURE_OBO]: false,
},
[PermissionTypes.REMOTE_AGENTS]: {
[Permissions.USE]: false,
@ -803,6 +812,7 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
[Permissions.CONFIGURE_OBO]: true,
},
[PermissionTypes.REMOTE_AGENTS]: {
[Permissions.USE]: true,
@ -911,6 +921,7 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.CREATE]: false,
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
[Permissions.CONFIGURE_OBO]: false,
},
[PermissionTypes.REMOTE_AGENTS]: {
[Permissions.USE]: false,
@ -952,6 +963,7 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
[Permissions.CONFIGURE_OBO]: true,
},
[PermissionTypes.REMOTE_AGENTS]: {
[Permissions.USE]: true,
@ -1077,6 +1089,7 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.CREATE]: false,
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
[Permissions.CONFIGURE_OBO]: false,
},
[PermissionTypes.REMOTE_AGENTS]: {
[Permissions.USE]: false,
@ -1122,6 +1135,7 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
[Permissions.CONFIGURE_OBO]: true,
},
[PermissionTypes.REMOTE_AGENTS]: {
[Permissions.USE]: true,
@ -2180,6 +2194,111 @@ describe('updateInterfacePermissions - permissions', () => {
expect(userCall[1][PermissionTypes.MCP_SERVERS]).not.toHaveProperty(Permissions.SHARE);
});
it('should backfill MCP_SERVERS.CONFIGURE_OBO for existing roles (post-OBO permission addition)', async () => {
// Existing deployment: MCP_SERVERS row already present from before CONFIGURE_OBO existed.
// initializeRoles only fills missing permission *types*, not sub-keys, so backfill is needed.
mockGetRoleByName.mockImplementation(async (roleName: string) => {
if (roleName === SystemRoles.USER) {
return {
permissions: {
[PermissionTypes.MCP_SERVERS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: false,
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
// CONFIGURE_OBO intentionally absent
},
},
};
}
return {
permissions: {
[PermissionTypes.MCP_SERVERS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
// CONFIGURE_OBO intentionally absent
},
},
};
});
const config = { interface: {} };
const configDefaults = { interface: {} } as TConfigDefaults;
const interfaceConfig = await loadDefaultInterface({ config, configDefaults });
const appConfig = { config, interfaceConfig } as unknown as AppConfig;
await updateInterfacePermissions({
appConfig,
getRoleByName: mockGetRoleByName,
updateAccessPermissions: mockUpdateAccessPermissions,
});
const userCall = mockUpdateAccessPermissions.mock.calls.find(
(call) => call[0] === SystemRoles.USER,
);
const adminCall = mockUpdateAccessPermissions.mock.calls.find(
(call) => call[0] === SystemRoles.ADMIN,
);
expect(userCall?.[1]?.[PermissionTypes.MCP_SERVERS]?.[Permissions.CONFIGURE_OBO]).toBe(false);
expect(adminCall?.[1]?.[PermissionTypes.MCP_SERVERS]?.[Permissions.CONFIGURE_OBO]).toBe(true);
});
it('should not overwrite an admin-set CONFIGURE_OBO value during backfill', async () => {
// Operator explicitly set USER.CONFIGURE_OBO=true via the role permissions editor.
mockGetRoleByName.mockImplementation(async (roleName: string) => {
if (roleName === SystemRoles.USER) {
return {
permissions: {
[PermissionTypes.MCP_SERVERS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: false,
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
[Permissions.CONFIGURE_OBO]: true,
},
},
};
}
return {
permissions: {
[PermissionTypes.MCP_SERVERS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
[Permissions.CONFIGURE_OBO]: true,
},
},
};
});
const config = { interface: {} };
const configDefaults = { interface: {} } as TConfigDefaults;
const interfaceConfig = await loadDefaultInterface({ config, configDefaults });
const appConfig = { config, interfaceConfig } as unknown as AppConfig;
await updateInterfacePermissions({
appConfig,
getRoleByName: mockGetRoleByName,
updateAccessPermissions: mockUpdateAccessPermissions,
});
const userCall = mockUpdateAccessPermissions.mock.calls.find(
(call) => call[0] === SystemRoles.USER,
);
// Either no update was queued, or the queued update does not flip CONFIGURE_OBO.
if (userCall) {
const queuedMcp = userCall[1]?.[PermissionTypes.MCP_SERVERS];
if (queuedMcp && Permissions.CONFIGURE_OBO in queuedMcp) {
expect(queuedMcp[Permissions.CONFIGURE_OBO]).toBe(true);
}
}
});
it('should apply explicit remoteAgents config to USER permissions (regression: loadDefaultInterface omission)', async () => {
const config = {
interface: {
@ -2451,6 +2570,7 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.CREATE]: false,
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
[Permissions.CONFIGURE_OBO]: false,
});
expect(adminCall[1][PermissionTypes.MCP_SERVERS]).toEqual({
@ -2458,6 +2578,7 @@ describe('updateInterfacePermissions - permissions', () => {
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
[Permissions.CONFIGURE_OBO]: true,
});
});
@ -2490,6 +2611,7 @@ describe('updateInterfacePermissions - permissions', () => {
expect(userCall[1][PermissionTypes.MCP_SERVERS]).toEqual({
[Permissions.CREATE]: false,
[Permissions.CONFIGURE_OBO]: false,
});
});
@ -2531,10 +2653,15 @@ describe('updateInterfacePermissions - permissions', () => {
expect(userCall[1][PermissionTypes.MCP_SERVERS]).toEqual({
[Permissions.CREATE]: false,
[Permissions.CONFIGURE_OBO]: false,
});
expect(userCall[1]).not.toHaveProperty(PermissionTypes.AGENTS);
expect(adminCall[1]).not.toHaveProperty(PermissionTypes.MCP_SERVERS);
// Admin's MCP_SERVERS doesn't migrate CREATE (already true) but does receive
// the CONFIGURE_OBO backfill since the mocked role doc lacked that sub-key.
expect(adminCall[1][PermissionTypes.MCP_SERVERS]).toEqual({
[Permissions.CONFIGURE_OBO]: true,
});
expect(adminCall[1]).not.toHaveProperty(PermissionTypes.AGENTS);
});

View file

@ -402,6 +402,17 @@ export async function updateInterfacePermissions({
),
}
: {}),
...((typeof interfaceConfig?.mcpServers === 'object' &&
'configureObo' in interfaceConfig.mcpServers) ||
!existingPermissions?.[PermissionTypes.MCP_SERVERS]
? {
[Permissions.CONFIGURE_OBO]: getPermissionValue(
loadedInterface.mcpServers?.configureObo,
defaultPerms[PermissionTypes.MCP_SERVERS]?.[Permissions.CONFIGURE_OBO],
undefined,
),
}
: {}),
},
[PermissionTypes.REMOTE_AGENTS]: {
[Permissions.USE]: getPermissionValue(
@ -621,6 +632,39 @@ export async function updateInterfacePermissions({
}
}
/**
* Backfill MCP_SERVERS.CONFIGURE_OBO for existing roles that pre-date the permission.
* The MCP_SERVERS permission type already exists on these role docs, so the
* `addPermissionIfNeeded` block above does not re-seed it. Only fill in the field
* when it is literally absent never overwrite an admin-set value.
*/
{
const existingMcpPerms = existingPermissions?.[PermissionTypes.MCP_SERVERS];
const oboExplicit =
typeof interfaceConfig?.mcpServers === 'object' &&
'configureObo' in interfaceConfig.mcpServers;
const alreadyQueued =
permissionsToUpdate[PermissionTypes.MCP_SERVERS]?.[Permissions.CONFIGURE_OBO] !== undefined;
if (
existingMcpPerms &&
existingMcpPerms[Permissions.CONFIGURE_OBO] === undefined &&
!oboExplicit &&
!alreadyQueued
) {
const backfillValue =
defaultPerms[PermissionTypes.MCP_SERVERS]?.[Permissions.CONFIGURE_OBO];
if (backfillValue !== undefined) {
logger.debug(
`Role '${roleName}': Backfilling MCP_SERVERS.CONFIGURE_OBO=${backfillValue}`,
);
permissionsToUpdate[PermissionTypes.MCP_SERVERS] = {
...permissionsToUpdate[PermissionTypes.MCP_SERVERS],
[Permissions.CONFIGURE_OBO]: backfillValue,
};
}
}
}
// Update permissions if any need updating
if (Object.keys(permissionsToUpdate).length > 0) {
await updateAccessPermissions(roleName, permissionsToUpdate, existingRole);

View file

@ -2,7 +2,7 @@ import { logger } from '@librechat/data-schemas';
import type * as t from './types';
import { MCPServersRegistry } from '~/mcp/registry/MCPServersRegistry';
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
import { hasCustomUserVars, isUserSourced } from './utils';
import { isUserSourced, requiresUserScopedConnection } from './utils';
import { MCPConnection } from './connection';
const CONNECT_CONCURRENCY = 3;
@ -147,6 +147,8 @@ export class ConnectionsRepository {
* App-level (shared) connections cannot serve servers that need per-user context:
* env/header placeholders like `{{MY_KEY}}` are only resolved by `processMCPEnv()`
* when real `customUserVars` values exist which requires a user-level connection.
* OBO servers also require a user-level connection because each tool call
* uses the current user's bearer token.
*/
private isAllowedToConnectToServer(config: t.ParsedServerConfig) {
if (config.inspectionFailed) {
@ -154,7 +156,7 @@ export class ConnectionsRepository {
}
if (
this.ownerId === undefined &&
(config.startup === false || config.requiresOAuth || hasCustomUserVars(config))
(config.startup === false || requiresUserScopedConnection(config))
) {
return false;
}

View file

@ -3,9 +3,16 @@ import type { OAuthClientInformation } from '@modelcontextprotocol/sdk/shared/au
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
import type { TokenMethods } from '@librechat/data-schemas';
import type { MCPOAuthTokens, OAuthMetadata, MCPOAuthFlowMetadata } from '~/mcp/oauth';
import type { OboTokenResolver, OboTrustChecker } from '~/mcp/oauth/obo';
import type { FlowStateManager } from '~/flow/manager';
import type * as t from './types';
import { MCPTokenStorage, MCPOAuthHandler, ReauthenticationRequiredError } from '~/mcp/oauth';
import {
MCPTokenStorage,
MCPOAuthHandler,
OboTokenResolutionError,
ReauthenticationRequiredError,
resolveOboToken,
} from '~/mcp/oauth';
import { PENDING_STALE_MS, normalizeExpiresAt } from '~/flow/manager';
import { sanitizeUrlForLogging, isClientRejectionMessage, isOAuthServer } from './utils';
import { withTimeout } from '~/utils/promise';
@ -35,6 +42,7 @@ export class MCPConnectionFactory {
// OAuth-related properties (only set when useOAuth is true)
protected readonly userId?: string;
protected readonly user?: t.OAuthConnectionOptions['user'];
protected readonly flowManager?: FlowStateManager<MCPOAuthTokens | null>;
protected readonly tokenMethods?: TokenMethods;
protected readonly signal?: AbortSignal;
@ -42,6 +50,8 @@ export class MCPConnectionFactory {
protected readonly oauthEnd?: () => Promise<void>;
protected readonly returnOnOAuth?: boolean;
protected readonly connectionTimeout?: number;
protected readonly oboTokenResolver?: OboTokenResolver;
protected readonly oboTrustChecker?: OboTrustChecker;
/** Creates a new MCP connection with optional OAuth support */
static async create(
@ -73,7 +83,12 @@ export class MCPConnectionFactory {
const oauthUrl: string | null = null;
let oauthRequired = false;
const oauthTokens = this.useOAuth ? await this.getOAuthTokens() : null;
let oauthTokens: MCPOAuthTokens | null = null;
if (this.usesObo) {
oauthTokens = await this.getOboTokens();
} else if (this.useOAuth) {
oauthTokens = await this.getOAuthTokens();
}
const connection = new MCPConnection({
serverName: this.serverName,
serverConfig: this.serverConfig,
@ -208,6 +223,8 @@ export class MCPConnectionFactory {
? `[MCP][${basic.serverName}][${options.user.id}]`
: `[MCP][${basic.serverName}]`;
this.user = options?.user;
if (options != null && 'useOAuth' in options) {
this.useOAuth = true;
this.userId = options.user?.id;
@ -217,14 +234,78 @@ export class MCPConnectionFactory {
this.oauthStart = options.oauthStart;
this.oauthEnd = options.oauthEnd;
this.returnOnOAuth = options.returnOnOAuth;
this.oboTokenResolver = options.oboTokenResolver;
this.oboTrustChecker = options.oboTrustChecker;
} else {
this.useOAuth = false;
}
}
/** Resolves OBO tokens when the server config specifies obo, returns null otherwise */
protected async getOboTokens(): Promise<MCPOAuthTokens | null> {
const oboConfig = this.serverConfig.obo;
if (!oboConfig || !this.oboTokenResolver || !this.user) {
return null;
}
if (this.oboTrustChecker) {
const config = this.serverConfig as t.ParsedServerConfig;
const trusted = await this.oboTrustChecker({
source: config.source,
author: config.author,
dbId: config.dbId,
});
if (!trusted) {
logger.warn(
`${this.logPrefix} OBO config not trusted (author lacks CONFIGURE_OBO permission); skipping OBO token exchange`,
);
return null;
}
}
logger.info(`${this.logPrefix} Resolving OBO token for scopes: ${oboConfig.scopes}`);
return resolveOboToken(this.user, oboConfig, this.oboTokenResolver);
}
/** Returns true if this server uses OBO instead of standard OAuth */
protected get usesObo(): boolean {
return !!this.serverConfig.obo && !!this.oboTokenResolver && !!this.user;
}
protected createOboConnectionError(error: OboTokenResolutionError): Error {
let recoveryHint = 'Re-authenticate the user and retry.';
if (error.retryable) {
recoveryHint = 'Please retry.';
} else if (error.reason === 'exchange_failed') {
recoveryHint = 'Re-authenticate the user or verify the configured OBO scopes and retry.';
}
return new Error(
`${error.userMessage} Unable to connect to OBO server "${this.serverName}". ${recoveryHint}`,
);
}
/** Creates the base MCP connection with OAuth tokens */
protected async createConnection(): Promise<MCPConnection> {
const oauthTokens = this.useOAuth ? await this.getOAuthTokens() : null;
let oauthTokens: MCPOAuthTokens | null = null;
if (this.usesObo) {
try {
oauthTokens = await this.getOboTokens();
} catch (error) {
if (error instanceof OboTokenResolutionError) {
throw this.createOboConnectionError(error);
}
throw error;
}
if (!oauthTokens) {
throw new Error(`OBO token exchange failed for "${this.serverName}".`);
}
} else if (this.useOAuth) {
oauthTokens = await this.getOAuthTokens();
}
const connection = new MCPConnection({
serverName: this.serverName,
serverConfig: this.serverConfig,
@ -235,7 +316,7 @@ export class MCPConnectionFactory {
});
let cleanupOAuthHandlers: (() => void) | null = null;
if (this.useOAuth) {
if (this.useOAuth && !this.usesObo) {
cleanupOAuthHandlers = this.handleOAuthEvents(connection);
} else {
const nonOAuthHandler = () => {

View file

@ -1,8 +1,10 @@
import pick from 'lodash/pick';
import { logger } from '@librechat/data-schemas';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import { CallToolResultSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type { TokenMethods, IUser } from '@librechat/data-schemas';
import type { OboTokenResolver, OboTrustChecker } from '~/mcp/oauth/obo';
import type { GraphTokenResolver } from '~/utils/graph';
import type { FlowStateManager } from '~/flow/manager';
import type { MCPOAuthTokens } from './oauth';
@ -14,11 +16,28 @@ import { MCPServersRegistry } from './registry/MCPServersRegistry';
import { UserConnectionManager } from './UserConnectionManager';
import { ConnectionsRepository } from './ConnectionsRepository';
import { MCPConnectionFactory } from './MCPConnectionFactory';
import { OboTokenResolutionError, resolveOboToken } from '~/mcp/oauth';
import { preProcessGraphTokens } from '~/utils/graph';
import { formatToolContent } from './parsers';
import { MCPConnection } from './connection';
import { processMCPEnv } from '~/utils/env';
import { isUserSourced, isOAuthServer } from './utils';
import { isUserSourced, requiresOAuthMachinery, requiresUserScopedConnection } from './utils';
function createOboToolCallErrorMessage(
logPrefix: string,
toolName: string,
error: OboTokenResolutionError,
): string {
let failureSuffix = 'Re-authenticate the user and retry.';
if (error.retryable) {
failureSuffix = 'Please retry.';
} else if (error.reason === 'exchange_failed') {
failureSuffix = 'Re-authenticate the user or verify the configured OBO scopes and retry.';
}
return `${logPrefix} ${error.userMessage} Cannot execute tool ${toolName}. ${failureSuffix}`;
}
/**
* Centralized manager for MCP server connections and tool execution.
@ -58,12 +77,29 @@ export class MCPManager extends UserConnectionManager {
serverConfig?: t.ParsedServerConfig;
} & Omit<t.OAuthConnectionOptions, 'useOAuth' | 'user' | 'flowManager'>,
): Promise<MCPConnection> {
const userId = args.user?.id;
const effectiveConfig =
args.serverConfig ??
(userId
? await MCPServersRegistry.getInstance().getServerConfig(args.serverName, userId)
: undefined);
if (effectiveConfig && userId && requiresUserScopedConnection(effectiveConfig)) {
return this.getUserConnection({
...args,
serverConfig: effectiveConfig,
} as Parameters<typeof this.getUserConnection>[0]);
}
//the get method checks if the config is still valid as app level
const existingAppConnection = await this.appConnections!.get(args.serverName);
if (existingAppConnection) {
return existingAppConnection;
} else if (args.user?.id) {
return this.getUserConnection(args as Parameters<typeof this.getUserConnection>[0]);
} else if (userId) {
return this.getUserConnection({
...args,
serverConfig: effectiveConfig,
} as Parameters<typeof this.getUserConnection>[0]);
} else {
throw new McpError(
ErrorCode.InvalidRequest,
@ -102,7 +138,7 @@ export class MCPManager extends UserConnectionManager {
return { tools: null, oauthRequired: false, oauthUrl: null };
}
const useOAuth = isOAuthServer(serverConfig);
const useOAuth = requiresOAuthMachinery(serverConfig);
const registry = MCPServersRegistry.getInstance();
const useSSRFProtection = registry.shouldEnableSSRFProtection();
@ -147,6 +183,8 @@ export class MCPManager extends UserConnectionManager {
customUserVars: args.customUserVars,
requestBody: args.requestBody,
connectionTimeout: args.connectionTimeout,
oboTokenResolver: args.oboTokenResolver,
oboTrustChecker: args.oboTrustChecker,
});
return { tools: result.tools, oauthRequired: result.oauthRequired, oauthUrl: result.oauthUrl };
@ -274,6 +312,8 @@ Please follow these instructions when using tools from the respective MCP server
oauthEnd,
customUserVars,
graphTokenResolver,
oboTokenResolver,
oboTrustChecker,
}: {
user?: IUser;
serverName: string;
@ -290,6 +330,8 @@ Please follow these instructions when using tools from the respective MCP server
oauthStart?: (authURL: string) => Promise<void>;
oauthEnd?: () => Promise<void>;
graphTokenResolver?: GraphTokenResolver;
oboTokenResolver?: OboTokenResolver;
oboTrustChecker?: OboTrustChecker;
}): Promise<t.FormattedToolResponse> {
/** User-specific connection */
let connection: MCPConnection | undefined;
@ -306,6 +348,8 @@ Please follow these instructions when using tools from the respective MCP server
tokenMethods,
oauthStart,
oauthEnd,
oboTokenResolver,
oboTrustChecker,
signal: options?.signal,
customUserVars,
requestBody,
@ -346,10 +390,53 @@ Please follow these instructions when using tools from the respective MCP server
options: graphProcessedConfig,
customUserVars,
});
if ('headers' in currentOptions) {
connection.setRequestHeaders(currentOptions.headers || {});
const resolvedHeaders: Record<string, string> =
'headers' in currentOptions ? { ...(currentOptions.headers || {}) } : {};
/** Refresh OBO token on each tool call to ensure it's current */
const oboConfig = rawConfig.obo;
if (oboConfig && oboTokenResolver && user) {
const oboTrusted = oboTrustChecker
? await oboTrustChecker({
source: rawConfig.source,
author: rawConfig.author,
dbId: rawConfig.dbId,
})
: true;
if (!oboTrusted) {
logger.warn(
`${logPrefix} OBO config not trusted (author lacks ${PermissionTypes.MCP_SERVERS}.${Permissions.CONFIGURE_OBO}); refusing to mint a downstream token`,
);
throw new McpError(
ErrorCode.InternalError,
`${logPrefix} OBO is not permitted for server "${serverName}". The user who configured it no longer has permission to use OBO.`,
);
}
let oboTokens: MCPOAuthTokens;
try {
oboTokens = await resolveOboToken(user, oboConfig, oboTokenResolver);
} catch (error) {
if (error instanceof OboTokenResolutionError) {
throw new McpError(
ErrorCode.InternalError,
createOboToolCallErrorMessage(logPrefix, toolName, error),
);
}
throw error;
}
if (!oboTokens.access_token) {
throw new McpError(
ErrorCode.InternalError,
`${logPrefix} OBO token refresh failed. Cannot execute tool ${toolName}. Re-authenticate the user and retry.`,
);
}
resolvedHeaders['Authorization'] = `Bearer ${oboTokens.access_token}`;
}
connection.setRequestHeaders(resolvedHeaders);
const result = await connection.client.request(
{
method: 'tools/call',

View file

@ -4,7 +4,7 @@ import type * as t from './types';
import { MCPServersRegistry } from '~/mcp/registry/MCPServersRegistry';
import { ConnectionsRepository } from '~/mcp/ConnectionsRepository';
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
import { isUserSourced, isOAuthServer } from './utils';
import { isUserSourced, requiresOAuthMachinery } from './utils';
import { MCPConnection } from './connection';
import { mcpConfig } from './mcpConfig';
@ -78,6 +78,8 @@ export abstract class UserConnectionManager {
tokenMethods,
oauthStart,
oauthEnd,
oboTokenResolver,
oboTrustChecker,
signal,
returnOnOAuth = false,
connectionTimeout,
@ -159,7 +161,7 @@ export abstract class UserConnectionManager {
allowedAddresses: registry.getAllowedAddresses(),
};
const useOAuth = isOAuthServer(config);
const useOAuth = requiresOAuthMachinery(config);
let connectionOptions: t.OAuthConnectionOptions | t.UserConnectionContext;
if (useOAuth) {
if (!flowManager) {
@ -178,6 +180,8 @@ export abstract class UserConnectionManager {
signal: signal,
oauthStart: oauthStart,
oauthEnd: oauthEnd,
oboTokenResolver: oboTokenResolver,
oboTrustChecker: oboTrustChecker,
returnOnOAuth: returnOnOAuth,
requestBody: requestBody,
connectionTimeout: connectionTimeout,

View file

@ -430,6 +430,18 @@ describe('ConnectionsRepository', () => {
expect(await repository.has('customVarStartupServer')).toBe(false);
});
it('should NOT allow connection to OBO servers', async () => {
mockServerConfigs.oboServer = {
type: 'streamable-http',
url: 'http://example.com',
obo: {
scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite',
},
};
expect(await repository.has('oboServer')).toBe(false);
});
it('should disconnect existing connection when server becomes not allowed', async () => {
// Initially setup as regular server
mockServerConfigs.changingServer = {
@ -523,6 +535,18 @@ describe('ConnectionsRepository', () => {
expect(await repository.has('customVarServer')).toBe(true);
});
it('should allow connection to OBO servers', async () => {
mockServerConfigs.oboServer = {
type: 'streamable-http',
url: 'http://example.com',
obo: {
scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite',
},
};
expect(await repository.has('oboServer')).toBe(true);
});
it('should return null from get() when server config does not exist', async () => {
const connection = await repository.get('nonexistent');
expect(connection).toBeNull();

View file

@ -8,6 +8,7 @@ import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
import { ConnectionsRepository } from '~/mcp/ConnectionsRepository';
import { MCPConnection } from '~/mcp/connection';
import { MCPManager } from '~/mcp/MCPManager';
import { OboTokenResolutionError, resolveOboToken } from '~/mcp/oauth';
import * as graphUtils from '~/utils/graph';
// Mock external dependencies
@ -25,6 +26,11 @@ jest.mock('~/utils/graph', () => ({
preProcessGraphTokens: jest.fn(),
}));
jest.mock('~/mcp/oauth', () => ({
...jest.requireActual('~/mcp/oauth'),
resolveOboToken: jest.fn(),
}));
jest.mock('~/utils/env', () => ({
processMCPEnv: jest.fn((params) => params.options),
}));
@ -55,6 +61,7 @@ jest.mock('~/mcp/ConnectionsRepository');
jest.mock('~/mcp/MCPConnectionFactory');
const mockLogger = logger as jest.Mocked<typeof logger>;
const mockResolveOboToken = resolveOboToken as jest.MockedFunction<typeof resolveOboToken>;
describe('MCPManager', () => {
const userId = 'test-user-123';
@ -793,6 +800,296 @@ describe('MCPManager', () => {
});
});
describe('callTool - OBO Integration', () => {
const mockUser: Partial<IUser> = {
id: 'user-123',
provider: 'openid',
openidId: 'oidc-sub-456',
};
const mockFlowManager = {
getState: jest.fn(),
setState: jest.fn(),
clearState: jest.fn(),
};
const mockConnection = {
isConnected: jest.fn().mockResolvedValue(true),
setRequestHeaders: jest.fn(),
timeout: 30000,
client: {
request: jest.fn().mockResolvedValue({
content: [{ type: 'text', text: 'Tool result' }],
isError: false,
}),
},
} as unknown as MCPConnection;
const mockOboTokenResolver = jest.fn();
const serverConfig: t.SSEOptions & { obo: { scopes: string } } = {
type: 'sse',
url: 'https://api.example.com',
headers: {
Authorization: 'Bearer bootstrap-token',
},
obo: {
scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite',
},
};
beforeEach(() => {
mockResolveOboToken.mockReset();
});
it('should bypass shared app connections for OBO servers and use a user-scoped connection', async () => {
const sharedAppConnection = {
isConnected: jest.fn().mockResolvedValue(true),
setRequestHeaders: jest.fn(),
timeout: 30000,
client: {
request: jest.fn().mockResolvedValue({
content: [{ type: 'text', text: 'Shared tool result' }],
isError: false,
}),
},
} as unknown as MCPConnection;
const userConnection = {
isConnected: jest.fn().mockResolvedValue(true),
setRequestHeaders: jest.fn(),
timeout: 30000,
client: {
request: jest.fn().mockResolvedValue({
content: [{ type: 'text', text: 'User tool result' }],
isError: false,
}),
},
} as unknown as MCPConnection;
const appConnections = {
get: jest.fn().mockResolvedValue(sharedAppConnection),
};
mockResolveOboToken.mockResolvedValue({
access_token: 'fresh-obo-token',
token_type: 'Bearer',
obtained_at: Date.now(),
expires_at: Date.now() + 3600_000,
});
mockAppConnections(appConnections);
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
const manager = await MCPManager.createInstance(newMCPServersConfig());
const getUserConnectionSpy = jest
.spyOn(manager, 'getUserConnection')
.mockResolvedValue(userConnection);
await manager.callTool({
user: mockUser as IUser,
serverName,
toolName: 'test_tool',
provider: 'openai',
flowManager: mockFlowManager as unknown as Parameters<
typeof manager.callTool
>[0]['flowManager'],
oboTokenResolver: mockOboTokenResolver,
});
expect(appConnections.get).not.toHaveBeenCalled();
expect(getUserConnectionSpy).toHaveBeenCalledWith(
expect.objectContaining({
serverName,
serverConfig,
user: mockUser,
}),
);
expect(userConnection.setRequestHeaders as jest.Mock).toHaveBeenCalledWith(
expect.objectContaining({
Authorization: 'Bearer fresh-obo-token',
}),
);
expect(sharedAppConnection.setRequestHeaders as jest.Mock).not.toHaveBeenCalled();
expect(userConnection.client.request as jest.Mock).toHaveBeenCalled();
expect(sharedAppConnection.client.request as jest.Mock).not.toHaveBeenCalled();
});
it('should replace Authorization with the refreshed OBO token on each tool call', async () => {
mockResolveOboToken.mockResolvedValue({
access_token: 'fresh-obo-token',
token_type: 'Bearer',
obtained_at: Date.now(),
expires_at: Date.now() + 3600_000,
});
const appConnections = {
get: jest.fn().mockResolvedValue(mockConnection),
};
mockAppConnections(appConnections);
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
const manager = await MCPManager.createInstance(newMCPServersConfig());
const getUserConnectionSpy = jest
.spyOn(manager, 'getUserConnection')
.mockResolvedValue(mockConnection);
await manager.callTool({
user: mockUser as IUser,
serverName,
toolName: 'test_tool',
provider: 'openai',
flowManager: mockFlowManager as unknown as Parameters<
typeof manager.callTool
>[0]['flowManager'],
oboTokenResolver: mockOboTokenResolver,
});
expect(mockResolveOboToken).toHaveBeenCalledWith(
mockUser,
serverConfig.obo,
mockOboTokenResolver,
);
expect(appConnections.get).not.toHaveBeenCalled();
expect(getUserConnectionSpy).toHaveBeenCalled();
expect(mockConnection.setRequestHeaders).toHaveBeenCalledWith(
expect.objectContaining({
Authorization: 'Bearer fresh-obo-token',
}),
);
expect(mockConnection.client.request).toHaveBeenCalled();
});
it('should fail closed with a retryable message when per-call OBO refresh has a transient failure', async () => {
mockResolveOboToken.mockRejectedValue(
new OboTokenResolutionError(
'exchange_failed',
'Temporary OBO token exchange failure.',
true,
),
);
const appConnections = {
get: jest.fn().mockResolvedValue(mockConnection),
};
mockAppConnections(appConnections);
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
const manager = await MCPManager.createInstance(newMCPServersConfig());
const getUserConnectionSpy = jest
.spyOn(manager, 'getUserConnection')
.mockResolvedValue(mockConnection);
await expect(
manager.callTool({
user: mockUser as IUser,
serverName,
toolName: 'test_tool',
provider: 'openai',
flowManager: mockFlowManager as unknown as Parameters<
typeof manager.callTool
>[0]['flowManager'],
oboTokenResolver: mockOboTokenResolver,
}),
).rejects.toMatchObject({
message: expect.stringContaining('Temporary OBO token exchange failure.'),
});
expect(appConnections.get).not.toHaveBeenCalled();
expect(getUserConnectionSpy).toHaveBeenCalled();
expect(mockConnection.setRequestHeaders).not.toHaveBeenCalled();
expect(mockConnection.client.request).not.toHaveBeenCalled();
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('[test_tool] Tool call failed'),
expect.anything(),
);
});
it('should fail closed with a re-authentication message when per-call OBO refresh has a permanent failure', async () => {
mockResolveOboToken.mockRejectedValue(
new OboTokenResolutionError(
'exchange_failed',
'The identity provider rejected the OBO token exchange.',
),
);
const appConnections = {
get: jest.fn().mockResolvedValue(mockConnection),
};
mockAppConnections(appConnections);
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
const manager = await MCPManager.createInstance(newMCPServersConfig());
const getUserConnectionSpy = jest
.spyOn(manager, 'getUserConnection')
.mockResolvedValue(mockConnection);
await expect(
manager.callTool({
user: mockUser as IUser,
serverName,
toolName: 'test_tool',
provider: 'openai',
flowManager: mockFlowManager as unknown as Parameters<
typeof manager.callTool
>[0]['flowManager'],
oboTokenResolver: mockOboTokenResolver,
}),
).rejects.toMatchObject({
message: expect.stringContaining('verify the configured OBO scopes'),
});
expect(appConnections.get).not.toHaveBeenCalled();
expect(getUserConnectionSpy).toHaveBeenCalled();
expect(mockConnection.setRequestHeaders).not.toHaveBeenCalled();
expect(mockConnection.client.request).not.toHaveBeenCalled();
});
});
describe('getConnection', () => {
const mockUser: Partial<IUser> = {
id: 'user-123',
provider: 'openid',
openidId: 'oidc-sub-456',
};
it('should continue using shared app connections for non-OBO servers', async () => {
const appConnection = {
isConnected: jest.fn().mockResolvedValue(true),
} as unknown as MCPConnection;
const appConnections = {
get: jest.fn().mockResolvedValue(appConnection),
};
const nonOboConfig: t.SSEOptions = {
type: 'sse',
url: 'https://api.example.com',
};
mockAppConnections(appConnections);
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(nonOboConfig);
const manager = await MCPManager.createInstance(newMCPServersConfig());
const getUserConnectionSpy = jest.spyOn(manager, 'getUserConnection');
const connection = await manager.getConnection({
serverName,
user: mockUser as IUser,
});
expect(connection).toBe(appConnection);
expect(appConnections.get).toHaveBeenCalledWith(serverName);
expect(getUserConnectionSpy).not.toHaveBeenCalled();
});
});
describe('discoverServerTools', () => {
const mockTools = [
{ name: 'tool1', description: 'First tool', inputSchema: { type: 'object' } },

View file

@ -3,6 +3,7 @@ import {
normalizeServerName,
redactAllServerSecrets,
redactServerSecrets,
requiresUserScopedConnection,
isInvalidClientMessage,
isClientRejectionMessage,
getMissingCustomUserVars,
@ -249,6 +250,17 @@ describe('redactServerSecrets', () => {
expect((redacted as Record<string, unknown>).someNewSensitiveField).toBeUndefined();
expect(redacted.title).toBe('Test');
});
it('should preserve obo config', () => {
const config: ParsedServerConfig = {
type: 'sse',
url: 'https://example.com/mcp',
title: 'OBO Server',
obo: { scopes: 'api://client-id/.default' },
};
const redacted = redactServerSecrets(config);
expect(redacted.obo).toEqual({ scopes: 'api://client-id/.default' });
});
});
describe('redactAllServerSecrets', () => {
@ -343,6 +355,39 @@ describe('isUserSourced', () => {
});
});
describe('requiresUserScopedConnection', () => {
it('returns true for OAuth servers', () => {
expect(requiresUserScopedConnection({ requiresOAuth: true })).toBe(true);
});
it('returns true for OBO servers', () => {
expect(
requiresUserScopedConnection({
obo: { scopes: 'api://client-id/.default' },
}),
).toBe(true);
});
it('returns true for servers with customUserVars', () => {
expect(
requiresUserScopedConnection({
customUserVars: {
API_KEY: { title: 'API Key', description: 'Your key' },
},
}),
).toBe(true);
});
it('returns false for app-shareable servers', () => {
expect(
requiresUserScopedConnection({
requiresOAuth: false,
customUserVars: {},
}),
).toBe(false);
});
});
describe('getMissingCustomUserVars', () => {
const configWithVars = (keys: string[]): Pick<ParsedServerConfig, 'customUserVars'> => ({
customUserVars: Object.fromEntries(

View file

@ -3,3 +3,4 @@ export * from './handler';
export * from './tokens';
export * from './detectOAuth';
export * from './methods';
export * from './obo';

View file

@ -0,0 +1,270 @@
import type { IUser } from '@librechat/data-schemas';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import type { OboTokenResolver } from './obo';
import { isOboConfigStillTrusted, resolveOboToken } from './obo';
jest.mock('@librechat/data-schemas', () => ({
logger: {
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
},
}));
jest.mock('~/utils/oidc', () => ({
extractOpenIDTokenInfo: jest.fn(),
isOpenIDTokenValid: jest.fn(),
}));
import { extractOpenIDTokenInfo, isOpenIDTokenValid } from '~/utils/oidc';
const mockExtractOpenIDTokenInfo = extractOpenIDTokenInfo as jest.Mock;
const mockIsOpenIDTokenValid = isOpenIDTokenValid as jest.Mock;
describe('resolveOboToken', () => {
const mockUser: Partial<IUser> = {
id: 'user-123',
provider: 'openid',
openidId: 'oidc-sub-456',
email: 'test@example.com',
name: 'Test User',
federatedTokens: {
access_token: 'federated-access-token',
id_token: 'federated-id-token',
expires_at: Math.floor(Date.now() / 1000) + 3600,
},
};
const oboConfig = { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' };
const mockResolver: OboTokenResolver = jest.fn().mockResolvedValue({
access_token: 'exchanged-mcp-token',
expires_in: 3600,
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should throw when user has no valid OpenID token info', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue(null);
await expect(resolveOboToken(mockUser as IUser, oboConfig, mockResolver)).rejects.toMatchObject(
{
reason: 'missing_upstream_token',
retryable: false,
},
);
expect(mockResolver).not.toHaveBeenCalled();
});
it('should throw when OpenID token is not valid (expired)', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'some-token' });
mockIsOpenIDTokenValid.mockReturnValue(false);
await expect(resolveOboToken(mockUser as IUser, oboConfig, mockResolver)).rejects.toMatchObject(
{
reason: 'missing_upstream_token',
retryable: false,
},
);
expect(mockResolver).not.toHaveBeenCalled();
});
it('should throw when access token is missing from token info', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue({ userId: 'user-123' });
mockIsOpenIDTokenValid.mockReturnValue(true);
await expect(resolveOboToken(mockUser as IUser, oboConfig, mockResolver)).rejects.toMatchObject(
{
reason: 'missing_upstream_access_token',
retryable: false,
},
);
expect(mockResolver).not.toHaveBeenCalled();
});
it('should call the resolver with correct arguments and return MCPOAuthTokens', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'federated-access-token' });
mockIsOpenIDTokenValid.mockReturnValue(true);
const beforeCall = Date.now();
const result = await resolveOboToken(mockUser as IUser, oboConfig, mockResolver);
const afterCall = Date.now();
expect(mockResolver).toHaveBeenCalledWith(
mockUser,
'federated-access-token',
'api://mcp-server-id/Mcp.Tools.ReadWrite',
true,
);
expect(result).not.toBeNull();
expect(result!.access_token).toBe('exchanged-mcp-token');
expect(result!.token_type).toBe('Bearer');
expect(result!.obtained_at).toBeGreaterThanOrEqual(beforeCall);
expect(result!.obtained_at).toBeLessThanOrEqual(afterCall);
expect(result!.expires_at).toBe(result!.obtained_at + 3600 * 1000);
});
it('should default expires_in to 3600 when not provided by resolver', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'federated-access-token' });
mockIsOpenIDTokenValid.mockReturnValue(true);
const resolverNoExpiry: OboTokenResolver = jest.fn().mockResolvedValue({
access_token: 'exchanged-token',
});
const result = await resolveOboToken(mockUser as IUser, oboConfig, resolverNoExpiry);
expect(result).not.toBeNull();
expect(result!.expires_at).toBe(result!.obtained_at + 3600 * 1000);
});
it('should throw when resolver returns no access_token', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'federated-access-token' });
mockIsOpenIDTokenValid.mockReturnValue(true);
const emptyResolver: OboTokenResolver = jest.fn().mockResolvedValue({});
await expect(
resolveOboToken(mockUser as IUser, oboConfig, emptyResolver),
).rejects.toMatchObject({
reason: 'empty_exchange_response',
retryable: false,
});
});
it('should throw a retryable error when resolver reports a transient failure', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'federated-access-token' });
mockIsOpenIDTokenValid.mockReturnValue(true);
const failingResolver: OboTokenResolver = jest
.fn()
.mockRejectedValue(Object.assign(new Error('temporary timeout'), { retryable: true }));
await expect(
resolveOboToken(mockUser as IUser, oboConfig, failingResolver),
).rejects.toMatchObject({
reason: 'exchange_failed',
retryable: true,
userMessage: 'Temporary OBO token exchange failure.',
});
});
it('should throw a non-retryable error when resolver reports a permanent failure', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'federated-access-token' });
mockIsOpenIDTokenValid.mockReturnValue(true);
const failingResolver: OboTokenResolver = jest
.fn()
.mockRejectedValue(new Error('invalid_grant: assertion invalid'));
await expect(
resolveOboToken(mockUser as IUser, oboConfig, failingResolver),
).rejects.toMatchObject({
reason: 'exchange_failed',
retryable: false,
userMessage: 'The identity provider rejected the OBO token exchange.',
});
});
it('should use the correct scopes from oboConfig', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'federated-access-token' });
mockIsOpenIDTokenValid.mockReturnValue(true);
const customConfig = { scopes: 'api://other-app/Custom.Scope' };
await resolveOboToken(mockUser as IUser, customConfig, mockResolver);
expect(mockResolver).toHaveBeenCalledWith(
mockUser,
'federated-access-token',
'api://other-app/Custom.Scope',
true,
);
});
it('should respect custom expires_in from resolver', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'federated-access-token' });
mockIsOpenIDTokenValid.mockReturnValue(true);
const shortLivedResolver: OboTokenResolver = jest.fn().mockResolvedValue({
access_token: 'short-lived-token',
expires_in: 300,
});
const result = await resolveOboToken(mockUser as IUser, oboConfig, shortLivedResolver);
expect(result).not.toBeNull();
expect(result!.expires_at).toBe(result!.obtained_at + 300 * 1000);
});
});
describe('isOboConfigStillTrusted', () => {
const adminPerms = {
[PermissionTypes.MCP_SERVERS]: {
[Permissions.CONFIGURE_OBO]: true,
},
};
const userPerms = {
[PermissionTypes.MCP_SERVERS]: {
[Permissions.CONFIGURE_OBO]: false,
},
};
const noOboPerms = {
[PermissionTypes.MCP_SERVERS]: {},
};
it('returns false when authorId is missing', async () => {
const result = await isOboConfigStillTrusted({
authorId: undefined,
getUserRoleByAuthorId: jest.fn(),
getRolePermissions: jest.fn(),
});
expect(result).toBe(false);
});
it('returns false when the author has no role (deleted user)', async () => {
const result = await isOboConfigStillTrusted({
authorId: 'gone',
getUserRoleByAuthorId: jest.fn().mockResolvedValue(null),
getRolePermissions: jest.fn(),
});
expect(result).toBe(false);
});
it('returns false when role lookup throws', async () => {
const result = await isOboConfigStillTrusted({
authorId: 'u1',
getUserRoleByAuthorId: jest.fn().mockResolvedValue('ADMIN'),
getRolePermissions: jest.fn().mockRejectedValue(new Error('db down')),
});
expect(result).toBe(false);
});
it('returns false when the role lacks CONFIGURE_OBO sub-key (older deployment)', async () => {
const result = await isOboConfigStillTrusted({
authorId: 'u1',
getUserRoleByAuthorId: jest.fn().mockResolvedValue('ADMIN'),
getRolePermissions: jest.fn().mockResolvedValue(noOboPerms),
});
expect(result).toBe(false);
});
it('returns false when CONFIGURE_OBO is explicitly false', async () => {
const result = await isOboConfigStillTrusted({
authorId: 'u1',
getUserRoleByAuthorId: jest.fn().mockResolvedValue('USER'),
getRolePermissions: jest.fn().mockResolvedValue(userPerms),
});
expect(result).toBe(false);
});
it('returns true when the author still has CONFIGURE_OBO', async () => {
const result = await isOboConfigStillTrusted({
authorId: 'u1',
getUserRoleByAuthorId: jest.fn().mockResolvedValue('ADMIN'),
getRolePermissions: jest.fn().mockResolvedValue(adminPerms),
});
expect(result).toBe(true);
});
});

View file

@ -0,0 +1,245 @@
import { logger } from '@librechat/data-schemas';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import type { IUser } from '@librechat/data-schemas';
import { extractOpenIDTokenInfo, isOpenIDTokenValid } from '~/utils/oidc';
import type { MCPOAuthTokens } from './types';
export interface OboConfig {
scopes: string;
}
/**
* Function type for performing OBO token exchange.
* Injected from the main API layer since it requires OpenID configuration and caching.
*/
export type OboTokenResolver = (
user: IUser,
accessToken: string,
scopes: string,
fromCache?: boolean,
) => Promise<{ access_token: string; expires_in?: number }>;
export type OboTokenResolutionReason =
| 'missing_upstream_token'
| 'missing_upstream_access_token'
| 'empty_exchange_response'
| 'exchange_failed';
const RETRYABLE_OBO_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
const RETRYABLE_OBO_ERROR_CODES = new Set(['ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN', 'ENOTFOUND']);
function getErrorStatus(error: unknown): number | undefined {
if (!error || typeof error !== 'object') {
return undefined;
}
const candidate = error as {
status?: number;
statusCode?: number;
response?: { status?: number };
};
return candidate.status ?? candidate.statusCode ?? candidate.response?.status;
}
function getErrorCode(error: unknown): string | undefined {
if (!error || typeof error !== 'object' || !('code' in error)) {
return undefined;
}
const code = (error as { code?: unknown }).code;
return typeof code === 'string' ? code.toUpperCase() : undefined;
}
function getErrorRetryableFlag(error: unknown): boolean | undefined {
if (!error || typeof error !== 'object' || !('retryable' in error)) {
return undefined;
}
const retryable = (error as { retryable?: unknown }).retryable;
return typeof retryable === 'boolean' ? retryable : undefined;
}
export class OboTokenResolutionError extends Error {
public readonly reason: OboTokenResolutionReason;
public readonly retryable: boolean;
public readonly userMessage: string;
public override readonly cause?: unknown;
constructor(
reason: OboTokenResolutionReason,
userMessage: string,
retryable = false,
cause?: unknown,
) {
super(userMessage);
this.name = 'OboTokenResolutionError';
this.reason = reason;
this.retryable = retryable;
this.userMessage = userMessage;
this.cause = cause;
}
}
function isRetryableOboExchangeError(error: unknown): boolean {
const taggedRetryable = getErrorRetryableFlag(error);
if (taggedRetryable != null) {
return taggedRetryable;
}
const status = getErrorStatus(error);
if (status != null && RETRYABLE_OBO_STATUS_CODES.has(status)) {
return true;
}
const code = getErrorCode(error);
if (code != null && RETRYABLE_OBO_ERROR_CODES.has(code)) {
return true;
}
if (!error || typeof error !== 'object' || !('message' in error)) {
return false;
}
const message = String((error as { message?: unknown }).message ?? '').toLowerCase();
return (
message.includes('timed out') ||
message.includes('timeout') ||
message.includes('econnreset') ||
message.includes('socket hang up') ||
message.includes('temporarily unavailable') ||
message.includes('too many requests') ||
message.includes('service unavailable')
);
}
/**
* Performs an OBO token exchange for the given user and MCP server OBO config.
* Returns MCPOAuthTokens suitable for injection into the MCP connection.
*/
export async function resolveOboToken(
user: IUser,
oboConfig: OboConfig,
oboTokenResolver: OboTokenResolver,
): Promise<MCPOAuthTokens> {
const tokenInfo = extractOpenIDTokenInfo(user);
if (!tokenInfo || !isOpenIDTokenValid(tokenInfo)) {
logger.warn(
`[OBO] No valid OpenID token available for OBO exchange (provider: ${user.provider}, hasOpenidId: ${!!user.openidId}, hasFederatedTokens: ${!!user.federatedTokens})`,
);
throw new OboTokenResolutionError(
'missing_upstream_token',
'No valid OpenID access token is available for OBO exchange.',
);
}
if (!tokenInfo.accessToken) {
logger.warn('[OBO] OpenID token info present but access_token is missing');
throw new OboTokenResolutionError(
'missing_upstream_access_token',
'The upstream OpenID access token is missing for OBO exchange.',
);
}
try {
const response = await oboTokenResolver(user, tokenInfo.accessToken, oboConfig.scopes, true);
if (!response?.access_token) {
logger.warn('[OBO] Token exchange did not return an access token');
throw new OboTokenResolutionError(
'empty_exchange_response',
'The identity provider returned no access token for the OBO exchange.',
);
}
const now = Date.now();
const expiresIn = response.expires_in ?? 3600;
return {
access_token: response.access_token,
token_type: 'Bearer',
obtained_at: now,
expires_at: now + expiresIn * 1000,
};
} catch (error) {
if (error instanceof OboTokenResolutionError) {
throw error;
}
logger.error('[OBO] Failed to exchange token:', error);
const retryable = isRetryableOboExchangeError(error);
throw new OboTokenResolutionError(
'exchange_failed',
retryable
? 'Temporary OBO token exchange failure.'
: 'The identity provider rejected the OBO token exchange.',
retryable,
error,
);
}
}
/**
* Re-evaluates whether the original author of a DB-stored OBO config still has
* permission to configure OBO. The connection layer calls this before performing
* an OBO token exchange so that retained configs fail closed if the author's role
* is downgraded after the server was created.
*
* Returns true when the author's role grants `MCP_SERVERS.CONFIGURE_OBO`. Any of
* the following degraded states return false (fail closed):
* - missing author id
* - user lookup miss / no role
* - role lookup miss
* - role missing the CONFIGURE_OBO bit
*/
export type GetUserRoleByAuthorId = (authorId: string) => Promise<string | null | undefined>;
export type GetRolePermissions = (
roleName: string,
) => Promise<Record<string, Record<string, boolean | undefined>> | null | undefined>;
export async function isOboConfigStillTrusted({
authorId,
getUserRoleByAuthorId,
getRolePermissions,
}: {
authorId: string | undefined;
getUserRoleByAuthorId: GetUserRoleByAuthorId;
getRolePermissions: GetRolePermissions;
}): Promise<boolean> {
if (!authorId) {
return false;
}
let roleName: string | null | undefined;
try {
roleName = await getUserRoleByAuthorId(authorId);
} catch (err) {
logger.warn('[OBO] Failed to resolve author role for OBO trust check', err);
return false;
}
if (!roleName) {
return false;
}
let permissions: Record<string, Record<string, boolean | undefined>> | null | undefined;
try {
permissions = await getRolePermissions(roleName);
} catch (err) {
logger.warn('[OBO] Failed to load role permissions for OBO trust check', err);
return false;
}
return permissions?.[PermissionTypes.MCP_SERVERS]?.[Permissions.CONFIGURE_OBO] === true;
}
/**
* Async predicate injected into MCP runtime to gate OBO exchanges per server.
* Returns true when OBO is allowed for the given config, false to fail closed.
*
* The runtime passes `source`, `author`, and `dbId` so the implementation can
* use the same `isUserSourced` semantics as the rest of the MCP layer (a
* missing `source` field on a legacy cached config still falls back to
* `dbId`-presence heuristics).
*/
export type OboTrustChecker = (config: {
source?: string;
author?: string;
dbId?: string;
}) => Promise<boolean>;

View file

@ -68,7 +68,8 @@ export class MCPServerInspector {
if (
this.config.startup !== false &&
!this.config.requiresOAuth &&
!hasCustomUserVars(this.config)
!hasCustomUserVars(this.config) &&
!this.config.obo
) {
let tempConnection = false;
if (!this.connection) {

View file

@ -148,6 +148,51 @@ describe('MCPServerInspector', () => {
expect(result.toolFunctions).toBeUndefined();
});
it('should skip capabilities fetch when obo is configured', async () => {
// OBO servers mint per-user delegated tokens at tool-call time; an
// unauthenticated probe at inspection has no valid bearer to attach,
// so the upstream rejects the MCP `initialize` handshake and the
// create/update fails with MCP_INSPECTION_FAILED. Treat `obo` as
// user-scoped auth alongside requiresOAuth and customUserVars.
mockDetectOAuthRequirement.mockResolvedValue({
requiresOAuth: false,
method: 'no-metadata-found',
});
const rawConfig: t.MCPOptions = {
type: 'streamable-http',
url: 'https://mcp-server.example.com/mcp',
obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
};
const result = await MCPServerInspector.inspect('test_server', rawConfig, mockConnection);
expect(result.obo).toEqual({ scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' });
expect(result.requiresOAuth).toBe(false);
expect(MCPConnectionFactory.create).not.toHaveBeenCalled();
expect(mockConnection.disconnect).not.toHaveBeenCalled();
});
it('should NOT create a temp connection when obo is configured and no connection is provided', async () => {
mockDetectOAuthRequirement.mockResolvedValue({
requiresOAuth: false,
method: 'no-metadata-found',
});
const rawConfig: t.MCPOptions = {
type: 'streamable-http',
url: 'https://mcp-server.example.com/mcp',
obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
};
const result = await MCPServerInspector.inspect('test_server', rawConfig);
expect(MCPConnectionFactory.create).not.toHaveBeenCalled();
expect(result.requiresOAuth).toBe(false);
expect(result.capabilities).toBeUndefined();
expect(result.toolFunctions).toBeUndefined();
});
it('should keep custom serverInstructions string and not fetch from server', async () => {
const rawConfig: t.MCPOptions = {
type: 'stdio',

View file

@ -455,11 +455,16 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
private async mapDBServerToParsedConfig(
serverDBDoc: MCPServerDocument,
): Promise<ParsedServerConfig> {
const authorId =
serverDBDoc.author != null
? (serverDBDoc.author as unknown as Types.ObjectId | string).toString()
: undefined;
const config: ParsedServerConfig = {
...serverDBDoc.config,
dbId: (serverDBDoc._id as Types.ObjectId).toString(),
source: 'user',
updatedAt: serverDBDoc.updatedAt?.getTime(),
...(authorId ? { author: authorId } : {}),
};
return sanitizeUserManagedOAuthConfig(await this.decryptConfig(config));
}

View file

@ -22,6 +22,7 @@ import type { LCTool } from '@librechat/agents';
import type { FlowStateManager } from '~/flow/manager';
import type { RequestBody } from '~/types/http';
import type * as o from '~/mcp/oauth/types';
import type { OboTokenResolver, OboTrustChecker } from '~/mcp/oauth/obo';
export type StdioOptions = z.infer<typeof StdioOptionsSchema>;
export type WebSocketOptions = z.infer<typeof WebSocketOptionsSchema>;
@ -168,6 +169,12 @@ export type ParsedServerConfig = MCPOptions & {
consumeOnly?: boolean;
/** True when inspection failed at startup; the server is known but not fully initialized */
inspectionFailed?: boolean;
/**
* User-id of the creating user (DB-sourced configs only). Used at runtime to gate
* OBO token exchanges by re-checking the author's CONFIGURE_OBO permission, so a
* stored config remains safe if the author's role is downgraded.
*/
author?: string;
};
export type AddServerResult = {
@ -202,6 +209,8 @@ export interface OAuthConnectionOptions extends UserConnectionContext {
oauthStart?: (authURL: string) => Promise<void>;
oauthEnd?: () => Promise<void>;
returnOnOAuth?: boolean;
oboTokenResolver?: OboTokenResolver;
oboTrustChecker?: OboTrustChecker;
}
/** Options accepted by UserConnectionManager.getUserConnection. OAuth fields are optional. */
@ -215,6 +224,8 @@ export interface UserMCPConnectionOptions extends UserConnectionContext {
oauthStart?: (authURL: string) => Promise<void>;
oauthEnd?: () => Promise<void>;
returnOnOAuth?: boolean;
oboTokenResolver?: OboTokenResolver;
oboTrustChecker?: OboTrustChecker;
}
export interface ToolDiscoveryOptions {
@ -229,6 +240,8 @@ export interface ToolDiscoveryOptions {
connectionTimeout?: number;
/** Pre-resolved config-source servers for tenant-scoped lookup */
configServers?: Record<string, ParsedServerConfig>;
oboTokenResolver?: OboTokenResolver;
oboTrustChecker?: OboTrustChecker;
}
export interface ToolDiscoveryResult {

View file

@ -13,11 +13,38 @@ export function isOAuthServer(
return config.requiresOAuth === true || config.oauth != null;
}
/**
* Whether a server needs the OAuth-style connection wiring (flow manager,
* token methods, OBO/OAuth resolvers). Distinct from `isOAuthServer`: OBO
* servers reuse the same wiring even though they don't run an OAuth handshake,
* because the runtime needs `oboTokenResolver`/`oboTrustChecker` plumbed through.
*
* Without this, an OBO server with `requiresOAuth: false` would land in the
* non-OAuth branch of MCPManager.discoverServerTools / UserConnectionManager,
* which omits the OBO resolver `usesObo` then evaluates to false in the
* factory and the connection sends a bare request that the upstream rejects.
*/
export function requiresOAuthMachinery(
config: Pick<ParsedServerConfig, 'requiresOAuth' | 'oauth' | 'obo'>,
): boolean {
return isOAuthServer(config) || config.obo != null;
}
/** Checks that `customUserVars` is present AND non-empty (guards against truthy `{}`) */
export function hasCustomUserVars(config: Pick<ParsedServerConfig, 'customUserVars'>): boolean {
return !!config.customUserVars && Object.keys(config.customUserVars).length > 0;
}
/**
* Returns true when a server requires a per-user connection instead of an
* app-shared connection.
*/
export function requiresUserScopedConnection(
config: Pick<ParsedServerConfig, 'requiresOAuth' | 'customUserVars' | 'obo'>,
): boolean {
return config.requiresOAuth === true || config.obo != null || hasCustomUserVars(config);
}
/**
* Returns the names of `customUserVars` declared on the server config for which
* the user has not supplied a non-blank value (unset, empty, or whitespace-only
@ -94,6 +121,10 @@ export function redactServerSecrets(config: ParsedServerConfig): Partial<ParsedS
safe.oauth = safeOAuth;
}
if (config.obo) {
safe.obo = config.obo;
}
return Object.fromEntries(
Object.entries(safe).filter(([, v]) => v !== undefined),
) as Partial<ParsedServerConfig>;

View file

@ -3,8 +3,50 @@ import {
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)', () => {
@ -203,6 +245,69 @@ describe('MCP schemas', () => {
});
});
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({
@ -437,3 +542,37 @@ describe('MCP schemas', () => {
});
});
});
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);
});
});

View file

@ -901,6 +901,7 @@ const mcpServersSchema = z
create: z.boolean().optional(),
share: z.boolean().optional(),
public: z.boolean().optional(),
configureObo: z.boolean().optional(),
trustCheckbox: z
.object({
label: localizedStringSchema.optional(),

View file

@ -127,6 +127,11 @@ const UserOAuthOptionsSchema = OAuthOptionsBaseSchema.omit({
})
.superRefine(validateOAuthClientCredentials);
const OboOptionsSchema = z.object({
/** Scopes to request for the downstream MCP server (e.g., "api://<client-id>/Mcp.Tools.ReadWrite") */
scopes: z.string().min(1),
});
const BaseOptionsSchema = z.object({
/** Display name for the MCP server - only letters, numbers, and spaces allowed */
title: z
@ -224,6 +229,7 @@ const ProxyUrlSchema = z
export const StdioOptionsSchema = BaseOptionsSchema.extend({
type: z.literal('stdio').default('stdio'),
obo: z.undefined().optional(),
/**
* The executable to run to start the server.
*/
@ -264,6 +270,7 @@ export const StdioOptionsSchema = BaseOptionsSchema.extend({
export const WebSocketOptionsSchema = BaseOptionsSchema.extend({
type: z.literal('websocket').default('websocket'),
obo: z.undefined().optional(),
url: z
.string()
.transform((val: string) => extractEnvVariable(val))
@ -282,6 +289,14 @@ export const WebSocketOptionsSchema = BaseOptionsSchema.extend({
export const SSEOptionsSchema = BaseOptionsSchema.extend({
type: z.literal('sse').default('sse'),
headers: z.record(z.string(), z.string()).optional(),
/**
* On-Behalf-Of (OBO) token exchange configuration.
* When configured, LibreChat exchanges the logged-in user's federated access token
* for a token scoped to this MCP server via the OAuth 2.0 OBO flow (jwt-bearer grant).
* The exchanged token is injected as a Bearer Authorization header automatically.
* Requires the user to be authenticated via OpenID Connect (e.g., Entra ID).
*/
obo: OboOptionsSchema.optional(),
/** Optional outbound proxy URL for this remote MCP transport */
proxy: ProxyUrlSchema.optional(),
url: z
@ -302,6 +317,14 @@ export const SSEOptionsSchema = BaseOptionsSchema.extend({
export const StreamableHTTPOptionsSchema = BaseOptionsSchema.extend({
type: z.union([z.literal('streamable-http'), z.literal('http')]),
headers: z.record(z.string(), z.string()).optional(),
/**
* On-Behalf-Of (OBO) token exchange configuration.
* When configured, LibreChat exchanges the logged-in user's federated access token
* for a token scoped to this MCP server via the OAuth 2.0 OBO flow (jwt-bearer grant).
* The exchanged token is injected as a Bearer Authorization header automatically.
* Requires the user to be authenticated via OpenID Connect (e.g., Entra ID).
*/
obo: OboOptionsSchema.optional(),
/** Optional outbound proxy URL for this remote MCP transport */
proxy: ProxyUrlSchema.optional(),
url: z
@ -400,3 +423,28 @@ export const MCPServerUserInputSchema = z.union([
]);
export type MCPServerUserInput = z.infer<typeof MCPServerUserInputSchema>;
/**
* Set of every field name that may appear in a user-submitted MCP server config,
* derived from `MCPServerUserInputSchema`'s union members. Used as the comparison
* surface for the OBO lockdown check in `updateMCPServerController` so that
* server-managed fields on the existing config (`dbId`, `source`, `author`,
* `requiresOAuth`, `oauthMetadata`, etc.) don't show up as differences and
* cause spurious 403s on legitimate saves.
*
* Schema-derived rather than hand-maintained: when a new field is added to
* `BaseOptionsSchema` or any transport variant, it flows into this set
* automatically. The OBO lockdown then locks the new field by default
* (since it won't be in the hand-curated `OBO_USER_EDITABLE_FIELDS`
* allowlist), preventing a silent privilege regression.
*/
export const MCP_USER_INPUT_FIELDS: ReadonlySet<string> = (() => {
const fields = new Set<string>();
for (const variant of MCPServerUserInputSchema.options) {
const shape = (variant as unknown as { shape: Record<string, unknown> }).shape;
for (const key of Object.keys(shape)) {
fields.add(key);
}
}
return fields;
})();

View file

@ -98,13 +98,14 @@ export const INTERFACE_PERMISSION_FIELDS = new Set(Object.values(PERMISSION_TYPE
* DB overrides other sub-keys (like `placeholder`, `trustCheckbox`) are UI-only and pass through.
*
* Mapping to Permissions enum:
* 'use' Permissions.USE (agents, prompts, mcpServers, remoteAgents, marketplace)
* 'create' Permissions.CREATE (agents, prompts, mcpServers, remoteAgents)
* 'share' Permissions.SHARE (agents, prompts, mcpServers, remoteAgents)
* 'public' Permissions.SHARE_PUBLIC (agents, prompts, mcpServers, remoteAgents)
* 'users' Permissions.VIEW_USERS (peoplePicker only)
* 'groups' Permissions.VIEW_GROUPS (peoplePicker only)
* 'roles' Permissions.VIEW_ROLES (peoplePicker only)
* 'use' Permissions.USE (agents, prompts, mcpServers, remoteAgents, marketplace)
* 'create' Permissions.CREATE (agents, prompts, mcpServers, remoteAgents)
* 'share' Permissions.SHARE (agents, prompts, mcpServers, remoteAgents)
* 'public' Permissions.SHARE_PUBLIC (agents, prompts, mcpServers, remoteAgents)
* 'users' Permissions.VIEW_USERS (peoplePicker only)
* 'groups' Permissions.VIEW_GROUPS (peoplePicker only)
* 'roles' Permissions.VIEW_ROLES (peoplePicker only)
* 'configureObo' Permissions.CONFIGURE_OBO (mcpServers only)
*/
export const PERMISSION_SUB_KEYS = new Set([
'use',
@ -114,6 +115,7 @@ export const PERMISSION_SUB_KEYS = new Set([
'users',
'groups',
'roles',
'configureObo',
]);
/**
@ -133,6 +135,12 @@ export enum Permissions {
VIEW_ROLES = 'VIEW_ROLES',
/** Can share resources publicly (with everyone) */
SHARE_PUBLIC = 'SHARE_PUBLIC',
/**
* Can configure MCP server On-Behalf-Of (OBO) token exchange. Gates the
* `obo` field on MCP server configs because OBO silently mints and forwards
* per-user delegated tokens to whatever URL the server points at.
*/
CONFIGURE_OBO = 'CONFIGURE_OBO',
}
export const promptPermissionsSchema = z.object({
@ -212,6 +220,7 @@ export const mcpServersPermissionsSchema = z.object({
[Permissions.CREATE]: z.boolean().default(true),
[Permissions.SHARE]: z.boolean().default(false),
[Permissions.SHARE_PUBLIC]: z.boolean().default(false),
[Permissions.CONFIGURE_OBO]: z.boolean().default(false),
});
export type TMcpServersPermissions = z.infer<typeof mcpServersPermissionsSchema>;

View file

@ -156,4 +156,20 @@ describe('roleDefaults', () => {
});
});
});
describe('MCP_SERVERS.CONFIGURE_OBO defaults', () => {
it('grants ADMIN CONFIGURE_OBO by default', () => {
const adminMcp = roleDefaults[SystemRoles.ADMIN].permissions[
PermissionTypes.MCP_SERVERS
] as Record<string, boolean>;
expect(adminMcp[Permissions.CONFIGURE_OBO]).toBe(true);
});
it('does not grant CONFIGURE_OBO to USER by default — gates the OBO config layer', () => {
const userMcp = roleDefaults[SystemRoles.USER].permissions[
PermissionTypes.MCP_SERVERS
] as Record<string, boolean>;
expect(userMcp[Permissions.CONFIGURE_OBO]).toBe(false);
});
});
});

View file

@ -97,6 +97,7 @@ const defaultRolesSchema = z.object({
[Permissions.CREATE]: z.boolean().default(true),
[Permissions.SHARE]: z.boolean().default(true),
[Permissions.SHARE_PUBLIC]: z.boolean().default(true),
[Permissions.CONFIGURE_OBO]: z.boolean().default(true),
}),
[PermissionTypes.REMOTE_AGENTS]: remoteAgentsPermissionsSchema.extend({
[Permissions.USE]: z.boolean().default(true),
@ -185,6 +186,7 @@ export const roleDefaults = defaultRolesSchema.parse({
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
[Permissions.CONFIGURE_OBO]: true,
},
[PermissionTypes.REMOTE_AGENTS]: {
[Permissions.USE]: true,
@ -236,6 +238,7 @@ export const roleDefaults = defaultRolesSchema.parse({
[Permissions.CREATE]: false,
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
[Permissions.CONFIGURE_OBO]: false,
},
[PermissionTypes.REMOTE_AGENTS]: {
[Permissions.USE]: false,

View file

@ -60,6 +60,7 @@ const rolePermissionsSchema = new Schema(
[Permissions.CREATE]: { type: Boolean },
[Permissions.SHARE]: { type: Boolean },
[Permissions.SHARE_PUBLIC]: { type: Boolean },
[Permissions.CONFIGURE_OBO]: { type: Boolean },
},
[PermissionTypes.REMOTE_AGENTS]: {
[Permissions.USE]: { type: Boolean },