This commit is contained in:
Danny Avila 2026-04-05 02:29:09 +00:00 committed by GitHub
commit 633497f443
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 312 additions and 9 deletions

View file

@ -8,6 +8,7 @@ import type { TMessage } from 'librechat-data-provider';
import type { ChatFormValues } from '~/common';
import { ChatContext, AddedChatContext, ChatFormProvider, useFileMapContext } from '~/Providers';
import { useAddedResponse, useResumeOnLoad, useAdaptiveSSE, useChatHelpers } from '~/hooks';
import MCPDeepLinkDialog from '~/components/SidePanel/MCPBuilder/MCPDeepLinkDialog';
import ConversationStarters from './Input/ConversationStarters';
import { useGetMessagesByConvoId } from '~/data-provider';
import MessagesView from './Messages/MessagesView';
@ -80,6 +81,7 @@ function ChatView({ index = 0 }: { index?: number }) {
<ChatFormProvider {...methods}>
<ChatContext.Provider value={chatHelpers}>
<AddedChatContext.Provider value={addedChatHelpers}>
<MCPDeepLinkDialog />
<Presentation>
<div className="relative flex h-full w-full flex-col">
<Header />

View file

@ -0,0 +1,20 @@
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { useMCPDeepLink, useHasAccess } from '~/hooks';
import MCPServerDialog from './MCPServerDialog';
export default function MCPDeepLinkDialog() {
const { isOpen, initialValues, onOpenChange } = useMCPDeepLink();
const hasCreateAccess = useHasAccess({
permissionType: PermissionTypes.MCP_SERVERS,
permission: Permissions.CREATE,
});
if (!hasCreateAccess) {
return null;
}
return (
<MCPServerDialog open={isOpen} onOpenChange={onOpenChange} initialValues={initialValues} />
);
}

View file

@ -53,11 +53,17 @@ export interface MCPServerFormData {
interface UseMCPServerFormProps {
server?: MCPServerDefinition | null;
initialValues?: Partial<MCPServerFormData>;
onSuccess?: (serverName: string, isOAuth: boolean) => void;
onClose?: () => void;
}
export function useMCPServerForm({ server, onSuccess, onClose }: UseMCPServerFormProps) {
export function useMCPServerForm({
server,
initialValues,
onSuccess,
onClose,
}: UseMCPServerFormProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
@ -111,11 +117,11 @@ export function useMCPServerForm({ server, onSuccess, onClose }: UseMCPServerFor
}
return {
title: '',
description: '',
url: '',
type: 'streamable-http',
icon: '',
title: initialValues?.title ?? '',
description: initialValues?.description ?? '',
url: initialValues?.url ?? '',
type: initialValues?.type ?? 'streamable-http',
icon: initialValues?.icon ?? '',
auth: {
auth_type: AuthTypeEnum.None,
api_key: '',
@ -130,7 +136,7 @@ export function useMCPServerForm({ server, onSuccess, onClose }: UseMCPServerFor
},
trust: false,
};
}, [server]);
}, [server, initialValues]);
// Form instance
const methods = useForm<MCPServerFormData>({
@ -142,8 +148,6 @@ export function useMCPServerForm({ server, onSuccess, onClose }: UseMCPServerFor
// Watch URL for auto-fill
const watchedUrl = watch('url');
const watchedTitle = watch('title');
// Auto-fill title from URL when title is empty
const handleUrlChange = useCallback(
(url: string) => {

View file

@ -23,6 +23,7 @@ import {
} from 'librechat-data-provider';
import { useAuthContext, useHasAccess, useResourcePermissions, MCPServerDefinition } from '~/hooks';
import { GenericGrantAccessDialog } from '~/components/Sharing';
import type { MCPServerFormData } from './hooks/useMCPServerForm';
import { useMCPServerForm } from './hooks/useMCPServerForm';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import MCPServerForm from './MCPServerForm';
@ -33,6 +34,7 @@ interface MCPServerDialogProps {
children?: React.ReactNode;
triggerRef?: React.MutableRefObject<HTMLDivElement | HTMLButtonElement | null>;
server?: MCPServerDefinition | null;
initialValues?: Partial<MCPServerFormData>;
}
export default function MCPServerDialog({
@ -41,6 +43,7 @@ export default function MCPServerDialog({
children,
triggerRef,
server,
initialValues,
}: MCPServerDialogProps) {
const localize = useLocalize();
const { user } = useAuthContext();
@ -55,6 +58,7 @@ export default function MCPServerDialog({
// Form hook
const formHook = useMCPServerForm({
server,
initialValues,
onSuccess: (serverName, isOAuth) => {
if (isOAuth) {
setCreatedServerId(serverName);

View file

@ -0,0 +1,108 @@
import React from 'react';
import { renderHook, act } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import useMCPDeepLink from '../useMCPDeepLink';
if (typeof Request === 'undefined') {
global.Request = class Request {
constructor(
public url: string,
public init?: RequestInit,
) {}
} as any;
}
function TestWrapper({ router }: { router: ReturnType<typeof createMemoryRouter> }) {
return <RouterProvider router={router} />;
}
function renderDeepLinkHook(state?: Record<string, unknown>) {
let hookResult: { current: ReturnType<typeof useMCPDeepLink> };
function HookConsumer() {
hookResult = { current: useMCPDeepLink() };
return null;
}
const router = createMemoryRouter(
[
{
path: 'c/:conversationId',
element: <HookConsumer />,
},
],
{
initialEntries: [{ pathname: '/c/new', state }],
},
);
const renderResult = renderHook(() => hookResult!.current, {
wrapper: ({ children }) => (
<>
<TestWrapper router={router} />
{children}
</>
),
});
return { ...renderResult, router, getHook: () => hookResult!.current };
}
describe('useMCPDeepLink', () => {
it('should open dialog with initialValues from route state including valid transport', async () => {
const { getHook } = renderDeepLinkHook({
mcpName: 'My Server',
mcpUrl: 'https://example.com/mcp',
mcpTransport: 'sse',
});
await act(async () => {});
const result = getHook();
expect(result.isOpen).toBe(true);
expect(result.initialValues).toEqual({
title: 'My Server',
url: 'https://example.com/mcp',
type: 'sse',
});
});
it('should ignore an invalid mcpTransport value', async () => {
const { getHook } = renderDeepLinkHook({
mcpName: 'Server',
mcpTransport: 'websocket',
});
await act(async () => {});
const result = getHook();
expect(result.isOpen).toBe(true);
expect(result.initialValues).toEqual({ title: 'Server' });
expect(result.initialValues).not.toHaveProperty('type');
});
it('should not open the dialog when route state has no MCP params', async () => {
const { getHook } = renderDeepLinkHook(undefined);
await act(async () => {});
const result = getHook();
expect(result.isOpen).toBe(false);
expect(result.initialValues).toBeUndefined();
});
it('should clear initialValues when dialog is closed via onOpenChange', async () => {
const { getHook } = renderDeepLinkHook({ mcpName: 'Server' });
await act(async () => {});
expect(getHook().isOpen).toBe(true);
expect(getHook().initialValues).toEqual({ title: 'Server' });
act(() => {
getHook().onOpenChange(false);
});
expect(getHook().isOpen).toBe(false);
expect(getHook().initialValues).toBeUndefined();
});
});

View file

@ -5,3 +5,4 @@ export * from './useMCPConnectionStatus';
export { useMCPIconMap } from './useMCPIconMap';
export { useRemoveMCPTool } from './useRemoveMCPTool';
export { default as useMCPDeepLink } from './useMCPDeepLink';

View file

@ -0,0 +1,61 @@
import { useState, useEffect, useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import type { MCPServerFormData } from '~/components/SidePanel/MCPBuilder/MCPServerDialog/hooks/useMCPServerForm';
const VALID_TRANSPORTS = new Set<MCPServerFormData['type']>(['streamable-http', 'sse']);
interface MCPDeepLinkState {
mcpName?: string;
mcpUrl?: string;
mcpTransport?: string;
}
interface MCPDeepLinkResult {
isOpen: boolean;
initialValues: Partial<MCPServerFormData> | undefined;
onOpenChange: (open: boolean) => void;
}
export default function useMCPDeepLink(): MCPDeepLinkResult {
const location = useLocation();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [initialValues, setInitialValues] = useState<Partial<MCPServerFormData> | undefined>(
undefined,
);
useEffect(() => {
const state = location.state as MCPDeepLinkState | null;
if (!state?.mcpName && !state?.mcpUrl) {
return;
}
const values: Partial<MCPServerFormData> = {};
if (state.mcpName) {
values.title = state.mcpName;
}
if (state.mcpUrl) {
values.url = state.mcpUrl;
}
if (
state.mcpTransport &&
VALID_TRANSPORTS.has(state.mcpTransport as MCPServerFormData['type'])
) {
values.type = state.mcpTransport as MCPServerFormData['type'];
}
setInitialValues(values);
setIsOpen(true);
navigate(location.pathname, { replace: true, state: {} });
}, [location.state, location.pathname, navigate]);
const onOpenChange = useCallback((open: boolean) => {
setIsOpen(open);
if (!open) {
setInitialValues(undefined);
}
}, []);
return { isOpen, initialValues, onOpenChange };
}

View file

@ -0,0 +1,24 @@
import { useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
const MCP_NAME_PARAM = 'name';
const MCP_URL_PARAM = 'url';
const MCP_TRANSPORT_PARAM = 'transport';
export default function MCPAddRedirect() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
useEffect(() => {
const mcpName = searchParams.get(MCP_NAME_PARAM) ?? undefined;
const mcpUrl = searchParams.get(MCP_URL_PARAM) ?? undefined;
const mcpTransport = searchParams.get(MCP_TRANSPORT_PARAM) ?? undefined;
navigate('/c/new', {
replace: true,
state: { mcpName, mcpUrl, mcpTransport },
});
}, [searchParams, navigate]);
return null;
}

View file

@ -0,0 +1,74 @@
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router-dom';
import MCPAddRedirect from '../MCPAddRedirect';
if (typeof Request === 'undefined') {
global.Request = class Request {
constructor(
public url: string,
public init?: RequestInit,
) {}
} as any;
}
function CaptureState() {
const location = useLocation();
(window as any).__capturedState = location.state;
// eslint-disable-next-line i18next/no-literal-string
return <div data-testid="chat-page">Chat</div>;
}
const createTestRouter = (initialEntry: string) =>
createMemoryRouter(
[
{
path: 'mcps/add',
element: <MCPAddRedirect />,
},
{
path: 'c/:conversationId',
element: <CaptureState />,
},
],
{ initialEntries: [initialEntry] },
);
describe('MCPAddRedirect', () => {
afterEach(() => {
(window as any).__capturedState = undefined;
});
it('should redirect to /c/new forwarding all params via route state', async () => {
const router = createTestRouter(
'/mcps/add?name=My+Server&url=https://example.com/mcp&transport=sse',
);
render(<RouterProvider router={router} />);
await waitFor(() => {
expect(router.state.location.pathname).toBe('/c/new');
});
expect(router.state.historyAction).toBe('REPLACE');
expect((window as any).__capturedState).toEqual({
mcpName: 'My Server',
mcpUrl: 'https://example.com/mcp',
mcpTransport: 'sse',
});
});
it('should redirect to /c/new even when no query params are provided', async () => {
const router = createTestRouter('/mcps/add');
render(<RouterProvider router={router} />);
await waitFor(() => {
expect(router.state.location.pathname).toBe('/c/new');
});
expect((window as any).__capturedState).toEqual({
mcpName: undefined,
mcpUrl: undefined,
mcpTransport: undefined,
});
});
});

View file

@ -16,6 +16,7 @@ import RouteErrorBoundary from './RouteErrorBoundary';
import StartupLayout from './Layouts/Startup';
import LoginLayout from './Layouts/Login';
import dashboardRoutes from './Dashboard';
import MCPAddRedirect from './MCPAddRedirect';
import ShareRoute from './ShareRoute';
import ChatRoute from './ChatRoute';
import Search from './Search';
@ -107,6 +108,10 @@ export const router = createBrowserRouter(
path: 'c/:conversationId?',
element: <ChatRoute />,
},
{
path: 'mcps/add',
element: <MCPAddRedirect />,
},
{
path: 'search',
element: <Search />,