mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-01 03:27:01 +00:00
* fix: Resolve MCP Runtime User Placeholders * fix: Harden MCP Runtime Placeholder Connections * fix: Update MCP Source Tag Test Expectations * fix: Complete MCP Runtime Placeholder Reinit * fix: Harden MCP Request Scoped Runtime Configs * fix: Align MCP OAuth Tests With Domain Policy * fix: Harden MCP Runtime Resolution Edges * fix: Avoid MCP Runtime Reprocessing Pitfalls * fix: Reuse MCP Request Scoped Tool Discovery * fix: Validate MCP Body Runtime Fields * 🛡️ refactor: Harden runtime placeholder edges from review - Warn at inspection when a trusted server URL contains runtime placeholders but no domain allowlist restricts the resolved target - Document the three resolution sites that must stay in sync so the validated config always matches the connected one - Note the per-call connect cost of ephemeral GRAPH/BODY connections - Drop the no-op removeUserConnection in callTool's ephemeral cleanup; ephemeral connections are never stored, and removing the entry could orphan a still-connected cached connection after a config change * 🪪 fix: Cover oauth_headers, Graph URL gating, and request-scoped reconnects Address Codex review: - Resolve runtime placeholders in oauth_headers (processMCPEnv + Graph pre-pass) and include the field in placeholder detection, so OAuth discovery/token requests no longer send literals; consolidate the detection field lists into one helper - Defer the early domain gate when the URL still carries a Graph placeholder (resolved async later); the authoritative assertResolvedRuntimeConfigAllowed check still enforces policy - Bypass the 10s reconnect throttle for request-scoped servers, which re-fetch tool definitions on every message by design
295 lines
8.7 KiB
TypeScript
295 lines
8.7 KiB
TypeScript
import { logger } from '@librechat/data-schemas';
|
|
import type { IUser } from '@librechat/data-schemas';
|
|
import {
|
|
GRAPH_TOKEN_PLACEHOLDER,
|
|
DEFAULT_GRAPH_SCOPES,
|
|
extractOpenIDTokenInfo,
|
|
isOpenIDTokenValid,
|
|
} from './oidc';
|
|
|
|
/**
|
|
* Pre-computed regex for matching the Graph token placeholder.
|
|
* Escapes curly braces in the placeholder string for safe regex use.
|
|
*/
|
|
const GRAPH_TOKEN_REGEX = new RegExp(GRAPH_TOKEN_PLACEHOLDER.replace(/[{}]/g, '\\$&'), 'g');
|
|
|
|
type GraphTokenResolvable =
|
|
| string
|
|
| string[]
|
|
| boolean
|
|
| number
|
|
| null
|
|
| undefined
|
|
| Record<string, string | string[] | boolean | number | null | undefined>;
|
|
|
|
/**
|
|
* Response from a Graph API token exchange.
|
|
*/
|
|
export interface GraphTokenResponse {
|
|
access_token: string;
|
|
token_type: string;
|
|
expires_in: number;
|
|
scope: string;
|
|
}
|
|
|
|
/**
|
|
* Function type for resolving Graph API tokens via OBO flow.
|
|
* This function is injected from the main API layer since it requires
|
|
* access to OpenID configuration and caching services.
|
|
*/
|
|
export type GraphTokenResolver = (
|
|
user: IUser,
|
|
accessToken: string,
|
|
scopes: string,
|
|
fromCache?: boolean,
|
|
) => Promise<GraphTokenResponse>;
|
|
|
|
/**
|
|
* Options for processing Graph token placeholders.
|
|
*/
|
|
export interface GraphTokenOptions {
|
|
user?: IUser;
|
|
graphTokenResolver?: GraphTokenResolver;
|
|
scopes?: string;
|
|
}
|
|
|
|
/**
|
|
* Checks if a string contains the Graph token placeholder.
|
|
* @param value - The string to check
|
|
* @returns True if the placeholder is present
|
|
*/
|
|
export function containsGraphTokenPlaceholder(value: string): boolean {
|
|
return typeof value === 'string' && value.includes(GRAPH_TOKEN_PLACEHOLDER);
|
|
}
|
|
|
|
/**
|
|
* Checks if any value in a record contains the Graph token placeholder.
|
|
* @param record - The record to check (e.g., headers, env vars)
|
|
* @returns True if any value contains the placeholder
|
|
*/
|
|
export function recordContainsGraphTokenPlaceholder(
|
|
record: Record<string, string> | undefined,
|
|
): boolean {
|
|
if (!record || typeof record !== 'object') {
|
|
return false;
|
|
}
|
|
return Object.values(record).some(containsGraphTokenPlaceholder);
|
|
}
|
|
|
|
function valueContainsGraphTokenPlaceholder(value: GraphTokenResolvable): boolean {
|
|
if (typeof value === 'string') {
|
|
return containsGraphTokenPlaceholder(value);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.some(containsGraphTokenPlaceholder);
|
|
}
|
|
if (value == null || typeof value !== 'object') {
|
|
return false;
|
|
}
|
|
return Object.values(value).some(valueContainsGraphTokenPlaceholder);
|
|
}
|
|
|
|
/**
|
|
* Checks if MCP options contain the Graph token placeholder in connection fields.
|
|
* @param options - The MCP options object
|
|
* @returns True if any field contains the placeholder
|
|
*/
|
|
export function mcpOptionsContainGraphTokenPlaceholder(options: {
|
|
args?: string[];
|
|
headers?: Record<string, string>;
|
|
env?: Record<string, string>;
|
|
oauth?: Record<string, string | string[] | boolean | number | null | undefined>;
|
|
oauth_headers?: Record<string, string>;
|
|
url?: string;
|
|
}): boolean {
|
|
if (options.url && containsGraphTokenPlaceholder(options.url)) {
|
|
return true;
|
|
}
|
|
if (options.args?.some(containsGraphTokenPlaceholder)) {
|
|
return true;
|
|
}
|
|
if (recordContainsGraphTokenPlaceholder(options.headers)) {
|
|
return true;
|
|
}
|
|
if (recordContainsGraphTokenPlaceholder(options.env)) {
|
|
return true;
|
|
}
|
|
if (recordContainsGraphTokenPlaceholder(options.oauth_headers)) {
|
|
return true;
|
|
}
|
|
return valueContainsGraphTokenPlaceholder(options.oauth);
|
|
}
|
|
|
|
/**
|
|
* Asynchronously resolves Graph token placeholders in a string.
|
|
* This function must be called before the synchronous processMCPEnv pipeline.
|
|
*
|
|
* @param value - The string containing the placeholder
|
|
* @param options - Options including user and graph token resolver
|
|
* @returns The string with Graph token placeholder replaced
|
|
*/
|
|
export async function resolveGraphTokenPlaceholder(
|
|
value: string,
|
|
options: GraphTokenOptions,
|
|
): Promise<string> {
|
|
if (!containsGraphTokenPlaceholder(value)) {
|
|
return value;
|
|
}
|
|
|
|
const { user, graphTokenResolver, scopes } = options;
|
|
|
|
if (!user || !graphTokenResolver) {
|
|
logger.warn(
|
|
'[resolveGraphTokenPlaceholder] User or graphTokenResolver not provided, cannot resolve Graph token',
|
|
);
|
|
return value;
|
|
}
|
|
|
|
const tokenInfo = extractOpenIDTokenInfo(user);
|
|
if (!tokenInfo || !isOpenIDTokenValid(tokenInfo)) {
|
|
logger.warn(
|
|
'[resolveGraphTokenPlaceholder] No valid OpenID token available for Graph token exchange',
|
|
);
|
|
return value;
|
|
}
|
|
|
|
if (!tokenInfo.accessToken) {
|
|
logger.warn('[resolveGraphTokenPlaceholder] No access token available for OBO exchange');
|
|
return value;
|
|
}
|
|
|
|
try {
|
|
const graphScopes = scopes || process.env.GRAPH_API_SCOPES || DEFAULT_GRAPH_SCOPES;
|
|
const graphTokenResponse = await graphTokenResolver(
|
|
user,
|
|
tokenInfo.accessToken,
|
|
graphScopes,
|
|
true, // Use cache
|
|
);
|
|
|
|
if (graphTokenResponse?.access_token) {
|
|
return value.replace(GRAPH_TOKEN_REGEX, graphTokenResponse.access_token);
|
|
}
|
|
|
|
logger.warn(
|
|
'[resolveGraphTokenPlaceholder] Graph token exchange did not return an access token',
|
|
);
|
|
return value;
|
|
} catch (error) {
|
|
logger.error('[resolveGraphTokenPlaceholder] Failed to exchange token for Graph API:', error);
|
|
return value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Asynchronously resolves Graph token placeholders in a record of string values.
|
|
*
|
|
* @param record - The record containing placeholders (e.g., headers)
|
|
* @param options - Options including user and graph token resolver
|
|
* @returns The record with Graph token placeholders replaced
|
|
*/
|
|
export async function resolveGraphTokensInRecord(
|
|
record: Record<string, string> | undefined,
|
|
options: GraphTokenOptions,
|
|
): Promise<Record<string, string> | undefined> {
|
|
if (!record || typeof record !== 'object') {
|
|
return record;
|
|
}
|
|
|
|
if (!recordContainsGraphTokenPlaceholder(record)) {
|
|
return record;
|
|
}
|
|
|
|
const resolved: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(record)) {
|
|
resolved[key] = await resolveGraphTokenPlaceholder(value, options);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
async function resolveGraphTokensInArray(
|
|
values: string[] | undefined,
|
|
options: GraphTokenOptions,
|
|
): Promise<string[] | undefined> {
|
|
if (!values || !values.some(containsGraphTokenPlaceholder)) {
|
|
return values;
|
|
}
|
|
|
|
const resolved: string[] = [];
|
|
for (const value of values) {
|
|
resolved.push(await resolveGraphTokenPlaceholder(value, options));
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
async function resolveGraphTokensInOAuth(
|
|
oauth: Record<string, string | string[] | boolean | number | null | undefined> | undefined,
|
|
options: GraphTokenOptions,
|
|
): Promise<Record<string, string | string[] | boolean | number | null | undefined> | undefined> {
|
|
if (!oauth || !valueContainsGraphTokenPlaceholder(oauth)) {
|
|
return oauth;
|
|
}
|
|
|
|
const resolved: Record<string, string | string[] | boolean | number | null | undefined> = {};
|
|
for (const [key, value] of Object.entries(oauth)) {
|
|
if (typeof value === 'string') {
|
|
resolved[key] = await resolveGraphTokenPlaceholder(value, options);
|
|
} else if (Array.isArray(value)) {
|
|
resolved[key] = await resolveGraphTokensInArray(value, options);
|
|
} else {
|
|
resolved[key] = value;
|
|
}
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
/**
|
|
* Pre-processes MCP options to resolve Graph token placeholders.
|
|
* This must be called before processMCPEnv since Graph token resolution is async.
|
|
*
|
|
* @param options - The MCP options object
|
|
* @param graphOptions - Options for Graph token resolution
|
|
* @returns The options with Graph token placeholders resolved
|
|
*/
|
|
export async function preProcessGraphTokens<
|
|
T extends {
|
|
args?: string[];
|
|
headers?: Record<string, string>;
|
|
env?: Record<string, string>;
|
|
oauth?: Record<string, string | string[] | boolean | number | null | undefined>;
|
|
oauth_headers?: Record<string, string>;
|
|
url?: string;
|
|
},
|
|
>(options: T, graphOptions: GraphTokenOptions): Promise<T> {
|
|
if (!mcpOptionsContainGraphTokenPlaceholder(options)) {
|
|
return options;
|
|
}
|
|
|
|
const result = { ...options };
|
|
|
|
if (result.url && containsGraphTokenPlaceholder(result.url)) {
|
|
result.url = await resolveGraphTokenPlaceholder(result.url, graphOptions);
|
|
}
|
|
|
|
if (result.args) {
|
|
result.args = await resolveGraphTokensInArray(result.args, graphOptions);
|
|
}
|
|
|
|
if (result.headers) {
|
|
result.headers = await resolveGraphTokensInRecord(result.headers, graphOptions);
|
|
}
|
|
|
|
if (result.env) {
|
|
result.env = await resolveGraphTokensInRecord(result.env, graphOptions);
|
|
}
|
|
|
|
if (result.oauth_headers) {
|
|
result.oauth_headers = await resolveGraphTokensInRecord(result.oauth_headers, graphOptions);
|
|
}
|
|
|
|
if (result.oauth) {
|
|
result.oauth = await resolveGraphTokensInOAuth(result.oauth, graphOptions);
|
|
}
|
|
|
|
return result;
|
|
}
|