From bda5f1c118a59f55107e9e043c270ca9c3bd8d63 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 17 Mar 2026 01:36:39 -0400 Subject: [PATCH 1/4] feat: support initialValues in MCPServerDialog for pre-filling form fields --- .../MCPServerDialog/hooks/useMCPServerForm.ts | 22 +++++++++++-------- .../MCPBuilder/MCPServerDialog/index.tsx | 4 ++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/hooks/useMCPServerForm.ts b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/hooks/useMCPServerForm.ts index 9522f450df..5f74b48864 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/hooks/useMCPServerForm.ts +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/hooks/useMCPServerForm.ts @@ -53,11 +53,17 @@ export interface MCPServerFormData { interface UseMCPServerFormProps { server?: MCPServerDefinition | null; + initialValues?: Partial; 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({ @@ -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) => { diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/index.tsx b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/index.tsx index c9d3473d60..c37ef4223d 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/index.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/index.tsx @@ -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; server?: MCPServerDefinition | null; + initialValues?: Partial; } 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); From 180c023af94d1a24e894e0ba196d5ab4f16604e3 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 17 Mar 2026 01:36:44 -0400 Subject: [PATCH 2/4] feat: add /mcps/add route for deep linking MCP server creation --- client/src/routes/MCPAddRedirect.tsx | 24 ++++++++++++++++++++++++ client/src/routes/index.tsx | 5 +++++ 2 files changed, 29 insertions(+) create mode 100644 client/src/routes/MCPAddRedirect.tsx diff --git a/client/src/routes/MCPAddRedirect.tsx b/client/src/routes/MCPAddRedirect.tsx new file mode 100644 index 0000000000..b0951cd1ab --- /dev/null +++ b/client/src/routes/MCPAddRedirect.tsx @@ -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; +} diff --git a/client/src/routes/index.tsx b/client/src/routes/index.tsx index 871fd3ff86..d460ad393c 100644 --- a/client/src/routes/index.tsx +++ b/client/src/routes/index.tsx @@ -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: , }, + { + path: 'mcps/add', + element: , + }, { path: 'search', element: , From d67669768ad708bceea56a6ed29a4990789c45dc Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 17 Mar 2026 01:36:50 -0400 Subject: [PATCH 3/4] feat: add useMCPDeepLink hook and MCPDeepLinkDialog for deep link state handling --- client/src/components/Chat/ChatView.tsx | 2 + .../MCPBuilder/MCPDeepLinkDialog.tsx | 20 ++++++ client/src/hooks/MCP/index.ts | 1 + client/src/hooks/MCP/useMCPDeepLink.ts | 61 +++++++++++++++++++ 4 files changed, 84 insertions(+) create mode 100644 client/src/components/SidePanel/MCPBuilder/MCPDeepLinkDialog.tsx create mode 100644 client/src/hooks/MCP/useMCPDeepLink.ts diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index 66dec68f64..42311c4cc7 100644 --- a/client/src/components/Chat/ChatView.tsx +++ b/client/src/components/Chat/ChatView.tsx @@ -8,6 +8,7 @@ import type { TMessage } from 'librechat-data-provider'; import type { ChatFormValues } from '~/common'; import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } 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 }) { +
{!isLoading &&
} diff --git a/client/src/components/SidePanel/MCPBuilder/MCPDeepLinkDialog.tsx b/client/src/components/SidePanel/MCPBuilder/MCPDeepLinkDialog.tsx new file mode 100644 index 0000000000..dd1705342d --- /dev/null +++ b/client/src/components/SidePanel/MCPBuilder/MCPDeepLinkDialog.tsx @@ -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 ( + + ); +} diff --git a/client/src/hooks/MCP/index.ts b/client/src/hooks/MCP/index.ts index 705ca58910..d581bef472 100644 --- a/client/src/hooks/MCP/index.ts +++ b/client/src/hooks/MCP/index.ts @@ -3,3 +3,4 @@ export * from './useMCPSelect'; export * from './useVisibleTools'; export * from './useMCPServerManager'; export { useRemoveMCPTool } from './useRemoveMCPTool'; +export { default as useMCPDeepLink } from './useMCPDeepLink'; diff --git a/client/src/hooks/MCP/useMCPDeepLink.ts b/client/src/hooks/MCP/useMCPDeepLink.ts new file mode 100644 index 0000000000..ef598d1ed5 --- /dev/null +++ b/client/src/hooks/MCP/useMCPDeepLink.ts @@ -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(['streamable-http', 'sse']); + +interface MCPDeepLinkState { + mcpName?: string; + mcpUrl?: string; + mcpTransport?: string; +} + +interface MCPDeepLinkResult { + isOpen: boolean; + initialValues: Partial | 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 | undefined>( + undefined, + ); + + useEffect(() => { + const state = location.state as MCPDeepLinkState | null; + if (!state?.mcpName && !state?.mcpUrl) { + return; + } + + const values: Partial = {}; + 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 }; +} From c17cb9a85d007b2444de5e33c5bddb37144e1bf4 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 17 Mar 2026 01:36:57 -0400 Subject: [PATCH 4/4] test: add tests for MCPAddRedirect and useMCPDeepLink --- .../MCP/__tests__/useMCPDeepLink.spec.tsx | 108 ++++++++++++++++++ .../routes/__tests__/MCPAddRedirect.spec.tsx | 74 ++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 client/src/hooks/MCP/__tests__/useMCPDeepLink.spec.tsx create mode 100644 client/src/routes/__tests__/MCPAddRedirect.spec.tsx diff --git a/client/src/hooks/MCP/__tests__/useMCPDeepLink.spec.tsx b/client/src/hooks/MCP/__tests__/useMCPDeepLink.spec.tsx new file mode 100644 index 0000000000..4815f682ee --- /dev/null +++ b/client/src/hooks/MCP/__tests__/useMCPDeepLink.spec.tsx @@ -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 }) { + return ; +} + +function renderDeepLinkHook(state?: Record) { + let hookResult: { current: ReturnType }; + + function HookConsumer() { + hookResult = { current: useMCPDeepLink() }; + return null; + } + + const router = createMemoryRouter( + [ + { + path: 'c/:conversationId', + element: , + }, + ], + { + initialEntries: [{ pathname: '/c/new', state }], + }, + ); + + const renderResult = renderHook(() => hookResult!.current, { + wrapper: ({ children }) => ( + <> + + {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(); + }); +}); diff --git a/client/src/routes/__tests__/MCPAddRedirect.spec.tsx b/client/src/routes/__tests__/MCPAddRedirect.spec.tsx new file mode 100644 index 0000000000..fbcaa00791 --- /dev/null +++ b/client/src/routes/__tests__/MCPAddRedirect.spec.tsx @@ -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
Chat
; +} + +const createTestRouter = (initialEntry: string) => + createMemoryRouter( + [ + { + path: 'mcps/add', + element: , + }, + { + path: 'c/:conversationId', + element: , + }, + ], + { 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(); + + 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(); + + await waitFor(() => { + expect(router.state.location.pathname).toBe('/c/new'); + }); + + expect((window as any).__capturedState).toEqual({ + mcpName: undefined, + mcpUrl: undefined, + mcpTransport: undefined, + }); + }); +});