diff --git a/docs/superpowers/plans/2026-08-22-dynamic-client-connection-guide-plan.md b/docs/superpowers/plans/2026-08-22-dynamic-client-connection-guide-plan.md new file mode 100644 index 00000000..af696d0b --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-dynamic-client-connection-guide-plan.md @@ -0,0 +1,193 @@ +# Dynamic Client Connection Guide Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Redesign the Client Connection Guide in the MCP Router web dashboard to dynamically generate standard JSON configurations based on domain, server scope, meta-mode toggle, and user App Keys, and embed it across Overview, App Keys, and My MCP Servers pages. + +**Architecture:** A unified React component `ClientSetupGuide.tsx` that queries active servers and user App Keys on mount, computes the exact client configuration JSON in real time for Standard (`mcpServers`), VS Code (`mcp.json`), and Generic SSE endpoints, provides a 1-click clipboard copy action, and is mounted cleanly in `DashboardView.tsx`, `SecurityView.tsx`, and `MyMcpServers.tsx`. + +**Tech Stack:** React 18, TypeScript, Vitest, Testing Library, Vite, .NET 8, Docker. + +## Global Constraints + +- **Clients Covered:** Standard `mcpServers` JSON (Claude Desktop, Antigravity / AGY, Cursor, Cline, Roo), VS Code `mcp.json`, and Generic SSE. +- **Dynamic Selectors:** Host Domain (`window.location.origin` / `http://10.0.0.10:8026` / Custom), Server Scope (`all` vs specific server ID), Meta Mode (`?meta=true` vs `?meta=false`), App Key Selector. +- **Output:** Clean, formatted JSON code block with one-click "Copy JSON" button. +- **Placement:** Overview (`DashboardView`), App Keys (`SecurityView`), and My MCP Servers (`MyMcpServers`). + +--- + +### Task 1: Redesign `ClientSetupGuide.tsx` Component + +**Files:** +- Modify: `frontend/src/components/clients/ClientSetupGuide.tsx` +- Test: `frontend/src/test/components/ClientSetupGuide.test.tsx` + +**Interfaces:** +- Consumes: `fetchServersApi()` from `api/serverApi.ts`, `fetchAppKeysApi()` from `api/appKeyApi.ts`, `showToast()` from `stores/useToastStore.ts`. +- Produces: `` React functional component. + +- [ ] **Step 1: Write the updated failing unit test suite in `frontend/src/test/components/ClientSetupGuide.test.tsx`** + +```tsx +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { ClientSetupGuide } from '../../components/clients/ClientSetupGuide'; +import * as serverApi from '../../api/serverApi'; +import * as appKeyApi from '../../api/appKeyApi'; + +vi.mock('../../api/serverApi'); +vi.mock('../../api/appKeyApi'); + +describe('ClientSetupGuide Component', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(serverApi.fetchServersApi).mockResolvedValue([ + { id: 'ha', displayName: 'Home Assistant', url: 'http://ha:8086/mcp', enabled: true, hidden: false, type: 'http', categories: [] }, + { id: 'docker', displayName: 'Docker Containers', url: 'http://docker:8000/sse', enabled: true, hidden: false, type: 'sse', categories: [] } + ]); + vi.mocked(appKeyApi.fetchAppKeysApi).mockResolvedValue([ + { id: 'key1', name: 'Work Laptop', username: 'spelech', keyPrefix: 'mcp_live_abc123', keyType: 'personal', createdAt: '2026-08-01' } + ]); + }); + + it('renders default standard mcpServers configuration with meta mode', async () => { + render(); + + expect(screen.getByText('Client Connection Guide')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText(/mcpServers/i)).toBeInTheDocument(); + }); + + // Check default URL has meta=true + expect(screen.getByText(/\/sse\?meta=true/i)).toBeInTheDocument(); + }); + + it('switches between format tabs (Standard, VS Code, Generic SSE)', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/mcpServers/i)).toBeInTheDocument(); + }); + + // Switch to VS Code + const vscodeBtn = screen.getByRole('button', { name: /VS Code/i }); + fireEvent.click(vscodeBtn); + expect(screen.getByText(/"type":\s*"sse"/i)).toBeInTheDocument(); + + // Switch to Generic SSE + const genericBtn = screen.getByRole('button', { name: /Generic SSE/i }); + fireEvent.click(genericBtn); + expect(screen.getByText(/sseEndpoint/i)).toBeInTheDocument(); + }); + + it('switches server scope from all servers to individual server', async () => { + render(); + + await waitFor(() => { + expect(screen.getByDisplayValue('all')).toBeInTheDocument(); + }); + + const serverSelect = screen.getByTestId('server-scope-select'); + fireEvent.change(serverSelect, { target: { value: 'docker' } }); + + expect(screen.getByText(/\/docker/i)).toBeInTheDocument(); + }); + + it('updates domain when LAN or custom is chosen', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/mcpServers/i)).toBeInTheDocument(); + }); + + const lanBtn = screen.getByRole('button', { name: /Local LAN/i }); + fireEvent.click(lanBtn); + expect(screen.getByText(/10\.0\.0\.10:8026/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: `npm --prefix /containers/dev/csharp-mcp-router/frontend test src/test/components/ClientSetupGuide.test.tsx` +Expected: FAIL with missing elements/data-testids. + +- [ ] **Step 3: Implement `ClientSetupGuide.tsx`** + +Implement `frontend/src/components/clients/ClientSetupGuide.tsx` with: +- Format tab buttons: Standard JSON, VS Code `mcp.json`, Generic SSE. +- Domain selector toolbar: Current Origin, Local LAN (`http://10.0.0.10:8026`), Custom URL input. +- Server scope dropdown: All Servers (`all`) + list of active MCP servers. +- Meta-mode toggle (when `all` is selected). +- App key dropdown listing fetched keys from `fetchAppKeysApi()` with fallback to `"mcp_live_YOUR_APP_KEY_HERE"`. +- One-click copy JSON button copying text to clipboard and calling `showToast('Configuration copied to clipboard!', 'success')`. + +- [ ] **Step 4: Run unit tests to verify they pass** + +Run: `npm --prefix /containers/dev/csharp-mcp-router/frontend test src/test/components/ClientSetupGuide.test.tsx` +Expected: PASS all tests. + +- [ ] **Step 5: Commit task changes** + +Run: `git -C /containers/dev/csharp-mcp-router add frontend/src/components/clients/ClientSetupGuide.tsx frontend/src/test/components/ClientSetupGuide.test.tsx && git -C /containers/dev/csharp-mcp-router commit -m "feat(frontend): implement dynamic multi-target client setup guide"` + +--- + +### Task 2: Embed `ClientSetupGuide` Across Views + +**Files:** +- Modify: `frontend/src/pages/MyMcpServers.tsx` +- Modify: `frontend/src/components/security/SecurityView.tsx` +- Modify: `frontend/src/components/servers/DashboardView.tsx` + +- [ ] **Step 1: Embed `` in `MyMcpServers.tsx`** + +Import and render `` inside `frontend/src/pages/MyMcpServers.tsx` below the credentials table in a container with margin-top: 25px. + +- [ ] **Step 2: Verify `SecurityView.tsx` and `DashboardView.tsx` render `` cleanly** + +Ensure `` is rendered consistently with responsive layout. + +- [ ] **Step 3: Run full frontend test suite and production build** + +Run: `npm --prefix /containers/dev/csharp-mcp-router/frontend test` +Run: `npm --prefix /containers/dev/csharp-mcp-router/frontend run build` +Expected: All tests pass, build succeeds cleanly into `wwwroot`. + +- [ ] **Step 4: Commit task changes** + +Run: `git -C /containers/dev/csharp-mcp-router add frontend/src/pages/MyMcpServers.tsx frontend/src/components/security/SecurityView.tsx frontend/src/components/servers/DashboardView.tsx && git -C /containers/dev/csharp-mcp-router commit -m "feat(frontend): embed dynamic client connection guide in My MCP Servers view"` + +--- + +### Task 3: Build & Deploy Container Image to GHCR and Homelab Stack + +**Files:** +- Modify: `/containers/mcp/docker-compose.yaml` (if needed) + +- [ ] **Step 1: Build C# router solution and run test suite** + +Run: `dotnet test /containers/dev/csharp-mcp-router/McpRouter.slnx` +Expected: All backend tests pass. + +- [ ] **Step 2: Build new Docker container image** + +Run: `docker build -t ghcr.io/spelech/csharp-mcp-router:latest /containers/dev/csharp-mcp-router` +Expected: Build succeeds and updates local image cache. + +- [ ] **Step 3: Restart `mcp-router` container** + +Run: `docker compose -f /containers/mcp/docker-compose.yaml up -d --force-recreate mcp-router` +Expected: Container starts cleanly and reports healthy status. + +- [ ] **Step 4: Empirical verification of dashboard and endpoints** + +Run: `curl -s http://10.0.0.10:8026/health` +Run: `curl -s -k --resolve mcp.wileyriley.com:443:10.0.0.10 https://mcp.wileyriley.com/health` +Verify HTTP 200 responses. + +- [ ] **Step 5: Run homelab atomic commit workflow** + +Run: `docker run --rm -v /containers/webservices/caddy/www:/www alpine chown -R 1000:1000 /www && ./commit.sh "feat(mcp-router): deploy updated dynamic client connection guide"` diff --git a/docs/superpowers/specs/2026-08-22-dynamic-client-connection-guide-design.md b/docs/superpowers/specs/2026-08-22-dynamic-client-connection-guide-design.md new file mode 100644 index 00000000..29738d13 --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-dynamic-client-connection-guide-design.md @@ -0,0 +1,101 @@ +# Design Spec: Dynamic Multi-Target Client Connection Guide + +**Date**: 2026-08-22 +**Status**: Approved + +--- + +## 1. Overview +The MCP Router gateway provides both unified gateway capabilities (`/sse?meta=true` for lightweight tool-search, `/sse?meta=false` for eager tool aggregation) and individual direct server routes (`/{serverId}`). Users need an interactive, dynamic client setup guide in the web dashboard that allows customizing the host domain, target server scope, meta-mode toggle, and user App Key selection, producing standard JSON configurations for AI clients (Claude Desktop, AGY, Cursor, VS Code, and Generic SSE). + +--- + +## 2. UI Components & Interaction Flow + +### 2.1 Configuration Controls (`ClientSetupGuide.tsx`) +The `ClientSetupGuide` component exposes: +1. **Client Format Tabs**: + - `standard`: Standard `mcpServers` JSON (Claude Desktop, Antigravity / AGY, Cursor, Cline, Roo Code). + - `vscode`: VS Code Extension `mcp.json` format. + - `generic`: Generic SSE and HTTP session endpoint breakdown. +2. **Domain / Host Selector**: + - `Current Host`: Uses `window.location.origin` (e.g., `https://mcp.wileyriley.com`). + - `Local LAN`: Uses `http://10.0.0.10:8026`. + - `Custom`: Allows entering an arbitrary URL or IP. +3. **Server Target Scope Selector**: + - `All Servers (Unified Gateway)`: Routes through `/sse`. + - `Specific MCP Server`: Dropdown dynamically populated from the active MCP servers list (e.g., `ha`, `docker`, `actual`, `seerr`, `contextcortex`, `quickcreds`). +4. **Meta-Mode Toggle** (enabled when All Servers is selected): + - `Meta-Mode (Recommended)`: Appends `?meta=true` for dynamic `search_tools` and `execute_tool`. + - `Direct / All Tools`: Appends `?meta=false` to eagerly expose all registered backend tools. +5. **App Key Selector**: + - Automatically queries user App Keys from `/api/appkeys` to populate a selection dropdown. + - Defaults to `"mcp_live_YOUR_APP_KEY_HERE"` if no keys exist. + +### 2.2 Live Code Preview & Actions +- Code snippet re-renders immediately on any state change with syntax formatting. +- **Copy Configuration** button copies the JSON directly to the clipboard and triggers a success toast notification. + +--- + +## 3. Formats & Schemas + +### Format 1: Standard `mcpServers` JSON +```json +{ + "mcpServers": { + "mcp-router": { + "url": "https://mcp.wileyriley.com/sse?meta=true", + "headers": { + "X-App-Key": "mcp_live_..." + } + } + } +} +``` + +### Format 2: VS Code Settings (`mcp.json`) +```json +{ + "mcp": { + "servers": { + "mcp-router": { + "type": "sse", + "url": "https://mcp.wileyriley.com/sse?meta=true", + "headers": { + "X-App-Key": "mcp_live_..." + } + } + } + } +} +``` + +### Format 3: Generic SSE / Raw Endpoints +```json +{ + "sseEndpoint": "https://mcp.wileyriley.com/sse?meta=true", + "messageEndpoint": "https://mcp.wileyriley.com/message?sessionId={sessionId}", + "authHeader": "X-App-Key: mcp_live_..." +} +``` + +--- + +## 4. Placement & Visibility + +The `ClientSetupGuide` component is rendered in three key areas: +1. **Overview (`DashboardView.tsx`)**: Underneath the main server grid. +2. **App Keys (`SecurityView.tsx`)**: Underneath the App Keys management card. +3. **My MCP Servers (`MyMcpServers.tsx`)**: Underneath the user credentials table. + +--- + +## 5. Verification & Testing Plan +1. **Unit Tests**: + - `frontend/src/test/components/ClientSetupGuide.test.tsx` verifying format selection, URL construction, custom domain input, and clipboard copying. +2. **Frontend Build**: + - Run `npm test` and `npm run build` in `/containers/dev/csharp-mcp-router/frontend`. +3. **Container Build & Deployment**: + - Rebuild `ghcr.io/spelech/csharp-mcp-router:latest` with the updated frontend assets. + - Restart `mcp-router` container and verify live in the web dashboard. diff --git a/frontend/src/components/clients/ClientSetupGuide.tsx b/frontend/src/components/clients/ClientSetupGuide.tsx index 4bf8e34a..b58e6cf1 100644 --- a/frontend/src/components/clients/ClientSetupGuide.tsx +++ b/frontend/src/components/clients/ClientSetupGuide.tsx @@ -1,132 +1,333 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; +import { fetchServersApi } from '../../api/serverApi'; +import { fetchAppKeysApi } from '../../api/appKeyApi'; +import { showToast } from '../../stores/useToastStore'; +import { McpServer, AppKeyItem } from '../../shared/types'; export const ClientSetupGuide: React.FC = () => { - const [selectedClient, setSelectedClient] = useState<'claude' | 'cursor' | 'cline' | 'generic'>('cursor'); + const [selectedFormat, setSelectedFormat] = useState<'standard' | 'vscode' | 'generic'>('standard'); + const [domainOption, setDomainOption] = useState<'origin' | 'lan' | 'custom'>('origin'); + const [customDomain, setCustomDomain] = useState('https://mcp.wileyriley.com'); + const [serverScope, setServerScope] = useState('all'); + const [metaMode, setMetaMode] = useState(true); + const [selectedKey, setSelectedKey] = useState(''); + const [servers, setServers] = useState([]); + const [appKeys, setAppKeys] = useState([]); - const renderConfig = () => { - switch (selectedClient) { - case 'claude': - return ( -
-

- Add the router to your Claude Desktop configuration file (claude_desktop_config.json): -

-
-              {JSON.stringify(
-                {
-                  mcpServers: {
-                    "mcp-router": {
-                      url: "http://10.0.0.10:8026/sse",
-                      headers: {
-                        "X-App-Key": "mcp_live_YOUR_APP_KEY_HERE"
-                      }
-                    }
-                  }
-                },
-                null,
-                2
-              )}
-            
-
- ); - case 'cursor': - return ( -
-

- In Cursor IDE Settings > Features > MCP Servers, click "Add New MCP Server": -

-
    -
  • Name: mcp-router
  • -
  • Type: sse
  • -
  • Server URL: http://10.0.0.10:8026/sse
  • -
  • Headers: X-App-Key: mcp_live_YOUR_APP_KEY_HERE
  • -
-
- ); - case 'cline': - return ( -
-

- In VSCode Cline / Roo-Code MCP Settings (cline_mcp_settings.json): -

-
-              {JSON.stringify(
-                {
-                  mcpServers: {
-                    "mcp-router": {
-                      url: "http://10.0.0.10:8026/sse",
-                      type: "sse",
-                      headers: {
-                        "X-App-Key": "mcp_live_YOUR_APP_KEY_HERE"
-                      }
-                    }
-                  }
-                },
-                null,
-                2
-              )}
-            
-
- ); + useEffect(() => { + let isMounted = true; + Promise.all([ + fetchServersApi().catch(() => []), + fetchAppKeysApi().catch(() => []) + ]).then(([fetchedServers, fetchedKeys]) => { + if (isMounted) { + setServers(fetchedServers || []); + setAppKeys(fetchedKeys || []); + } + }); + return () => { + isMounted = false; + }; + }, []); + + const getBaseUrl = (): string => { + if (domainOption === 'lan') { + return 'http://10.0.0.10:8026'; + } + if (domainOption === 'custom') { + return customDomain.trim() || 'http://10.0.0.10:8026'; + } + if (typeof window !== 'undefined' && window.location && window.location.origin && window.location.origin !== 'null') { + return window.location.origin; + } + return 'http://10.0.0.10:8026'; + }; + + const baseUrl = getBaseUrl().replace(/\/+$/, ''); + + const getEndpointUrl = (): string => { + if (serverScope === 'all') { + return `${baseUrl}/sse?meta=${metaMode ? 'true' : 'false'}`; + } + return `${baseUrl}/${serverScope}`; + }; + + const endpointUrl = getEndpointUrl(); + const effectiveKey = selectedKey || 'mcp_live_YOUR_APP_KEY_HERE'; + + const getConfigObject = () => { + switch (selectedFormat) { + case 'vscode': + return { + "mcp.servers": { + "mcp-router": { + "type": "sse", + "url": endpointUrl, + "headers": { + "X-App-Key": effectiveKey + } + } + } + }; case 'generic': + return { + "sseEndpoint": endpointUrl, + "messageEndpoint": `${baseUrl}/message?sessionId={sessionId}`, + "authHeader": `X-App-Key: ${effectiveKey}` + }; + case 'standard': default: - return ( -
-

- Direct connection via SSE transport: -

-
    -
  • SSE Endpoint: http://10.0.0.10:8026/sse
  • -
  • HTTP Message Endpoint: http://10.0.0.10:8026/messages?sessionId=<session_id>
  • -
  • Authentication: Include header X-App-Key: <your_key> or Authorization: Bearer <token>
  • -
-
- ); + return { + "mcpServers": { + "mcp-router": { + "url": endpointUrl, + "headers": { + "X-App-Key": effectiveKey + } + } + } + }; + } + }; + + const configJson = JSON.stringify(getConfigObject(), null, 2); + + const handleCopy = async () => { + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(configJson); + } + showToast('Configuration copied to clipboard!', 'success'); + } catch { + showToast('Failed to copy configuration to clipboard', 'error'); } }; return (
-

- Client Connection Guide -

-

- Connect your preferred AI client or IDE extension to the unified MCP Router gateway. -

- -
- +
+
+

+ Client Connection Guide +

+

+ Connect your preferred AI client or IDE extension to the unified MCP Router gateway. +

+
+
+ +
-
- {renderConfig()} +
+ {/* Domain Selector */} +
+ +
+ + + +
+ {domainOption === 'custom' && ( + setCustomDomain(e.target.value)} + placeholder="https://example.com" + style={{ + width: '100%', + padding: '6px 10px', + borderRadius: '6px', + background: 'rgba(0,0,0,0.3)', + color: '#fff', + border: '1px solid var(--glass-border)', + fontSize: '12px' + }} + /> + )} +
+ + {/* Server Scope */} +
+ + +
+ + {/* Meta-Mode Toggle */} + {serverScope === 'all' && ( +
+ + +
+ )} + + {/* App Key */} +
+ + +
+
+ +
+
+ + {selectedFormat === 'standard' && 'Add to your client configuration file (claude_desktop_config.json / agy settings / Cursor / Cline):'} + {selectedFormat === 'vscode' && 'Add to your VS Code MCP configuration (mcp.json):'} + {selectedFormat === 'generic' && 'Direct connection endpoints for custom SSE MCP clients:'} + + +
+ +
+          {configJson}
+        
); diff --git a/frontend/src/pages/MyMcpServers.tsx b/frontend/src/pages/MyMcpServers.tsx index 487e662d..baba932e 100644 --- a/frontend/src/pages/MyMcpServers.tsx +++ b/frontend/src/pages/MyMcpServers.tsx @@ -3,6 +3,7 @@ import { McpServer } from '../shared/types'; import { fetchServersApi } from '../api/serverApi'; import { fetchUserCredentialsApi, saveUserCredentialApi, UserCredential } from '../api/userCredentialsApi'; import { showToast } from '../stores/useToastStore'; +import { ClientSetupGuide } from '../components/clients/ClientSetupGuide'; export const MyMcpServers: React.FC = () => { const [servers, setServers] = useState([]); @@ -98,6 +99,10 @@ export const MyMcpServers: React.FC = () => {
+
+ +
+ {editingServer && (
setEditingServer(null)}>
e.stopPropagation()}> diff --git a/frontend/src/test/components/App.test.tsx b/frontend/src/test/components/App.test.tsx index f7737680..18f28e0a 100644 --- a/frontend/src/test/components/App.test.tsx +++ b/frontend/src/test/components/App.test.tsx @@ -54,8 +54,9 @@ describe('App component', () => { }); expect(secTab).toHaveClass('active'); expect(overTab).not.toHaveClass('active'); - // Admin security view includes Registered Clients Card + // Admin security view includes Registered Clients Card and ClientSetupGuide expect(screen.getByText(/Dynamic Client Registration \(RFC 7591\)/i)).toBeInTheDocument(); + expect(screen.getByText(/Client Connection Guide/i)).toBeInTheDocument(); // Switch to Test Bench await act(async () => { diff --git a/frontend/src/test/components/ClientSetupGuide.test.tsx b/frontend/src/test/components/ClientSetupGuide.test.tsx index c940d4ad..3565900f 100644 --- a/frontend/src/test/components/ClientSetupGuide.test.tsx +++ b/frontend/src/test/components/ClientSetupGuide.test.tsx @@ -1,29 +1,152 @@ /** @requirement UI-109 */ -import { describe, it, expect } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { ClientSetupGuide } from '../../components/clients/ClientSetupGuide'; +import * as serverApi from '../../api/serverApi'; +import * as appKeyApi from '../../api/appKeyApi'; +import { useToastStore } from '../../stores/useToastStore'; + +vi.mock('../../api/serverApi'); +vi.mock('../../api/appKeyApi'); describe('ClientSetupGuide Component', () => { - it('renders Cursor setup by default and switches between clients', () => { + const sampleServers = [ + { id: 'ha', displayName: 'Home Assistant', url: 'http://ha:8086/mcp', enabled: true, hidden: false, type: 'http', categories: [] }, + { id: 'docker', displayName: 'Docker Containers', url: 'http://docker:8000/sse', enabled: true, hidden: false, type: 'sse', categories: [] } + ]; + + const sampleKeys = [ + { id: 'key1', name: 'Work Laptop', username: 'spelech', keyPrefix: 'mcp_live_abc123', keyType: 'personal' as const, scopes: ['all'], createdAt: '2026-08-01' }, + { id: 'key2', name: 'Agent Service', username: 'spelech', keyPrefix: 'mcp_live_xyz789', keyType: 'system' as const, scopes: ['docker'], createdAt: '2026-08-10' } + ]; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(serverApi.fetchServersApi).mockResolvedValue(sampleServers as any); + vi.mocked(appKeyApi.fetchAppKeysApi).mockResolvedValue(sampleKeys as any); + }); + + it('renders default standard mcpServers configuration with meta mode', async () => { render(); expect(screen.getByText('Client Connection Guide')).toBeInTheDocument(); - expect(screen.getByText(/In Cursor IDE Settings/i)).toBeInTheDocument(); - // Switch to Claude Desktop - const claudeBtn = screen.getByRole('button', { name: /Claude Desktop/i }); - fireEvent.click(claudeBtn); - expect(screen.getByText(/claude_desktop_config\.json/i)).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText(/mcpServers/i)).toBeInTheDocument(); + }); - // Switch to Cline / Roo - const clineBtn = screen.getByRole('button', { name: /Cline \/ Roo/i }); - fireEvent.click(clineBtn); - expect(screen.getByText(/cline_mcp_settings\.json/i)).toBeInTheDocument(); + // Check default URL has meta=true + expect(screen.getByText(/\/sse\?meta=true/i)).toBeInTheDocument(); + // Check default server scope is all + expect(screen.getByTestId('server-scope-select')).toHaveValue('all'); + }); + + it('switches between format tabs (Standard, VS Code, Generic SSE)', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/mcpServers/i)).toBeInTheDocument(); + }); + + // Switch to VS Code + const vscodeBtn = screen.getByRole('button', { name: /VS Code/i }); + fireEvent.click(vscodeBtn); + expect(screen.getByText(/"type":\s*"sse"/i)).toBeInTheDocument(); + expect(screen.getByText(/"mcp\.servers"/i)).toBeInTheDocument(); // Switch to Generic SSE const genericBtn = screen.getByRole('button', { name: /Generic SSE/i }); fireEvent.click(genericBtn); - expect(screen.getByText(/Direct connection via SSE transport/i)).toBeInTheDocument(); + expect(screen.getByText(/sseEndpoint/i)).toBeInTheDocument(); + expect(screen.getByText(/"messageEndpoint":\s*".*\/message\?sessionId=\{sessionId\}"/i)).toBeInTheDocument(); + expect(screen.getByText(/"authHeader":\s*"X-App-Key:\s*mcp_live_YOUR_APP_KEY_HERE"/i)).toBeInTheDocument(); + }); + + it('switches server scope from all servers to individual server', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('server-scope-select')).toBeInTheDocument(); + }); + + const serverSelect = screen.getByTestId('server-scope-select'); + fireEvent.change(serverSelect, { target: { value: 'docker' } }); + + expect(screen.getByText(/\/docker/i)).toBeInTheDocument(); + }); + + it('updates domain when LAN or custom is chosen', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/mcpServers/i)).toBeInTheDocument(); + }); + + const lanBtn = screen.getByRole('button', { name: /Local LAN/i }); + fireEvent.click(lanBtn); + expect(screen.getByText(/10\.0\.0\.10:8026/i)).toBeInTheDocument(); + + // Select Custom domain + const customBtn = screen.getByRole('button', { name: /Custom/i }); + fireEvent.click(customBtn); + const customInput = screen.getByPlaceholderText(/https:\/\/example\.com/i); + fireEvent.change(customInput, { target: { value: 'https://my-custom-router.internal:9999' } }); + expect(screen.getByText(/https:\/\/my-custom-router\.internal:9999/i)).toBeInTheDocument(); + }); + + it('toggles meta mode when server scope is all', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/\/sse\?meta=true/i)).toBeInTheDocument(); + }); + + const metaToggle = screen.getByLabelText(/Meta-Mode/i); + fireEvent.click(metaToggle); + expect(screen.getByText(/\/sse\?meta=false/i)).toBeInTheDocument(); + + fireEvent.click(metaToggle); + expect(screen.getByText(/\/sse\?meta=true/i)).toBeInTheDocument(); + }); + + it('populates app keys dropdown and injects selected key', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('app-key-select')).toBeInTheDocument(); + }); + + const keySelect = screen.getByTestId('app-key-select'); + // Select the first key + fireEvent.change(keySelect, { target: { value: 'mcp_live_abc123...' } }); + expect(screen.getByText(/"X-App-Key":\s*"mcp_live_abc123\.\.\."/i)).toBeInTheDocument(); + + // Select the second key + fireEvent.change(keySelect, { target: { value: 'mcp_live_xyz789...' } }); + expect(screen.getByText(/"X-App-Key":\s*"mcp_live_xyz789\.\.\."/i)).toBeInTheDocument(); + }); + + it('copies configuration to clipboard and triggers success toast', async () => { + const writeTextSpy = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { + clipboard: { + writeText: writeTextSpy, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Copy JSON|Copy Configuration/i })).toBeInTheDocument(); + }); + + const copyBtn = screen.getByRole('button', { name: /Copy JSON|Copy Configuration/i }); + await act(async () => { + fireEvent.click(copyBtn); + }); + + expect(writeTextSpy).toHaveBeenCalled(); + expect(useToastStore.getState().toasts.some((t) => t.message.includes('Configuration copied to clipboard!'))).toBe(true); }); }); diff --git a/frontend/src/test/pages/MyMcpServers.test.tsx b/frontend/src/test/pages/MyMcpServers.test.tsx index 336dc92d..6f37a68f 100644 --- a/frontend/src/test/pages/MyMcpServers.test.tsx +++ b/frontend/src/test/pages/MyMcpServers.test.tsx @@ -98,4 +98,21 @@ describe('MyMcpServers Page', () => { expect(saveSpy).toHaveBeenCalledWith('user-srv-1', '{"apiKey":"secret123"}'); expect(screen.queryByText(/Edit Auth for My Custom Service/i)).not.toBeInTheDocument(); }); + + /** + * @requirement UI-109 + * @category UI + * @type PositiveFeature + * @description Renders ClientSetupGuide below the user credentials card. + */ + it('renders client setup guide below credentials card', async () => { + vi.spyOn(serverApi, 'fetchServersApi').mockResolvedValue([]); + vi.spyOn(userCredentialsApi, 'fetchUserCredentialsApi').mockResolvedValue([]); + + await act(async () => { + render(); + }); + + expect(screen.getByText('Client Connection Guide')).toBeInTheDocument(); + }); });