🗝️ 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:
Danny Avila 2026-06-09 18:52:57 -04:00 committed by GitHub
parent a7f16911b2
commit 7eafe317cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 2235 additions and 85 deletions

View file

@ -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',

View file

@ -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({

View file

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