mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-01 03:27:01 +00:00
🗝️ fix: Resolve MCP Runtime User and Request Placeholders (#13626)
* fix: Resolve MCP Runtime User Placeholders * fix: Harden MCP Runtime Placeholder Connections * fix: Update MCP Source Tag Test Expectations * fix: Complete MCP Runtime Placeholder Reinit * fix: Harden MCP Request Scoped Runtime Configs * fix: Align MCP OAuth Tests With Domain Policy * fix: Harden MCP Runtime Resolution Edges * fix: Avoid MCP Runtime Reprocessing Pitfalls * fix: Reuse MCP Request Scoped Tool Discovery * fix: Validate MCP Body Runtime Fields * 🛡️ refactor: Harden runtime placeholder edges from review - Warn at inspection when a trusted server URL contains runtime placeholders but no domain allowlist restricts the resolved target - Document the three resolution sites that must stay in sync so the validated config always matches the connected one - Note the per-call connect cost of ephemeral GRAPH/BODY connections - Drop the no-op removeUserConnection in callTool's ephemeral cleanup; ephemeral connections are never stored, and removing the entry could orphan a still-connected cached connection after a config change * 🪪 fix: Cover oauth_headers, Graph URL gating, and request-scoped reconnects Address Codex review: - Resolve runtime placeholders in oauth_headers (processMCPEnv + Graph pre-pass) and include the field in placeholder detection, so OAuth discovery/token requests no longer send literals; consolidate the detection field lists into one helper - Defer the early domain gate when the URL still carries a Graph placeholder (resolved async later); the authoritative assertResolvedRuntimeConfigAllowed check still enforces policy - Bypass the 10s reconnect throttle for request-scoped servers, which re-fetch tool definitions on every message by design
This commit is contained in:
parent
a7f16911b2
commit
7eafe317cc
29 changed files with 2235 additions and 85 deletions
|
|
@ -1126,6 +1126,42 @@ describe('processMCPEnv', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should process user placeholders in oauth_headers', () => {
|
||||
const user = createTestUser({ id: 'user-123', email: 'test@example.com' });
|
||||
const options: MCPOptions = {
|
||||
type: 'streamable-http',
|
||||
url: 'https://mcp.example.com/api',
|
||||
oauth_headers: {
|
||||
'X-User-Id': '{{LIBRECHAT_USER_ID}}',
|
||||
'X-Static': 'static-value',
|
||||
},
|
||||
};
|
||||
|
||||
const result = processMCPEnv({ options, user });
|
||||
|
||||
expect('oauth_headers' in result! && result.oauth_headers).toEqual({
|
||||
'X-User-Id': 'user-123',
|
||||
'X-Static': 'static-value',
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT resolve user placeholders in oauth_headers when dbSourced', () => {
|
||||
const user = createTestUser({ id: 'user-123' });
|
||||
const options: MCPOptions = {
|
||||
type: 'streamable-http',
|
||||
url: 'https://mcp.example.com/api',
|
||||
oauth_headers: {
|
||||
'X-User-Id': '{{LIBRECHAT_USER_ID}}',
|
||||
},
|
||||
};
|
||||
|
||||
const result = processMCPEnv({ options, user, dbSourced: true });
|
||||
|
||||
expect('oauth_headers' in result! && result.oauth_headers).toEqual({
|
||||
'X-User-Id': '{{LIBRECHAT_USER_ID}}',
|
||||
});
|
||||
});
|
||||
|
||||
it('should process user field placeholders in all fields', () => {
|
||||
const user = createTestUser({
|
||||
id: 'user-123',
|
||||
|
|
|
|||
|
|
@ -379,6 +379,22 @@ export function processMCPEnv(params: {
|
|||
newObj.headers = processedHeaders;
|
||||
}
|
||||
|
||||
// Process OAuth headers if they exist; sent on OAuth discovery/token requests
|
||||
if ('oauth_headers' in newObj && newObj.oauth_headers) {
|
||||
const processedOAuthHeaders: Record<string, string> = {};
|
||||
for (const [key, originalValue] of Object.entries(newObj.oauth_headers)) {
|
||||
processedOAuthHeaders[key] = processSingleValue({
|
||||
user,
|
||||
body,
|
||||
dbSourced,
|
||||
originalValue,
|
||||
customUserVars,
|
||||
isHeader: true,
|
||||
});
|
||||
}
|
||||
newObj.oauth_headers = processedOAuthHeaders;
|
||||
}
|
||||
|
||||
// Process URL if it exists (for WebSocket, SSE, StreamableHTTP types)
|
||||
if ('url' in newObj && newObj.url) {
|
||||
newObj.url = processSingleValue({
|
||||
|
|
|
|||
|
|
@ -13,6 +13,15 @@ import {
|
|||
*/
|
||||
const GRAPH_TOKEN_REGEX = new RegExp(GRAPH_TOKEN_PLACEHOLDER.replace(/[{}]/g, '\\$&'), 'g');
|
||||
|
||||
type GraphTokenResolvable =
|
||||
| string
|
||||
| string[]
|
||||
| boolean
|
||||
| number
|
||||
| null
|
||||
| undefined
|
||||
| Record<string, string | string[] | boolean | number | null | undefined>;
|
||||
|
||||
/**
|
||||
* Response from a Graph API token exchange.
|
||||
*/
|
||||
|
|
@ -67,26 +76,48 @@ export function recordContainsGraphTokenPlaceholder(
|
|||
return Object.values(record).some(containsGraphTokenPlaceholder);
|
||||
}
|
||||
|
||||
function valueContainsGraphTokenPlaceholder(value: GraphTokenResolvable): boolean {
|
||||
if (typeof value === 'string') {
|
||||
return containsGraphTokenPlaceholder(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(containsGraphTokenPlaceholder);
|
||||
}
|
||||
if (value == null || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return Object.values(value).some(valueContainsGraphTokenPlaceholder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if MCP options contain the Graph token placeholder in headers, env, or url.
|
||||
* Checks if MCP options contain the Graph token placeholder in connection fields.
|
||||
* @param options - The MCP options object
|
||||
* @returns True if any field contains the placeholder
|
||||
*/
|
||||
export function mcpOptionsContainGraphTokenPlaceholder(options: {
|
||||
args?: string[];
|
||||
headers?: Record<string, string>;
|
||||
env?: Record<string, string>;
|
||||
oauth?: Record<string, string | string[] | boolean | number | null | undefined>;
|
||||
oauth_headers?: Record<string, string>;
|
||||
url?: string;
|
||||
}): boolean {
|
||||
if (options.url && containsGraphTokenPlaceholder(options.url)) {
|
||||
return true;
|
||||
}
|
||||
if (options.args?.some(containsGraphTokenPlaceholder)) {
|
||||
return true;
|
||||
}
|
||||
if (recordContainsGraphTokenPlaceholder(options.headers)) {
|
||||
return true;
|
||||
}
|
||||
if (recordContainsGraphTokenPlaceholder(options.env)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
if (recordContainsGraphTokenPlaceholder(options.oauth_headers)) {
|
||||
return true;
|
||||
}
|
||||
return valueContainsGraphTokenPlaceholder(options.oauth);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -176,6 +207,42 @@ export async function resolveGraphTokensInRecord(
|
|||
return resolved;
|
||||
}
|
||||
|
||||
async function resolveGraphTokensInArray(
|
||||
values: string[] | undefined,
|
||||
options: GraphTokenOptions,
|
||||
): Promise<string[] | undefined> {
|
||||
if (!values || !values.some(containsGraphTokenPlaceholder)) {
|
||||
return values;
|
||||
}
|
||||
|
||||
const resolved: string[] = [];
|
||||
for (const value of values) {
|
||||
resolved.push(await resolveGraphTokenPlaceholder(value, options));
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function resolveGraphTokensInOAuth(
|
||||
oauth: Record<string, string | string[] | boolean | number | null | undefined> | undefined,
|
||||
options: GraphTokenOptions,
|
||||
): Promise<Record<string, string | string[] | boolean | number | null | undefined> | undefined> {
|
||||
if (!oauth || !valueContainsGraphTokenPlaceholder(oauth)) {
|
||||
return oauth;
|
||||
}
|
||||
|
||||
const resolved: Record<string, string | string[] | boolean | number | null | undefined> = {};
|
||||
for (const [key, value] of Object.entries(oauth)) {
|
||||
if (typeof value === 'string') {
|
||||
resolved[key] = await resolveGraphTokenPlaceholder(value, options);
|
||||
} else if (Array.isArray(value)) {
|
||||
resolved[key] = await resolveGraphTokensInArray(value, options);
|
||||
} else {
|
||||
resolved[key] = value;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-processes MCP options to resolve Graph token placeholders.
|
||||
* This must be called before processMCPEnv since Graph token resolution is async.
|
||||
|
|
@ -186,8 +253,11 @@ export async function resolveGraphTokensInRecord(
|
|||
*/
|
||||
export async function preProcessGraphTokens<
|
||||
T extends {
|
||||
args?: string[];
|
||||
headers?: Record<string, string>;
|
||||
env?: Record<string, string>;
|
||||
oauth?: Record<string, string | string[] | boolean | number | null | undefined>;
|
||||
oauth_headers?: Record<string, string>;
|
||||
url?: string;
|
||||
},
|
||||
>(options: T, graphOptions: GraphTokenOptions): Promise<T> {
|
||||
|
|
@ -201,6 +271,10 @@ export async function preProcessGraphTokens<
|
|||
result.url = await resolveGraphTokenPlaceholder(result.url, graphOptions);
|
||||
}
|
||||
|
||||
if (result.args) {
|
||||
result.args = await resolveGraphTokensInArray(result.args, graphOptions);
|
||||
}
|
||||
|
||||
if (result.headers) {
|
||||
result.headers = await resolveGraphTokensInRecord(result.headers, graphOptions);
|
||||
}
|
||||
|
|
@ -209,5 +283,13 @@ export async function preProcessGraphTokens<
|
|||
result.env = await resolveGraphTokensInRecord(result.env, graphOptions);
|
||||
}
|
||||
|
||||
if (result.oauth_headers) {
|
||||
result.oauth_headers = await resolveGraphTokensInRecord(result.oauth_headers, graphOptions);
|
||||
}
|
||||
|
||||
if (result.oauth) {
|
||||
result.oauth = await resolveGraphTokensInOAuth(result.oauth, graphOptions);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue