mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-01 11:53:55 +00:00
fix(mcp): harden MCP Apps host security and CJS compatibility
Reimplement the MCP Apps ui-meta helpers (RESOURCE_MIME_TYPE, getToolUiResourceUri, isToolVisibilityModelOnly, isToolVisibilityAppOnly) in packages/api/src/mcp/apps.ts so @librechat/api no longer imports the ESM-only @modelcontextprotocol/ext-apps from its CommonJS build. ext-apps remains a client-only dependency, removing the require(ESM) boundary that throws ERR_REQUIRE_ESM on Node versions without synchronous require(esm) support. Add an mcpSettings.apps toggle (enabled unless explicitly false). Thread enableApps through connection creation so the io.modelcontextprotocol/ui capability is advertised only when apps are enabled, and gate the resource and app-tool-call routes with a requireMCPAppsEnabled middleware. Authorize app-driven resources/read against the resources and templates a server advertises, so a sandboxed app cannot proxy arbitrary uris. ui:// resources stay allowed and the check fails closed. Render MCP apps in shared and search transcripts display-only by withholding the host-bound bridge handlers and capabilities in read-only views, so an embedded app cannot call tools or read resources with the viewer's auth while the stored tool result still renders.
This commit is contained in:
parent
2f650687d6
commit
ea75afc99a
25 changed files with 469 additions and 55 deletions
|
|
@ -3,6 +3,7 @@ const { logger } = require('@librechat/data-schemas');
|
|||
const { CacheKeys, Constants } = require('librechat-data-provider');
|
||||
const { getUserMCPAuthMap } = require('@librechat/api');
|
||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { resolveConfigServers } = require('~/server/services/MCP');
|
||||
const {
|
||||
findPluginAuthsByKeys,
|
||||
|
|
@ -249,10 +250,35 @@ const serveMCPSandbox = async (_req, res) => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Blocks MCP App endpoints when an admin has disabled apps via `mcpSettings.apps: false`.
|
||||
* Defense-in-depth alongside the connection-level capability gate: even if a server still
|
||||
* advertises UI tools, the host refuses to proxy resource reads and app tool calls while off.
|
||||
*/
|
||||
const requireMCPAppsEnabled = async (req, res, next) => {
|
||||
try {
|
||||
const appConfig =
|
||||
req.config ??
|
||||
(await getAppConfig({
|
||||
role: req.user?.role,
|
||||
userId: req.user?.id,
|
||||
tenantId: req.user?.tenantId,
|
||||
}));
|
||||
if (appConfig?.mcpSettings?.apps === false) {
|
||||
return res.status(403).json({ error: 'MCP Apps are disabled' });
|
||||
}
|
||||
return next();
|
||||
} catch (error) {
|
||||
logger.error('[requireMCPAppsEnabled] Error:', error);
|
||||
return res.status(500).json({ error: 'Failed to resolve MCP Apps configuration' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
readMCPResource,
|
||||
listMCPResources,
|
||||
listMCPResourceTemplates,
|
||||
appToolCall,
|
||||
serveMCPSandbox,
|
||||
requireMCPAppsEnabled,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ const {
|
|||
listMCPResourceTemplates,
|
||||
appToolCall,
|
||||
serveMCPSandbox,
|
||||
requireMCPAppsEnabled,
|
||||
} = require('~/server/controllers/mcpApps');
|
||||
const mcpAppToolCallLimiter = require('~/server/middleware/limiters/mcpAppToolCallLimiter');
|
||||
const {
|
||||
|
|
@ -992,13 +993,25 @@ router.delete(
|
|||
* Read a UI resource from an MCP server
|
||||
* @route POST /api/mcp/resources/read
|
||||
*/
|
||||
router.post('/resources/read', requireJwtAuth, checkMCPUsePermissions, readMCPResource);
|
||||
router.post(
|
||||
'/resources/read',
|
||||
requireJwtAuth,
|
||||
checkMCPUsePermissions,
|
||||
requireMCPAppsEnabled,
|
||||
readMCPResource,
|
||||
);
|
||||
|
||||
/**
|
||||
* List resources available on an MCP server
|
||||
* @route POST /api/mcp/resources/list
|
||||
*/
|
||||
router.post('/resources/list', requireJwtAuth, checkMCPUsePermissions, listMCPResources);
|
||||
router.post(
|
||||
'/resources/list',
|
||||
requireJwtAuth,
|
||||
checkMCPUsePermissions,
|
||||
requireMCPAppsEnabled,
|
||||
listMCPResources,
|
||||
);
|
||||
|
||||
/**
|
||||
* List resource templates available on an MCP server
|
||||
|
|
@ -1008,6 +1021,7 @@ router.post(
|
|||
'/resources/templates/list',
|
||||
requireJwtAuth,
|
||||
checkMCPUsePermissions,
|
||||
requireMCPAppsEnabled,
|
||||
listMCPResourceTemplates,
|
||||
);
|
||||
|
||||
|
|
@ -1019,6 +1033,7 @@ router.post(
|
|||
'/app-tool-call',
|
||||
requireJwtAuth,
|
||||
checkMCPUsePermissions,
|
||||
requireMCPAppsEnabled,
|
||||
mcpAppToolCallLimiter,
|
||||
appToolCall,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ async function initializeMCPs() {
|
|||
appConfig?.mcpSettings?.allowedDomains,
|
||||
appConfig?.mcpSettings?.allowedAddresses,
|
||||
resolveMCPAllowlists,
|
||||
appConfig?.mcpSettings?.apps,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('[MCP] Failed to initialize MCPServersRegistry:', error);
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ describe('initializeMCPs', () => {
|
|||
['localhost'],
|
||||
undefined,
|
||||
expect.any(Function), // per-request allowlist resolver
|
||||
undefined, // mcpSettings.apps
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -100,6 +101,7 @@ describe('initializeMCPs', () => {
|
|||
allowedDomains,
|
||||
undefined,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -116,6 +118,7 @@ describe('initializeMCPs', () => {
|
|||
undefined,
|
||||
undefined,
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ interface MessagesViewContextValue {
|
|||
conversation: ReturnType<typeof useChatContext>['conversation'];
|
||||
conversationId: string | null | undefined;
|
||||
|
||||
/** True when the view cannot mutate server state (shared/search); MCP App bridges render display-only. */
|
||||
readOnly: boolean;
|
||||
|
||||
/** Submission and control states */
|
||||
isSubmitting: ReturnType<typeof useChatContext>['isSubmitting'];
|
||||
abortScroll: ReturnType<typeof useChatContext>['abortScroll'];
|
||||
|
|
@ -92,6 +95,7 @@ export function MessagesViewProvider({ children }: { children: React.ReactNode }
|
|||
/** Combine all values into final context value */
|
||||
const contextValue = useMemo<MessagesViewContextValue>(
|
||||
() => ({
|
||||
readOnly: false,
|
||||
...conversationValues,
|
||||
...submissionStates,
|
||||
...messageOperations,
|
||||
|
|
@ -113,6 +117,15 @@ export function useMessagesViewContext() {
|
|||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when MCP App bridges should be display-only: the shared view, the /search route, or any
|
||||
* mount outside an interactive MessagesViewProvider. Defaults to read-only when no provider is
|
||||
* present so a new render context never accidentally enables live, auth-bearing app actions.
|
||||
*/
|
||||
export function useIsMessagesViewReadOnly(): boolean {
|
||||
return useContext(MessagesViewContext)?.readOnly ?? true;
|
||||
}
|
||||
|
||||
/** Hook for components that only need conversation data */
|
||||
export function useMessagesConversation() {
|
||||
const { conversation, conversationId } = useMessagesViewContext();
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ export function ShareMessagesProvider({ messages, children }: ShareMessagesProvi
|
|||
() => ({
|
||||
conversation: null,
|
||||
conversationId: undefined,
|
||||
// Share view is read-only: MCP App bridges must render display-only and never proxy
|
||||
// auth-bearing tool calls or resource reads against the viewer's MCP servers.
|
||||
readOnly: true,
|
||||
// These are required by the context but not used in share view
|
||||
ask: () => {},
|
||||
regenerate: () => {},
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
listMCPResources,
|
||||
listMCPResourceTemplates,
|
||||
} from '~/utils/mcpApps';
|
||||
import { useOptionalMessagesOperations } from '~/Providers';
|
||||
import { useOptionalMessagesOperations, useIsMessagesViewReadOnly } from '~/Providers';
|
||||
import { logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -35,6 +35,10 @@ export function useAppBridge(
|
|||
) {
|
||||
const user = useRecoilValue(store.user);
|
||||
const { ask } = useOptionalMessagesOperations();
|
||||
// Shared transcripts and /search render read-only: the embedded app must not proxy tool calls or
|
||||
// resource reads against the viewer's MCP servers with the viewer's auth. Such views render the
|
||||
// app display-only (initial tool input/result still shown), with no host-bound action handlers.
|
||||
const readOnly = useIsMessagesViewReadOnly();
|
||||
const queryClient = useQueryClient();
|
||||
const bridgeRef = useRef<AppBridge | null>(null);
|
||||
// The bridge mounts once per resourceId and reads these only inside its handlers, so a changed
|
||||
|
|
@ -46,7 +50,9 @@ export function useAppBridge(
|
|||
const onTeardownRef = useRef(onTeardown);
|
||||
const toolArgsRef = useRef(toolArgs);
|
||||
const toolResultRef = useRef(toolResult);
|
||||
const readOnlyRef = useRef(readOnly);
|
||||
askRef.current = ask;
|
||||
readOnlyRef.current = readOnly;
|
||||
onSizeChangedRef.current = onSizeChanged;
|
||||
onLoadedRef.current = onLoaded;
|
||||
onTeardownRef.current = onTeardown;
|
||||
|
|
@ -73,16 +79,17 @@ export function useAppBridge(
|
|||
|
||||
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
const { locale, timeZone } = Intl.DateTimeFormat().resolvedOptions();
|
||||
// Display-only views advertise no host-bound action capabilities, so a well-behaved app
|
||||
// disables those affordances instead of issuing calls the host will ignore.
|
||||
const interactive = !readOnlyRef.current;
|
||||
|
||||
bridge = new AppBridge(
|
||||
null,
|
||||
{ name: 'LibreChat', version: '1.0.0' },
|
||||
{
|
||||
openLinks: {},
|
||||
serverTools: {},
|
||||
serverResources: {},
|
||||
logging: {},
|
||||
message: { text: {} },
|
||||
...(interactive ? { serverTools: {}, serverResources: {}, message: { text: {} } } : {}),
|
||||
},
|
||||
{
|
||||
hostContext: {
|
||||
|
|
@ -96,13 +103,6 @@ export function useAppBridge(
|
|||
},
|
||||
);
|
||||
|
||||
bridge.oncalltool = async (params) =>
|
||||
callMCPAppTool(
|
||||
resource.serverName as string,
|
||||
params.name,
|
||||
(params.arguments as Record<string, unknown>) ?? {},
|
||||
) as never;
|
||||
|
||||
bridge.onopenlink = async ({ url }) => {
|
||||
try {
|
||||
const { protocol } = new URL(url);
|
||||
|
|
@ -117,25 +117,36 @@ export function useAppBridge(
|
|||
return {};
|
||||
};
|
||||
|
||||
bridge.onreadresource = async (params) =>
|
||||
readMCPResource(resource.serverName as string, params.uri) as never;
|
||||
// Host-bound actions (tool calls, resource reads/lists, model messages) run with the viewer's
|
||||
// auth, so they are only wired in interactive views — never for shared transcripts or /search.
|
||||
if (interactive) {
|
||||
bridge.oncalltool = async (params) =>
|
||||
callMCPAppTool(
|
||||
resource.serverName as string,
|
||||
params.name,
|
||||
(params.arguments as Record<string, unknown>) ?? {},
|
||||
) as never;
|
||||
|
||||
bridge.onlistresources = async (params) =>
|
||||
listMCPResources(resource.serverName as string, params?.cursor) as never;
|
||||
bridge.onreadresource = async (params) =>
|
||||
readMCPResource(resource.serverName as string, params.uri) as never;
|
||||
|
||||
bridge.onlistresourcetemplates = async (params) =>
|
||||
listMCPResourceTemplates(resource.serverName as string, params?.cursor) as never;
|
||||
bridge.onlistresources = async (params) =>
|
||||
listMCPResources(resource.serverName as string, params?.cursor) as never;
|
||||
|
||||
bridge.onmessage = async ({ content }) => {
|
||||
const text = (content as MessageContentBlock[])
|
||||
.filter((block) => block.type === 'text' && typeof block.text === 'string')
|
||||
.map((block) => block.text)
|
||||
.join('\n');
|
||||
if (text) {
|
||||
askRef.current({ text });
|
||||
}
|
||||
return {};
|
||||
};
|
||||
bridge.onlistresourcetemplates = async (params) =>
|
||||
listMCPResourceTemplates(resource.serverName as string, params?.cursor) as never;
|
||||
|
||||
bridge.onmessage = async ({ content }) => {
|
||||
const text = (content as MessageContentBlock[])
|
||||
.filter((block) => block.type === 'text' && typeof block.text === 'string')
|
||||
.map((block) => block.text)
|
||||
.join('\n');
|
||||
if (text) {
|
||||
askRef.current({ text });
|
||||
}
|
||||
return {};
|
||||
};
|
||||
}
|
||||
|
||||
bridge.addEventListener('sandboxready', async () => {
|
||||
if (sandboxReadyHandled) {
|
||||
|
|
|
|||
|
|
@ -115,7 +115,6 @@
|
|||
"@keyv/redis": "^4.3.3",
|
||||
"@librechat/agents": "^3.2.44",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/ext-apps": "^1.7.4",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/instrumentation-express": "^0.56.0",
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ export class ConnectionsRepository {
|
|||
useSSRFProtection,
|
||||
allowedDomains,
|
||||
allowedAddresses,
|
||||
enableApps: registry.getAppsEnabled(),
|
||||
},
|
||||
this.oauthOpts,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export class MCPConnectionFactory {
|
|||
protected readonly useSSRFProtection: boolean;
|
||||
protected readonly allowedDomains?: string[] | null;
|
||||
protected readonly allowedAddresses?: string[] | null;
|
||||
protected readonly enableApps?: boolean;
|
||||
protected readonly ephemeralConnection: boolean;
|
||||
|
||||
// OAuth-related properties (only set when useOAuth is true)
|
||||
|
|
@ -176,6 +177,7 @@ export class MCPConnectionFactory {
|
|||
oauthTokens,
|
||||
useSSRFProtection: this.useSSRFProtection,
|
||||
allowedAddresses: this.allowedAddresses,
|
||||
enableApps: this.enableApps,
|
||||
ephemeralConnection: this.ephemeralConnection,
|
||||
});
|
||||
|
||||
|
|
@ -250,6 +252,7 @@ export class MCPConnectionFactory {
|
|||
oauthTokens: null,
|
||||
useSSRFProtection: this.useSSRFProtection,
|
||||
allowedAddresses: this.allowedAddresses,
|
||||
enableApps: this.enableApps,
|
||||
ephemeralConnection: this.ephemeralConnection,
|
||||
});
|
||||
|
||||
|
|
@ -302,6 +305,7 @@ export class MCPConnectionFactory {
|
|||
this.useSSRFProtection = basic.useSSRFProtection === true;
|
||||
this.allowedDomains = basic.allowedDomains;
|
||||
this.allowedAddresses = basic.allowedAddresses;
|
||||
this.enableApps = basic.enableApps;
|
||||
this.ephemeralConnection = basic.ephemeralConnection === true;
|
||||
this.connectionTimeout = options?.connectionTimeout;
|
||||
this.tenantContext = tenantStorage?.getStore?.();
|
||||
|
|
@ -400,6 +404,7 @@ export class MCPConnectionFactory {
|
|||
oauthTokens,
|
||||
useSSRFProtection: this.useSSRFProtection,
|
||||
allowedAddresses: this.allowedAddresses,
|
||||
enableApps: this.enableApps,
|
||||
ephemeralConnection: this.ephemeralConnection,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import pick from 'lodash/pick';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { Permissions, PermissionTypes } from 'librechat-data-provider';
|
||||
import {
|
||||
getToolUiResourceUri,
|
||||
isToolVisibilityModelOnly,
|
||||
} from '@modelcontextprotocol/ext-apps/app-bridge';
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
ReadResourceResultSchema,
|
||||
|
|
@ -13,6 +9,10 @@ import {
|
|||
ErrorCode,
|
||||
McpError,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import type {
|
||||
ListResourcesResult,
|
||||
ListResourceTemplatesResult,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
||||
import type { TokenMethods, IUser } from '@librechat/data-schemas';
|
||||
import type { UIResource } from 'librechat-data-provider';
|
||||
|
|
@ -32,6 +32,7 @@ import {
|
|||
requiresUserScopedConnection,
|
||||
} from './utils';
|
||||
import { mcpOptionsContainGraphTokenPlaceholder, preProcessGraphTokens } from '~/utils/graph';
|
||||
import { getToolUiResourceUri, isToolVisibilityModelOnly } from './apps';
|
||||
import { MCPServersInitializer } from './registry/MCPServersInitializer';
|
||||
import { OboTokenResolutionError, resolveOboToken } from '~/mcp/oauth';
|
||||
import { MCPServerInspector } from './registry/MCPServerInspector';
|
||||
|
|
@ -77,6 +78,19 @@ export class MCPManager extends UserConnectionManager {
|
|||
* live tools/list_changed notifications (toolListVersion) that createdAt alone would miss.
|
||||
*/
|
||||
private readonly toolCacheConnStamp = new Map<string, string>();
|
||||
/**
|
||||
* Per-connection snapshot of the resource URIs and URI templates a server advertises, used to
|
||||
* authorize app-driven `resources/read` so an embedded app can only proxy resources the server
|
||||
* publicly exposes — not arbitrary `file://`/`secret://` URIs it happens to be reachable for.
|
||||
*/
|
||||
private readonly advertisedResourceCache = new Map<
|
||||
string,
|
||||
{ uris: Set<string>; templates: RegExp[] }
|
||||
>();
|
||||
|
||||
private readonly advertisedResourceConnStamp = new Map<string, string>();
|
||||
/** Bounds the resources/list + templates/list pagination loops when snapshotting advertised resources. */
|
||||
private static readonly RESOURCE_LIST_MAX_PAGES = 20;
|
||||
|
||||
/** Creates and initializes the singleton MCPManager instance */
|
||||
public static async createInstance(configs: t.MCPServers): Promise<MCPManager> {
|
||||
|
|
@ -205,6 +219,7 @@ export class MCPManager extends UserConnectionManager {
|
|||
useSSRFProtection,
|
||||
allowedDomains,
|
||||
allowedAddresses,
|
||||
enableApps: registry.getAppsEnabled(),
|
||||
};
|
||||
|
||||
const finalizeDiscoveryResult = async (
|
||||
|
|
@ -363,6 +378,8 @@ Please follow these instructions when using tools from the respective MCP server
|
|||
this.modelOnlyToolCache.delete(cacheKey);
|
||||
this.knownToolNamesCache.delete(cacheKey);
|
||||
this.toolCacheConnStamp.delete(cacheKey);
|
||||
this.advertisedResourceCache.delete(cacheKey);
|
||||
this.advertisedResourceConnStamp.delete(cacheKey);
|
||||
return;
|
||||
}
|
||||
if (serverName) {
|
||||
|
|
@ -372,6 +389,8 @@ Please follow these instructions when using tools from the respective MCP server
|
|||
this.modelOnlyToolCache.delete(key);
|
||||
this.knownToolNamesCache.delete(key);
|
||||
this.toolCacheConnStamp.delete(key);
|
||||
this.advertisedResourceCache.delete(key);
|
||||
this.advertisedResourceConnStamp.delete(key);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -379,6 +398,8 @@ Please follow these instructions when using tools from the respective MCP server
|
|||
this.modelOnlyToolCache.clear();
|
||||
this.knownToolNamesCache.clear();
|
||||
this.toolCacheConnStamp.clear();
|
||||
this.advertisedResourceCache.clear();
|
||||
this.advertisedResourceConnStamp.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -635,6 +656,7 @@ Please follow these instructions when using tools from the respective MCP server
|
|||
useSSRFProtection,
|
||||
allowedDomains,
|
||||
allowedAddresses,
|
||||
enableApps: registry.getAppsEnabled(),
|
||||
},
|
||||
{
|
||||
useOAuth: true,
|
||||
|
|
@ -862,6 +884,8 @@ Please follow these instructions when using tools from the respective MCP server
|
|||
);
|
||||
}
|
||||
|
||||
await this.assertResourceReadable(connection, `${serverName}:${userId}`, uri, logPrefix);
|
||||
|
||||
const result = await connection.client.request(
|
||||
{
|
||||
method: 'resources/read',
|
||||
|
|
@ -874,6 +898,131 @@ Please follow these instructions when using tools from the respective MCP server
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorizes an app-driven `resources/read`. App UI resources (`ui://`) are always allowed;
|
||||
* any other URI must be one the server actually advertises (an exact `resources/list` entry or
|
||||
* a `resources/templates/list` match), so a sandboxed app cannot exfiltrate unrelated resources
|
||||
* the host connection can otherwise reach. Fails closed when the advertised set is unavailable.
|
||||
*/
|
||||
private async assertResourceReadable(
|
||||
connection: MCPConnection,
|
||||
cacheKey: string,
|
||||
uri: string,
|
||||
logPrefix: string,
|
||||
): Promise<void> {
|
||||
if (uri.startsWith('ui://')) {
|
||||
return;
|
||||
}
|
||||
let advertised: { uris: Set<string>; templates: RegExp[] };
|
||||
try {
|
||||
advertised = await this.getAdvertisedResources(connection, cacheKey);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`${logPrefix} Could not list advertised resources to authorize read of "${uri}"; denying.`,
|
||||
error,
|
||||
);
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidRequest,
|
||||
`${logPrefix} Resource "${uri}" is not permitted.`,
|
||||
);
|
||||
}
|
||||
if (advertised.uris.has(uri) || advertised.templates.some((pattern) => pattern.test(uri))) {
|
||||
return;
|
||||
}
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidRequest,
|
||||
`${logPrefix} Resource "${uri}" is not advertised by the server and cannot be read by an app.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Snapshots (and caches per connection) the resource URIs and URI templates a server advertises. */
|
||||
private async getAdvertisedResources(
|
||||
connection: MCPConnection,
|
||||
cacheKey: string,
|
||||
): Promise<{ uris: Set<string>; templates: RegExp[] }> {
|
||||
const cached = this.advertisedResourceCache.get(cacheKey);
|
||||
if (cached && this.advertisedResourceConnStamp.get(cacheKey) === this.connStamp(connection)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const uris = new Set<string>();
|
||||
let cursor: string | undefined;
|
||||
for (let page = 0; page < MCPManager.RESOURCE_LIST_MAX_PAGES; page++) {
|
||||
const result: ListResourcesResult = await connection.client.request(
|
||||
{ method: 'resources/list', params: cursor != null ? { cursor } : {} },
|
||||
ListResourcesResultSchema,
|
||||
{ timeout: connection.timeout },
|
||||
);
|
||||
for (const resource of result.resources) {
|
||||
uris.add(resource.uri);
|
||||
}
|
||||
if (result.nextCursor == null) {
|
||||
break;
|
||||
}
|
||||
cursor = result.nextCursor;
|
||||
}
|
||||
|
||||
const templates: RegExp[] = [];
|
||||
try {
|
||||
cursor = undefined;
|
||||
for (let page = 0; page < MCPManager.RESOURCE_LIST_MAX_PAGES; page++) {
|
||||
const result: ListResourceTemplatesResult = await connection.client.request(
|
||||
{ method: 'resources/templates/list', params: cursor != null ? { cursor } : {} },
|
||||
ListResourceTemplatesResultSchema,
|
||||
{ timeout: connection.timeout },
|
||||
);
|
||||
for (const template of result.resourceTemplates) {
|
||||
const pattern = MCPManager.uriTemplateToRegExp(template.uriTemplate);
|
||||
if (pattern) {
|
||||
templates.push(pattern);
|
||||
}
|
||||
}
|
||||
if (result.nextCursor == null) {
|
||||
break;
|
||||
}
|
||||
cursor = result.nextCursor;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`[MCP][${cacheKey}] resources/templates/list unavailable; skipping templates.`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
const entry = { uris, templates };
|
||||
this.advertisedResourceCache.set(cacheKey, entry);
|
||||
this.advertisedResourceConnStamp.set(cacheKey, this.connStamp(connection));
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an RFC 6570 resource URI template into an anchored matcher. Simple expansions match a
|
||||
* single path segment; reserved/operator expansions (`{+x}`, `{#x}`, `{/x}`, ...) may span `/`.
|
||||
*/
|
||||
private static uriTemplateToRegExp(template: string): RegExp | null {
|
||||
try {
|
||||
let pattern = '';
|
||||
for (let i = 0; i < template.length; ) {
|
||||
const char = template[i];
|
||||
if (char !== '{') {
|
||||
pattern += char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const end = template.indexOf('}', i);
|
||||
if (end === -1) {
|
||||
pattern += template.slice(i).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
break;
|
||||
}
|
||||
pattern += '+#./;?&'.includes(template[i + 1] ?? '') ? '.+' : '[^/]+';
|
||||
i = end + 1;
|
||||
}
|
||||
return new RegExp(`^${pattern}$`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxies an MCP App resources/list request to the server. Paired with readResource so the
|
||||
* advertised serverResources capability is fully backed (resource-browser apps need listing).
|
||||
|
|
|
|||
|
|
@ -472,6 +472,7 @@ export abstract class UserConnectionManager {
|
|||
useSSRFProtection,
|
||||
allowedDomains,
|
||||
allowedAddresses,
|
||||
enableApps: registry.getAppsEnabled(),
|
||||
ephemeralConnection,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ const mockRegistryInstance = {
|
|||
shouldEnableSSRFProtection: mockShouldEnableSSRFProtection,
|
||||
getAllowedDomains: mockGetAllowedDomains,
|
||||
getAllowedAddresses: mockGetAllowedAddresses,
|
||||
getAppsEnabled: jest.fn().mockReturnValue(true),
|
||||
resolveAllowlists: jest.fn(async () => ({
|
||||
allowedDomains: mockGetAllowedDomains(),
|
||||
allowedAddresses: mockGetAllowedAddresses(),
|
||||
|
|
@ -122,6 +123,7 @@ describe('ConnectionsRepository', () => {
|
|||
useSSRFProtection: false,
|
||||
allowedDomains: null,
|
||||
allowedAddresses: null,
|
||||
enableApps: true,
|
||||
dbSourced: false,
|
||||
},
|
||||
undefined,
|
||||
|
|
@ -147,6 +149,7 @@ describe('ConnectionsRepository', () => {
|
|||
useSSRFProtection: false,
|
||||
allowedDomains: null,
|
||||
allowedAddresses: null,
|
||||
enableApps: true,
|
||||
dbSourced: false,
|
||||
},
|
||||
undefined,
|
||||
|
|
@ -189,6 +192,7 @@ describe('ConnectionsRepository', () => {
|
|||
useSSRFProtection: false,
|
||||
allowedDomains: null,
|
||||
allowedAddresses: null,
|
||||
enableApps: true,
|
||||
dbSourced: false,
|
||||
},
|
||||
undefined,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ const mockRegistryInstance = {
|
|||
shouldEnableSSRFProtection: mockShouldEnableSSRFProtection,
|
||||
getAllowedDomains: mockGetAllowedDomains,
|
||||
getAllowedAddresses: mockGetAllowedAddresses,
|
||||
getAppsEnabled: jest.fn().mockReturnValue(true),
|
||||
// Mirrors the real per-request resolver by reading the base-allowlist mocks above, so
|
||||
// existing tests that override getAllowedDomains/shouldEnableSSRFProtection still apply.
|
||||
resolveAllowlists: jest.fn(async () => ({
|
||||
|
|
@ -1357,6 +1358,104 @@ describe('MCPManager', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('readResource - app resource authorization', () => {
|
||||
const mockUser: Partial<IUser> = { id: 'user-123' };
|
||||
|
||||
const buildConnection = (request: jest.Mock) =>
|
||||
({
|
||||
isConnected: jest.fn().mockResolvedValue(true),
|
||||
setRequestHeaders: jest.fn(),
|
||||
fetchTools: jest.fn().mockResolvedValue([]),
|
||||
timeout: 30000,
|
||||
client: { request },
|
||||
}) as unknown as MCPConnection;
|
||||
|
||||
const advertisingRequest = (readResult: unknown) =>
|
||||
jest.fn().mockImplementation((req: { method: string }) => {
|
||||
if (req.method === 'resources/list') {
|
||||
return Promise.resolve({ resources: [{ uri: 'file://allowed.txt' }] });
|
||||
}
|
||||
if (req.method === 'resources/templates/list') {
|
||||
return Promise.resolve({ resourceTemplates: [{ uriTemplate: 'db://items/{id}' }] });
|
||||
}
|
||||
return Promise.resolve(readResult);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({
|
||||
source: 'yaml',
|
||||
type: 'sse',
|
||||
url: 'https://example.com/mcp',
|
||||
});
|
||||
});
|
||||
|
||||
it('proxies a ui:// resource without consulting the advertised list', async () => {
|
||||
const request = jest.fn().mockResolvedValue({ contents: [{ uri: 'ui://app/main' }] });
|
||||
const manager = await MCPManager.createInstance(newMCPServersConfig());
|
||||
jest.spyOn(manager, 'getConnection').mockResolvedValue(buildConnection(request));
|
||||
|
||||
await manager.readResource({
|
||||
userId: 'user-123',
|
||||
serverName: 'srv',
|
||||
uri: 'ui://app/main',
|
||||
user: mockUser as IUser,
|
||||
});
|
||||
|
||||
const methods = request.mock.calls.map((c) => (c[0] as { method: string }).method);
|
||||
expect(methods).toEqual(['resources/read']);
|
||||
});
|
||||
|
||||
it('proxies a non-ui:// resource the server advertises', async () => {
|
||||
const request = advertisingRequest({ contents: [{ uri: 'file://allowed.txt' }] });
|
||||
const manager = await MCPManager.createInstance(newMCPServersConfig());
|
||||
jest.spyOn(manager, 'getConnection').mockResolvedValue(buildConnection(request));
|
||||
|
||||
await manager.readResource({
|
||||
userId: 'user-123',
|
||||
serverName: 'srv',
|
||||
uri: 'file://allowed.txt',
|
||||
user: mockUser as IUser,
|
||||
});
|
||||
|
||||
const methods = request.mock.calls.map((c) => (c[0] as { method: string }).method);
|
||||
expect(methods).toContain('resources/read');
|
||||
});
|
||||
|
||||
it('allows a resource matching an advertised URI template', async () => {
|
||||
const request = advertisingRequest({ contents: [] });
|
||||
const manager = await MCPManager.createInstance(newMCPServersConfig());
|
||||
jest.spyOn(manager, 'getConnection').mockResolvedValue(buildConnection(request));
|
||||
|
||||
await manager.readResource({
|
||||
userId: 'user-123',
|
||||
serverName: 'srv',
|
||||
uri: 'db://items/42',
|
||||
user: mockUser as IUser,
|
||||
});
|
||||
|
||||
const methods = request.mock.calls.map((c) => (c[0] as { method: string }).method);
|
||||
expect(methods).toContain('resources/read');
|
||||
});
|
||||
|
||||
it('rejects a non-ui:// resource the server does not advertise', async () => {
|
||||
const request = advertisingRequest({ contents: [] });
|
||||
const manager = await MCPManager.createInstance(newMCPServersConfig());
|
||||
jest.spyOn(manager, 'getConnection').mockResolvedValue(buildConnection(request));
|
||||
|
||||
await expect(
|
||||
manager.readResource({
|
||||
userId: 'user-123',
|
||||
serverName: 'srv',
|
||||
uri: 'file:///etc/passwd',
|
||||
user: mockUser as IUser,
|
||||
}),
|
||||
).rejects.toThrow(/not advertised/);
|
||||
|
||||
const methods = request.mock.calls.map((c) => (c[0] as { method: string }).method);
|
||||
expect(methods).not.toContain('resources/read');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConnection', () => {
|
||||
const mockUser: Partial<IUser> = {
|
||||
id: 'user-123',
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ describe('MCP OAuth Race Condition Fixes', () => {
|
|||
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
|
||||
getAllowedDomains: jest.fn().mockReturnValue(null),
|
||||
getAllowedAddresses: jest.fn().mockReturnValue(null),
|
||||
getAppsEnabled: jest.fn().mockReturnValue(true),
|
||||
resolveAllowlists: jest.fn().mockResolvedValue({
|
||||
allowedDomains: null,
|
||||
allowedAddresses: null,
|
||||
|
|
@ -170,6 +171,7 @@ describe('MCP OAuth Race Condition Fixes', () => {
|
|||
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
|
||||
getAllowedDomains: jest.fn().mockReturnValue(null),
|
||||
getAllowedAddresses: jest.fn().mockReturnValue(null),
|
||||
getAppsEnabled: jest.fn().mockReturnValue(true),
|
||||
resolveAllowlists: jest.fn().mockResolvedValue({
|
||||
allowedDomains: null,
|
||||
allowedAddresses: null,
|
||||
|
|
@ -256,6 +258,7 @@ describe('MCP OAuth Race Condition Fixes', () => {
|
|||
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
|
||||
getAllowedDomains: jest.fn().mockReturnValue(null),
|
||||
getAllowedAddresses: jest.fn().mockReturnValue(null),
|
||||
getAppsEnabled: jest.fn().mockReturnValue(true),
|
||||
resolveAllowlists: jest.fn().mockResolvedValue({
|
||||
allowedDomains: null,
|
||||
allowedAddresses: null,
|
||||
|
|
@ -361,6 +364,7 @@ describe('MCP OAuth Race Condition Fixes', () => {
|
|||
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
|
||||
getAllowedDomains: jest.fn().mockReturnValue(null),
|
||||
getAllowedAddresses: jest.fn().mockReturnValue(null),
|
||||
getAppsEnabled: jest.fn().mockReturnValue(true),
|
||||
resolveAllowlists: jest.fn().mockResolvedValue({
|
||||
allowedDomains: null,
|
||||
allowedAddresses: null,
|
||||
|
|
|
|||
56
packages/api/src/mcp/apps.ts
Normal file
56
packages/api/src/mcp/apps.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* MCP Apps tool-metadata helpers, mirrored from the spec's reference
|
||||
* implementation in `@modelcontextprotocol/ext-apps`. They are reimplemented
|
||||
* here so `@librechat/api` (emitted as CommonJS) never statically imports the
|
||||
* ESM-only ext-apps package; the client bundle keeps importing ext-apps directly.
|
||||
*/
|
||||
|
||||
interface ToolWithMeta {
|
||||
_meta?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
type McpUiToolVisibility = 'model' | 'app';
|
||||
|
||||
interface McpUiToolMeta {
|
||||
resourceUri?: string;
|
||||
visibility?: McpUiToolVisibility[];
|
||||
}
|
||||
|
||||
/** Deprecated flat metadata key for a tool's UI resource URI. */
|
||||
export const RESOURCE_URI_META_KEY = 'ui/resourceUri';
|
||||
|
||||
/** MIME type identifying HTML content as an MCP App UI resource. */
|
||||
export const RESOURCE_MIME_TYPE = 'text/html;profile=mcp-app';
|
||||
|
||||
/**
|
||||
* Extract a tool's UI resource URI. Prefers the nested `_meta.ui.resourceUri`
|
||||
* format and falls back to the deprecated flat `_meta["ui/resourceUri"]`.
|
||||
* Throws if a URI is present but does not use the `ui://` scheme.
|
||||
*/
|
||||
export function getToolUiResourceUri(tool: ToolWithMeta): string | undefined {
|
||||
const uiMeta = tool._meta?.ui as McpUiToolMeta | undefined;
|
||||
let uri: unknown = uiMeta?.resourceUri;
|
||||
|
||||
if (uri === undefined) {
|
||||
uri = tool._meta?.[RESOURCE_URI_META_KEY];
|
||||
}
|
||||
|
||||
if (typeof uri === 'string' && uri.startsWith('ui://')) {
|
||||
return uri;
|
||||
} else if (uri !== undefined) {
|
||||
throw new Error(`Invalid UI resource URI: ${JSON.stringify(uri)}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** True when a tool is exposed to the model only (never callable from an app). */
|
||||
export function isToolVisibilityModelOnly(tool: ToolWithMeta): boolean {
|
||||
const visibility = (tool._meta?.ui as McpUiToolMeta | undefined)?.visibility;
|
||||
return Array.isArray(visibility) && visibility.length === 1 && visibility[0] === 'model';
|
||||
}
|
||||
|
||||
/** True when a tool is exposed to the app only (hidden from the model). */
|
||||
export function isToolVisibilityAppOnly(tool: ToolWithMeta): boolean {
|
||||
const visibility = (tool._meta?.ui as McpUiToolMeta | undefined)?.visibility;
|
||||
return Array.isArray(visibility) && visibility.length === 1 && visibility[0] === 'app';
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ import { logger } from '@librechat/data-schemas';
|
|||
import { fetch as undiciFetch, Agent, ProxyAgent } from 'undici';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/app-bridge';
|
||||
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import {
|
||||
|
|
@ -22,6 +21,7 @@ import type {
|
|||
Dispatcher,
|
||||
} from 'undici';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { ClientCapabilities } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { MCPOAuthTokens } from './oauth/types';
|
||||
import type * as t from './types';
|
||||
import { createSSRFSafeUndiciConnect, isSSRFTarget, resolveHostnameSSRF } from '~/auth';
|
||||
|
|
@ -29,6 +29,7 @@ import { runOutsideTracing } from '~/utils/tracing';
|
|||
import { isAddressAllowed } from '~/auth/domain';
|
||||
import { sanitizeUrlForLogging } from './utils';
|
||||
import { withTimeout } from '~/utils/promise';
|
||||
import { RESOURCE_MIME_TYPE } from './apps';
|
||||
import { mcpConfig } from './mcpConfig';
|
||||
|
||||
type FetchLike = (url: string | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
|
@ -1096,6 +1097,7 @@ interface MCPConnectionParams {
|
|||
oauthTokens?: MCPOAuthTokens | null;
|
||||
useSSRFProtection?: boolean;
|
||||
allowedAddresses?: string[] | null;
|
||||
enableApps?: boolean;
|
||||
ephemeralConnection?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -1258,21 +1260,19 @@ export class MCPConnection extends EventEmitter {
|
|||
if (params.oauthTokens) {
|
||||
this.oauthTokens = params.oauthTokens;
|
||||
}
|
||||
// Advertise MCP Apps support so servers using the ext-apps graceful-degradation path
|
||||
// (getUiCapability) expose app-enhanced tools. The capability rides on the `extensions`
|
||||
// field keyed by ext-apps EXTENSION_ID. Suppressed when MCP Apps are disabled by config.
|
||||
const appsEnabled = params.enableApps !== false;
|
||||
const capabilities: ClientCapabilities = appsEnabled
|
||||
? { extensions: { 'io.modelcontextprotocol/ui': { mimeTypes: [RESOURCE_MIME_TYPE] } } }
|
||||
: {};
|
||||
this.client = new Client(
|
||||
{
|
||||
name: '@librechat/api-client',
|
||||
version: '1.2.3',
|
||||
},
|
||||
{
|
||||
// Advertise MCP Apps support so servers using the ext-apps graceful-degradation path
|
||||
// (getUiCapability) expose app-enhanced tools. The capability rides on the `extensions`
|
||||
// field keyed by ext-apps EXTENSION_ID.
|
||||
capabilities: {
|
||||
extensions: {
|
||||
'io.modelcontextprotocol/ui': { mimeTypes: [RESOURCE_MIME_TYPE] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ capabilities },
|
||||
);
|
||||
|
||||
this.setupEventListeners();
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export class MCPServerInspector {
|
|||
private readonly useSSRFProtection: boolean = false,
|
||||
private readonly allowedDomains?: string[] | null,
|
||||
private readonly allowedAddresses?: string[] | null,
|
||||
private readonly enableApps?: boolean,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -45,6 +46,7 @@ export class MCPServerInspector {
|
|||
connection?: MCPConnection,
|
||||
allowedDomains?: string[] | null,
|
||||
allowedAddresses?: string[] | null,
|
||||
enableApps?: boolean,
|
||||
): Promise<t.ParsedServerConfig> {
|
||||
// Validate domain against allowlist BEFORE attempting connection
|
||||
const isDomainAllowed = await isMCPDomainAllowed(rawConfig, allowedDomains, allowedAddresses);
|
||||
|
|
@ -62,6 +64,7 @@ export class MCPServerInspector {
|
|||
useSSRFProtection,
|
||||
allowedDomains,
|
||||
allowedAddresses,
|
||||
enableApps,
|
||||
);
|
||||
await inspector.inspectServer();
|
||||
inspector.config.initDuration = Date.now() - start;
|
||||
|
|
@ -89,6 +92,7 @@ export class MCPServerInspector {
|
|||
useSSRFProtection: this.useSSRFProtection,
|
||||
allowedDomains: this.allowedDomains,
|
||||
allowedAddresses: this.allowedAddresses,
|
||||
enableApps: this.enableApps,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ export class MCPServersRegistry {
|
|||
/** YAML-derived base allowlists; used at boot and as the fallback when no resolver is set. */
|
||||
private readonly allowedDomains?: string[] | null;
|
||||
private readonly allowedAddresses?: string[] | null;
|
||||
private readonly appsEnabled?: boolean;
|
||||
/** Resolves the per-request (tenant-scoped) merged allowlists; falls back to the base above. */
|
||||
private readonly allowlistResolver?: MCPAllowlistResolver;
|
||||
private readonly readThroughCache: Keyv<t.ParsedServerConfig>;
|
||||
|
|
@ -148,6 +149,7 @@ export class MCPServersRegistry {
|
|||
allowedDomains?: string[] | null,
|
||||
allowedAddresses?: string[] | null,
|
||||
allowlistResolver?: MCPAllowlistResolver,
|
||||
appsEnabled?: boolean,
|
||||
) {
|
||||
this.dbConfigsRepo = new ServerConfigsDB(mongoose);
|
||||
this.cacheConfigsRepo = ServerConfigsCacheFactory.create(APP_CACHE_NAMESPACE, false);
|
||||
|
|
@ -155,6 +157,7 @@ export class MCPServersRegistry {
|
|||
this.allowedDomains = allowedDomains;
|
||||
this.allowedAddresses = allowedAddresses;
|
||||
this.allowlistResolver = allowlistResolver;
|
||||
this.appsEnabled = appsEnabled;
|
||||
|
||||
const ttl = cacheConfig.MCP_REGISTRY_CACHE_TTL;
|
||||
|
||||
|
|
@ -175,6 +178,7 @@ export class MCPServersRegistry {
|
|||
allowedDomains?: string[] | null,
|
||||
allowedAddresses?: string[] | null,
|
||||
allowlistResolver?: MCPAllowlistResolver,
|
||||
appsEnabled?: boolean,
|
||||
): MCPServersRegistry {
|
||||
if (!mongoose) {
|
||||
throw new Error(
|
||||
|
|
@ -192,6 +196,7 @@ export class MCPServersRegistry {
|
|||
allowedDomains,
|
||||
allowedAddresses,
|
||||
allowlistResolver,
|
||||
appsEnabled,
|
||||
);
|
||||
return MCPServersRegistry.instance;
|
||||
}
|
||||
|
|
@ -214,6 +219,11 @@ export class MCPServersRegistry {
|
|||
return this.allowedAddresses;
|
||||
}
|
||||
|
||||
/** YAML base apps-enabled flag (boot/fallback). MCP Apps are enabled unless explicitly disabled. */
|
||||
public getAppsEnabled(): boolean {
|
||||
return this.appsEnabled !== false;
|
||||
}
|
||||
|
||||
/** Returns true when no explicit allowedDomains allowlist is configured, enabling SSRF TOCTOU protection */
|
||||
public shouldEnableSSRFProtection(): boolean {
|
||||
return !Array.isArray(this.allowedDomains) || this.allowedDomains.length === 0;
|
||||
|
|
@ -418,6 +428,7 @@ export class MCPServersRegistry {
|
|||
undefined,
|
||||
allowedDomains,
|
||||
allowedAddresses,
|
||||
this.getAppsEnabled(),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`[MCPServersRegistry] Failed to inspect server "${serverName}":`, error);
|
||||
|
|
@ -476,6 +487,7 @@ export class MCPServersRegistry {
|
|||
undefined,
|
||||
allowedDomains,
|
||||
allowedAddresses,
|
||||
this.getAppsEnabled(),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`[MCPServersRegistry] Reinspection failed for server "${serverName}":`, error);
|
||||
|
|
@ -524,6 +536,7 @@ export class MCPServersRegistry {
|
|||
undefined,
|
||||
allowedDomains,
|
||||
allowedAddresses,
|
||||
this.getAppsEnabled(),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`[MCPServersRegistry] Failed to inspect server "${serverName}":`, error);
|
||||
|
|
@ -672,6 +685,7 @@ export class MCPServersRegistry {
|
|||
undefined,
|
||||
allowedDomains,
|
||||
allowedAddresses,
|
||||
this.getAppsEnabled(),
|
||||
),
|
||||
CONFIG_SERVER_INIT_TIMEOUT_MS,
|
||||
`${prefix} Server initialization timed out`,
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@ describe('MCPServersInitializer', () => {
|
|||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
// Verify all configs were processed by inspector
|
||||
// Signature: inspect(serverName, rawConfig, connection?, allowedDomains?, allowedAddresses?)
|
||||
// Signature: inspect(serverName, rawConfig, connection?, allowedDomains?, allowedAddresses?, enableApps?)
|
||||
expect(mockInspect).toHaveBeenCalledTimes(5);
|
||||
expect(mockInspect).toHaveBeenCalledWith(
|
||||
'disabled_server',
|
||||
|
|
@ -371,6 +371,7 @@ describe('MCPServersInitializer', () => {
|
|||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(mockInspect).toHaveBeenCalledWith(
|
||||
'oauth_server',
|
||||
|
|
@ -378,6 +379,7 @@ describe('MCPServersInitializer', () => {
|
|||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(mockInspect).toHaveBeenCalledWith(
|
||||
'file_tools_server',
|
||||
|
|
@ -385,6 +387,7 @@ describe('MCPServersInitializer', () => {
|
|||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(mockInspect).toHaveBeenCalledWith(
|
||||
'search_tools_server',
|
||||
|
|
@ -392,6 +395,7 @@ describe('MCPServersInitializer', () => {
|
|||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(mockInspect).toHaveBeenCalledWith(
|
||||
'remote_no_oauth_server',
|
||||
|
|
@ -399,6 +403,7 @@ describe('MCPServersInitializer', () => {
|
|||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ describe('MCPServersRegistry', () => {
|
|||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -303,6 +304,7 @@ describe('MCPServersRegistry', () => {
|
|||
undefined,
|
||||
['admin-added.com'],
|
||||
['10.0.0.0/8'],
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -633,6 +635,7 @@ describe('MCPServersRegistry', () => {
|
|||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expect(result['config-only-server']).toBeDefined();
|
||||
expect(result['config-only-server'].iconPath).toBe(
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ describe('MCPServersRegistry — ensureConfigServers', () => {
|
|||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -199,6 +200,7 @@ describe('MCPServersRegistry — ensureConfigServers', () => {
|
|||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Constants } from 'librechat-data-provider';
|
|||
import type { JsonSchemaType } from '@librechat/agents';
|
||||
import type { LCAvailableTools, LCFunctionTool, ParsedServerConfig } from './types';
|
||||
import { requiresEphemeralUserConnection } from './utils';
|
||||
import { isToolVisibilityAppOnly } from './apps';
|
||||
|
||||
export interface MCPToolInput {
|
||||
name: string;
|
||||
|
|
@ -90,15 +91,7 @@ export function createMCPToolCacheService(deps: MCPToolCacheDeps): MCPToolCacheS
|
|||
}
|
||||
|
||||
for (const tool of tools) {
|
||||
const uiMeta = (tool._meta as Record<string, unknown>)?.ui as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const visibility = uiMeta?.visibility as string[] | undefined;
|
||||
if (
|
||||
Array.isArray(visibility) &&
|
||||
visibility.includes('app') &&
|
||||
!visibility.includes('model')
|
||||
) {
|
||||
if (isToolVisibilityAppOnly(tool)) {
|
||||
continue;
|
||||
}
|
||||
const name = `${tool.name}${mcpDelimiter}${serverName}`;
|
||||
|
|
|
|||
|
|
@ -191,6 +191,8 @@ export interface BasicConnectionOptions {
|
|||
allowedDomains?: string[] | null;
|
||||
/** Admin exemption list of host:port pairs that bypass the SSRF private-IP block */
|
||||
allowedAddresses?: string[] | null;
|
||||
/** When false, the MCP "UI" (apps) capability is not advertised during handshake. Defaults to enabled. */
|
||||
enableApps?: boolean;
|
||||
/** When true, only resolve customUserVars in processMCPEnv (for DB-stored servers) */
|
||||
dbSourced?: boolean;
|
||||
/** When true, serverConfig has already gone through processMCPEnv for this request */
|
||||
|
|
|
|||
|
|
@ -1705,6 +1705,7 @@ export const configSchema = z.object({
|
|||
.object({
|
||||
allowedDomains: z.array(z.string()).optional(),
|
||||
allowedAddresses: allowedAddressesSchema,
|
||||
apps: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
interface: interfaceSchema,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue