🪪 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:
Max Sanna 2026-01-19 22:35:15 +01:00 committed by Danny Avila
parent ed61b7f967
commit dd4bbd38fc
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
10 changed files with 1411 additions and 15 deletions

View file

@ -46,10 +46,10 @@ type SafeUser = Pick<IUser, AllowedUserField>;
* if (headerValue.startsWith('b64:')) {
* const decoded = Buffer.from(headerValue.slice(4), 'base64').toString('utf8');
* }
*
*
* @param value - The string value to encode
* @returns ASCII-safe string (encoded if necessary)
*
*
* @example
* encodeHeaderValue("José") // Returns "José" (é = 233, safe)
* encodeHeaderValue("Marić") // Returns "b64:TWFyacSH" (ć = 263, needs encoding)
@ -59,17 +59,17 @@ export function encodeHeaderValue(value: string): string {
if (!value || typeof value !== 'string') {
return '';
}
// Check if string contains extended Unicode characters (> 255)
// Characters 0-255 (ASCII + Latin-1) are safe and don't need encoding
// Characters > 255 (e.g., ć=263, đ=272, ł=322) need Base64 encoding
// eslint-disable-next-line no-control-regex
const hasExtendedUnicode = /[^\u0000-\u00FF]/.test(value);
if (!hasExtendedUnicode) {
return value; // Safe to pass through
}
// Encode to Base64 for extended Unicode characters
const base64 = Buffer.from(value, 'utf8').toString('base64');
return `b64:${base64}`;
@ -118,7 +118,11 @@ const ALLOWED_BODY_FIELDS = ['conversationId', 'parentMessageId', 'messageId'] a
* @param isHeader - Whether this value will be used in an HTTP header
* @returns The processed string with placeholders replaced (and encoded if necessary)
*/
function processUserPlaceholders(value: string, user?: IUser, isHeader: boolean = false): string {
function processUserPlaceholders(
value: string,
user?: Partial<IUser>,
isHeader: boolean = false,
): string {
if (!user || typeof value !== 'string') {
return value;
}
@ -208,7 +212,7 @@ function processSingleValue({
}: {
originalValue: string;
customUserVars?: Record<string, string>;
user?: IUser;
user?: Partial<IUser>;
body?: RequestBody;
isHeader?: boolean;
}): string {
@ -255,7 +259,7 @@ function processSingleValue({
*/
export function processMCPEnv(params: {
options: Readonly<MCPOptions>;
user?: IUser;
user?: Partial<IUser>;
customUserVars?: Record<string, string>;
body?: RequestBody;
}): MCPOptions {

View file

@ -0,0 +1,467 @@
import type { TUser } from 'librechat-data-provider';
import type { GraphTokenResolver, GraphTokenOptions } from './graph';
import {
containsGraphTokenPlaceholder,
recordContainsGraphTokenPlaceholder,
mcpOptionsContainGraphTokenPlaceholder,
resolveGraphTokenPlaceholder,
resolveGraphTokensInRecord,
preProcessGraphTokens,
} from './graph';
// Mock the logger
jest.mock('@librechat/data-schemas', () => ({
logger: {
warn: jest.fn(),
error: jest.fn(),
},
}));
// Mock the oidc module
jest.mock('./oidc', () => ({
GRAPH_TOKEN_PLACEHOLDER: '{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
DEFAULT_GRAPH_SCOPES: 'https://graph.microsoft.com/.default',
extractOpenIDTokenInfo: jest.fn(),
isOpenIDTokenValid: jest.fn(),
}));
import { extractOpenIDTokenInfo, isOpenIDTokenValid } from './oidc';
const mockExtractOpenIDTokenInfo = extractOpenIDTokenInfo as jest.Mock;
const mockIsOpenIDTokenValid = isOpenIDTokenValid as jest.Mock;
describe('Graph Token Utilities', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('containsGraphTokenPlaceholder', () => {
it('should return true when string contains the placeholder', () => {
const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
expect(containsGraphTokenPlaceholder(value)).toBe(true);
});
it('should return false when string does not contain the placeholder', () => {
const value = 'Bearer some-static-token';
expect(containsGraphTokenPlaceholder(value)).toBe(false);
});
it('should return false for empty string', () => {
expect(containsGraphTokenPlaceholder('')).toBe(false);
});
it('should return false for non-string values', () => {
expect(containsGraphTokenPlaceholder(123 as unknown as string)).toBe(false);
expect(containsGraphTokenPlaceholder(null as unknown as string)).toBe(false);
expect(containsGraphTokenPlaceholder(undefined as unknown as string)).toBe(false);
});
it('should detect placeholder in the middle of a string', () => {
const value = 'prefix-{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}-suffix';
expect(containsGraphTokenPlaceholder(value)).toBe(true);
});
});
describe('recordContainsGraphTokenPlaceholder', () => {
it('should return true when any value contains the placeholder', () => {
const record = {
Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
'Content-Type': 'application/json',
};
expect(recordContainsGraphTokenPlaceholder(record)).toBe(true);
});
it('should return false when no value contains the placeholder', () => {
const record = {
Authorization: 'Bearer static-token',
'Content-Type': 'application/json',
};
expect(recordContainsGraphTokenPlaceholder(record)).toBe(false);
});
it('should return false for undefined record', () => {
expect(recordContainsGraphTokenPlaceholder(undefined)).toBe(false);
});
it('should return false for null record', () => {
expect(recordContainsGraphTokenPlaceholder(null as unknown as Record<string, string>)).toBe(
false,
);
});
it('should return false for empty record', () => {
expect(recordContainsGraphTokenPlaceholder({})).toBe(false);
});
it('should return false for non-object values', () => {
expect(recordContainsGraphTokenPlaceholder('string' as unknown as Record<string, string>)).toBe(
false,
);
});
});
describe('mcpOptionsContainGraphTokenPlaceholder', () => {
it('should return true when url contains the placeholder', () => {
const options = {
url: 'https://api.example.com?token={{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
};
expect(mcpOptionsContainGraphTokenPlaceholder(options)).toBe(true);
});
it('should return true when headers contain the placeholder', () => {
const options = {
headers: {
Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
},
};
expect(mcpOptionsContainGraphTokenPlaceholder(options)).toBe(true);
});
it('should return true when env contains the placeholder', () => {
const options = {
env: {
GRAPH_TOKEN: '{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
},
};
expect(mcpOptionsContainGraphTokenPlaceholder(options)).toBe(true);
});
it('should return false when no field contains the placeholder', () => {
const options = {
url: 'https://api.example.com',
headers: { Authorization: 'Bearer static-token' },
env: { API_KEY: 'some-key' },
};
expect(mcpOptionsContainGraphTokenPlaceholder(options)).toBe(false);
});
it('should return false for empty options', () => {
expect(mcpOptionsContainGraphTokenPlaceholder({})).toBe(false);
});
});
describe('resolveGraphTokenPlaceholder', () => {
const mockUser: Partial<TUser> = {
id: 'user-123',
provider: 'openid',
openidId: 'oidc-sub-456',
};
const mockGraphTokenResolver: GraphTokenResolver = jest.fn().mockResolvedValue({
access_token: 'resolved-graph-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'https://graph.microsoft.com/.default',
});
it('should return original value when no placeholder is present', async () => {
const value = 'Bearer static-token';
const result = await resolveGraphTokenPlaceholder(value, {
user: mockUser as TUser,
graphTokenResolver: mockGraphTokenResolver,
});
expect(result).toBe('Bearer static-token');
});
it('should return original value when user is not provided', async () => {
const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
const result = await resolveGraphTokenPlaceholder(value, {
graphTokenResolver: mockGraphTokenResolver,
});
expect(result).toBe(value);
});
it('should return original value when graphTokenResolver is not provided', async () => {
const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
const result = await resolveGraphTokenPlaceholder(value, {
user: mockUser as TUser,
});
expect(result).toBe(value);
});
it('should return original value when token info is invalid', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue(null);
const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
const result = await resolveGraphTokenPlaceholder(value, {
user: mockUser as TUser,
graphTokenResolver: mockGraphTokenResolver,
});
expect(result).toBe(value);
});
it('should return original value when token is not valid', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'access-token' });
mockIsOpenIDTokenValid.mockReturnValue(false);
const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
const result = await resolveGraphTokenPlaceholder(value, {
user: mockUser as TUser,
graphTokenResolver: mockGraphTokenResolver,
});
expect(result).toBe(value);
});
it('should return original value when access token is missing', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue({ userId: 'user-123' });
mockIsOpenIDTokenValid.mockReturnValue(true);
const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
const result = await resolveGraphTokenPlaceholder(value, {
user: mockUser as TUser,
graphTokenResolver: mockGraphTokenResolver,
});
expect(result).toBe(value);
});
it('should resolve placeholder with graph token', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'access-token' });
mockIsOpenIDTokenValid.mockReturnValue(true);
const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
const result = await resolveGraphTokenPlaceholder(value, {
user: mockUser as TUser,
graphTokenResolver: mockGraphTokenResolver,
});
expect(result).toBe('Bearer resolved-graph-token');
});
it('should resolve multiple placeholders in a string', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'access-token' });
mockIsOpenIDTokenValid.mockReturnValue(true);
const value =
'Primary: {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}, Secondary: {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
const result = await resolveGraphTokenPlaceholder(value, {
user: mockUser as TUser,
graphTokenResolver: mockGraphTokenResolver,
});
expect(result).toBe('Primary: resolved-graph-token, Secondary: resolved-graph-token');
});
it('should return original value when graph token exchange fails', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'access-token' });
mockIsOpenIDTokenValid.mockReturnValue(true);
const failingResolver: GraphTokenResolver = jest.fn().mockRejectedValue(new Error('Exchange failed'));
const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
const result = await resolveGraphTokenPlaceholder(value, {
user: mockUser as TUser,
graphTokenResolver: failingResolver,
});
expect(result).toBe(value);
});
it('should return original value when graph token response has no access_token', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'access-token' });
mockIsOpenIDTokenValid.mockReturnValue(true);
const emptyResolver: GraphTokenResolver = jest.fn().mockResolvedValue({});
const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
const result = await resolveGraphTokenPlaceholder(value, {
user: mockUser as TUser,
graphTokenResolver: emptyResolver,
});
expect(result).toBe(value);
});
it('should use provided scopes', async () => {
mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'access-token' });
mockIsOpenIDTokenValid.mockReturnValue(true);
const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
await resolveGraphTokenPlaceholder(value, {
user: mockUser as TUser,
graphTokenResolver: mockGraphTokenResolver,
scopes: 'custom-scope',
});
expect(mockGraphTokenResolver).toHaveBeenCalledWith(
mockUser,
'access-token',
'custom-scope',
true,
);
});
});
describe('resolveGraphTokensInRecord', () => {
const mockUser: Partial<TUser> = {
id: 'user-123',
provider: 'openid',
};
const mockGraphTokenResolver: GraphTokenResolver = jest.fn().mockResolvedValue({
access_token: 'resolved-graph-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'https://graph.microsoft.com/.default',
});
const options: GraphTokenOptions = {
user: mockUser as TUser,
graphTokenResolver: mockGraphTokenResolver,
};
beforeEach(() => {
mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'access-token' });
mockIsOpenIDTokenValid.mockReturnValue(true);
});
it('should return undefined for undefined record', async () => {
const result = await resolveGraphTokensInRecord(undefined, options);
expect(result).toBeUndefined();
});
it('should return record unchanged when no placeholders present', async () => {
const record = {
Authorization: 'Bearer static-token',
'Content-Type': 'application/json',
};
const result = await resolveGraphTokensInRecord(record, options);
expect(result).toEqual(record);
});
it('should resolve placeholders in record values', async () => {
const record = {
Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
'Content-Type': 'application/json',
};
const result = await resolveGraphTokensInRecord(record, options);
expect(result).toEqual({
Authorization: 'Bearer resolved-graph-token',
'Content-Type': 'application/json',
});
});
it('should handle non-string values gracefully', async () => {
const record = {
Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
numericValue: 123 as unknown as string,
};
const result = await resolveGraphTokensInRecord(record, options);
expect(result).toEqual({
Authorization: 'Bearer resolved-graph-token',
numericValue: 123,
});
});
});
describe('preProcessGraphTokens', () => {
const mockUser: Partial<TUser> = {
id: 'user-123',
provider: 'openid',
};
const mockGraphTokenResolver: GraphTokenResolver = jest.fn().mockResolvedValue({
access_token: 'resolved-graph-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'https://graph.microsoft.com/.default',
});
const graphOptions: GraphTokenOptions = {
user: mockUser as TUser,
graphTokenResolver: mockGraphTokenResolver,
};
beforeEach(() => {
mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'access-token' });
mockIsOpenIDTokenValid.mockReturnValue(true);
});
it('should return options unchanged when no placeholders present', async () => {
const options = {
url: 'https://api.example.com',
headers: { Authorization: 'Bearer static-token' },
env: { API_KEY: 'some-key' },
};
const result = await preProcessGraphTokens(options, graphOptions);
expect(result).toEqual(options);
});
it('should resolve placeholder in url', async () => {
const options = {
url: 'https://api.example.com?token={{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
};
const result = await preProcessGraphTokens(options, graphOptions);
expect(result.url).toBe('https://api.example.com?token=resolved-graph-token');
});
it('should resolve placeholder in headers', async () => {
const options = {
headers: {
Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
'Content-Type': 'application/json',
},
};
const result = await preProcessGraphTokens(options, graphOptions);
expect(result.headers).toEqual({
Authorization: 'Bearer resolved-graph-token',
'Content-Type': 'application/json',
});
});
it('should resolve placeholder in env', async () => {
const options = {
env: {
GRAPH_TOKEN: '{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
OTHER_VAR: 'static-value',
},
};
const result = await preProcessGraphTokens(options, graphOptions);
expect(result.env).toEqual({
GRAPH_TOKEN: 'resolved-graph-token',
OTHER_VAR: 'static-value',
});
});
it('should resolve placeholders in all fields simultaneously', async () => {
const options = {
url: 'https://api.example.com?token={{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
headers: {
Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
},
env: {
GRAPH_TOKEN: '{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
},
};
const result = await preProcessGraphTokens(options, graphOptions);
expect(result.url).toBe('https://api.example.com?token=resolved-graph-token');
expect(result.headers).toEqual({
Authorization: 'Bearer resolved-graph-token',
});
expect(result.env).toEqual({
GRAPH_TOKEN: 'resolved-graph-token',
});
});
it('should not mutate the original options object', async () => {
const options = {
url: 'https://api.example.com?token={{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
headers: {
Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
},
};
const originalUrl = options.url;
const originalAuth = options.headers.Authorization;
await preProcessGraphTokens(options, graphOptions);
expect(options.url).toBe(originalUrl);
expect(options.headers.Authorization).toBe(originalAuth);
});
it('should preserve additional properties in generic type', async () => {
const options = {
url: 'https://api.example.com?token={{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
customProperty: 'custom-value',
anotherProperty: 42,
};
const result = await preProcessGraphTokens(options, graphOptions);
expect(result.customProperty).toBe('custom-value');
expect(result.anotherProperty).toBe(42);
expect(result.url).toBe('https://api.example.com?token=resolved-graph-token');
});
});
});

View 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;
}

View file

@ -7,11 +7,13 @@ export * from './env';
export * from './events';
export * from './files';
export * from './generators';
export * from './graph';
export * from './path';
export * from './key';
export * from './latex';
export * from './llm';
export * from './math';
export * from './oidc';
export * from './openid';
export * from './promise';
export * from './sanitizeTitle';

View file

@ -34,7 +34,22 @@ const OPENID_TOKEN_FIELDS = [
'EXPIRES_AT',
] as const;
export function extractOpenIDTokenInfo(user: IUser | null | undefined): OpenIDTokenInfo | null {
/**
* Placeholder for Microsoft Graph API access token.
* This placeholder is resolved asynchronously via OBO (On-Behalf-Of) flow
* and requires special handling outside the synchronous processMCPEnv pipeline.
*/
export const GRAPH_TOKEN_PLACEHOLDER = '{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}';
/**
* Default Microsoft Graph API scopes for OBO token exchange.
* Can be overridden via GRAPH_API_SCOPES environment variable.
*/
export const DEFAULT_GRAPH_SCOPES = 'https://graph.microsoft.com/.default';
export function extractOpenIDTokenInfo(
user: Partial<IUser> | null | undefined,
): OpenIDTokenInfo | null {
if (!user) {
return null;
}