diff --git a/README.md b/README.md index 9769e13a..f50bc4da 100644 --- a/README.md +++ b/README.md @@ -7,45 +7,45 @@ **[Website](https://mcpmux.com)** · **[Download](https://mcpmux.com/download)** · **[Discover Servers](https://mcpmux.com)** · **[Features](https://mcpmux.com/features)** -![McpMux Dashboard](docs/screenshots/dashboard.png) +![McpMux Dashboard](docs/screenshots/space-switcher.png) --- ## The Problem -Every AI client has its own MCP config file. Same servers, same credentials — duplicated everywhere. +You use Cursor, Claude Code, VS Code, and Windsurf. They all support MCP. But every client has its own config file — and none of them talk to each other. ``` Cursor → config.json → github, slack, db + API keys -Claude Desktop → config.json → github, slack, db + API keys (again) -VS Code → settings.json → github, slack, db + API keys (again) -Windsurf → config.json → github, slack, db + API keys (again) +Claude Code → config.json → github, slack, db + API keys (copy-paste) +VS Code → settings.json → github, slack, db + API keys (copy-paste) +Windsurf → config.json → github, slack, db + API keys (copy-paste) ``` -Add a server? **Update every client.** Rotate an API key? **Update every client.** New project? **Reconfigure everything.** +Add a server? **Edit four files.** Rotate an API key? **Edit four files.** New teammate, new machine, new project? **Start from scratch.** -And all those credentials? Sitting in **plain-text JSON files** on disk. +And those API keys? Sitting in **plain-text JSON files** anyone can read. ## The Fix -McpMux is a desktop app that runs a local gateway. Configure your servers once, point all clients to one URL. +McpMux is a desktop app that runs a local MCP gateway. You configure servers once — every AI client connects to the same URL. ``` Cursor ─┐ -Claude Desktop ─┤──→ McpMux (localhost) ──→ all your MCP servers +Claude Code ─┤──→ McpMux (localhost:45818) ──→ all your MCP servers VS Code ─┤ encrypted credentials Windsurf ─┘ one config, one place ``` -Add a server in McpMux and every client has it instantly. No files to edit. +Add a server in McpMux and it appears in Cursor, Claude, VS Code, and Windsurf instantly. No files to edit. No credentials to copy. --- ## How It Works -**1.** Install servers from the built-in registry (or add manually) +**1.** Install servers from the built-in registry — or add your own -**2.** Paste one config into your AI clients: +**2.** Paste one config into each AI client (the last config you'll ever need): ```json { @@ -58,77 +58,81 @@ Add a server in McpMux and every client has it instantly. No files to edit. } ``` -**3.** Done. All tools from all servers are available in every client. +**3.** Done. Every tool from every server is available in every client, right now. -McpMux routes calls to the right server, refreshes OAuth tokens automatically, and keeps credentials encrypted in your OS keychain. You don't think about it. +McpMux routes calls to the right server, refreshes OAuth tokens automatically, and keeps credentials encrypted in your OS keychain — you never think about it again. --- ## Features -### Server Management +### All Your Servers, One Place -Add, remove, and monitor all your MCP servers in one place. See connection status, auth type, and transport at a glance. +No more duplicating server configs across Cursor, Claude, VS Code, and Windsurf. Install a server in McpMux and it's immediately available everywhere. Expand any server to inspect its tools, prompts, and resources. See live connection status. OAuth tokens refresh automatically in the background. -![My Servers](docs/screenshots/servers.png) +![Server Management — Azure, AWS, Notion, Docker, Slack all connected](docs/screenshots/servers.png) -### Server Registry +### 100+ Servers, One Click -Browse and one-click install from a curated registry of MCP servers. Filter by category, auth type, or hosting. Cached for offline use. You can also browse the full registry at [mcpmux.com](https://mcpmux.com). +Stop hunting for MCP server repos and hand-writing transport configs. Browse a curated registry of 100+ servers — GitHub, Slack, PostgreSQL, Docker, Notion, AWS, Azure, and more. Click install, enter your credentials, and the server is live across every AI client you use. You can also browse the full registry at [mcpmux.com](https://mcpmux.com). -![Discover Servers](docs/screenshots/discover.png) +![Discover — browse 100+ servers and install with one click](docs/screenshots/discover.png) -### Spaces +The full registry is also available on the web at [mcpmux.com](https://mcpmux.com) — with search, categories, and one-click install via deep links. -Isolated workspaces with their own servers and credentials. Switch between "Work" and "Personal" in one click — your AI clients follow automatically. +![mcpmux.com — server registry on the web](docs/screenshots/discover-web.png) -![Workspaces](docs/screenshots/spaces.png) +### Workspaces That Keep Things Separate -### Feature Sets +Create isolated Spaces — each with their own servers, credentials, and permissions. A "Work" space for company databases and internal APIs. A "Personal" space for side projects. Switch in one click from the sidebar and every connected AI client follows automatically. No more accidentally querying your personal database from a work project. -Control exactly which tools, prompts, and resources are available. Create custom permission bundles like "Read Only" or "Dev Tools" and assign them to clients. +![Workspaces — switch context instantly from the sidebar](docs/screenshots/space-switcher.png) -![Feature Sets](docs/screenshots/featuresets.png) +### Control What Each Client Can Do -### Connected Clients +Not every AI client should have the same power. Create Feature Sets — permission bundles that control exactly which tools, prompts, and resources a client can access. Build a "Read Only" set for cautious workflows, a "React Development" set with just GitHub and Filesystem, or a "Full Stack Dev" set with everything. Assign them per-client so each tool only goes where you want it. -See every AI client connected to your gateway. Manage OAuth registrations and control which space each client can access. +![Feature Sets — granular per-server tool selection](docs/screenshots/featureset-detail.png) -![Connected Clients](docs/screenshots/clients.png) +### See and Manage Every Connected Client -### Settings +Cursor, VS Code, Windsurf, Claude Code — see every AI client connected to your gateway in real time. Click any client to manage its workspace, grant or revoke feature sets, and see exactly which tools it can access. New clients authenticate via OAuth with a one-click approval flow. -Auto-updates, launch at startup, system tray, and appearance preferences. - -![Settings](docs/screenshots/settings.png) +![Client Management — per-client permissions and effective features](docs/screenshots/client-detail.png) --- ## Security -MCP defaults to plain-text config files with raw API keys. McpMux replaces that with: +MCP defaults to plain-text config files with raw API keys. McpMux replaces that with defense in depth: -- **OS Keychain** — secrets in platform-native secure storage, not on disk -- **AES-256-GCM** — field-level database encryption -- **OAuth 2.1 + PKCE** — standard auth with automatic token refresh +- **OS Keychain** — secrets in platform-native secure storage (DPAPI on Windows, Keychain on macOS, libsecret on Linux), never in plain-text files +- **AES-256-GCM** — field-level database encryption for all sensitive data +- **OAuth 2.1 + PKCE** — standard auth flow with automatic token refresh - **Local-only gateway** — binds to `127.0.0.1`, nothing exposed to the network -- **Per-client access keys** — granular permissions per AI client -- **Sanitized logs** — tokens never appear in log files -- **Memory zeroization** — secrets wiped from memory after use +- **Per-client access keys** — each AI client authenticates independently with granular permissions +- **Sanitized logs** — tokens and secrets never appear in log output +- **Memory zeroization** — secrets wiped from memory after use via `zeroize` -All MCP traffic stays on your machine. Cloud sync (optional) only covers config metadata — never credentials or payloads. +All MCP traffic stays on your machine. Cloud sync (optional, coming soon) only covers config metadata — never credentials or payloads. --- ## Getting Started -**1. [Download McpMux](https://mcpmux.com/download)** — Windows (MSI), macOS (DMG), Linux (DEB/RPM/AppImage) +**1. [Download McpMux](https://mcpmux.com/download)** — Windows, macOS, Linux + +**2. Add servers** — use the Discover tab to browse 100+ servers, or [explore servers on mcpmux.com](https://mcpmux.com) and install with one click -**2. Add servers** — Discover tab to browse the registry, or [explore servers on mcpmux.com](https://mcpmux.com) and install with one click +**3. Paste the config** — copy the snippet from the Dashboard into your AI clients -**3. Paste config** — Copy the snippet from the Dashboard into your AI clients +That's the last config file you'll ever touch. -That's the last config file you'll need to touch. +> **Linux quick install:** `curl -fsSL https://install.mcpmux.com | bash` +> +> **macOS via Homebrew:** `brew install --cask mcpmux/tap/mcpmux` +> +> See [mcpmux.com/download](https://mcpmux.com/download) for all platforms and install methods. --- diff --git a/apps/desktop/src-tauri/src/commands/server_feature.rs b/apps/desktop/src-tauri/src/commands/server_feature.rs index b8783051..2dc7248a 100644 --- a/apps/desktop/src-tauri/src/commands/server_feature.rs +++ b/apps/desktop/src-tauri/src/commands/server_feature.rs @@ -129,3 +129,56 @@ pub async fn get_server_feature( Ok(feature.map(Into::into)) } + +/// Seed server features for E2E testing. +/// +/// Accepts an array of feature definitions and upserts them into the database. +/// This is intended for screenshot capture and E2E tests where servers aren't +/// actually connected but we need realistic feature data in the UI. +#[tauri::command] +pub async fn seed_server_features( + features: Vec, + state: State<'_, AppState>, +) -> Result, String> { + let storage_features: Vec = features + .into_iter() + .map(|f| { + let mut sf = match f.feature_type.as_str() { + "tool" => ServerFeature::new_tool(&f.space_id, &f.server_id, &f.feature_name), + "prompt" => ServerFeature::new_prompt(&f.space_id, &f.server_id, &f.feature_name), + "resource" => { + ServerFeature::new_resource(&f.space_id, &f.server_id, &f.feature_name) + } + _ => ServerFeature::new_tool(&f.space_id, &f.server_id, &f.feature_name), + }; + if let Some(dn) = f.display_name { + sf = sf.with_display_name(dn); + } + if let Some(desc) = f.description { + sf = sf.with_description(desc); + } + sf + }) + .collect(); + + let ids: Vec = storage_features.iter().map(|f| f.id.clone()).collect(); + + state + .server_feature_repository + .upsert_many(&storage_features) + .await + .map_err(|e| format!("Failed to seed features: {e}"))?; + + Ok(ids) +} + +/// Input for seeding a server feature (E2E testing). +#[derive(Debug, serde::Deserialize)] +pub struct SeedFeatureInput { + pub space_id: String, + pub server_id: String, + pub feature_type: String, + pub feature_name: String, + pub display_name: Option, + pub description: Option, +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 21abbeb4..eed65845 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -741,6 +741,7 @@ pub fn run() { commands::list_server_features_by_server, commands::list_server_features_by_type, commands::get_server_feature, + commands::seed_server_features, // Client commands commands::list_clients, commands::get_client, diff --git a/apps/desktop/src/components/OAuthConsentModal.tsx b/apps/desktop/src/components/OAuthConsentModal.tsx index a763c6c0..9d1b7fa9 100644 --- a/apps/desktop/src/components/OAuthConsentModal.tsx +++ b/apps/desktop/src/components/OAuthConsentModal.tsx @@ -13,7 +13,7 @@ import { useState, useEffect } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; -import { Shield, Check, X, AlertCircle, Loader2, Globe, Lock } from 'lucide-react'; +import { Check, X, AlertCircle, Loader2, Globe, Lock } from 'lucide-react'; import { Button, Card, @@ -23,6 +23,25 @@ import { CardContent, } from '@mcpmux/ui'; import { listSpaces, type Space } from '@/lib/api/spaces'; +import { resolveKnownClientKey } from '@/lib/clientIcons'; +import cursorIcon from '@/assets/client-icons/cursor.svg'; +import vscodeIcon from '@/assets/client-icons/vscode.png'; +import claudeIcon from '@/assets/client-icons/claude.svg'; +import windsurfIcon from '@/assets/client-icons/windsurf.svg'; + +/** Bundled icon assets for known clients */ +const CLIENT_ICON_ASSETS: Record = { + cursor: cursorIcon, + vscode: vscodeIcon, + claude: claudeIcon, + windsurf: windsurfIcon, +}; + +/** Look up a bundled logo for a known client by name */ +function getClientLogo(clientName: string): string | null { + const key = resolveKnownClientKey(clientName); + return key ? CLIENT_ICON_ASSETS[key] ?? null : null; +} /** Minimal deep link payload - only request_id */ interface OAuthDeepLinkPayload { @@ -267,15 +286,18 @@ export function OAuthConsentModal() { // Consent state - show approval modal const { details } = modalState; const scopes = details.scope?.split(' ').filter(Boolean) || ['mcp']; + const logoUrl = getClientLogo(details.clientName); return (
-
- -
+ McpMux
Authorization Request @@ -286,12 +308,21 @@ export function OAuthConsentModal() { {/* Client Info */} -
-
{details.clientName}
-
- {details.clientId.length > 50 - ? `${details.clientId.substring(0, 50)}...` - : details.clientId} +
+ {logoUrl && ( + {details.clientName} + )} +
+
{details.clientName}
+
+ {details.clientId.length > 50 + ? `${details.clientId.substring(0, 50)}...` + : details.clientId} +
diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md index 2868791c..b737996a 100644 --- a/docs/screenshots/README.md +++ b/docs/screenshots/README.md @@ -8,22 +8,44 @@ Automated screenshots of the McpMux desktop app for docs and marketing. # 1. Build the desktop app (required — E2E runs against the release binary) pnpm build -# 2. Run the screenshot capture spec +# 2. (Optional) Start the discover web UI for web screenshots +cd ../mcpmux.discover.ui && pnpm dev & + +# 3. Run the screenshot capture spec pnpm exec wdio run tests/e2e/wdio.conf.ts --spec tests/e2e/specs/capture-screenshots.manual.ts ``` ## What Gets Captured +### Page Screenshots (content area only — no sidebar/titlebar) + | Screenshot | Page | Content | |---|---|---| | `dashboard.png` | Dashboard | Gateway status, stats cards, client config snippet | | `servers.png` | My Servers | Installed servers with connection states | -| `discover.png` | Discover | Server registry (mock bundle with 12 servers) | -| `spaces.png` | Spaces | Workspace cards (default + 3 additional) | +| `discover.png` | Discover | Server registry (mock bundle with 14 servers) | +| `spaces.png` | Spaces | Workspace cards (default + 5 additional) | | `featuresets.png` | FeatureSets | Permission bundles | -| `clients.png` | Clients | Connected AI clients (Cursor, VS Code, Claude Desktop) | +| `clients.png` | Clients | Connected AI clients (Cursor, VS Code, Claude Desktop, Windsurf, Claude Code) | | `settings.png` | Settings | App settings page | +### Detail Screenshots (modals, panels, focused views) + +| Screenshot | Content | +|---|---| +| `featureset-detail.png` | Feature set panel with permission checkboxes | +| `client-detail.png` | Client detail panel with connection mode/locking | +| `client-permissions.png` | Client panel with Permissions section expanded (feature set granting) | +| `server-expanded.png` | Server card expanded showing tools, prompts, resources | +| `space-switcher.png` | Sidebar with space switcher dropdown open (full window) | +| `install-modal.png` | Server install modal from registry | + +### Web Screenshots + +| Screenshot | Content | +|---|---| +| `discover-web.png` | Discover web UI (Next.js site — requires dev server on port 3000) | + ## Output Locations Screenshots are saved to two locations: @@ -31,6 +53,13 @@ Screenshots are saved to two locations: - **`mcp-mux/docs/screenshots/`** — Used by the project README - **`mcpmux.discover.ui/public/screenshots/`** — Used by the discover website +## Screenshot Strategy + +- **Page screenshots** capture the `
` content area only (excluding sidebar and titlebar) for maximum readability when displayed on the website +- **Detail screenshots** capture specific elements (modals, panels) for feature-focused views +- **Full-window screenshots** are used only when sidebar context is valuable (e.g., space switcher) +- Window size is 1600×1000 — content area is ~1360×964 + ## Editing Mock Data All screenshot mock data lives in a single file: @@ -43,7 +72,10 @@ This controls: - **Spaces** created (name, icon) - **Servers** installed and enabled (by ID from `fixtures.ts`) - **FeatureSets** created (name, description) -- **OAuth Clients** shown on the Clients page (name, version, connection mode) + +OAuth client registrations are defined in `capture-screenshots.manual.ts` itself (the `OAUTH_CLIENTS` constant). + +Server icons use real GitHub avatar URLs matching the production server definitions in `mcp-servers/servers/*.json`. After editing, re-run the capture command above. @@ -51,11 +83,13 @@ After editing, re-run the capture command above. 1. The E2E framework launches the Tauri app with a mock bundle API (port 8787) 2. The `before()` hook seeds data via `window.__TAURI_TEST_API__` (spaces, servers, feature sets) -3. OAuth clients are mocked by intercepting `window.__TAURI_INTERNALS__.invoke` -4. Each `it()` block navigates to a page and saves a screenshot +3. OAuth clients are registered via DCR POST to the gateway and then approved +4. Each `it()` block navigates to a page, optionally opens a panel/modal, and saves a focused screenshot +5. The discover-web screenshot navigates to `localhost:3000` (Next.js dev server) ## Notes - This spec uses `.manual.ts` (not `.wdio.ts`) so it is **excluded from `pnpm test:e2e`** — it only runs when invoked explicitly - Server definitions come from `tests/e2e/mocks/mock-bundle-api/fixtures.ts` - The app must be built before running (`pnpm build`) — the signing error is non-blocking +- For discover-web screenshots, start the discover UI dev server first: `cd ../mcpmux.discover.ui && pnpm dev` diff --git a/docs/screenshots/client-detail.png b/docs/screenshots/client-detail.png new file mode 100644 index 00000000..5891a081 Binary files /dev/null and b/docs/screenshots/client-detail.png differ diff --git a/docs/screenshots/client-permissions.png b/docs/screenshots/client-permissions.png new file mode 100644 index 00000000..b563d1dc Binary files /dev/null and b/docs/screenshots/client-permissions.png differ diff --git a/docs/screenshots/clients.png b/docs/screenshots/clients.png index 0e9d3a68..29efc4b3 100644 Binary files a/docs/screenshots/clients.png and b/docs/screenshots/clients.png differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png index 2e8c883e..28d60611 100644 Binary files a/docs/screenshots/dashboard.png and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/discover-web.png b/docs/screenshots/discover-web.png new file mode 100644 index 00000000..a050ff10 Binary files /dev/null and b/docs/screenshots/discover-web.png differ diff --git a/docs/screenshots/discover.png b/docs/screenshots/discover.png index 71397282..6c30d6ad 100644 Binary files a/docs/screenshots/discover.png and b/docs/screenshots/discover.png differ diff --git a/docs/screenshots/featureset-detail.png b/docs/screenshots/featureset-detail.png new file mode 100644 index 00000000..9487a063 Binary files /dev/null and b/docs/screenshots/featureset-detail.png differ diff --git a/docs/screenshots/featuresets.png b/docs/screenshots/featuresets.png index 45f38eee..37f2691f 100644 Binary files a/docs/screenshots/featuresets.png and b/docs/screenshots/featuresets.png differ diff --git a/docs/screenshots/server-expanded.png b/docs/screenshots/server-expanded.png new file mode 100644 index 00000000..f6a0bc85 Binary files /dev/null and b/docs/screenshots/server-expanded.png differ diff --git a/docs/screenshots/servers.png b/docs/screenshots/servers.png index 862599d2..55104669 100644 Binary files a/docs/screenshots/servers.png and b/docs/screenshots/servers.png differ diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png index 1037433b..315c6f12 100644 Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ diff --git a/docs/screenshots/space-switcher.png b/docs/screenshots/space-switcher.png new file mode 100644 index 00000000..60e8865d Binary files /dev/null and b/docs/screenshots/space-switcher.png differ diff --git a/docs/screenshots/spaces.png b/docs/screenshots/spaces.png index 8e03f15f..2a406e3f 100644 Binary files a/docs/screenshots/spaces.png and b/docs/screenshots/spaces.png differ diff --git a/tests/e2e/helpers/tauri-api.ts b/tests/e2e/helpers/tauri-api.ts index 1dc5283f..bd05137c 100644 --- a/tests/e2e/helpers/tauri-api.ts +++ b/tests/e2e/helpers/tauri-api.ts @@ -103,6 +103,15 @@ export async function grantFeatureSetToClient( return invoke('grant_feature_set_to_client', { clientId, spaceId, featureSetId }); } +/** Grant a feature set to an OAuth/inbound client (Cursor, VS Code, etc.) */ +export async function grantOAuthClientFeatureSet( + clientId: string, + spaceId: string, + featureSetId: string +): Promise { + return invoke('grant_oauth_client_feature_set', { clientId, spaceId, featureSetId }); +} + // ============================================================================ // FeatureSet API // ============================================================================ @@ -132,6 +141,23 @@ export async function deleteFeatureSet(id: string): Promise { return invoke('delete_feature_set', { id }); } +/** Add a feature (tool/prompt/resource) to a feature set. */ +export async function addFeatureToSet( + featureSetId: string, + featureId: string, + mode: 'include' | 'exclude' = 'include' +): Promise { + return invoke('add_feature_to_set', { featureSetId, featureId, mode }); +} + +/** List all server features in a space. */ +export async function listServerFeatures( + spaceId: string, + includeUnavailable?: boolean +): Promise<{ id: string; server_id: string; feature_type: string; feature_name: string }[]> { + return invoke('list_server_features', { spaceId, includeUnavailable }); +} + // ============================================================================ // Server API // ============================================================================ @@ -191,6 +217,24 @@ export async function approveOAuthClient(clientId: string): Promise { return invoke('approve_oauth_client', { clientId }); } +// ============================================================================ +// Server Feature Seeding API (for E2E / screenshots) +// ============================================================================ + +export interface SeedFeatureInput { + space_id: string; + server_id: string; + feature_type: 'tool' | 'prompt' | 'resource'; + feature_name: string; + display_name?: string; + description?: string; +} + +/** Seed server features into the database for screenshot/E2E purposes. */ +export async function seedServerFeatures(features: SeedFeatureInput[]): Promise { + return invoke('seed_server_features', { features }); +} + // ============================================================================ // Logs API // ============================================================================ diff --git a/tests/e2e/mocks/mock-bundle-api/fixtures.ts b/tests/e2e/mocks/mock-bundle-api/fixtures.ts index 937f70a1..7e5e974f 100644 --- a/tests/e2e/mocks/mock-bundle-api/fixtures.ts +++ b/tests/e2e/mocks/mock-bundle-api/fixtures.ts @@ -101,7 +101,7 @@ const TEST_SERVERS: ServerDefinition[] = [ name: 'GitHub', alias: 'github', description: 'Repository management, issues, pull requests, and code search via the GitHub API', - icon: 'https://cdn.simpleicons.org/github', + icon: 'https://avatars.githubusercontent.com/u/9919?v=4', schema_version: '2.0', categories: ['developer-tools'], tags: ['github', 'git', 'repository', 'issues'], @@ -141,7 +141,7 @@ const TEST_SERVERS: ServerDefinition[] = [ name: 'Filesystem', alias: 'fs', description: 'Secure file operations with configurable access controls and sandboxing', - icon: 'https://cdn.simpleicons.org/files/4285F4', + icon: 'https://avatars.githubusercontent.com/u/182288589?v=4', schema_version: '2.0', categories: ['file-system'], tags: ['filesystem', 'files', 'directory', 'local'], @@ -188,7 +188,7 @@ const TEST_SERVERS: ServerDefinition[] = [ name: 'PostgreSQL', alias: 'postgres', description: 'Query databases, manage schemas, inspect tables, and run migrations', - icon: 'https://cdn.simpleicons.org/postgresql', + icon: 'https://avatars.githubusercontent.com/u/177543?v=4', schema_version: '2.0', categories: ['database'], tags: ['postgres', 'database', 'sql', 'schema'], @@ -238,7 +238,7 @@ const TEST_SERVERS: ServerDefinition[] = [ name: 'Slack', alias: 'slack', description: 'Send messages, manage channels, and search conversations across workspaces', - icon: 'https://github.com/slackapi.png?size=128', + icon: 'https://avatars.githubusercontent.com/u/6962987?v=4', schema_version: '2.0', categories: ['productivity'], tags: ['slack', 'messaging', 'chat', 'team'], @@ -273,7 +273,7 @@ const TEST_SERVERS: ServerDefinition[] = [ name: 'Brave Search', alias: 'brave', description: 'Web search with privacy-focused results and AI-ready summaries', - icon: 'https://cdn.simpleicons.org/brave', + icon: 'https://avatars.githubusercontent.com/u/12301619?v=4', schema_version: '2.0', categories: ['search'], tags: ['search', 'web', 'brave', 'privacy'], @@ -309,7 +309,7 @@ const TEST_SERVERS: ServerDefinition[] = [ name: 'Docker', alias: 'docker', description: 'Manage containers, images, networks, and volumes from your AI client', - icon: 'https://cdn.simpleicons.org/docker', + icon: 'https://avatars.githubusercontent.com/u/5429470?v=4', schema_version: '2.0', categories: ['developer-tools'], tags: ['docker', 'containers', 'devops', 'infrastructure'], @@ -346,7 +346,7 @@ const TEST_SERVERS: ServerDefinition[] = [ name: 'Notion', alias: 'notion', description: 'Create pages, query databases, and manage workspaces programmatically', - icon: 'https://cdn.simpleicons.org/notion', + icon: 'https://avatars.githubusercontent.com/u/4792552?v=4', schema_version: '2.0', categories: ['productivity'], tags: ['notion', 'notes', 'wiki', 'database'], @@ -381,7 +381,7 @@ const TEST_SERVERS: ServerDefinition[] = [ name: 'AWS', alias: 'aws', description: 'Interact with S3, Lambda, DynamoDB, and other AWS services', - icon: 'https://github.com/aws.png?size=128', + icon: 'https://avatars.githubusercontent.com/u/2232217?v=4', schema_version: '2.0', categories: ['cloud'], tags: ['aws', 'cloud', 's3', 'lambda'], @@ -417,7 +417,7 @@ const TEST_SERVERS: ServerDefinition[] = [ name: 'SQLite', alias: 'sqlite', description: 'Local database queries with read/write access control and schema inspection', - icon: 'https://cdn.simpleicons.org/sqlite', + icon: 'https://avatars.githubusercontent.com/u/182288589?v=4', schema_version: '2.0', categories: ['database'], tags: ['sqlite', 'database', 'local', 'sql'], @@ -454,7 +454,7 @@ const TEST_SERVERS: ServerDefinition[] = [ name: 'Sentry', alias: 'sentry', description: 'Error tracking, performance monitoring, and release management', - icon: 'https://cdn.simpleicons.org/sentry', + icon: 'https://avatars.githubusercontent.com/u/1396951?v=4', schema_version: '2.0', categories: ['developer-tools'], tags: ['sentry', 'errors', 'monitoring', 'performance'], @@ -490,7 +490,7 @@ const TEST_SERVERS: ServerDefinition[] = [ name: 'Linear', alias: 'linear', description: 'Issue tracking, project management, and sprint workflows', - icon: 'https://cdn.simpleicons.org/linear', + icon: 'https://avatars.githubusercontent.com/u/49998002?v=4', schema_version: '2.0', categories: ['productivity'], tags: ['linear', 'issues', 'project-management', 'sprints'], @@ -525,7 +525,7 @@ const TEST_SERVERS: ServerDefinition[] = [ name: 'Cloudflare', alias: 'cf', description: 'Search and browse Cloudflare documentation and API references', - icon: 'https://cdn.simpleicons.org/cloudflare', + icon: 'https://avatars.githubusercontent.com/u/314135?v=4', schema_version: '2.0', categories: ['cloud'], tags: ['cloudflare', 'docs', 'cdn', 'workers'], @@ -560,7 +560,7 @@ const TEST_SERVERS: ServerDefinition[] = [ name: 'Cloudflare Workers', alias: 'cf-workers', description: 'Deploy, manage, and monitor Cloudflare Workers and KV namespaces', - icon: 'https://cdn.simpleicons.org/cloudflareworkers', + icon: 'https://avatars.githubusercontent.com/u/314135?v=4', schema_version: '2.0', categories: ['cloud'], tags: ['cloudflare', 'workers', 'serverless', 'edge'], @@ -596,7 +596,7 @@ const TEST_SERVERS: ServerDefinition[] = [ name: 'Azure', alias: 'azure', description: 'Manage Azure resources, deploy services, and monitor infrastructure', - icon: 'https://github.com/azure.png?size=128', + icon: 'https://avatars.githubusercontent.com/u/6844498?v=4', schema_version: '2.0', categories: ['cloud'], tags: ['azure', 'cloud', 'microsoft', 'infrastructure'], diff --git a/tests/e2e/mocks/screenshot-preseed.ts b/tests/e2e/mocks/screenshot-preseed.ts index 49d9d8ec..7e79af9a 100644 --- a/tests/e2e/mocks/screenshot-preseed.ts +++ b/tests/e2e/mocks/screenshot-preseed.ts @@ -6,18 +6,19 @@ * pnpm exec wdio run tests/e2e/wdio.conf.ts --spec tests/e2e/specs/capture-screenshots.manual.ts */ +import type { SeedFeatureInput } from '../helpers/tauri-api'; + export const PRESEED: { spaces: { name: string; icon: string }[]; serversToInstall: string[]; featureSets: { name: string; description: string; icon?: string }[]; + serverFeatures: (spaceId: string) => SeedFeatureInput[]; } = { /** Additional spaces to create (default space is created automatically) */ spaces: [ - { name: 'Work Projects', icon: '💼' }, + { name: 'Work', icon: '💼' }, { name: 'Personal', icon: '🏠' }, - { name: 'Experiments', icon: '🧪' }, - { name: 'Production', icon: '🚀' }, - { name: 'Staging', icon: '🔧' }, + { name: 'Open Source', icon: '🌍' }, ], /** Server IDs to install in the default space (must match IDs in fixtures.ts). @@ -37,8 +38,80 @@ export const PRESEED: { /** Custom feature sets to create in the default space */ featureSets: [ - { name: 'Read Only', description: 'Only read operations — no writes or deletes', icon: '🔒' }, - { name: 'Dev Tools', description: 'GitHub + PostgreSQL + Filesystem access', icon: '🛠️' }, - { name: 'Full Access', description: 'All servers and capabilities enabled', icon: '🚀' }, + { name: 'Read Only', description: 'Read-only access — no writes, deletes, or mutations allowed', icon: '🔒' }, + { name: 'React Development', description: 'GitHub, Filesystem, and search tools for React projects', icon: '⚛️' }, + { name: 'Cloudflare Workers', description: 'Cloudflare Workers, KV, and deployment tools', icon: '☁️' }, + { name: 'Research & Analysis', description: 'Web search, Notion, and database query access', icon: '🔍' }, + { name: 'Full Stack Dev', description: 'All development servers — GitHub, Docker, Postgres, Filesystem', icon: '🚀' }, + ], + + /** + * Mock server features (tools, prompts, resources) to seed into the DB. + * These appear in the server expanded view and feature set detail panel. + * Based on real MCP server capabilities. + */ + serverFeatures: (spaceId: string): SeedFeatureInput[] => [ + // ── GitHub Server ────────────────────────────────────────────────── + { space_id: spaceId, server_id: 'github-server', feature_type: 'tool', feature_name: 'create_or_update_file', display_name: 'Create or Update File', description: 'Create or update a single file in a GitHub repository' }, + { space_id: spaceId, server_id: 'github-server', feature_type: 'tool', feature_name: 'search_repositories', display_name: 'Search Repositories', description: 'Search for GitHub repositories by keyword, language, or topic' }, + { space_id: spaceId, server_id: 'github-server', feature_type: 'tool', feature_name: 'create_issue', display_name: 'Create Issue', description: 'Create a new issue in a GitHub repository' }, + { space_id: spaceId, server_id: 'github-server', feature_type: 'tool', feature_name: 'create_pull_request', display_name: 'Create Pull Request', description: 'Create a new pull request in a GitHub repository' }, + { space_id: spaceId, server_id: 'github-server', feature_type: 'tool', feature_name: 'get_file_contents', display_name: 'Get File Contents', description: 'Get the contents of a file or directory from a GitHub repository' }, + { space_id: spaceId, server_id: 'github-server', feature_type: 'tool', feature_name: 'push_files', display_name: 'Push Files', description: 'Push multiple files to a GitHub repository in a single commit' }, + { space_id: spaceId, server_id: 'github-server', feature_type: 'tool', feature_name: 'list_commits', display_name: 'List Commits', description: 'Get the list of commits for a branch in a repository' }, + { space_id: spaceId, server_id: 'github-server', feature_type: 'tool', feature_name: 'search_code', display_name: 'Search Code', description: 'Search for code across GitHub repositories' }, + { space_id: spaceId, server_id: 'github-server', feature_type: 'resource', feature_name: 'repo://owner/repo', display_name: 'Repository Contents', description: 'Access repository file tree and metadata' }, + + // ── Filesystem Server ────────────────────────────────────────────── + { space_id: spaceId, server_id: 'filesystem-server', feature_type: 'tool', feature_name: 'read_file', display_name: 'Read File', description: 'Read the complete contents of a file from the filesystem' }, + { space_id: spaceId, server_id: 'filesystem-server', feature_type: 'tool', feature_name: 'write_file', display_name: 'Write File', description: 'Create or overwrite a file with new content' }, + { space_id: spaceId, server_id: 'filesystem-server', feature_type: 'tool', feature_name: 'list_directory', display_name: 'List Directory', description: 'List all files and subdirectories in a given path' }, + { space_id: spaceId, server_id: 'filesystem-server', feature_type: 'tool', feature_name: 'search_files', display_name: 'Search Files', description: 'Recursively search for files matching a pattern' }, + { space_id: spaceId, server_id: 'filesystem-server', feature_type: 'tool', feature_name: 'move_file', display_name: 'Move File', description: 'Move or rename a file or directory' }, + { space_id: spaceId, server_id: 'filesystem-server', feature_type: 'resource', feature_name: 'file:///project', display_name: 'Project Files', description: 'Access allowed project directory contents' }, + + // ── PostgreSQL Server ────────────────────────────────────────────── + { space_id: spaceId, server_id: 'postgres-server', feature_type: 'tool', feature_name: 'query', display_name: 'Run SQL Query', description: 'Execute a read-only SQL query against the database' }, + { space_id: spaceId, server_id: 'postgres-server', feature_type: 'tool', feature_name: 'list_tables', display_name: 'List Tables', description: 'List all tables in the connected database' }, + { space_id: spaceId, server_id: 'postgres-server', feature_type: 'tool', feature_name: 'describe_table', display_name: 'Describe Table', description: 'Get column names, types, and constraints for a table' }, + { space_id: spaceId, server_id: 'postgres-server', feature_type: 'resource', feature_name: 'postgres://schema', display_name: 'Database Schema', description: 'Access the full database schema as a resource' }, + + // ── Slack Server ─────────────────────────────────────────────────── + { space_id: spaceId, server_id: 'slack-server', feature_type: 'tool', feature_name: 'send_message', display_name: 'Send Message', description: 'Send a message to a Slack channel or user' }, + { space_id: spaceId, server_id: 'slack-server', feature_type: 'tool', feature_name: 'list_channels', display_name: 'List Channels', description: 'List all accessible Slack channels in the workspace' }, + { space_id: spaceId, server_id: 'slack-server', feature_type: 'tool', feature_name: 'search_messages', display_name: 'Search Messages', description: 'Search for messages across Slack channels' }, + { space_id: spaceId, server_id: 'slack-server', feature_type: 'prompt', feature_name: 'summarize_channel', display_name: 'Summarize Channel', description: 'Generate a summary of recent channel activity' }, + + // ── Brave Search ─────────────────────────────────────────────────── + { space_id: spaceId, server_id: 'brave-search', feature_type: 'tool', feature_name: 'web_search', display_name: 'Web Search', description: 'Search the web using Brave Search API' }, + { space_id: spaceId, server_id: 'brave-search', feature_type: 'tool', feature_name: 'local_search', display_name: 'Local Search', description: 'Search for local businesses and points of interest' }, + + // ── Docker Server ────────────────────────────────────────────────── + { space_id: spaceId, server_id: 'docker-server', feature_type: 'tool', feature_name: 'list_containers', display_name: 'List Containers', description: 'List all Docker containers and their status' }, + { space_id: spaceId, server_id: 'docker-server', feature_type: 'tool', feature_name: 'run_container', display_name: 'Run Container', description: 'Create and start a new Docker container' }, + { space_id: spaceId, server_id: 'docker-server', feature_type: 'tool', feature_name: 'container_logs', display_name: 'Container Logs', description: 'Get logs from a running or stopped container' }, + { space_id: spaceId, server_id: 'docker-server', feature_type: 'tool', feature_name: 'list_images', display_name: 'List Images', description: 'List all Docker images on the host' }, + + // ── Notion Server ────────────────────────────────────────────────── + { space_id: spaceId, server_id: 'notion-server', feature_type: 'tool', feature_name: 'search_pages', display_name: 'Search Pages', description: 'Search for pages and databases in your Notion workspace' }, + { space_id: spaceId, server_id: 'notion-server', feature_type: 'tool', feature_name: 'create_page', display_name: 'Create Page', description: 'Create a new page in a Notion database' }, + { space_id: spaceId, server_id: 'notion-server', feature_type: 'tool', feature_name: 'update_page', display_name: 'Update Page', description: 'Update properties and content of an existing page' }, + { space_id: spaceId, server_id: 'notion-server', feature_type: 'resource', feature_name: 'notion://workspace', display_name: 'Workspace Pages', description: 'Access your Notion workspace page hierarchy' }, + + // ── AWS Server ───────────────────────────────────────────────────── + { space_id: spaceId, server_id: 'aws-server', feature_type: 'tool', feature_name: 'list_s3_buckets', display_name: 'List S3 Buckets', description: 'List all S3 buckets in the AWS account' }, + { space_id: spaceId, server_id: 'aws-server', feature_type: 'tool', feature_name: 'get_s3_object', display_name: 'Get S3 Object', description: 'Download an object from an S3 bucket' }, + { space_id: spaceId, server_id: 'aws-server', feature_type: 'tool', feature_name: 'describe_instances', display_name: 'Describe EC2 Instances', description: 'List and describe EC2 instances with their status' }, + { space_id: spaceId, server_id: 'aws-server', feature_type: 'tool', feature_name: 'invoke_lambda', display_name: 'Invoke Lambda', description: 'Invoke an AWS Lambda function with a given payload' }, + + // ── Cloudflare Workers Server ────────────────────────────────────── + { space_id: spaceId, server_id: 'cloudflare-workers-server', feature_type: 'tool', feature_name: 'list_workers', display_name: 'List Workers', description: 'List all Cloudflare Workers in the account' }, + { space_id: spaceId, server_id: 'cloudflare-workers-server', feature_type: 'tool', feature_name: 'get_worker_code', display_name: 'Get Worker Code', description: 'Retrieve the source code of a deployed Worker' }, + { space_id: spaceId, server_id: 'cloudflare-workers-server', feature_type: 'tool', feature_name: 'list_kv_namespaces', display_name: 'List KV Namespaces', description: 'List all Workers KV namespaces in the account' }, + + // ── Azure Server ─────────────────────────────────────────────────── + { space_id: spaceId, server_id: 'azure-server', feature_type: 'tool', feature_name: 'list_resource_groups', display_name: 'List Resource Groups', description: 'List all Azure resource groups in the subscription' }, + { space_id: spaceId, server_id: 'azure-server', feature_type: 'tool', feature_name: 'list_vms', display_name: 'List Virtual Machines', description: 'List all virtual machines and their status' }, + { space_id: spaceId, server_id: 'azure-server', feature_type: 'tool', feature_name: 'query_cosmos_db', display_name: 'Query Cosmos DB', description: 'Execute a SQL query against an Azure Cosmos DB container' }, ], }; diff --git a/tests/e2e/specs/capture-screenshots.manual.ts b/tests/e2e/specs/capture-screenshots.manual.ts index 2336e750..0e092a14 100644 --- a/tests/e2e/specs/capture-screenshots.manual.ts +++ b/tests/e2e/specs/capture-screenshots.manual.ts @@ -1,8 +1,8 @@ /** * Screenshot Capture for Docs & Marketing * - * Seeds realistic data, navigates to each page, and saves screenshots - * to docs/screenshots/ for the README and discover UI. + * Seeds realistic data, navigates to each page, and saves focused screenshots + * of the main content area (excluding sidebar/titlebar) for maximum readability. * * Strategy: * - Real Tauri API calls for spaces, servers, feature sets, gateway, OAuth clients @@ -11,6 +11,8 @@ * (window.__TAURI_INTERNALS__.invoke is non-writable/non-configurable in Tauri v2, * so we cannot mock invoke; instead we emit events that useServerManager listens to) * - OAuth clients registered via HTTP POST to gateway's DCR endpoint, then approved + * - Screenshots capture the
content area only (not full window) for readability + * - Additional detail screenshots capture modals/panels for feature-specific views * * This file uses .manual.ts (not .wdio.ts) so it is excluded from `pnpm test:e2e`. * It must be invoked explicitly: @@ -43,6 +45,13 @@ import { enableServerV2, emitEvent, approveOAuthClient, + seedServerFeatures, + listFeatureSetsBySpace, + listServerFeatures, + listClients, + addFeatureToSet, + grantFeatureSetToClient, + grantOAuthClientFeatureSet, } from '../helpers/tauri-api'; import { PRESEED } from '../mocks/screenshot-preseed'; @@ -54,17 +63,68 @@ function ensureDir(dir: string) { fs.mkdirSync(dir, { recursive: true }); } +/** + * Save a screenshot of the main content area only (excludes sidebar and titlebar). + * Falls back to full browser screenshot if
element is not found. + */ async function saveScreenshot(name: string) { ensureDir(DOCS_DIR); const docsPath = path.join(DOCS_DIR, `${name}.png`); - await browser.saveScreenshot(docsPath); - console.log(`[screenshot] Saved: ${docsPath}`); + + // Try to capture just the main content area for focused, readable screenshots + try { + const main = await $('main'); + const isDisplayed = await main.isDisplayed(); + if (isDisplayed) { + await main.saveScreenshot(docsPath); + console.log(`[screenshot] Saved (content-focused): ${docsPath}`); + } else { + await browser.saveScreenshot(docsPath); + console.log(`[screenshot] Saved (full window fallback): ${docsPath}`); + } + } catch { + await browser.saveScreenshot(docsPath); + console.log(`[screenshot] Saved (full window fallback): ${docsPath}`); + } // Copy to discover UI public folder + copyToDiscover(docsPath, name); +} + +/** + * Save a full-window screenshot (for contexts where sidebar context is valuable). + */ +async function saveFullScreenshot(name: string) { + ensureDir(DOCS_DIR); + const docsPath = path.join(DOCS_DIR, `${name}.png`); + await browser.saveScreenshot(docsPath); + console.log(`[screenshot] Saved (full window): ${docsPath}`); + copyToDiscover(docsPath, name); +} + +/** + * Save a screenshot of a specific element by selector. + */ +async function saveElementScreenshot(name: string, selector: string) { + ensureDir(DOCS_DIR); + const docsPath = path.join(DOCS_DIR, `${name}.png`); + + try { + const element = await $(selector); + await element.waitForDisplayed({ timeout: 5000 }); + await element.saveScreenshot(docsPath); + console.log(`[screenshot] Saved (element: ${selector}): ${docsPath}`); + copyToDiscover(docsPath, name); + } catch (err) { + console.warn(`[screenshot] Failed to capture element ${selector}: ${err}`); + } +} + +function copyToDiscover(srcPath: string, name: string) { try { ensureDir(DISCOVER_DIR); const discoverPath = path.join(DISCOVER_DIR, `${name}.png`); - fs.copyFileSync(docsPath, discoverPath); + fs.copyFileSync(srcPath, discoverPath); console.log(`[screenshot] Copied to: ${discoverPath}`); } catch (err) { console.warn(`[screenshot] Could not copy to discover UI: ${err}`); @@ -77,31 +137,31 @@ const OAUTH_CLIENTS = [ { client_name: 'Cursor', redirect_uris: ['http://127.0.0.1:6274/callback'], - logo_uri: 'https://github.com/getcursor.png?size=128', + logo_uri: 'https://avatars.githubusercontent.com/u/157927506?v=4', software_id: 'com.cursor.app', software_version: '0.48.2', }, { client_name: 'VS Code', redirect_uris: ['http://127.0.0.1:6275/callback'], - logo_uri: 'https://github.com/microsoft.png?size=128', + logo_uri: 'https://avatars.githubusercontent.com/u/9950313?v=4', software_id: 'com.microsoft.vscode', software_version: '1.96.4', }, - { - client_name: 'Claude Desktop', - redirect_uris: ['http://127.0.0.1:6276/callback'], - logo_uri: 'https://github.com/anthropics.png?size=128', - software_id: 'com.anthropic.claude-desktop', - software_version: '1.2.0', - }, { client_name: 'Windsurf', redirect_uris: ['http://127.0.0.1:6277/callback'], - logo_uri: 'https://github.com/codeium.png?size=128', + logo_uri: 'https://avatars.githubusercontent.com/u/137354558?v=4', software_id: 'com.codeium.windsurf', software_version: '1.6.0', }, + { + client_name: 'Claude Code', + redirect_uris: ['http://127.0.0.1:6278/callback'], + logo_uri: 'https://avatars.githubusercontent.com/u/76263028?v=4', + software_id: 'com.anthropic.claude-code', + software_version: '1.0.0', + }, ]; /** @@ -214,11 +274,13 @@ describe('Screenshot Capture', function () { let defaultSpaceId: string; let gatewayUrl: string; + let oauthClientIds: string[] = []; before(async () => { - // Set a fixed window size for readable, consistent screenshots. - // 1280x800 is optimal: text is legible, full UI visible, standard 16:10 ratio. - await browser.setWindowSize(1280, 800); + // Use a large window so content area screenshots are high-res and readable. + // Content area = window minus sidebar (240px) and titlebar (36px). + // At 1600x1000, the content area is ~1360x964 — plenty of detail. + await browser.setWindowSize(1600, 1000); // ---- Seed data from preseed config ---- @@ -276,18 +338,154 @@ describe('Screenshot Capture', function () { } } + // Seed server features (tools, prompts, resources) into the DB + // so the server expanded view and feature set panel show real data. + try { + const featureDefs = PRESEED.serverFeatures(defaultSpaceId); + const featureIds = await seedServerFeatures(featureDefs); + console.log(`[setup] Seeded ${featureIds.length} server features`); + + // Assign realistic subsets of features to custom feature sets. + // Deliberately partial selection so screenshots show mixed enable/disable states. + const allFeatureSets = await listFeatureSetsBySpace(defaultSpaceId); + const allFeatures = await listServerFeatures(defaultSpaceId, true); + + // ── React Development: GitHub + Filesystem read tools ── + const reactDevFS = allFeatureSets.find((fs) => fs.name === 'React Development'); + if (reactDevFS) { + const reactInclude = new Set([ + 'get_file_contents', 'search_repositories', 'search_code', 'list_commits', + 'read_file', 'list_directory', 'search_files', 'write_file', + 'web_search', + ]); + const reactServerIds = ['github-server', 'filesystem-server', 'brave-search']; + const reactFeatures = allFeatures.filter( + (f) => reactServerIds.includes(f.server_id) && reactInclude.has(f.feature_name) + ); + for (const feature of reactFeatures) { + try { await addFeatureToSet(reactDevFS.id, feature.id, 'include'); } catch { /* ignore */ } + } + console.log(`[setup] Assigned ${reactFeatures.length} features to React Development`); + } + + // ── Cloudflare Workers: Cloudflare + Docker tools ── + const cloudflareFS = allFeatureSets.find((fs) => fs.name === 'Cloudflare Workers'); + if (cloudflareFS) { + const cfInclude = new Set([ + 'list_workers', 'get_worker_code', 'list_kv_namespaces', + 'list_containers', 'run_container', 'container_logs', + ]); + const cfServerIds = ['cloudflare-workers-server', 'docker-server']; + const cfFeatures = allFeatures.filter( + (f) => cfServerIds.includes(f.server_id) && cfInclude.has(f.feature_name) + ); + for (const feature of cfFeatures) { + try { await addFeatureToSet(cloudflareFS.id, feature.id, 'include'); } catch { /* ignore */ } + } + console.log(`[setup] Assigned ${cfFeatures.length} features to Cloudflare Workers`); + } + + // ── Research & Analysis: Brave Search + Notion + PostgreSQL query ── + const researchFS = allFeatureSets.find((fs) => fs.name === 'Research & Analysis'); + if (researchFS) { + const researchInclude = new Set([ + 'web_search', 'local_search', + 'search_pages', 'create_page', + 'query', 'list_tables', 'describe_table', + ]); + const researchServerIds = ['brave-search', 'notion-server', 'postgres-server']; + const researchFeatures = allFeatures.filter( + (f) => researchServerIds.includes(f.server_id) && researchInclude.has(f.feature_name) + ); + for (const feature of researchFeatures) { + try { await addFeatureToSet(researchFS.id, feature.id, 'include'); } catch { /* ignore */ } + } + console.log(`[setup] Assigned ${researchFeatures.length} features to Research & Analysis`); + } + + // ── Full Stack Dev: GitHub + Filesystem + PostgreSQL + Docker ── + const fullStackFS = allFeatureSets.find((fs) => fs.name === 'Full Stack Dev'); + if (fullStackFS) { + const fullStackServerIds = ['github-server', 'filesystem-server', 'postgres-server', 'docker-server']; + const fullStackFeatures = allFeatures.filter( + (f) => fullStackServerIds.includes(f.server_id) + ); + for (const feature of fullStackFeatures) { + try { await addFeatureToSet(fullStackFS.id, feature.id, 'include'); } catch { /* ignore */ } + } + console.log(`[setup] Assigned ${fullStackFeatures.length} features to Full Stack Dev`); + } + + // ── Read Only: only read/list/search/query tools (no writes, deletes, mutations) ── + const readOnlyFS = allFeatureSets.find((fs) => fs.name === 'Read Only'); + if (readOnlyFS) { + const readOnlyInclude = new Set([ + 'get_file_contents', 'search_repositories', 'search_code', 'list_commits', + 'read_file', 'list_directory', 'search_files', + 'query', 'list_tables', 'describe_table', + 'list_channels', 'search_messages', + 'web_search', 'local_search', + 'list_containers', 'container_logs', 'list_images', + 'search_pages', + 'list_s3_buckets', 'get_s3_object', 'describe_instances', + 'list_workers', 'get_worker_code', 'list_kv_namespaces', + 'list_resource_groups', 'list_vms', 'query_cosmos_db', + ]); + const readOnlyFeatures = allFeatures.filter( + (f) => readOnlyInclude.has(f.feature_name) + ); + for (const feature of readOnlyFeatures) { + try { await addFeatureToSet(readOnlyFS.id, feature.id, 'include'); } catch { /* ignore */ } + } + console.log(`[setup] Assigned ${readOnlyFeatures.length} features to Read Only`); + } + } catch (e) { + console.warn('[setup] Feature seeding failed:', e); + } + // Ensure gateway is running (may have auto-started when servers were enabled) try { gatewayUrl = await ensureGatewayRunning(); await browser.pause(1000); // Let gateway fully initialize // Register and approve OAuth clients via DCR - await registerOAuthClients(gatewayUrl); + oauthClientIds = await registerOAuthClients(gatewayUrl); } catch (e) { console.warn('[setup] Gateway/OAuth setup failed:', e); gatewayUrl = ''; } + // Grant feature sets to OAuth clients so Permissions checkboxes and Effective Features have data. + // IMPORTANT: Use grantOAuthClientFeatureSet (inbound_clients table), NOT grantFeatureSetToClient (clients table). + // Only grant specific custom sets — NOT "All Features" — to show realistic granular permissions. + try { + const allFeatureSets = await listFeatureSetsBySpace(defaultSpaceId); + const reactDevFS = allFeatureSets.find((fs) => fs.name === 'React Development'); + const fullStackFS = allFeatureSets.find((fs) => fs.name === 'Full Stack Dev'); + const readOnlyFS = allFeatureSets.find((fs) => fs.name === 'Read Only'); + + if (oauthClientIds.length > 0) { + const firstClientId = oauthClientIds[0]; // Cursor + // Grant "React Development" (custom, 9 features) + if (reactDevFS) { + await grantOAuthClientFeatureSet(firstClientId, defaultSpaceId, reactDevFS.id); + console.log(`[setup] Granted React Development to first OAuth client`); + } + // Grant "Full Stack Dev" (custom, 55 features) + if (fullStackFS) { + await grantOAuthClientFeatureSet(firstClientId, defaultSpaceId, fullStackFS.id); + console.log(`[setup] Granted Full Stack Dev to first OAuth client`); + } + // Grant "Read Only" to second client (VS Code) for variety + if (oauthClientIds.length > 1 && readOnlyFS) { + await grantOAuthClientFeatureSet(oauthClientIds[1], defaultSpaceId, readOnlyFS.id); + console.log(`[setup] Granted Read Only to second OAuth client`); + } + } + } catch (e) { + console.warn('[setup] OAuth client feature set grant failed:', e); + } + // Set active space back to default await setActiveSpace(defaultSpaceId); @@ -297,6 +495,8 @@ describe('Screenshot Capture', function () { await browser.pause(3000); }); + // ── Page Screenshots (content area only) ─────────────────────────── + it('captures My Servers', async () => { const nav = await byTestId('nav-my-servers'); await safeClick(nav); @@ -331,16 +531,102 @@ describe('Screenshot Capture', function () { await saveScreenshot('featuresets'); }); - it('captures Connected Clients', async () => { + it('captures Connected Clients with approval modal', async () => { const nav = await byTestId('nav-clients'); await safeClick(nav); await browser.pause(2000); - await saveScreenshot('clients'); + + // Trigger an OAuth consent modal over the clients page. + // Flow: register a new client → hit /oauth/authorize → extract request_id → emit event + if (gatewayUrl) { + try { + // Register a new client (not yet approved) for the consent flow + const consentResp = await fetch(`${gatewayUrl}/oauth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'VS Code', + redirect_uris: ['http://127.0.0.1:6280/callback'], + logo_uri: 'https://avatars.githubusercontent.com/u/9950313?v=4', + software_id: 'com.microsoft.vscode', + software_version: '1.96.4', + }), + }); + + if (consentResp.ok) { + const consentClient = (await consentResp.json()) as { client_id: string }; + + // Generate PKCE challenge + const codeVerifier = 'screenshot-test-verifier-01234567890123456789012345678901234567890123'; + const encoder = new TextEncoder(); + const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(codeVerifier)); + const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(hashBuffer))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + + // Hit authorize endpoint — creates pending consent in gateway memory + const authorizeUrl = `${gatewayUrl}/oauth/authorize?` + new URLSearchParams({ + response_type: 'code', + client_id: consentClient.client_id, + redirect_uri: 'http://127.0.0.1:6280/callback', + scope: 'mcp', + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }).toString(); + + const authResp = await fetch(authorizeUrl, { redirect: 'manual' }); + // The response is HTML containing a deep link with request_id + const html = await authResp.text(); + const match = html.match(/request_id=([a-f0-9-]+)/); + + if (match) { + const requestId = match[1]; + console.log(`[setup] Created pending consent: ${requestId}`); + + // Emit the event that triggers OAuthConsentModal (simulates deep link) + await emitEvent('oauth-consent-request', { requestId }); + await browser.pause(3000); // Wait for modal to validate and render + console.log('[setup] Consent modal should be visible'); + } else { + console.warn('[screenshot] Could not extract request_id from authorize response'); + } + } + } catch (e) { + console.warn('[screenshot] Failed to trigger consent modal:', e); + } + } + + // Capture full window to show consent modal overlay + clients behind + await saveFullScreenshot('clients'); + + // Dismiss the consent modal — click the "Dismiss" link or remove via JS + try { + // Try clicking the "Dismiss (client will wait)" button + const dismissBtns = await $$('button'); + for (const btn of dismissBtns) { + const text = await btn.getText(); + if (text.includes('Dismiss')) { + await browser.execute((el: HTMLElement) => el.click(), btn as any); + await browser.pause(500); + break; + } + } + // Fallback: force-remove any z-50 overlay still present + await browser.execute(() => { + const overlay = document.querySelector('.fixed.inset-0.bg-black\\/50'); + if (overlay) overlay.remove(); + }); + await browser.pause(500); + } catch { /* ignore */ } }); // Dashboard captured after other pages so it remounts with fresh data // (client count, server stats reflect all setup done in before() hook) it('captures Dashboard', async () => { + // Clear any lingering overlays from consent modal + await browser.execute(() => { + document.querySelectorAll('.fixed.inset-0').forEach((el) => el.remove()); + }); + await browser.pause(300); const nav = await byTestId('nav-dashboard'); await safeClick(nav); await browser.pause(2000); @@ -353,4 +639,289 @@ describe('Screenshot Capture', function () { await browser.pause(2000); await saveScreenshot('settings'); }); + + // ── Detail Screenshots (modals, panels, focused views) ───────────── + + // Helper: dismiss any open panel/modal and navigate to ensure clean state + async function dismissAndNavigate(navTestId: string) { + // Force-remove any z-50 overlay (consent modal, etc.) that may be blocking + await browser.execute(() => { + document.querySelectorAll('.fixed.inset-0').forEach((el) => el.remove()); + }); + await browser.pause(300); + + // Try Escape multiple times to close any open panels/modals/dropdowns + for (let i = 0; i < 3; i++) { + await browser.keys('Escape'); + await browser.pause(300); + } + + // Click the main content area to defocus any panels + try { + const main = await $('main'); + if (await main.isDisplayed()) { + await main.click(); + await browser.pause(300); + } + } catch { /* ignore */ } + + // Now navigate — use direct click without safeClick's strict waitForClickable + // since we may still have overlay remnants + const nav = await byTestId(navTestId); + try { + await nav.waitForClickable({ timeout: 5000 }); + await nav.click(); + } catch { + // Fallback: force-click via JS if element exists but is "not clickable" + await browser.execute((el: HTMLElement) => el.click(), nav as any); + } + await browser.pause(2000); + } + + it('captures Feature Set detail panel', async () => { + await dismissAndNavigate('nav-featuresets'); + + // Click "React Development" feature set to show the detail panel with assigned features + try { + const featureSetCards = await $$('[data-testid="featuresets-page"] [data-testid^="featureset-card"]'); + let clicked = false; + for (const card of featureSetCards) { + const text = await card.getText(); + if (text.includes('React Development')) { + await card.click(); + clicked = true; + break; + } + } + if (!clicked && featureSetCards.length > 0) { + await featureSetCards[0].click(); + } + // Wait for features to load — the "Included Features" section is expanded by default + await browser.pause(3000); + + await saveFullScreenshot('featureset-detail'); + } catch (err) { + console.warn(`[screenshot] Could not capture feature set detail: ${err}`); + } + }); + + it('captures Client detail panel with granted feature sets', async () => { + await dismissAndNavigate('nav-clients'); + + // Click first client card to open the detail/authorization panel + try { + const clientCards = await $$('[data-testid="clients-page"] [data-testid^="client-card"]'); + if (clientCards.length > 0) { + await clientCards[0].click(); + await browser.pause(2000); + + // Expand "Permissions" section if collapsed (it defaults to expanded) + // and collapse "Quick Settings" to make room for permissions + const buttons = await $$('button'); + for (const btn of buttons) { + const text = await btn.getText(); + if (text.includes('Quick Settings')) { + // Quick Settings is expanded by default — collapse it to focus on Permissions + await btn.click(); + await browser.pause(500); + break; + } + } + + await browser.pause(1000); + await saveFullScreenshot('client-detail'); + } else { + console.warn('[screenshot] No client cards found for detail screenshot'); + } + } catch (err) { + console.warn(`[screenshot] Could not capture client detail: ${err}`); + } + }); + + it('captures Server Install modal', async () => { + await dismissAndNavigate('nav-discover'); + await browser.pause(1000); // Extra wait for registry load + + // Click the first server card's install button + try { + const installBtns = await $$('[data-testid^="install-btn-"]'); + if (installBtns.length > 0) { + await installBtns[0].click(); + await browser.pause(1500); + + // Capture the install modal + await saveElementScreenshot('install-modal', '[data-testid="install-modal"]'); + + // Close the modal + const cancelBtn = await $('[data-testid="install-modal-cancel-btn"]'); + if (await cancelBtn.isDisplayed().catch(() => false)) { + await cancelBtn.click(); + } else { + await browser.keys('Escape'); + } + await browser.pause(500); + } else { + console.warn('[screenshot] No install buttons found for modal screenshot'); + } + } catch (err) { + console.warn(`[screenshot] Could not capture install modal: ${err}`); + } + }); + + it('captures Server expanded view with tools/resources', async () => { + await dismissAndNavigate('nav-my-servers'); + + // Ensure servers show as connected + await emitConnectedStatus(PRESEED.serversToInstall, defaultSpaceId); + await browser.pause(2000); + + // Emit features-refreshed event so the UI knows features are available + // and shows the expand chevron with feature count badges + for (const serverId of PRESEED.serversToInstall) { + await emitEvent('server-features-refreshed', { + space_id: defaultSpaceId, + server_id: serverId, + tools_count: 5, + prompts_count: 1, + resources_count: 1, + added: [], + removed: [], + }); + } + await browser.pause(1000); + + // Expand GitHub server to show its rich tool set (8 seeded tools + 1 resource) + try { + // First scroll to GitHub in the server list (it may be below the fold) + const githubCard = await $('[data-testid="server-card-github-server"]'); + if (await githubCard.isExisting()) { + await githubCard.scrollIntoView({ block: 'center' }); + await browser.pause(500); + } + + // Now click its expand button + let expandBtn = await $('[data-testid="expand-server-github-server"]'); + if (!(await expandBtn.isExisting())) { + // Fallback: any expandable server + expandBtn = await $('[data-testid^="expand-server-"]'); + } + if (await expandBtn.isDisplayed()) { + await expandBtn.click(); + await browser.pause(3000); // Wait for features to load from DB + await saveFullScreenshot('server-expanded'); + } else { + console.warn('[screenshot] No expand button found'); + } + } catch (err) { + console.warn(`[screenshot] Could not capture server expanded: ${err}`); + } + }); + + it('captures Client permissions panel (granting feature sets)', async () => { + await dismissAndNavigate('nav-clients'); + + // Click first client to open detail panel + try { + const clientCards = await $$('[data-testid="clients-page"] [data-testid^="client-card"]'); + if (clientCards.length > 0) { + await clientCards[0].click(); + await browser.pause(2000); + + // Collapse "Quick Settings" to make room for the features sections + const buttons = await $$('button'); + for (const btn of buttons) { + const text = await btn.getText(); + if (text.includes('Quick Settings')) { + await btn.click(); + await browser.pause(500); + break; + } + } + + // Expand "Effective Features" to show resolved tools/prompts/resources + const buttons2 = await $$('button'); + for (const btn of buttons2) { + const text = await btn.getText(); + if (text.includes('Effective Features')) { + await btn.click(); + await browser.pause(2000); // Wait for features to load + break; + } + } + + await saveFullScreenshot('client-permissions'); + } + } catch (err) { + console.warn(`[screenshot] Could not capture client permissions: ${err}`); + } + }); + + it('captures Space switcher dropdown', async () => { + await dismissAndNavigate('nav-dashboard'); + + // The space switcher is in the sidebar — click it to open the dropdown + try { + const sidebar = await $('[data-testid="sidebar"]'); + const spaceSwitcherButtons = await sidebar.$$('button'); + + for (const btn of spaceSwitcherButtons) { + const text = await btn.getText(); + // The space switcher shows the current space name + if (text.includes('Default') || text.includes('Work') || text.includes('Personal') || text.includes('My Space') || text.includes('Open Source')) { + await btn.click(); + await browser.pause(1000); + + // Capture the full window to show sidebar + dropdown + await saveFullScreenshot('space-switcher'); + + // Close the dropdown + await browser.keys('Escape'); + await browser.pause(500); + break; + } + } + } catch (err) { + console.warn(`[screenshot] Could not capture space switcher: ${err}`); + } + }); + + // ── Discover Web Screenshot ──────────────────────────────────────── + + it('captures Discover Web UI', async function () { + // Take a screenshot of the discover web UI (Next.js site) + // This requires the discover dev server running on port 3000 + this.timeout(30000); + const DISCOVER_WEB_URL = 'http://localhost:3000'; + + // First check if the dev server is running + try { + const resp = await fetch(DISCOVER_WEB_URL, { signal: AbortSignal.timeout(3000) }); + if (!resp.ok) { + console.warn('[screenshot] Discover dev server returned non-OK status, skipping'); + return; + } + } catch { + console.warn('[screenshot] Discover dev server not running on port 3000, skipping'); + console.warn('[screenshot] To capture: cd ../mcpmux.discover.ui && pnpm dev'); + return; + } + + try { + const originalUrl = await browser.getUrl(); + + await browser.url(DISCOVER_WEB_URL); + await browser.pause(3000); + + await browser.setWindowSize(1920, 1080); + await browser.pause(500); + + await saveFullScreenshot('discover-web'); + + await browser.url(originalUrl); + await browser.setWindowSize(1600, 1000); + await browser.pause(2000); + } catch (err) { + console.warn(`[screenshot] Could not capture discover web UI: ${err}`); + } + }); });