diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/mcp-integrations-accordion.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/mcp-integrations-accordion.tsx index c34ec651..83843ab4 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/mcp-integrations-accordion.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/mcp-integrations-accordion.tsx @@ -1,13 +1,20 @@ 'use client' -import { Plug, CheckCircle2, XCircle, AlertCircle } from 'lucide-react' +import { Plug, CheckCircle2, XCircle, AlertCircle, KeyRound, KeyRoundIcon } from 'lucide-react' import { AccordionItem, AccordionTrigger, AccordionContent, } from '@/components/ui/accordion' import { Badge } from '@/components/ui/badge' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' import { useMcpStatus } from '@/services/queries/use-mcp' +import type { McpServer } from '@/services/api/sessions' type McpIntegrationsAccordionProps = { projectName: string @@ -21,8 +28,19 @@ export function McpIntegrationsAccordion({ // Fetch real MCP status from runner const { data: mcpStatus } = useMcpStatus(projectName, sessionName) const mcpServers = mcpStatus?.servers || [] - const getStatusIcon = (status: 'configured' | 'connected' | 'disconnected' | 'error') => { - switch (status) { + + const getStatusIcon = (server: McpServer) => { + // If we have auth info, use that for the icon + if (server.authenticated !== undefined) { + if (server.authenticated) { + return + } else { + return + } + } + + // Fall back to status-based icons + switch (server.status) { case 'configured': case 'connected': return @@ -34,8 +52,28 @@ export function McpIntegrationsAccordion({ } } - const getStatusBadge = (status: 'configured' | 'connected' | 'disconnected' | 'error') => { - switch (status) { + const getAuthBadge = (server: McpServer) => { + // If auth info is available, show auth status + if (server.authenticated !== undefined) { + if (server.authenticated) { + return ( + + + Authenticated + + ) + } else { + return ( + + + Not Authenticated + + ) + } + } + + // Fall back to status-based badges + switch (server.status) { case 'configured': return ( @@ -82,17 +120,28 @@ export function McpIntegrationsAccordion({ > - {getStatusIcon(server.status)} + {getStatusIcon(server)} {server.displayName} - {server.name} + {server.authMessage || server.name} - {getStatusBadge(server.status)} + + + + {getAuthBadge(server)} + + {server.authMessage && ( + + {server.authMessage} + + )} + + )) diff --git a/components/frontend/src/services/api/sessions.ts b/components/frontend/src/services/api/sessions.ts index 437fe1e3..48044835 100644 --- a/components/frontend/src/services/api/sessions.ts +++ b/components/frontend/src/services/api/sessions.ts @@ -21,6 +21,8 @@ export type McpServer = { name: string; displayName: string; status: 'configured' | 'connected' | 'disconnected' | 'error'; + authenticated?: boolean; + authMessage?: string; source?: string; command?: string; }; diff --git a/components/operator/internal/handlers/sessions.go b/components/operator/internal/handlers/sessions.go index bd26a965..f4efc485 100644 --- a/components/operator/internal/handlers/sessions.go +++ b/components/operator/internal/handlers/sessions.go @@ -2115,6 +2115,7 @@ func copySecretToNamespace(ctx context.Context, sourceSecret *corev1.Secret, tar } // deleteAmbientVertexSecret deletes the ambient-vertex secret from a namespace if it was copied +// and no other active sessions in the namespace still need it. func deleteAmbientVertexSecret(ctx context.Context, namespace string) error { secret, err := config.K8sClient.CoreV1().Secrets(namespace).Get(ctx, types.AmbientVertexSecretName, v1.GetOptions{}) if err != nil { @@ -2131,7 +2132,36 @@ func deleteAmbientVertexSecret(ctx context.Context, namespace string) error { return nil } - log.Printf("Deleting copied %s secret from namespace %s", types.AmbientVertexSecretName, namespace) + // Check if there are other active sessions in this namespace that might need this secret + // Don't delete the shared secret if other sessions are Running, Creating, or Pending + gvr := types.GetAgenticSessionResource() + sessions, err := config.DynamicClient.Resource(gvr).Namespace(namespace).List(ctx, v1.ListOptions{}) + if err != nil { + log.Printf("Warning: failed to list sessions in namespace %s, skipping secret deletion: %v", namespace, err) + return nil // Don't delete if we can't verify no other sessions need it + } + + activeCount := 0 + for _, session := range sessions.Items { + status, _, _ := unstructured.NestedMap(session.Object, "status") + phase := "" + if status != nil { + if p, ok := status["phase"].(string); ok { + phase = p + } + } + // Count sessions that are active and might need the vertex secret + if phase == "Running" || phase == "Creating" || phase == "Pending" { + activeCount++ + } + } + + if activeCount > 0 { + log.Printf("Skipping %s secret deletion in namespace %s: %d active session(s) may still need it", types.AmbientVertexSecretName, namespace, activeCount) + return nil + } + + log.Printf("Deleting copied %s secret from namespace %s (no active sessions)", types.AmbientVertexSecretName, namespace) err = config.K8sClient.CoreV1().Secrets(namespace).Delete(ctx, types.AmbientVertexSecretName, v1.DeleteOptions{}) if err != nil && !errors.IsNotFound(err) { return fmt.Errorf("failed to delete %s secret: %w", types.AmbientVertexSecretName, err) @@ -2141,6 +2171,7 @@ func deleteAmbientVertexSecret(ctx context.Context, namespace string) error { } // deleteAmbientLangfuseSecret deletes the ambient-admin-langfuse-secret from a namespace if it was copied +// and no other active sessions in the namespace still need it. func deleteAmbientLangfuseSecret(ctx context.Context, namespace string) error { const langfuseSecretName = "ambient-admin-langfuse-secret" secret, err := config.K8sClient.CoreV1().Secrets(namespace).Get(ctx, langfuseSecretName, v1.GetOptions{}) @@ -2158,7 +2189,36 @@ func deleteAmbientLangfuseSecret(ctx context.Context, namespace string) error { return nil } - log.Printf("Deleting copied %s secret from namespace %s", langfuseSecretName, namespace) + // Check if there are other active sessions in this namespace that might need this secret + // Don't delete the shared secret if other sessions are Running, Creating, or Pending + gvr := types.GetAgenticSessionResource() + sessions, err := config.DynamicClient.Resource(gvr).Namespace(namespace).List(ctx, v1.ListOptions{}) + if err != nil { + log.Printf("Warning: failed to list sessions in namespace %s, skipping secret deletion: %v", namespace, err) + return nil // Don't delete if we can't verify no other sessions need it + } + + activeCount := 0 + for _, session := range sessions.Items { + status, _, _ := unstructured.NestedMap(session.Object, "status") + phase := "" + if status != nil { + if p, ok := status["phase"].(string); ok { + phase = p + } + } + // Count sessions that are active and might need the langfuse secret + if phase == "Running" || phase == "Creating" || phase == "Pending" { + activeCount++ + } + } + + if activeCount > 0 { + log.Printf("Skipping %s secret deletion in namespace %s: %d active session(s) may still need it", langfuseSecretName, namespace, activeCount) + return nil + } + + log.Printf("Deleting copied %s secret from namespace %s (no active sessions)", langfuseSecretName, namespace) err = config.K8sClient.CoreV1().Secrets(namespace).Delete(ctx, langfuseSecretName, v1.DeleteOptions{}) if err != nil && !errors.IsNotFound(err) { return fmt.Errorf("failed to delete %s secret: %w", langfuseSecretName, err) diff --git a/components/runners/claude-code-runner/.mcp.json b/components/runners/claude-code-runner/.mcp.json index 55971cdc..9ca9ce41 100644 --- a/components/runners/claude-code-runner/.mcp.json +++ b/components/runners/claude-code-runner/.mcp.json @@ -1,5 +1,9 @@ { "mcpServers": { + "webfetch": { + "command": "uvx", + "args": ["mcp-server-fetch"] + }, "mcp-atlassian": { "command": "mcp-atlassian", "args": [], diff --git a/components/runners/claude-code-runner/adapter.py b/components/runners/claude-code-runner/adapter.py index 734031a0..c6be986a 100644 --- a/components/runners/claude-code-runner/adapter.py +++ b/components/runners/claude-code-runner/adapter.py @@ -398,22 +398,10 @@ async def _run_claude_agent_sdk( logger.info(f"Claude SDK CWD: {cwd_path}") logger.info(f"Claude SDK additional directories: {add_dirs}") - # Load MCP server configuration - mcp_servers = self._load_mcp_config(cwd_path) + # Load MCP server configuration (webfetch is included in static .mcp.json) + mcp_servers = self._load_mcp_config(cwd_path) or {} - # Add WebFetch.MCP as default if no MCP config exists - if not mcp_servers: - mcp_servers = {} - - # Always include WebFetch.MCP for better web content extraction - if "webfetch" not in mcp_servers: - mcp_servers["webfetch"] = { - "command": "npx", - "args": ["-y", "@manooll/webfetch-mcp"] - } - logger.info("Added WebFetch.MCP as default web fetch provider") - - # Disable built-in WebFetch in favor of WebFetch.MCP + # Disable built-in WebFetch in favor of WebFetch.MCP from config allowed_tools = ["Read", "Write", "Bash", "Glob", "Grep", "Edit", "MultiEdit", "WebSearch"] if mcp_servers: for server_name in mcp_servers.keys(): diff --git a/components/runners/claude-code-runner/main.py b/components/runners/claude-code-runner/main.py index 32542c79..f31f1d8a 100644 --- a/components/runners/claude-code-runner/main.py +++ b/components/runners/claude-code-runner/main.py @@ -284,14 +284,54 @@ async def interrupt_run(): raise HTTPException(status_code=500, detail=str(e)) +def _check_mcp_authentication(server_name: str) -> tuple[bool | None, str | None]: + """ + Check if credentials are available for known MCP servers. + + Returns: + Tuple of (is_authenticated, auth_message) + Returns (None, None) for servers we don't know how to check + """ + from pathlib import Path + + # Google Workspace MCP - we know how to check this + if server_name == "google-workspace": + # Check mounted secret location first, then workspace copy + secret_path = Path("/app/.google_workspace_mcp/credentials/credentials.json") + workspace_path = Path("/workspace/.google_workspace_mcp/credentials/credentials.json") + + for cred_path in [workspace_path, secret_path]: + if cred_path.exists(): + try: + if cred_path.stat().st_size > 0: + return True, "Google OAuth credentials available" + except OSError: + pass + return False, "Google OAuth not configured - authenticate via Integrations page" + + # Jira/Atlassian MCP - we know how to check this + if server_name in ("mcp-atlassian", "jira"): + jira_url = os.getenv("JIRA_URL", "").strip() + jira_token = os.getenv("JIRA_API_TOKEN", "").strip() + + if jira_url and jira_token: + return True, "Jira credentials configured" + elif jira_url: + return False, "Jira URL set but API token missing" + else: + return False, "Jira not configured - set credentials in Workspace Settings" + + # For all other servers (webfetch, unknown) - don't claim to know auth status + return None, None + + @app.get("/mcp/status") async def get_mcp_status(): """ - Returns MCP servers configured for this session. + Returns MCP servers configured for this session with authentication status. Goes straight to the source - uses adapter's _load_mcp_config() method. - Note: Status is "configured" not "connected" - we can't verify runtime state - without introspecting the Claude SDK's internal MCP server processes. + For known integrations (Google, Jira), also checks if credentials are present. """ try: global adapter @@ -323,21 +363,31 @@ async def get_mcp_status(): if mcp_config: for server_name, server_config in mcp_config.items(): - # Check if this is WebFetch (auto-added by adapter) - is_webfetch = server_name == "webfetch" + # Check authentication status for known servers (Google, Jira) + is_authenticated, auth_message = _check_mcp_authentication(server_name) + + # Platform servers are built-in (webfetch), workflow servers come from config + is_platform = server_name == "webfetch" - mcp_servers_list.append({ + server_info = { "name": server_name, "displayName": server_name.replace('-', ' ').replace('_', ' ').title(), - "status": "configured", # Honest: we know it's configured, not if it's actually running + "status": "configured", "command": server_config.get("command", ""), - "source": "platform" if is_webfetch else "workflow" - }) + "source": "platform" if is_platform else "workflow" + } + + # Only include auth fields for servers we know how to check + if is_authenticated is not None: + server_info["authenticated"] = is_authenticated + server_info["authMessage"] = auth_message + + mcp_servers_list.append(server_info) return { "servers": mcp_servers_list, "totalCount": len(mcp_servers_list), - "note": "Status shows 'configured' - actual runtime state requires SDK introspection" + "note": "Status shows 'configured' - check 'authenticated' field for credential status" } except Exception as e:
- {server.name} + {server.authMessage || server.name}
{server.authMessage}