mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-07 00:15:23 +02:00
Merge c17cb9a85d into 8ed0bcf5ca
This commit is contained in:
commit
633497f443
10 changed files with 312 additions and 9 deletions
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
);
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
108
client/src/hooks/MCP/__tests__/useMCPDeepLink.spec.tsx
Normal file
108
client/src/hooks/MCP/__tests__/useMCPDeepLink.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -5,3 +5,4 @@ export * from './useMCPConnectionStatus';
|
|||
|
||||
export { useMCPIconMap } from './useMCPIconMap';
|
||||
export { useRemoveMCPTool } from './useRemoveMCPTool';
|
||||
export { default as useMCPDeepLink } from './useMCPDeepLink';
|
||||
|
|
|
|||
61
client/src/hooks/MCP/useMCPDeepLink.ts
Normal file
61
client/src/hooks/MCP/useMCPDeepLink.ts
Normal 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 };
|
||||
}
|
||||
24
client/src/routes/MCPAddRedirect.tsx
Normal file
24
client/src/routes/MCPAddRedirect.tsx
Normal 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;
|
||||
}
|
||||
74
client/src/routes/__tests__/MCPAddRedirect.spec.tsx
Normal file
74
client/src/routes/__tests__/MCPAddRedirect.spec.tsx
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 />,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue