mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +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
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue