mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-29 22:05:18 +01: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
|
|
@ -29,6 +29,7 @@ const {
|
||||||
getMCPManager,
|
getMCPManager,
|
||||||
} = require('~/config');
|
} = require('~/config');
|
||||||
const { findToken, createToken, updateToken } = require('~/models');
|
const { findToken, createToken, updateToken } = require('~/models');
|
||||||
|
const { getGraphApiToken } = require('./GraphTokenService');
|
||||||
const { reinitMCPServer } = require('./Tools/mcp');
|
const { reinitMCPServer } = require('./Tools/mcp');
|
||||||
const { getAppConfig } = require('./Config');
|
const { getAppConfig } = require('./Config');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
@ -501,6 +502,7 @@ function createToolInstance({
|
||||||
},
|
},
|
||||||
oauthStart,
|
oauthStart,
|
||||||
oauthEnd,
|
oauthEnd,
|
||||||
|
graphTokenResolver: getGraphApiToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isAssistantsEndpoint(provider) && Array.isArray(result)) {
|
if (isAssistantsEndpoint(provider) && Array.isArray(result)) {
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,10 @@ jest.mock('./Tools/mcp', () => ({
|
||||||
reinitMCPServer: jest.fn(),
|
reinitMCPServer: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock('./GraphTokenService', () => ({
|
||||||
|
getGraphApiToken: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('tests for the new helper functions used by the MCP connection status endpoints', () => {
|
describe('tests for the new helper functions used by the MCP connection status endpoints', () => {
|
||||||
let mockGetMCPManager;
|
let mockGetMCPManager;
|
||||||
let mockGetFlowStateManager;
|
let mockGetFlowStateManager;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { logger } from '@librechat/data-schemas';
|
||||||
import { CallToolResultSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
import { CallToolResultSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
||||||
import type { TokenMethods, IUser } from '@librechat/data-schemas';
|
import type { TokenMethods, IUser } from '@librechat/data-schemas';
|
||||||
|
import type { GraphTokenResolver } from '~/utils/graph';
|
||||||
import type { FlowStateManager } from '~/flow/manager';
|
import type { FlowStateManager } from '~/flow/manager';
|
||||||
import type { MCPOAuthTokens } from './oauth';
|
import type { MCPOAuthTokens } from './oauth';
|
||||||
import type { RequestBody } from '~/types';
|
import type { RequestBody } from '~/types';
|
||||||
|
|
@ -12,6 +13,7 @@ import { ConnectionsRepository } from './ConnectionsRepository';
|
||||||
import { MCPServerInspector } from './registry/MCPServerInspector';
|
import { MCPServerInspector } from './registry/MCPServerInspector';
|
||||||
import { MCPServersInitializer } from './registry/MCPServersInitializer';
|
import { MCPServersInitializer } from './registry/MCPServersInitializer';
|
||||||
import { MCPServersRegistry } from './registry/MCPServersRegistry';
|
import { MCPServersRegistry } from './registry/MCPServersRegistry';
|
||||||
|
import { preProcessGraphTokens } from '~/utils/graph';
|
||||||
import { formatToolContent } from './parsers';
|
import { formatToolContent } from './parsers';
|
||||||
import { MCPConnection } from './connection';
|
import { MCPConnection } from './connection';
|
||||||
import { processMCPEnv } from '~/utils/env';
|
import { processMCPEnv } from '~/utils/env';
|
||||||
|
|
@ -160,6 +162,10 @@ Please follow these instructions when using tools from the respective MCP server
|
||||||
* Calls a tool on an MCP server, using either a user-specific connection
|
* Calls a tool on an MCP server, using either a user-specific connection
|
||||||
* (if userId is provided) or an app-level connection. Updates the last activity timestamp
|
* (if userId is provided) or an app-level connection. Updates the last activity timestamp
|
||||||
* for user-specific connections upon successful call initiation.
|
* for user-specific connections upon successful call initiation.
|
||||||
|
*
|
||||||
|
* @param graphTokenResolver - Optional function to resolve Graph API tokens via OBO flow.
|
||||||
|
* When provided and the server config contains `{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}` placeholders,
|
||||||
|
* they will be resolved to actual Graph API tokens before the tool call.
|
||||||
*/
|
*/
|
||||||
async callTool({
|
async callTool({
|
||||||
user,
|
user,
|
||||||
|
|
@ -174,6 +180,7 @@ Please follow these instructions when using tools from the respective MCP server
|
||||||
oauthStart,
|
oauthStart,
|
||||||
oauthEnd,
|
oauthEnd,
|
||||||
customUserVars,
|
customUserVars,
|
||||||
|
graphTokenResolver,
|
||||||
}: {
|
}: {
|
||||||
user?: IUser;
|
user?: IUser;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
|
|
@ -187,6 +194,7 @@ Please follow these instructions when using tools from the respective MCP server
|
||||||
flowManager: FlowStateManager<MCPOAuthTokens | null>;
|
flowManager: FlowStateManager<MCPOAuthTokens | null>;
|
||||||
oauthStart?: (authURL: string) => Promise<void>;
|
oauthStart?: (authURL: string) => Promise<void>;
|
||||||
oauthEnd?: () => Promise<void>;
|
oauthEnd?: () => Promise<void>;
|
||||||
|
graphTokenResolver?: GraphTokenResolver;
|
||||||
}): Promise<t.FormattedToolResponse> {
|
}): Promise<t.FormattedToolResponse> {
|
||||||
/** User-specific connection */
|
/** User-specific connection */
|
||||||
let connection: MCPConnection | undefined;
|
let connection: MCPConnection | undefined;
|
||||||
|
|
@ -220,9 +228,16 @@ Please follow these instructions when using tools from the respective MCP server
|
||||||
serverName,
|
serverName,
|
||||||
userId,
|
userId,
|
||||||
)) as t.MCPOptions;
|
)) as t.MCPOptions;
|
||||||
|
|
||||||
|
// Pre-process Graph token placeholders (async) before sync processMCPEnv
|
||||||
|
const graphProcessedConfig = await preProcessGraphTokens(rawConfig, {
|
||||||
|
user,
|
||||||
|
graphTokenResolver,
|
||||||
|
scopes: process.env.GRAPH_API_SCOPES,
|
||||||
|
});
|
||||||
const currentOptions = processMCPEnv({
|
const currentOptions = processMCPEnv({
|
||||||
user,
|
user,
|
||||||
options: rawConfig,
|
options: graphProcessedConfig,
|
||||||
customUserVars: customUserVars,
|
customUserVars: customUserVars,
|
||||||
body: requestBody,
|
body: requestBody,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { logger } from '@librechat/data-schemas';
|
import { logger } from '@librechat/data-schemas';
|
||||||
|
import type { IUser } from '@librechat/data-schemas';
|
||||||
|
import type { GraphTokenResolver } from '~/utils/graph';
|
||||||
import type * as t from '~/mcp/types';
|
import type * as t from '~/mcp/types';
|
||||||
import { MCPManager } from '~/mcp/MCPManager';
|
|
||||||
import { MCPServersInitializer } from '~/mcp/registry/MCPServersInitializer';
|
import { MCPServersInitializer } from '~/mcp/registry/MCPServersInitializer';
|
||||||
import { MCPServerInspector } from '~/mcp/registry/MCPServerInspector';
|
import { MCPServerInspector } from '~/mcp/registry/MCPServerInspector';
|
||||||
import { ConnectionsRepository } from '~/mcp/ConnectionsRepository';
|
import { ConnectionsRepository } from '~/mcp/ConnectionsRepository';
|
||||||
import { MCPConnection } from '../connection';
|
import { MCPConnection } from '~/mcp/connection';
|
||||||
|
import { MCPManager } from '~/mcp/MCPManager';
|
||||||
|
import * as graphUtils from '~/utils/graph';
|
||||||
|
|
||||||
// Mock external dependencies
|
// Mock external dependencies
|
||||||
jest.mock('@librechat/data-schemas', () => ({
|
jest.mock('@librechat/data-schemas', () => ({
|
||||||
|
|
@ -16,6 +19,15 @@ jest.mock('@librechat/data-schemas', () => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/utils/graph', () => ({
|
||||||
|
...jest.requireActual('~/utils/graph'),
|
||||||
|
preProcessGraphTokens: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/utils/env', () => ({
|
||||||
|
processMCPEnv: jest.fn((params) => params.options),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockRegistryInstance = {
|
const mockRegistryInstance = {
|
||||||
getServerConfig: jest.fn(),
|
getServerConfig: jest.fn(),
|
||||||
getAllServerConfigs: jest.fn(),
|
getAllServerConfigs: jest.fn(),
|
||||||
|
|
@ -389,4 +401,390 @@ describe('MCPManager', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('callTool - Graph Token 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 mockGraphTokenResolver: GraphTokenResolver = jest.fn().mockResolvedValue({
|
||||||
|
access_token: 'resolved-graph-token',
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: 3600,
|
||||||
|
scope: 'https://graph.microsoft.com/.default',
|
||||||
|
});
|
||||||
|
|
||||||
|
function createServerConfigWithGraphPlaceholder(): t.SSEOptions {
|
||||||
|
return {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Mock preProcessGraphTokens to simulate token resolution
|
||||||
|
(graphUtils.preProcessGraphTokens as jest.Mock).mockImplementation(
|
||||||
|
async (options, graphOptions) => {
|
||||||
|
if (
|
||||||
|
options.headers?.Authorization?.includes('{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}') &&
|
||||||
|
graphOptions.graphTokenResolver
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
Authorization: 'Bearer resolved-graph-token',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call preProcessGraphTokens with graphTokenResolver when provided', async () => {
|
||||||
|
const serverConfig = createServerConfigWithGraphPlaceholder();
|
||||||
|
|
||||||
|
mockAppConnections({
|
||||||
|
get: jest.fn().mockResolvedValue(mockConnection),
|
||||||
|
});
|
||||||
|
|
||||||
|
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
|
||||||
|
|
||||||
|
const manager = await MCPManager.createInstance(newMCPServersConfig());
|
||||||
|
|
||||||
|
await manager.callTool({
|
||||||
|
user: mockUser as IUser,
|
||||||
|
serverName,
|
||||||
|
toolName: 'test_tool',
|
||||||
|
provider: 'openai',
|
||||||
|
flowManager: mockFlowManager as unknown as Parameters<
|
||||||
|
typeof manager.callTool
|
||||||
|
>[0]['flowManager'],
|
||||||
|
graphTokenResolver: mockGraphTokenResolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graphUtils.preProcessGraphTokens).toHaveBeenCalledWith(
|
||||||
|
serverConfig,
|
||||||
|
expect.objectContaining({
|
||||||
|
user: mockUser,
|
||||||
|
graphTokenResolver: mockGraphTokenResolver,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve graph token placeholders in headers before tool call', async () => {
|
||||||
|
const serverConfig = createServerConfigWithGraphPlaceholder();
|
||||||
|
|
||||||
|
mockAppConnections({
|
||||||
|
get: jest.fn().mockResolvedValue(mockConnection),
|
||||||
|
});
|
||||||
|
|
||||||
|
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
|
||||||
|
|
||||||
|
const manager = await MCPManager.createInstance(newMCPServersConfig());
|
||||||
|
|
||||||
|
await manager.callTool({
|
||||||
|
user: mockUser as IUser,
|
||||||
|
serverName,
|
||||||
|
toolName: 'test_tool',
|
||||||
|
provider: 'openai',
|
||||||
|
flowManager: mockFlowManager as unknown as Parameters<
|
||||||
|
typeof manager.callTool
|
||||||
|
>[0]['flowManager'],
|
||||||
|
graphTokenResolver: mockGraphTokenResolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the connection received the resolved headers
|
||||||
|
expect(mockConnection.setRequestHeaders).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
Authorization: 'Bearer resolved-graph-token',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass options unchanged when no graphTokenResolver is provided', async () => {
|
||||||
|
const serverConfig: t.SSEOptions = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer static-token',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset mock to return options unchanged
|
||||||
|
(graphUtils.preProcessGraphTokens as jest.Mock).mockImplementation(
|
||||||
|
async (options) => options,
|
||||||
|
);
|
||||||
|
|
||||||
|
mockAppConnections({
|
||||||
|
get: jest.fn().mockResolvedValue(mockConnection),
|
||||||
|
});
|
||||||
|
|
||||||
|
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
|
||||||
|
|
||||||
|
const manager = await MCPManager.createInstance(newMCPServersConfig());
|
||||||
|
|
||||||
|
await manager.callTool({
|
||||||
|
user: mockUser as IUser,
|
||||||
|
serverName,
|
||||||
|
toolName: 'test_tool',
|
||||||
|
provider: 'openai',
|
||||||
|
flowManager: mockFlowManager as unknown as Parameters<
|
||||||
|
typeof manager.callTool
|
||||||
|
>[0]['flowManager'],
|
||||||
|
// No graphTokenResolver provided
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify preProcessGraphTokens was still called (to check for placeholders)
|
||||||
|
expect(graphUtils.preProcessGraphTokens).toHaveBeenCalledWith(
|
||||||
|
serverConfig,
|
||||||
|
expect.objectContaining({
|
||||||
|
user: mockUser,
|
||||||
|
graphTokenResolver: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle graph token resolution failure gracefully', async () => {
|
||||||
|
const serverConfig = createServerConfigWithGraphPlaceholder();
|
||||||
|
|
||||||
|
// Simulate resolution failure - returns original value unchanged
|
||||||
|
(graphUtils.preProcessGraphTokens as jest.Mock).mockImplementation(
|
||||||
|
async (options) => options,
|
||||||
|
);
|
||||||
|
|
||||||
|
mockAppConnections({
|
||||||
|
get: jest.fn().mockResolvedValue(mockConnection),
|
||||||
|
});
|
||||||
|
|
||||||
|
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
|
||||||
|
|
||||||
|
const manager = await MCPManager.createInstance(newMCPServersConfig());
|
||||||
|
|
||||||
|
// Should not throw, even when token resolution fails
|
||||||
|
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'],
|
||||||
|
graphTokenResolver: mockGraphTokenResolver,
|
||||||
|
}),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
|
||||||
|
// Headers should contain the unresolved placeholder
|
||||||
|
expect(mockConnection.setRequestHeaders).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve graph tokens in env variables', async () => {
|
||||||
|
const serverConfig: t.StdioOptions = {
|
||||||
|
type: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
env: {
|
||||||
|
GRAPH_TOKEN: '{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
|
||||||
|
OTHER_VAR: 'static-value',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock resolution for env variables
|
||||||
|
(graphUtils.preProcessGraphTokens as jest.Mock).mockImplementation(async (options) => {
|
||||||
|
if (options.env?.GRAPH_TOKEN?.includes('{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}')) {
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
env: {
|
||||||
|
...options.env,
|
||||||
|
GRAPH_TOKEN: 'resolved-graph-token',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAppConnections({
|
||||||
|
get: jest.fn().mockResolvedValue(mockConnection),
|
||||||
|
});
|
||||||
|
|
||||||
|
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
|
||||||
|
|
||||||
|
const manager = await MCPManager.createInstance(newMCPServersConfig());
|
||||||
|
|
||||||
|
await manager.callTool({
|
||||||
|
user: mockUser as IUser,
|
||||||
|
serverName,
|
||||||
|
toolName: 'test_tool',
|
||||||
|
provider: 'openai',
|
||||||
|
flowManager: mockFlowManager as unknown as Parameters<
|
||||||
|
typeof manager.callTool
|
||||||
|
>[0]['flowManager'],
|
||||||
|
graphTokenResolver: mockGraphTokenResolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graphUtils.preProcessGraphTokens).toHaveBeenCalledWith(
|
||||||
|
serverConfig,
|
||||||
|
expect.objectContaining({
|
||||||
|
graphTokenResolver: mockGraphTokenResolver,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve graph tokens in URL', async () => {
|
||||||
|
const serverConfig: t.SSEOptions = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://api.example.com?token={{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock resolution for URL
|
||||||
|
(graphUtils.preProcessGraphTokens as jest.Mock).mockImplementation(async (options) => {
|
||||||
|
if (options.url?.includes('{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}')) {
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
url: 'https://api.example.com?token=resolved-graph-token',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAppConnections({
|
||||||
|
get: jest.fn().mockResolvedValue(mockConnection),
|
||||||
|
});
|
||||||
|
|
||||||
|
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
|
||||||
|
|
||||||
|
const manager = await MCPManager.createInstance(newMCPServersConfig());
|
||||||
|
|
||||||
|
await manager.callTool({
|
||||||
|
user: mockUser as IUser,
|
||||||
|
serverName,
|
||||||
|
toolName: 'test_tool',
|
||||||
|
provider: 'openai',
|
||||||
|
flowManager: mockFlowManager as unknown as Parameters<
|
||||||
|
typeof manager.callTool
|
||||||
|
>[0]['flowManager'],
|
||||||
|
graphTokenResolver: mockGraphTokenResolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graphUtils.preProcessGraphTokens).toHaveBeenCalledWith(
|
||||||
|
serverConfig,
|
||||||
|
expect.objectContaining({
|
||||||
|
graphTokenResolver: mockGraphTokenResolver,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass scopes from environment variable to preProcessGraphTokens', async () => {
|
||||||
|
const originalEnv = process.env.GRAPH_API_SCOPES;
|
||||||
|
process.env.GRAPH_API_SCOPES = 'custom.scope.read custom.scope.write';
|
||||||
|
|
||||||
|
const serverConfig = createServerConfigWithGraphPlaceholder();
|
||||||
|
|
||||||
|
mockAppConnections({
|
||||||
|
get: jest.fn().mockResolvedValue(mockConnection),
|
||||||
|
});
|
||||||
|
|
||||||
|
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
|
||||||
|
|
||||||
|
const manager = await MCPManager.createInstance(newMCPServersConfig());
|
||||||
|
|
||||||
|
await manager.callTool({
|
||||||
|
user: mockUser as IUser,
|
||||||
|
serverName,
|
||||||
|
toolName: 'test_tool',
|
||||||
|
provider: 'openai',
|
||||||
|
flowManager: mockFlowManager as unknown as Parameters<
|
||||||
|
typeof manager.callTool
|
||||||
|
>[0]['flowManager'],
|
||||||
|
graphTokenResolver: mockGraphTokenResolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(graphUtils.preProcessGraphTokens).toHaveBeenCalledWith(
|
||||||
|
serverConfig,
|
||||||
|
expect.objectContaining({
|
||||||
|
scopes: 'custom.scope.read custom.scope.write',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Restore environment
|
||||||
|
if (originalEnv !== undefined) {
|
||||||
|
process.env.GRAPH_API_SCOPES = originalEnv;
|
||||||
|
} else {
|
||||||
|
delete process.env.GRAPH_API_SCOPES;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work correctly when config has no graph token placeholders', async () => {
|
||||||
|
const serverConfig: t.SSEOptions = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer static-token',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock to return unchanged options when no placeholders
|
||||||
|
(graphUtils.preProcessGraphTokens as jest.Mock).mockImplementation(
|
||||||
|
async (options) => options,
|
||||||
|
);
|
||||||
|
|
||||||
|
mockAppConnections({
|
||||||
|
get: jest.fn().mockResolvedValue(mockConnection),
|
||||||
|
});
|
||||||
|
|
||||||
|
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(serverConfig);
|
||||||
|
|
||||||
|
const manager = await MCPManager.createInstance(newMCPServersConfig());
|
||||||
|
|
||||||
|
const result = await manager.callTool({
|
||||||
|
user: mockUser as IUser,
|
||||||
|
serverName,
|
||||||
|
toolName: 'test_tool',
|
||||||
|
provider: 'openai',
|
||||||
|
flowManager: mockFlowManager as unknown as Parameters<
|
||||||
|
typeof manager.callTool
|
||||||
|
>[0]['flowManager'],
|
||||||
|
graphTokenResolver: mockGraphTokenResolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(mockConnection.setRequestHeaders).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
Authorization: 'Bearer static-token',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,23 @@ import {
|
||||||
StreamableHTTPOptionsSchema,
|
StreamableHTTPOptionsSchema,
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
import type { TUser } from 'librechat-data-provider';
|
import type { TUser } from 'librechat-data-provider';
|
||||||
|
import type { IUser } from '@librechat/data-schemas';
|
||||||
|
import type { GraphTokenResolver } from '~/utils/graph';
|
||||||
|
import { preProcessGraphTokens } from '~/utils/graph';
|
||||||
import { processMCPEnv } from '~/utils/env';
|
import { processMCPEnv } from '~/utils/env';
|
||||||
|
import * as oidcUtils from '~/utils/oidc';
|
||||||
|
|
||||||
|
// Mock oidc utilities for graph token tests
|
||||||
|
jest.mock('~/utils/oidc', () => ({
|
||||||
|
...jest.requireActual('~/utils/oidc'),
|
||||||
|
extractOpenIDTokenInfo: jest.fn(),
|
||||||
|
isOpenIDTokenValid: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
// Helper function to create test user objects
|
// Helper function to create test user objects
|
||||||
function createTestUser(
|
function createTestUser(
|
||||||
overrides: Partial<TUser> & Record<string, unknown> = {},
|
overrides: Partial<TUser> & Record<string, unknown> = {},
|
||||||
): TUser & Record<string, unknown> {
|
): Omit<TUser, 'createdAt' | 'updatedAt'> | undefined {
|
||||||
return {
|
return {
|
||||||
id: 'test-user-id',
|
id: 'test-user-id',
|
||||||
username: 'testuser',
|
username: 'testuser',
|
||||||
|
|
@ -18,8 +29,6 @@ function createTestUser(
|
||||||
avatar: 'https://example.com/avatar.png',
|
avatar: 'https://example.com/avatar.png',
|
||||||
provider: 'email',
|
provider: 'email',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
createdAt: new Date('2021-01-01').toISOString(),
|
|
||||||
updatedAt: new Date('2021-01-01').toISOString(),
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -858,5 +867,270 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should leave {{LIBRECHAT_GRAPH_ACCESS_TOKEN}} unchanged (resolved by preProcessGraphTokens)', () => {
|
||||||
|
const user = createTestUser({ id: 'user-123' });
|
||||||
|
const options: MCPOptions = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://example.com',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
|
||||||
|
'X-User-Id': '{{LIBRECHAT_USER_ID}}',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = processMCPEnv({ options, user });
|
||||||
|
|
||||||
|
expect('headers' in result && result.headers).toEqual({
|
||||||
|
// Graph token placeholder remains - it should be resolved by preProcessGraphTokens before processMCPEnv
|
||||||
|
Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
|
||||||
|
// User ID is resolved by processMCPEnv
|
||||||
|
'X-User-Id': 'user-123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('preProcessGraphTokens and processMCPEnv working in tandem', () => {
|
||||||
|
const mockExtractOpenIDTokenInfo = oidcUtils.extractOpenIDTokenInfo as jest.Mock;
|
||||||
|
const mockIsOpenIDTokenValid = oidcUtils.isOpenIDTokenValid as jest.Mock;
|
||||||
|
|
||||||
|
const mockGraphTokenResolver: GraphTokenResolver = jest.fn().mockResolvedValue({
|
||||||
|
access_token: 'resolved-graph-api-token',
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: 3600,
|
||||||
|
scope: 'https://graph.microsoft.com/.default',
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Set up mocks to simulate valid OpenID token
|
||||||
|
mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'test-access-token' });
|
||||||
|
mockIsOpenIDTokenValid.mockReturnValue(true);
|
||||||
|
(mockGraphTokenResolver as jest.Mock).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve both graph tokens and user placeholders in sequence', async () => {
|
||||||
|
const user = createTestUser({
|
||||||
|
id: 'user-123',
|
||||||
|
email: 'test@example.com',
|
||||||
|
provider: 'openid',
|
||||||
|
openidId: 'oidc-sub-456',
|
||||||
|
}) as unknown as IUser;
|
||||||
|
|
||||||
|
const options: MCPOptions = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://graph.microsoft.com/v1.0/me',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
|
||||||
|
'X-User-Id': '{{LIBRECHAT_USER_ID}}',
|
||||||
|
'X-User-Email': '{{LIBRECHAT_USER_EMAIL}}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 1: preProcessGraphTokens resolves graph token placeholders (async)
|
||||||
|
const graphProcessedConfig = await preProcessGraphTokens(options, {
|
||||||
|
user,
|
||||||
|
graphTokenResolver: mockGraphTokenResolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: processMCPEnv resolves user and env placeholders (sync)
|
||||||
|
const finalConfig = processMCPEnv({
|
||||||
|
options: graphProcessedConfig,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect('headers' in finalConfig && finalConfig.headers).toEqual({
|
||||||
|
Authorization: 'Bearer resolved-graph-api-token',
|
||||||
|
'X-User-Id': 'user-123',
|
||||||
|
'X-User-Email': 'test@example.com',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve graph tokens in env and user placeholders in headers', async () => {
|
||||||
|
const user = createTestUser({
|
||||||
|
id: 'user-456',
|
||||||
|
username: 'johndoe',
|
||||||
|
provider: 'openid',
|
||||||
|
}) as unknown as IUser;
|
||||||
|
|
||||||
|
const options: MCPOptions = {
|
||||||
|
command: 'node',
|
||||||
|
args: ['mcp-server.js', '--user', '{{LIBRECHAT_USER_USERNAME}}'],
|
||||||
|
env: {
|
||||||
|
GRAPH_ACCESS_TOKEN: '{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
|
||||||
|
USER_ID: '{{LIBRECHAT_USER_ID}}',
|
||||||
|
API_KEY: '${TEST_API_KEY}',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 1: preProcessGraphTokens
|
||||||
|
const graphProcessedConfig = await preProcessGraphTokens(options, {
|
||||||
|
user,
|
||||||
|
graphTokenResolver: mockGraphTokenResolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: processMCPEnv
|
||||||
|
const finalConfig = processMCPEnv({
|
||||||
|
options: graphProcessedConfig,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect('env' in finalConfig && finalConfig.env).toEqual({
|
||||||
|
GRAPH_ACCESS_TOKEN: 'resolved-graph-api-token',
|
||||||
|
USER_ID: 'user-456',
|
||||||
|
API_KEY: 'test-api-key-value',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect('args' in finalConfig && finalConfig.args).toEqual([
|
||||||
|
'mcp-server.js',
|
||||||
|
'--user',
|
||||||
|
'johndoe',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve graph tokens in URL alongside other placeholders', async () => {
|
||||||
|
const user = createTestUser({
|
||||||
|
id: 'user-789',
|
||||||
|
provider: 'openid',
|
||||||
|
}) as unknown as IUser;
|
||||||
|
|
||||||
|
const customUserVars = {
|
||||||
|
TENANT_ID: 'my-tenant-123',
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: MCPOptions = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://{{TENANT_ID}}.example.com/api?token={{LIBRECHAT_GRAPH_ACCESS_TOKEN}}&user={{LIBRECHAT_USER_ID}}',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 1: preProcessGraphTokens
|
||||||
|
const graphProcessedConfig = await preProcessGraphTokens(options, {
|
||||||
|
user,
|
||||||
|
graphTokenResolver: mockGraphTokenResolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: processMCPEnv with customUserVars
|
||||||
|
const finalConfig = processMCPEnv({
|
||||||
|
options: graphProcessedConfig,
|
||||||
|
user,
|
||||||
|
customUserVars,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect('url' in finalConfig && finalConfig.url).toBe(
|
||||||
|
'https://my-tenant-123.example.com/api?token=resolved-graph-api-token&user=user-789',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle config with no graph token placeholders (only user placeholders)', async () => {
|
||||||
|
const user = createTestUser({
|
||||||
|
id: 'user-abc',
|
||||||
|
email: 'user@company.com',
|
||||||
|
}) as unknown as IUser;
|
||||||
|
|
||||||
|
const options: MCPOptions = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
headers: {
|
||||||
|
'X-User-Id': '{{LIBRECHAT_USER_ID}}',
|
||||||
|
'X-User-Email': '{{LIBRECHAT_USER_EMAIL}}',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 1: preProcessGraphTokens (no-op since no graph placeholders)
|
||||||
|
const graphProcessedConfig = await preProcessGraphTokens(options, {
|
||||||
|
user,
|
||||||
|
graphTokenResolver: mockGraphTokenResolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: processMCPEnv
|
||||||
|
const finalConfig = processMCPEnv({
|
||||||
|
options: graphProcessedConfig,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect('headers' in finalConfig && finalConfig.headers).toEqual({
|
||||||
|
'X-User-Id': 'user-abc',
|
||||||
|
'X-User-Email': 'user@company.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
// graphTokenResolver should not have been called
|
||||||
|
expect(mockGraphTokenResolver).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle config with only graph token placeholders (no user placeholders)', async () => {
|
||||||
|
const user = createTestUser({
|
||||||
|
id: 'user-xyz',
|
||||||
|
provider: 'openid',
|
||||||
|
}) as unknown as IUser;
|
||||||
|
|
||||||
|
const options: MCPOptions = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://graph.microsoft.com/v1.0/me',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset mock call count
|
||||||
|
(mockGraphTokenResolver as jest.Mock).mockClear();
|
||||||
|
|
||||||
|
// Step 1: preProcessGraphTokens
|
||||||
|
const graphProcessedConfig = await preProcessGraphTokens(options, {
|
||||||
|
user,
|
||||||
|
graphTokenResolver: mockGraphTokenResolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: processMCPEnv
|
||||||
|
const finalConfig = processMCPEnv({
|
||||||
|
options: graphProcessedConfig,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect('headers' in finalConfig && finalConfig.headers).toEqual({
|
||||||
|
Authorization: 'Bearer resolved-graph-api-token',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not mutate original options through the tandem processing', async () => {
|
||||||
|
const user = createTestUser({
|
||||||
|
id: 'user-immutable',
|
||||||
|
provider: 'openid',
|
||||||
|
}) as unknown as IUser;
|
||||||
|
|
||||||
|
const originalOptions: MCPOptions = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}',
|
||||||
|
'X-User-Id': '{{LIBRECHAT_USER_ID}}',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store original values
|
||||||
|
const originalAuth = originalOptions.headers?.Authorization;
|
||||||
|
const originalUserId = originalOptions.headers?.['X-User-Id'];
|
||||||
|
|
||||||
|
// Step 1 & 2: Process through both functions
|
||||||
|
const graphProcessedConfig = await preProcessGraphTokens(originalOptions, {
|
||||||
|
user,
|
||||||
|
graphTokenResolver: mockGraphTokenResolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
processMCPEnv({
|
||||||
|
options: graphProcessedConfig,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Original should be unchanged
|
||||||
|
expect(originalOptions.headers?.Authorization).toBe(originalAuth);
|
||||||
|
expect(originalOptions.headers?.['X-User-Id']).toBe(originalUserId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,10 @@ type SafeUser = Pick<IUser, AllowedUserField>;
|
||||||
* if (headerValue.startsWith('b64:')) {
|
* if (headerValue.startsWith('b64:')) {
|
||||||
* const decoded = Buffer.from(headerValue.slice(4), 'base64').toString('utf8');
|
* const decoded = Buffer.from(headerValue.slice(4), 'base64').toString('utf8');
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* @param value - The string value to encode
|
* @param value - The string value to encode
|
||||||
* @returns ASCII-safe string (encoded if necessary)
|
* @returns ASCII-safe string (encoded if necessary)
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* encodeHeaderValue("José") // Returns "José" (é = 233, safe)
|
* encodeHeaderValue("José") // Returns "José" (é = 233, safe)
|
||||||
* encodeHeaderValue("Marić") // Returns "b64:TWFyacSH" (ć = 263, needs encoding)
|
* encodeHeaderValue("Marić") // Returns "b64:TWFyacSH" (ć = 263, needs encoding)
|
||||||
|
|
@ -59,17 +59,17 @@ export function encodeHeaderValue(value: string): string {
|
||||||
if (!value || typeof value !== 'string') {
|
if (!value || typeof value !== 'string') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if string contains extended Unicode characters (> 255)
|
// Check if string contains extended Unicode characters (> 255)
|
||||||
// Characters 0-255 (ASCII + Latin-1) are safe and don't need encoding
|
// Characters 0-255 (ASCII + Latin-1) are safe and don't need encoding
|
||||||
// Characters > 255 (e.g., ć=263, đ=272, ł=322) need Base64 encoding
|
// Characters > 255 (e.g., ć=263, đ=272, ł=322) need Base64 encoding
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
const hasExtendedUnicode = /[^\u0000-\u00FF]/.test(value);
|
const hasExtendedUnicode = /[^\u0000-\u00FF]/.test(value);
|
||||||
|
|
||||||
if (!hasExtendedUnicode) {
|
if (!hasExtendedUnicode) {
|
||||||
return value; // Safe to pass through
|
return value; // Safe to pass through
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode to Base64 for extended Unicode characters
|
// Encode to Base64 for extended Unicode characters
|
||||||
const base64 = Buffer.from(value, 'utf8').toString('base64');
|
const base64 = Buffer.from(value, 'utf8').toString('base64');
|
||||||
return `b64:${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
|
* @param isHeader - Whether this value will be used in an HTTP header
|
||||||
* @returns The processed string with placeholders replaced (and encoded if necessary)
|
* @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') {
|
if (!user || typeof value !== 'string') {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
@ -208,7 +212,7 @@ function processSingleValue({
|
||||||
}: {
|
}: {
|
||||||
originalValue: string;
|
originalValue: string;
|
||||||
customUserVars?: Record<string, string>;
|
customUserVars?: Record<string, string>;
|
||||||
user?: IUser;
|
user?: Partial<IUser>;
|
||||||
body?: RequestBody;
|
body?: RequestBody;
|
||||||
isHeader?: boolean;
|
isHeader?: boolean;
|
||||||
}): string {
|
}): string {
|
||||||
|
|
@ -255,7 +259,7 @@ function processSingleValue({
|
||||||
*/
|
*/
|
||||||
export function processMCPEnv(params: {
|
export function processMCPEnv(params: {
|
||||||
options: Readonly<MCPOptions>;
|
options: Readonly<MCPOptions>;
|
||||||
user?: IUser;
|
user?: Partial<IUser>;
|
||||||
customUserVars?: Record<string, string>;
|
customUserVars?: Record<string, string>;
|
||||||
body?: RequestBody;
|
body?: RequestBody;
|
||||||
}): MCPOptions {
|
}): MCPOptions {
|
||||||
|
|
|
||||||
467
packages/api/src/utils/graph.spec.ts
Normal file
467
packages/api/src/utils/graph.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
@ -7,11 +7,13 @@ export * from './env';
|
||||||
export * from './events';
|
export * from './events';
|
||||||
export * from './files';
|
export * from './files';
|
||||||
export * from './generators';
|
export * from './generators';
|
||||||
|
export * from './graph';
|
||||||
export * from './path';
|
export * from './path';
|
||||||
export * from './key';
|
export * from './key';
|
||||||
export * from './latex';
|
export * from './latex';
|
||||||
export * from './llm';
|
export * from './llm';
|
||||||
export * from './math';
|
export * from './math';
|
||||||
|
export * from './oidc';
|
||||||
export * from './openid';
|
export * from './openid';
|
||||||
export * from './promise';
|
export * from './promise';
|
||||||
export * from './sanitizeTitle';
|
export * from './sanitizeTitle';
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,22 @@ const OPENID_TOKEN_FIELDS = [
|
||||||
'EXPIRES_AT',
|
'EXPIRES_AT',
|
||||||
] as const;
|
] 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) {
|
if (!user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue