📦 chore: Bump MCP SDK: Fix Types and MCP OAuth due to Update (#10811)

* chore: Bump @modelcontextprotocol/sdk to version 1.24.3

* refactor: Update resource handling in MCP parsers and types

- Simplified resource text checks in `parseAsString` and `formatToolContent` functions to ensure proper existence checks.
- Removed unnecessary resource name and description handling to streamline output.
- Updated type definitions in `index.ts` to align with the new structure from `@modelcontextprotocol/sdk`, enhancing type safety and clarity.
- Added `logo_uri` and `tos_uri` properties to `MCPOAuthHandler` for improved OAuth metadata support.

* refactor: Update custom endpoint configurations and type definitions

- Removed unused type imports and streamlined the custom parameters handling in `loadCustomEndpointsConfig`.
- Adjusted the `TCustomEndpointsConfig` type to utilize `TConfig` instead of `TEndpoint`, enhancing type accuracy.
- Made the endpoint schema optional in the configuration to improve flexibility.

* fix: Implement token cleanup and error handling for invalid OAuth tokens

- Added `cleanupInvalidTokens` method to remove invalid OAuth tokens from storage when detected.
- Introduced `isInvalidTokenError` method to identify errors indicating revoked or expired tokens.
- Integrated token cleanup logic into the connection attempt process to ensure fresh OAuth flow on invalid token detection.

* feat: Add revoke OAuth functionality in Server Initialization

- Introduced a new button to revoke OAuth for servers, enhancing user control over OAuth permissions.
- Updated the `useMCPServerManager` hook to include a standalone `revokeOAuthForServer` function for managing OAuth revocation.
- Adjusted the UI to conditionally render the revoke button based on server requirements.

* fix: error handling for authentication in MCPConnection

- Updated the error handling logic in MCPConnection to better identify various authentication error indicators, including 401 status, invalid tokens, and unauthorized messages.
- Removed the deprecated cleanupInvalidTokens method and integrated its logic into the connection attempt process for improved clarity and efficiency.
- Adjusted the MCPConnectionFactory to streamline the connection attempt process and handle OAuth errors more effectively.

* refactor: Update button rendering in ServerInitializationSection

- Removed the existing button for server initialization and replaced it with a new button implementation, maintaining the same functionality.
- Ensured consistent rendering of the button within the component's layout.

* chore: update resource type usage in parsers.test.ts
This commit is contained in:
Danny Avila 2025-12-04 19:52:32 -05:00
parent e6288c379c
commit 394bb6242b
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
14 changed files with 155 additions and 90 deletions

View file

@ -47,7 +47,7 @@
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@modelcontextprotocol/sdk": "^1.21.0",
"@modelcontextprotocol/sdk": "^1.24.3",
"@node-saml/passport-saml": "^5.1.0",
"@smithy/node-http-handler": "^4.4.5",
"axios": "^1.12.1",

View file

@ -1,8 +1,7 @@
import React from 'react';
import { RefreshCw } from 'lucide-react';
import { RefreshCw, Trash2 } from 'lucide-react';
import { Button, Spinner } from '@librechat/client';
import { useLocalize, useMCPServerManager, useMCPConnectionStatus } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
interface ServerInitializationSectionProps {
sidePanel?: boolean;
@ -22,12 +21,13 @@ export default function ServerInitializationSection({
const localize = useLocalize();
const {
getOAuthUrl,
isCancellable,
isInitializing,
cancelOAuthFlow,
initializeServer,
availableMCPServers,
cancelOAuthFlow,
isInitializing,
isCancellable,
getOAuthUrl,
revokeOAuthForServer,
} = useMCPServerManager({ conversationId });
const { connectionStatus } = useMCPConnectionStatus({
@ -73,7 +73,6 @@ export default function ServerInitializationSection({
// Unified button rendering
const isReinit = shouldShowReinit;
const outerClass = isReinit ? 'flex justify-start' : 'flex justify-end';
const buttonVariant = isReinit ? undefined : 'default';
let buttonText = '';
@ -94,13 +93,24 @@ export default function ServerInitializationSection({
);
return (
<div className={outerClass}>
<div className="flex items-center gap-2">
{requiresOAuth && revokeOAuthForServer && (
<Button
size="sm"
variant="destructive"
onClick={() => revokeOAuthForServer(serverName)}
aria-label={localize('com_ui_revoke')}
>
<Trash2 className="h-4 w-4" />
{localize('com_ui_revoke')}
</Button>
)}
<Button
variant={buttonVariant}
onClick={() => initializeServer(serverName, false)}
disabled={isServerInitializing}
size={sidePanel ? 'sm' : 'default'}
className="w-full"
className="flex-1"
>
{icon}
{buttonText}

View file

@ -517,6 +517,19 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
[selectedToolForConfig, updateUserPluginsMutation, mcpValues, setMCPValues],
);
/** Standalone revoke function for OAuth servers - doesn't require selectedToolForConfig */
const revokeOAuthForServer = useCallback(
(serverName: string) => {
const payload: TUpdateUserPlugins = {
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
};
updateUserPluginsMutation.mutate(payload);
},
[updateUserPluginsMutation],
);
const handleSave = useCallback(
(authData: Record<string, string>) => {
if (selectedToolForConfig) {
@ -678,6 +691,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
setSelectedToolForConfig,
handleSave,
handleRevoke,
revokeOAuthForServer,
getServerStatusIconProps,
getConfigDialogProps,
checkEffectivePermission,

45
package-lock.json generated
View file

@ -61,7 +61,7 @@
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@modelcontextprotocol/sdk": "^1.21.0",
"@modelcontextprotocol/sdk": "^1.24.3",
"@node-saml/passport-saml": "^5.1.0",
"@smithy/node-http-handler": "^4.4.5",
"axios": "^1.12.1",
@ -1467,9 +1467,9 @@
}
},
"api/node_modules/jose": {
"version": "6.0.11",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.11.tgz",
"integrity": "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==",
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@ -18362,9 +18362,9 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.21.0.tgz",
"integrity": "sha512-YFBsXJMFCyI1zP98u7gezMFKX4lgu/XpoZJk7ufI6UlFKXLj2hAMUuRlQX/nrmIPOmhRrG6tw2OQ2ZA/ZlXYpQ==",
"version": "1.24.3",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.24.3.tgz",
"integrity": "sha512-YgSHW29fuzKKAHTGe9zjNoo+yF8KaQPzDC2W9Pv41E7/57IfY+AMGJ/aDFlgTLcVVELoggKE4syABCE75u3NCw==",
"license": "MIT",
"dependencies": {
"ajv": "^8.17.1",
@ -18376,23 +18376,37 @@
"eventsource-parser": "^3.0.0",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"jose": "^6.1.1",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.24.1"
"zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@cfworker/json-schema": "^4.1.1"
"@cfworker/json-schema": "^4.1.1",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"@cfworker/json-schema": {
"optional": true
},
"zod": {
"optional": false
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/jose": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@mongodb-js/saslprep": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.1.tgz",
@ -48069,11 +48083,12 @@
}
},
"node_modules/zod-to-json-schema": {
"version": "3.24.3",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.3.tgz",
"integrity": "sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A==",
"version": "3.25.0",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
"integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
"zod": "^3.25 || ^4"
}
},
"node_modules/zwitch": {
@ -48129,7 +48144,7 @@
"@langchain/core": "^0.3.79",
"@librechat/agents": "^3.0.50",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.21.0",
"@modelcontextprotocol/sdk": "^1.24.3",
"axios": "^1.12.1",
"connect-redis": "^8.1.0",
"diff": "^7.0.0",

View file

@ -86,7 +86,7 @@
"@langchain/core": "^0.3.79",
"@librechat/agents": "^3.0.50",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.21.0",
"@modelcontextprotocol/sdk": "^1.24.3",
"axios": "^1.12.1",
"connect-redis": "^8.1.0",
"diff": "^7.0.0",

View file

@ -1,5 +1,5 @@
import { EModelEndpoint, extractEnvVariable, normalizeEndpointName } from 'librechat-data-provider';
import type { TCustomEndpoints, TEndpoint, TConfig } from 'librechat-data-provider';
import type { TCustomEndpoints, TEndpoint } from 'librechat-data-provider';
import type { TCustomEndpointsConfig } from '~/types/endpoints';
import { isUserProvided } from '~/utils';
@ -45,7 +45,7 @@ export function loadCustomEndpointsConfig(
type: EModelEndpoint.custom,
userProvide: isUserProvided(resolvedApiKey),
userProvideURL: isUserProvided(resolvedBaseURL),
customParams: customParams as TConfig['customParams'],
customParams,
modelDisplayLabel,
iconURL,
};

View file

@ -259,15 +259,21 @@ export class MCPConnectionFactory {
attempts++;
if (this.useOAuth && this.isOAuthError(error)) {
// Only handle OAuth if this is a user connection (has oauthStart handler)
// For returnOnOAuth mode, let the event handler (handleOAuthEvents) deal with OAuth
// We just need to stop retrying and let the error propagate
if (this.returnOnOAuth) {
logger.info(
`${this.logPrefix} OAuth required (return on OAuth mode), stopping retries`,
);
throw error;
}
// Normal flow - wait for OAuth to complete
if (this.oauthStart && !oauthHandled) {
const errorWithFlag = error as (Error & { isOAuthError?: boolean }) | undefined;
if (errorWithFlag?.isOAuthError) {
oauthHandled = true;
logger.info(`${this.logPrefix} Handling OAuth`);
await this.handleOAuthRequired();
}
}
// Don't retry on OAuth errors - just throw
logger.info(`${this.logPrefix} OAuth required, stopping connection attempts`);
throw error;
@ -288,15 +294,29 @@ export class MCPConnectionFactory {
return false;
}
// Check for SSE error with 401 status
if ('message' in error && typeof error.message === 'string') {
return error.message.includes('401') || error.message.includes('Non-200 status code (401)');
}
// Check for error code
if ('code' in error) {
const code = (error as { code?: number }).code;
return code === 401 || code === 403;
if (code === 401 || code === 403) {
return true;
}
}
// Check message for various auth error indicators
if ('message' in error && typeof error.message === 'string') {
const message = error.message.toLowerCase();
// Check for 401 status
if (message.includes('401') || message.includes('non-200 status code (401)')) {
return true;
}
// Check for invalid_token (OAuth servers return this for expired/revoked tokens)
if (message.includes('invalid_token')) {
return true;
}
// Check for authentication required
if (message.includes('authentication required') || message.includes('unauthorized')) {
return true;
}
}
return false;

View file

@ -170,8 +170,6 @@ describe('formatToolContent', () => {
uri: 'ui://carousel',
mimeType: 'application/json',
text: '{"items": []}',
name: 'carousel',
description: 'A carousel component',
},
},
],
@ -184,8 +182,6 @@ describe('formatToolContent', () => {
text:
'Resource Text: {"items": []}\n' +
'Resource URI: ui://carousel\n' +
'Resource: carousel\n' +
'Resource Description: A carousel component\n' +
'Resource MIME Type: application/json',
},
]);
@ -196,8 +192,6 @@ describe('formatToolContent', () => {
uri: 'ui://carousel',
mimeType: 'application/json',
text: '{"items": []}',
name: 'carousel',
description: 'A carousel component',
},
],
},
@ -211,8 +205,6 @@ describe('formatToolContent', () => {
type: 'resource',
resource: {
uri: 'file://document.pdf',
name: 'Document',
description: 'Important document',
mimeType: 'application/pdf',
text: 'Document content',
},
@ -227,8 +219,6 @@ describe('formatToolContent', () => {
text:
'Resource Text: Document content\n' +
'Resource URI: file://document.pdf\n' +
'Resource: Document\n' +
'Resource Description: Important document\n' +
'Resource MIME Type: application/pdf',
},
]);
@ -242,7 +232,6 @@ describe('formatToolContent', () => {
type: 'resource',
resource: {
uri: 'https://example.com/resource',
name: 'Example Resource',
text: '',
},
},
@ -253,7 +242,7 @@ describe('formatToolContent', () => {
expect(content).toEqual([
{
type: 'text',
text: 'Resource URI: https://example.com/resource\n' + 'Resource: Example Resource',
text: 'Resource URI: https://example.com/resource',
},
]);
expect(artifacts).toBeUndefined();
@ -275,7 +264,6 @@ describe('formatToolContent', () => {
type: 'resource',
resource: {
uri: 'file://data.csv',
name: 'Data file',
text: '',
},
},
@ -291,8 +279,7 @@ describe('formatToolContent', () => {
'Resource Text: {"label": "Click me"}\n' +
'Resource URI: ui://button\n' +
'Resource MIME Type: application/json\n\n' +
'Resource URI: file://data.csv\n' +
'Resource: Data file',
'Resource URI: file://data.csv',
},
]);
expect(artifacts).toEqual({
@ -397,8 +384,6 @@ describe('formatToolContent', () => {
type: 'resource',
resource: {
uri: 'https://api.example.com/data',
name: 'API Data',
description: 'External data source',
text: '',
},
},
@ -417,9 +402,7 @@ describe('formatToolContent', () => {
'Resource Text: {"type": "bar"}\n' +
'Resource URI: ui://chart\n' +
'Resource MIME Type: application/json\n\n' +
'Resource URI: https://api.example.com/data\n' +
'Resource: API Data\n' +
'Resource Description: External data source',
'Resource URI: https://api.example.com/data',
},
{ type: 'text', text: 'Conclusion' },
]);

View file

@ -759,15 +759,29 @@ export class MCPConnection extends EventEmitter {
return false;
}
// Check for SSE error with 401 status
if ('message' in error && typeof error.message === 'string') {
return error.message.includes('401') || error.message.includes('Non-200 status code (401)');
}
// Check for error code
if ('code' in error) {
const code = (error as { code?: number }).code;
return code === 401 || code === 403;
if (code === 401 || code === 403) {
return true;
}
}
// Check message for various auth error indicators
if ('message' in error && typeof error.message === 'string') {
const message = error.message.toLowerCase();
// Check for 401 status
if (message.includes('401') || message.includes('non-200 status code (401)')) {
return true;
}
// Check for invalid_token (OAuth servers return this for expired/revoked tokens)
if (message.includes('invalid_token')) {
return true;
}
// Check for authentication required
if (message.includes('authentication required') || message.includes('unauthorized')) {
return true;
}
}
return false;

View file

@ -88,10 +88,21 @@ export class MCPOAuthHandler {
logger.debug(
`[MCPOAuth] Discovering OAuth metadata from ${sanitizeUrlForLogging(authServerUrl)}`,
);
const rawMetadata = await discoverAuthorizationServerMetadata(authServerUrl, {
let rawMetadata = await discoverAuthorizationServerMetadata(authServerUrl, {
fetchFn,
});
// If discovery failed and we're using a path-based URL, try the base URL
if (!rawMetadata && authServerUrl.pathname !== '/') {
const baseUrl = new URL(authServerUrl.origin);
logger.debug(
`[MCPOAuth] Discovery failed with path, trying base URL: ${sanitizeUrlForLogging(baseUrl)}`,
);
rawMetadata = await discoverAuthorizationServerMetadata(baseUrl, {
fetchFn,
});
}
if (!rawMetadata) {
/**
* No metadata discovered - create fallback metadata using default OAuth endpoint paths.
@ -165,6 +176,8 @@ export class MCPOAuthHandler {
response_types: ['code'] as string[],
token_endpoint_auth_method: 'client_secret_basic',
scope: undefined as string | undefined,
logo_uri: undefined as string | undefined,
tos_uri: undefined as string | undefined,
};
const supportedGrantTypes = metadata.grant_types_supported || ['authorization_code'];

View file

@ -56,18 +56,12 @@ function parseAsString(result: t.MCPToolCallResponse): string {
}
if (item.type === 'resource') {
const resourceText = [];
if (item.resource.text != null && item.resource.text) {
if ('text' in item.resource && item.resource.text != null && item.resource.text) {
resourceText.push(item.resource.text);
}
if (item.resource.uri) {
resourceText.push(`Resource URI: ${item.resource.uri}`);
}
if (item.resource.name) {
resourceText.push(`Resource: ${item.resource.name}`);
}
if (item.resource.description) {
resourceText.push(`Description: ${item.resource.description}`);
}
if (item.resource.mimeType != null && item.resource.mimeType) {
resourceText.push(`Type: ${item.resource.mimeType}`);
}
@ -143,18 +137,12 @@ export function formatToolContent(
}
const resourceText = [];
if (item.resource.text != null && item.resource.text) {
if ('text' in item.resource && item.resource.text != null && item.resource.text) {
resourceText.push(`Resource Text: ${item.resource.text}`);
}
if (item.resource.uri.length) {
resourceText.push(`Resource URI: ${item.resource.uri}`);
}
if (item.resource.name) {
resourceText.push(`Resource: ${item.resource.name}`);
}
if (item.resource.description) {
resourceText.push(`Resource Description: ${item.resource.description}`);
}
if (item.resource.mimeType != null && item.resource.mimeType) {
resourceText.push(`Resource MIME Type: ${item.resource.mimeType}`);
}

View file

@ -8,9 +8,16 @@ import {
WebSocketOptionsSchema,
StreamableHTTPOptionsSchema,
} from 'librechat-data-provider';
import type {
EmbeddedResource,
ListToolsResult,
ImageContent,
AudioContent,
TextContent,
Tool,
} from '@modelcontextprotocol/sdk/types.js';
import type { SearchResultData, UIResource, TPlugin } from 'librechat-data-provider';
import type { TokenMethods, JsonSchemaType, IUser } from '@librechat/data-schemas';
import type * as t from '@modelcontextprotocol/sdk/types.js';
import type { FlowStateManager } from '~/flow/manager';
import type { RequestBody } from '~/types/http';
import type * as o from '~/mcp/oauth/types';
@ -57,10 +64,10 @@ export interface MCPPrompt {
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
export type MCPTool = z.infer<typeof t.ToolSchema>;
export type MCPToolListResponse = z.infer<typeof t.ListToolsResultSchema>;
export type ToolContentPart = t.TextContent | t.ImageContent | t.EmbeddedResource | t.AudioContent;
export type ImageContent = Extract<ToolContentPart, { type: 'image' }>;
export type MCPTool = Tool;
export type MCPToolListResponse = ListToolsResult;
export type ToolContentPart = TextContent | ImageContent | EmbeddedResource | AudioContent;
export type { TextContent, ImageContent, EmbeddedResource, AudioContent };
export type MCPToolCallResponse =
| undefined
| {

View file

@ -1,8 +1,8 @@
import type { ClientOptions, OpenAIClientOptions } from '@librechat/agents';
import type { TEndpoint } from 'librechat-data-provider';
import type { TConfig } from 'librechat-data-provider';
import type { EndpointTokenConfig, ServerRequest } from '~/types';
export type TCustomEndpointsConfig = Partial<{ [key: string]: Omit<TEndpoint, 'order'> }>;
export type TCustomEndpointsConfig = Partial<{ [key: string]: Omit<TConfig, 'order'> }>;
/**
* Interface for user key values retrieved from the database

View file

@ -324,7 +324,8 @@ export const endpointSchema = baseEndpointSchema.merge(
defaultParamsEndpoint: z.string().default('custom'),
paramDefinitions: z.array(z.record(z.any())).optional(),
})
.strict(),
.strict()
.optional(),
customOrder: z.number().optional(),
directEndpoint: z.boolean().optional(),
titleMessageRole: z.string().optional(),