diff --git a/apps/docs/integrations/custom-mcp-servers.mdx b/apps/docs/integrations/custom-mcp-servers.mdx index d99f0e69c..b4a57d9e6 100644 --- a/apps/docs/integrations/custom-mcp-servers.mdx +++ b/apps/docs/integrations/custom-mcp-servers.mdx @@ -48,6 +48,20 @@ real credentials per request. This means: and a task cannot run an **Always ask** tool until its session owner allows that exact call. +## Test a remote connection + +After saving a remote server, open **Manage tools** on **Integrations** (or in +Personal Settings for a private server), then select **Test connection**. Each +test makes a fresh request using the saved URL and authentication and reports +whether Roomote can retrieve the server's tool catalog. A server with no tools +can still pass the connection test. + +If a test fails, review the displayed error, correct the server's settings or +reconnect OAuth if needed, and test again. Testing does not execute any tool, +change tool approval choices, or enable the server. It checks the remote +connection from Roomote, not execution inside a task sandbox. Local stdio +servers must be checked in a sandbox instead. + ## OAuth For servers that support the MCP authorization spec, pick **OAuth** and use diff --git a/apps/web/src/components/settings/CustomMcpServers.client.test.tsx b/apps/web/src/components/settings/CustomMcpServers.client.test.tsx index f8019f950..65beec1fc 100644 --- a/apps/web/src/components/settings/CustomMcpServers.client.test.tsx +++ b/apps/web/src/components/settings/CustomMcpServers.client.test.tsx @@ -451,6 +451,178 @@ describe('useCustomMcpServers', () => { ).toBeNull(); }); + it('tests a fresh cached connection, coalesces clicks, and accepts a valid empty catalog', async () => { + state.servers = [buildServer()]; + toolsRequestMock.mockResolvedValueOnce({ + tools: [{ name: 'cached_tool', description: null, enabled: true }], + }); + const { queryClient } = renderHarness(); + queryClient.setQueryDefaults(['customMcpServers', 'listTools'], { + staleTime: Infinity, + }); + fireEvent.click( + await screen.findByRole('button', { + name: 'Manage internal-tools tools', + }), + ); + await screen.findByRole('group', { name: 'Approval mode for cached_tool' }); + let finish!: (result: { tools: typeof state.tools }) => void; + toolsRequestMock.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const test = screen.getByRole('button', { + name: 'Test internal-tools connection', + }); + fireEvent.click(test); + await waitFor(() => expect(toolsRequestMock).toHaveBeenCalledTimes(2)); + expect(test).toBeDisabled(); + expect(test).toHaveAttribute('aria-busy', 'true'); + expect( + screen.getByRole('group', { name: 'Approval mode for cached_tool' }), + ).toBeInTheDocument(); + fireEvent.click(test); + expect(toolsRequestMock).toHaveBeenCalledTimes(2); + finish({ tools: [] }); + await waitFor(() => expect(test).toBeEnabled()); + expect(toastSuccessMock).toHaveBeenCalledWith( + 'Connection test passed for internal-tools.', + ); + expect( + screen.queryByRole('group', { name: 'Approval mode for cached_tool' }), + ).toBeNull(); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('reports repeated test failures and tests again after the cause is removed', async () => { + state.servers = [buildServer()]; + toolsRequestMock.mockResolvedValueOnce({ tools: [] }); + renderHarness(); + fireEvent.click( + await screen.findByRole('button', { + name: 'Manage internal-tools tools', + }), + ); + const test = await screen.findByRole('button', { + name: 'Test internal-tools connection', + }); + await waitFor(() => expect(test).toBeEnabled()); + for (const message of ['Unavailable', 'Still unavailable']) { + toolsRequestMock.mockRejectedValueOnce(new Error(message)); + fireEvent.click(test); + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(message), + ); + expect(toastErrorMock).toHaveBeenLastCalledWith( + 'Connection test failed for internal-tools.', + ); + expect(test).toBeEnabled(); + } + toolsRequestMock.mockResolvedValueOnce({ + tools: [{ name: 'recovered_tool', description: null, enabled: true }], + }); + fireEvent.click(test); + expect( + await screen.findByRole('group', { + name: 'Approval mode for recovered_tool', + }), + ).toBeInTheDocument(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(toastSuccessMock).toHaveBeenCalledWith( + 'Connection test passed for internal-tools.', + ); + expect(toolsRequestMock).toHaveBeenCalledTimes(4); + expect(setEnabledMock).not.toHaveBeenCalled(); + expect(approvals.setMode).not.toHaveBeenCalled(); + }); + + it.each([true, false])( + 'keeps opposite late connection-test outcomes scoped to the tested server (first fails: %s)', + async (firstFails) => { + const first = buildServer(); + const second = buildServer({ + id: '00000000-0000-4000-8000-000000000302', + name: 'second-tools', + }); + state.servers = [first, second]; + toolsRequestMock.mockResolvedValueOnce({ tools: [] }); + renderHarness(); + fireEvent.click( + await screen.findByRole('button', { + name: 'Manage internal-tools tools', + }), + ); + const test = await screen.findByRole('button', { + name: 'Test internal-tools connection', + }); + await waitFor(() => expect(test).toBeEnabled()); + let finish!: (result: { tools: typeof state.tools }) => void; + let fail!: (error: Error) => void; + toolsRequestMock.mockImplementationOnce( + () => + new Promise((resolve, reject) => { + finish = resolve; + fail = reject; + }), + ); + fireEvent.click(test); + await waitFor(() => expect(test).toBeDisabled()); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + if (firstFails) { + toolsRequestMock.mockResolvedValueOnce({ + tools: [{ name: 'second_tool', description: null, enabled: true }], + }); + } else { + toolsRequestMock.mockRejectedValueOnce(new Error('Second unavailable')); + } + fireEvent.click( + await screen.findByRole('button', { + name: 'Manage second-tools tools', + }), + ); + if (firstFails) { + await screen.findByRole('group', { + name: 'Approval mode for second_tool', + }); + fail(new Error('First unavailable')); + } else { + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Second unavailable', + ); + finish({ + tools: [{ name: 'first_tool', description: null, enabled: true }], + }); + } + await waitFor(() => + expect( + firstFails ? toastErrorMock : toastSuccessMock, + ).toHaveBeenCalledWith( + `Connection test ${firstFails ? 'failed' : 'passed'} for internal-tools.`, + ), + ); + expect( + firstFails ? toastSuccessMock : toastErrorMock, + ).not.toHaveBeenCalled(); + if (firstFails) { + expect( + screen.getByRole('group', { name: 'Approval mode for second_tool' }), + ).toBeInTheDocument(); + } else { + expect(screen.getByRole('alert')).toHaveTextContent( + 'Second unavailable', + ); + } + expect( + screen.queryByRole('group', { name: 'Approval mode for first_tool' }), + ).toBeNull(); + expect( + screen.getByRole('button', { name: 'Test second-tools connection' }), + ).toBeEnabled(); + }, + ); + it('keeps a pending retry and its late response scoped to the selected server', async () => { const first = buildServer(); const second = buildServer({ diff --git a/apps/web/src/components/settings/CustomMcpServers.tsx b/apps/web/src/components/settings/CustomMcpServers.tsx index 63a3a9f6f..551684b0b 100644 --- a/apps/web/src/components/settings/CustomMcpServers.tsx +++ b/apps/web/src/components/settings/CustomMcpServers.tsx @@ -755,6 +755,7 @@ function CustomToolManagementDialog({ onOpenChange: (open: boolean) => void; }) { const trpc = useTRPC(); + const queryClient = useQueryClient(); const { isAdmin } = useAuthorizedUser(); const [retryError, setRetryError] = useState<{ serverId: string; @@ -785,13 +786,39 @@ function CustomToolManagementDialog({ setRetryError((current) => (current === error ? null : current)); } }; + const testConnection = async () => { + if (!server || toolsQuery.isFetching) return; + try { + await queryClient.fetchQuery( + trpc.customMcpServers.listTools.queryOptions( + { id: server.id }, + { staleTime: 0, retry: false }, + ), + ); + toast.success(`Connection test passed for ${server.name}.`); + } catch { + toast.error(`Connection test failed for ${server.name}.`); + } + }; return ( - - Manage tools for {server?.name ?? 'integration'} - +
+ + Manage tools for {server?.name ?? 'integration'} + + +
Choose how the model can use tools from this integration.