mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 12:44:28 +00:00
🪪 feat: Microsoft Graph Access Token Placeholder for MCP Servers (#10867)
* feat: MCP Graph Token env var * Addressing copilot remarks * Addressed Copilot review remarks * Fixed graphtokenservice mock in MCP test suite * fix: remove unnecessary type check and cast in resolveGraphTokensInRecord * ci: add Graph Token integration tests in MCPManager * refactor: update user type definitions to use Partial<IUser> in multiple functions * test: enhance MCP tests for graph token processing and user placeholder resolution - Added comprehensive tests to validate the interaction between preProcessGraphTokens and processMCPEnv. - Ensured correct resolution of graph tokens and user placeholders in various configurations. - Mocked OIDC utilities to facilitate testing of token extraction and validation. - Verified that original options remain unchanged after processing. * chore: import order * chore: imports --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
ed61b7f967
commit
dd4bbd38fc
10 changed files with 1411 additions and 15 deletions
215
packages/api/src/utils/graph.ts
Normal file
215
packages/api/src/utils/graph.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
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',
|
||||
);
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if MCP options contain the Graph token placeholder in headers, env, or url.
|
||||
* @param options - The MCP options object
|
||||
* @returns True if any field contains the placeholder
|
||||
*/
|
||||
export function mcpOptionsContainGraphTokenPlaceholder(options: {
|
||||
headers?: Record<string, string>;
|
||||
env?: Record<string, string>;
|
||||
url?: string;
|
||||
}): boolean {
|
||||
if (options.url && containsGraphTokenPlaceholder(options.url)) {
|
||||
return true;
|
||||
}
|
||||
if (recordContainsGraphTokenPlaceholder(options.headers)) {
|
||||
return true;
|
||||
}
|
||||
if (recordContainsGraphTokenPlaceholder(options.env)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
headers?: Record<string, string>;
|
||||
env?: 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.headers) {
|
||||
result.headers = await resolveGraphTokensInRecord(result.headers, graphOptions);
|
||||
}
|
||||
|
||||
if (result.env) {
|
||||
result.env = await resolveGraphTokensInRecord(result.env, graphOptions);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue