diff --git a/components/backend/handlers/mcp_config.go b/components/backend/handlers/mcp_config.go new file mode 100644 index 000000000..cf992d60e --- /dev/null +++ b/components/backend/handlers/mcp_config.go @@ -0,0 +1,136 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/gin-gonic/gin" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const mcpConfigMapName = "ambient-mcp-config" +const mcpConfigKey = "mcp.json" +const httpToolsKey = "http-tools.json" + +// getConfigMapKey reads a key from the ambient-mcp-config ConfigMap and returns +// its parsed JSON. If the ConfigMap or key does not exist, returns emptyValue. +func getConfigMapKey(c *gin.Context, key string, emptyValue interface{}) { + projectName := c.Param("projectName") + k8sClient, _ := GetK8sClientsForRequest(c) + if k8sClient == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } + + cm, err := k8sClient.CoreV1().ConfigMaps(projectName).Get(c.Request.Context(), mcpConfigMapName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusOK, emptyValue) + return + } + log.Printf("Failed to get ConfigMap %s/%s: %v", projectName, mcpConfigMapName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read config"}) + return + } + + raw, ok := cm.Data[key] + if !ok { + c.JSON(http.StatusOK, emptyValue) + return + } + + var config map[string]interface{} + if err := json.Unmarshal([]byte(raw), &config); err != nil { + preview := raw + if len(preview) > 200 { + preview = preview[:200] + "..." + } + log.Printf("Failed to parse JSON for key %q from ConfigMap %s/%s: %v (raw: %s)", key, projectName, mcpConfigMapName, err, preview) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse config"}) + return + } + + c.JSON(http.StatusOK, config) +} + +// updateConfigMapKey creates or updates a key in the ambient-mcp-config ConfigMap +// using the request body as the JSON value. +func updateConfigMapKey(c *gin.Context, key string) { + projectName := c.Param("projectName") + k8sClient, _ := GetK8sClientsForRequest(c) + if k8sClient == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } + + var req map[string]interface{} + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + configJSON, err := json.Marshal(req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to serialize config"}) + return + } + + cm, err := k8sClient.CoreV1().ConfigMaps(projectName).Get(c.Request.Context(), mcpConfigMapName, v1.GetOptions{}) + if errors.IsNotFound(err) { + newCM := &corev1.ConfigMap{ + ObjectMeta: v1.ObjectMeta{ + Name: mcpConfigMapName, + Namespace: projectName, + Labels: map[string]string{"app": "ambient-mcp-config"}, + Annotations: map[string]string{ + "ambient-code.io/mcp-config": "true", + }, + }, + Data: map[string]string{ + key: string(configJSON), + }, + } + if _, err := k8sClient.CoreV1().ConfigMaps(projectName).Create(c.Request.Context(), newCM, v1.CreateOptions{}); err != nil { + log.Printf("Failed to create ConfigMap %s/%s: %v", projectName, mcpConfigMapName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create config"}) + return + } + } else if err != nil { + log.Printf("Failed to get ConfigMap %s/%s: %v", projectName, mcpConfigMapName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read config"}) + return + } else { + if cm.Data == nil { + cm.Data = map[string]string{} + } + cm.Data[key] = string(configJSON) + if _, err := k8sClient.CoreV1().ConfigMaps(projectName).Update(c.Request.Context(), cm, v1.UpdateOptions{}); err != nil { + log.Printf("Failed to update ConfigMap %s/%s: %v", projectName, mcpConfigMapName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update config"}) + return + } + } + + c.JSON(http.StatusOK, gin.H{"message": "Config updated"}) +} + +// GetMcpConfig handles GET /api/projects/:projectName/mcp-config +func GetMcpConfig(c *gin.Context) { + getConfigMapKey(c, mcpConfigKey, gin.H{"servers": map[string]interface{}{}}) +} + +// UpdateMcpConfig handles PUT /api/projects/:projectName/mcp-config +func UpdateMcpConfig(c *gin.Context) { updateConfigMapKey(c, mcpConfigKey) } + +// GetHttpTools handles GET /api/projects/:projectName/http-tools +func GetHttpTools(c *gin.Context) { + getConfigMapKey(c, httpToolsKey, gin.H{"tools": []interface{}{}}) +} + +// UpdateHttpTools handles PUT /api/projects/:projectName/http-tools +func UpdateHttpTools(c *gin.Context) { updateConfigMapKey(c, httpToolsKey) } diff --git a/components/backend/handlers/mcp_test_server.go b/components/backend/handlers/mcp_test_server.go new file mode 100644 index 000000000..e8fbc0397 --- /dev/null +++ b/components/backend/handlers/mcp_test_server.go @@ -0,0 +1,375 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "sort" + "strings" + "time" + + "github.com/gin-gonic/gin" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/client-go/kubernetes" +) + +// mcpTestScript is an inline Python script that runs inside the test Pod. +// It starts the MCP server process, performs the JSON-RPC initialize handshake +// over stdio with Content-Length framing, and outputs a single JSON line. +const mcpTestScript = ` +import subprocess, json, sys, os, time + +def main(): + cmd = os.environ.get("MCP_TEST_COMMAND", "") + args_json = os.environ.get("MCP_TEST_ARGS", "[]") + env_json = os.environ.get("MCP_TEST_ENV", "{}") + if not cmd: + print(json.dumps({"success": False, "error": "MCP_TEST_COMMAND not set"})) + sys.exit(1) + + try: + args = json.loads(args_json) + except Exception: + args = [] + try: + extra_env = json.loads(env_json) + except Exception: + extra_env = {} + + proc_env = os.environ.copy() + proc_env.update(extra_env) + + try: + proc = subprocess.Popen( + [cmd] + args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=proc_env, + ) + except Exception as e: + print(json.dumps({"success": False, "error": f"Failed to start process: {e}"})) + sys.exit(1) + + init_req = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "mcp-test", "version": "1.0.0"} + } + }) + msg = f"Content-Length: {len(init_req)}\r\n\r\n{init_req}" + + try: + proc.stdin.write(msg.encode()) + proc.stdin.flush() + except Exception as e: + proc.kill() + print(json.dumps({"success": False, "error": f"Failed to send initialize: {e}"})) + sys.exit(1) + + deadline = time.time() + 30 + header = b"" + try: + while time.time() < deadline: + b = proc.stdout.read(1) + if not b: + break + header += b + if header.endswith(b"\r\n\r\n"): + break + + content_length = 0 + for line in header.decode().split("\r\n"): + if line.lower().startswith("content-length:"): + content_length = int(line.split(":", 1)[1].strip()) + + if content_length == 0: + proc.kill() + stderr_out = proc.stderr.read(4096).decode(errors="replace") + print(json.dumps({"success": False, "error": f"No response from server. stderr: {stderr_out[:500]}"})) + sys.exit(1) + + body = b"" + while len(body) < content_length and time.time() < deadline: + chunk = proc.stdout.read(content_length - len(body)) + if not chunk: + break + body += chunk + + resp = json.loads(body.decode()) + server_info = resp.get("result", {}).get("serverInfo", {}) + + notif = json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + notif_msg = f"Content-Length: {len(notif)}\r\n\r\n{notif}" + try: + proc.stdin.write(notif_msg.encode()) + proc.stdin.flush() + except Exception: + pass + + proc.kill() + print(json.dumps({"success": True, "serverInfo": server_info})) + + except Exception as e: + proc.kill() + print(json.dumps({"success": False, "error": f"Protocol error: {e}"})) + sys.exit(1) + +main() +` + +type mcpTestRequest struct { + Command string `json:"command" binding:"required"` + Args []string `json:"args"` + Env map[string]string `json:"env"` +} + +type mcpTestResponse struct { + Valid bool `json:"valid"` + ServerInfo map[string]interface{} `json:"serverInfo,omitempty"` + Error string `json:"error,omitempty"` +} + +// allowedMcpCommands is the set of commands permitted in MCP test pods. +// These correspond to standard MCP server launchers available in the runner image. +var allowedMcpCommands = map[string]bool{ + "npx": true, + "node": true, + "python": true, + "python3": true, + "uvx": true, + "uv": true, + "docker": true, + "podman": true, +} + +// dangerousArgPatterns contains argument patterns that could enable code execution. +var dangerousArgPatterns = []string{ + "-c", + "--eval", + "--exec", + "-e", + "--input-type=module", + "--import", + "-p", + "--print", +} + +// blockedEnvVars contains environment variables that could be used for privilege escalation. +var blockedEnvVars = map[string]bool{ + "PATH": true, + "LD_PRELOAD": true, + "LD_LIBRARY_PATH": true, + "PYTHONPATH": true, + "NODE_PATH": true, + "NODE_OPTIONS": true, + "PYTHONSTARTUP": true, + "PYTHONHOME": true, +} + +// validateMcpArgs checks that arguments don't contain dangerous patterns that could +// enable arbitrary code execution. +func validateMcpArgs(args []string) error { + for i, arg := range args { + lowerArg := strings.ToLower(arg) + for _, pattern := range dangerousArgPatterns { + if lowerArg == pattern || strings.HasPrefix(lowerArg, pattern+"=") { + return fmt.Errorf("argument %d (%q) contains blocked pattern %q", i, arg, pattern) + } + } + } + return nil +} + +// validateMcpEnv checks that environment variables don't include blocked keys +// that could be used for privilege escalation. +func validateMcpEnv(env map[string]string) error { + for key := range env { + if blockedEnvVars[strings.ToUpper(key)] { + return fmt.Errorf("environment variable %q is not allowed", key) + } + } + return nil +} + +// TestMcpServer handles POST /api/projects/:projectName/mcp-config/test +// Spawns a temporary Pod using the runner image to test an MCP server connection. +func TestMcpServer(c *gin.Context) { + var req mcpTestRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if !allowedMcpCommands[req.Command] { + names := make([]string, 0, len(allowedMcpCommands)) + for k := range allowedMcpCommands { + names = append(names, k) + } + sort.Strings(names) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("command %q is not allowed; permitted commands: %s", req.Command, strings.Join(names, ", "))}) + return + } + + // Validate args to prevent code execution via dangerous flags + if err := validateMcpArgs(req.Args); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid arguments: %v", err)}) + return + } + + // Validate env vars to prevent privilege escalation + if err := validateMcpEnv(req.Env); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid environment: %v", err)}) + return + } + + projectName := c.Param("projectName") + k8sClient, _ := GetK8sClientsForRequest(c) + if k8sClient == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } + + argsJSON, _ := json.Marshal(req.Args) + envJSON, _ := json.Marshal(req.Env) + + runnerImage := os.Getenv("AMBIENT_CODE_RUNNER_IMAGE") + if runnerImage == "" { + runnerImage = "quay.io/ambient_code/vteam_claude_runner:latest" + } + + podName := fmt.Sprintf("mcp-test-%s", rand.String(8)) + + envVars := []corev1.EnvVar{ + {Name: "MCP_TEST_COMMAND", Value: req.Command}, + {Name: "MCP_TEST_ARGS", Value: string(argsJSON)}, + {Name: "MCP_TEST_ENV", Value: string(envJSON)}, + } + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: podName, + Namespace: projectName, + Labels: map[string]string{ + "app": "mcp-test", + "ambient-code.io/mcp-test": "true", + }, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "mcp-test", + Image: runnerImage, + Command: []string{"python3", "-c", mcpTestScript}, + Env: envVars, + SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: BoolPtr(false), + ReadOnlyRootFilesystem: BoolPtr(false), + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + }, + }, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceMemory: resource.MustParse("256Mi"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("64Mi"), + }, + }, + }, + }, + }, + } + + ctx := c.Request.Context() + + _, err := k8sClient.CoreV1().Pods(projectName).Create(ctx, pod, metav1.CreateOptions{}) + if err != nil { + log.Printf("Failed to create MCP test pod in %s: %v", projectName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create test pod: %v", err)}) + return + } + + defer func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = k8sClient.CoreV1().Pods(projectName).Delete(cleanupCtx, podName, metav1.DeleteOptions{}) + }() + + result, err := waitForMcpTestPod(ctx, k8sClient, projectName, podName) + if err != nil { + c.JSON(http.StatusOK, mcpTestResponse{Valid: false, Error: err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +func waitForMcpTestPod(ctx context.Context, clientset kubernetes.Interface, namespace, podName string) (*mcpTestResponse, error) { + timeout := time.After(60 * time.Second) + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("request cancelled") + case <-timeout: + return nil, fmt.Errorf("test timed out after 60s") + case <-ticker.C: + pod, err := clientset.CoreV1().Pods(namespace).Get(ctx, podName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get pod status: %v", err) + } + switch pod.Status.Phase { + case corev1.PodSucceeded, corev1.PodFailed: + return readMcpTestLogs(ctx, clientset, namespace, podName) + } + } + } +} + +func readMcpTestLogs(ctx context.Context, clientset kubernetes.Interface, namespace, podName string) (*mcpTestResponse, error) { + logReq := clientset.CoreV1().Pods(namespace).GetLogs(podName, &corev1.PodLogOptions{}) + logStream, err := logReq.Stream(ctx) + if err != nil { + return nil, fmt.Errorf("failed to read pod logs: %v", err) + } + defer logStream.Close() + + logBytes, err := io.ReadAll(logStream) + if err != nil { + return nil, fmt.Errorf("failed to read pod log stream: %v", err) + } + logOutput := string(logBytes) + + var podResult struct { + Success bool `json:"success"` + ServerInfo map[string]interface{} `json:"serverInfo"` + Error string `json:"error"` + } + if err := json.Unmarshal([]byte(logOutput), &podResult); err != nil { + return &mcpTestResponse{Valid: false, Error: fmt.Sprintf("failed to parse test output: %s", logOutput)}, nil + } + + if podResult.Success { + return &mcpTestResponse{Valid: true, ServerInfo: podResult.ServerInfo}, nil + } + return &mcpTestResponse{Valid: false, Error: podResult.Error}, nil +} diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index f8f432751..e549cc2dc 100644 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -382,7 +382,12 @@ func ListSessions(c *gin.Context) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - list, err := k8sDyn.Resource(gvr).Namespace(project).List(ctx, v1.ListOptions{}) + listOpts := v1.ListOptions{} + if labelSelector := c.Query("labelSelector"); labelSelector != "" { + listOpts.LabelSelector = labelSelector + } + + list, err := k8sDyn.Resource(gvr).Namespace(project).List(ctx, listOpts) if err != nil { log.Printf("Failed to list agentic sessions in project %s: %v", project, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list agentic sessions"}) diff --git a/components/backend/routes.go b/components/backend/routes.go index 69f52d096..e0cff35c7 100644 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -109,6 +109,12 @@ func registerRoutes(r *gin.Engine) { projectGroup.GET("/integration-secrets", handlers.ListIntegrationSecrets) projectGroup.PUT("/integration-secrets", handlers.UpdateIntegrationSecrets) + projectGroup.GET("/mcp-config", handlers.GetMcpConfig) + projectGroup.PUT("/mcp-config", handlers.UpdateMcpConfig) + projectGroup.POST("/mcp-config/test", handlers.TestMcpServer) + projectGroup.GET("/http-tools", handlers.GetHttpTools) + projectGroup.PUT("/http-tools", handlers.UpdateHttpTools) + // GitLab authentication endpoints (DEPRECATED - moved to cluster-scoped) // Kept for backward compatibility, will be removed in future version projectGroup.POST("/auth/gitlab/connect", handlers.ConnectGitLabGlobal) diff --git a/components/frontend/src/app/api/projects/[name]/http-tools/route.ts b/components/frontend/src/app/api/projects/[name]/http-tools/route.ts new file mode 100644 index 000000000..1ec2504fb --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/http-tools/route.ts @@ -0,0 +1,5 @@ +import { createProxyRouteHandlers } from '@/lib/api-route-helpers'; + +export const { GET, PUT } = createProxyRouteHandlers( + (name) => `/projects/${encodeURIComponent(name)}/http-tools` +); diff --git a/components/frontend/src/app/api/projects/[name]/mcp-config/route.ts b/components/frontend/src/app/api/projects/[name]/mcp-config/route.ts new file mode 100644 index 000000000..c80b8334c --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/mcp-config/route.ts @@ -0,0 +1,5 @@ +import { createProxyRouteHandlers } from '@/lib/api-route-helpers'; + +export const { GET, PUT } = createProxyRouteHandlers( + (name) => `/projects/${encodeURIComponent(name)}/mcp-config` +); diff --git a/components/frontend/src/app/api/projects/[name]/mcp-config/test/route.ts b/components/frontend/src/app/api/projects/[name]/mcp-config/test/route.ts new file mode 100644 index 000000000..16bfa553d --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/mcp-config/test/route.ts @@ -0,0 +1,29 @@ +import { BACKEND_URL } from '@/lib/config'; +import { buildForwardHeadersAsync } from '@/lib/auth'; + +export async function POST( + request: Request, + { params }: { params: Promise<{ name: string }> }, +) { + try { + const { name } = await params; + const body = await request.text(); + const headers = await buildForwardHeadersAsync(request); + const response = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(name)}/mcp-config/test`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body, + }, + ); + const text = await response.text(); + return new Response(text, { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + console.error('Error proxying POST mcp-config/test:', error); + return Response.json({ error: 'Failed to test MCP server' }, { status: 500 }); + } +} diff --git a/components/frontend/src/app/projects/[name]/page.tsx b/components/frontend/src/app/projects/[name]/page.tsx index 5194df718..36b36da7d 100644 --- a/components/frontend/src/app/projects/[name]/page.tsx +++ b/components/frontend/src/app/projects/[name]/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { useParams, useSearchParams } from 'next/navigation'; -import { Star, Settings, Users, Loader2 } from 'lucide-react'; +import { Star, Settings, Users, Loader2, Server } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; @@ -14,9 +14,10 @@ import { Breadcrumbs } from '@/components/breadcrumbs'; import { SessionsSection } from '@/components/workspace-sections/sessions-section'; import { SharingSection } from '@/components/workspace-sections/sharing-section'; import { SettingsSection } from '@/components/workspace-sections/settings-section'; +import { McpConfigEditor } from '@/components/mcp-config-editor'; import { useProject } from '@/services/queries/use-projects'; -type Section = 'sessions' | 'sharing' | 'settings'; +type Section = 'sessions' | 'sharing' | 'settings' | 'mcp-servers'; export default function ProjectDetailsPage() { const params = useParams(); @@ -33,7 +34,7 @@ export default function ProjectDetailsPage() { // Update active section when query parameter changes useEffect(() => { const sectionParam = searchParams.get('section') as Section; - if (sectionParam && ['sessions', 'sharing', 'settings'].includes(sectionParam)) { + if (sectionParam && ['sessions', 'sharing', 'settings', 'mcp-servers'].includes(sectionParam)) { setActiveSection(sectionParam); } }, [searchParams]); @@ -41,6 +42,7 @@ export default function ProjectDetailsPage() { const navItems = [ { id: 'sessions' as Section, label: 'Sessions', icon: Star }, { id: 'sharing' as Section, label: 'Sharing', icon: Users }, + { id: 'mcp-servers' as Section, label: 'MCP Servers', icon: Server }, { id: 'settings' as Section, label: 'Workspace Settings', icon: Settings }, ]; @@ -120,6 +122,7 @@ export default function ProjectDetailsPage() { {/* Main Content */} {activeSection === 'sessions' && } {activeSection === 'sharing' && } + {activeSection === 'mcp-servers' && } {activeSection === 'settings' && } diff --git a/components/frontend/src/components/create-session-dialog.tsx b/components/frontend/src/components/create-session-dialog.tsx index f25ec3770..54c8ee453 100644 --- a/components/frontend/src/components/create-session-dialog.tsx +++ b/components/frontend/src/components/create-session-dialog.tsx @@ -32,6 +32,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { LabelEditor } from "@/components/label-editor"; import type { CreateAgenticSessionRequest } from "@/types/agentic-session"; import { useCreateSession } from "@/services/queries/use-sessions"; import { useIntegrationsStatus } from "@/services/queries/use-integrations"; @@ -67,6 +68,7 @@ export function CreateSessionDialog({ onSuccess, }: CreateSessionDialogProps) { const [open, setOpen] = useState(false); + const [labels, setLabels] = useState>({}); const router = useRouter(); const createSessionMutation = useCreateSession(); @@ -104,6 +106,9 @@ export function CreateSessionDialog({ if (trimmedName) { request.displayName = trimmedName; } + if (Object.keys(labels).length > 0) { + request.labels = labels; + } createSessionMutation.mutate( { projectName, data: request }, @@ -126,6 +131,7 @@ export function CreateSessionDialog({ setOpen(newOpen); if (!newOpen) { form.reset(); + setLabels({}); } }; @@ -193,6 +199,17 @@ export function CreateSessionDialog({ )} /> + {/* Labels */} +
+ Labels + +
+ {/* Integration auth status */}
Integrations diff --git a/components/frontend/src/components/http-tool-dialog.tsx b/components/frontend/src/components/http-tool-dialog.tsx new file mode 100644 index 000000000..e9493d7fb --- /dev/null +++ b/components/frontend/src/components/http-tool-dialog.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Plus, Trash2, Loader2 } from "lucide-react"; +import type { HttpToolConfig, HttpMethod } from "@/services/api/http-tools"; + +type KVEntry = { key: string; value: string }; + +const HTTP_METHODS: HttpMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE"]; + +type HttpToolDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (tool: HttpToolConfig) => void; + saving: boolean; + initialTool?: HttpToolConfig; +}; + +export function HttpToolDialog({ open, onOpenChange, onSave, saving, initialTool }: HttpToolDialogProps) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [method, setMethod] = useState("GET"); + const [endpoint, setEndpoint] = useState(""); + const [headers, setHeaders] = useState([]); + const [params, setParams] = useState([]); + const isEditing = !!initialTool; + + useEffect(() => { + if (open) { + setName(initialTool?.name ?? ""); + setDescription(initialTool?.description ?? ""); + setMethod(initialTool?.method ?? "GET"); + setEndpoint(initialTool?.endpoint ?? ""); + setHeaders( + initialTool?.headers + ? Object.entries(initialTool.headers).map(([key, value]) => ({ key, value })) + : [] + ); + setParams( + initialTool?.params + ? Object.entries(initialTool.params).map(([key, value]) => ({ key, value })) + : [] + ); + } + }, [open, initialTool]); + + const handleSubmit = () => { + if (!name.trim() || !endpoint.trim()) return; + const toRecord = (entries: KVEntry[]) => { + const rec: Record = {}; + for (const e of entries) { + if (e.key.trim()) rec[e.key.trim()] = e.value; + } + return rec; + }; + onSave({ + name: name.trim(), + description: description.trim(), + method, + endpoint: endpoint.trim(), + headers: toRecord(headers), + params: toRecord(params), + }); + }; + + const addEntry = (setter: typeof setHeaders) => setter((prev) => [...prev, { key: "", value: "" }]); + const removeEntry = (setter: typeof setHeaders, index: number) => setter((prev) => prev.filter((_, i) => i !== index)); + const updateEntry = (setter: typeof setHeaders, index: number, field: "key" | "value", val: string) => + setter((prev) => prev.map((e, i) => (i === index ? { ...e, [field]: val } : e))); + + const renderKVSection = (label: string, entries: KVEntry[], setter: typeof setHeaders) => ( +
+
+ + +
+ {entries.map((entry, i) => ( +
+ updateEntry(setter, i, "key", e.target.value)} placeholder="Key" className="flex-1" /> + updateEntry(setter, i, "value", e.target.value)} placeholder="Value" className="flex-1" /> + +
+ ))} +
+ ); + + return ( + + + + {isEditing ? "Edit HTTP Tool" : "Add HTTP Tool"} + +
+
+ + setName(e.target.value)} placeholder="e.g. fetch-weather" disabled={isEditing} /> +
+
+ + setDescription(e.target.value)} placeholder="What does this tool do?" /> +
+
+
+ + +
+
+ + setEndpoint(e.target.value)} placeholder="https://api.example.com/data" /> +
+
+ {renderKVSection("Headers", headers, setHeaders)} + {renderKVSection("Parameters", params, setParams)} +
+ + + + +
+
+ ); +} diff --git a/components/frontend/src/components/http-tools-tab.tsx b/components/frontend/src/components/http-tools-tab.tsx new file mode 100644 index 000000000..953623f50 --- /dev/null +++ b/components/frontend/src/components/http-tools-tab.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useState } from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Plus, MoreHorizontal, Pencil, Trash2, Globe, AlertCircle } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { successToast, errorToast } from "@/hooks/use-toast"; +import { useHttpTools, useUpdateHttpTools } from "@/services/queries/use-http-tools"; +import { HttpToolDialog } from "@/components/http-tool-dialog"; +import type { HttpToolConfig } from "@/services/api/http-tools"; + +type HttpToolsTabProps = { + projectName: string; +}; + +export function HttpToolsTab({ projectName }: HttpToolsTabProps) { + const { data, isLoading, error } = useHttpTools(projectName); + const updateMutation = useUpdateHttpTools(); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingTool, setEditingTool] = useState(null); + + const tools = data?.tools ?? []; + + const handleAdd = () => { + setEditingTool(null); + setDialogOpen(true); + }; + + const handleEdit = (tool: HttpToolConfig) => { + setEditingTool(tool); + setDialogOpen(true); + }; + + const handleDelete = (name: string) => { + const updated = tools.filter((t) => t.name !== name); + updateMutation.mutate( + { projectName, data: { tools: updated } }, + { + onSuccess: () => successToast(`Removed HTTP tool "${name}"`), + onError: () => errorToast("Failed to remove HTTP tool"), + } + ); + }; + + const handleSave = (tool: HttpToolConfig) => { + const updated = editingTool + ? tools.map((t) => (t.name === editingTool.name ? tool : t)) + : [...tools, tool]; + updateMutation.mutate( + { projectName, data: { tools: updated } }, + { + onSuccess: () => { + successToast(editingTool ? `Updated HTTP tool "${tool.name}"` : `Added HTTP tool "${tool.name}"`); + setDialogOpen(false); + setEditingTool(null); + }, + onError: () => errorToast("Failed to save HTTP tool"), + } + ); + }; + + if (isLoading) { + return ( +
+ + + +
+ ); + } + + if (error) { + return ( + + + + Failed to load HTTP tools configuration: {error instanceof Error ? error.message : "Unknown error"} + + + ); + } + + return ( + <> +
+ +
+ + {tools.length === 0 ? ( +
+ +

No HTTP tools configured

+

Add an HTTP tool to give sessions access to external APIs

+
+ ) : ( + + + + Name + Method + Endpoint + Headers + + + + + {tools.map((tool) => ( + + +
+ {tool.name} + {tool.description && ( +

{tool.description}

+ )} +
+
+ + {tool.method} + + {tool.endpoint} + + {Object.keys(tool.headers ?? {}).length > 0 ? ( + {Object.keys(tool.headers).length} headers + ) : ( + -- + )} + + + + + + + + handleEdit(tool)}> + Edit + + handleDelete(tool.name)} className="text-destructive"> + Delete + + + + +
+ ))} +
+
+ )} + + { + setDialogOpen(open); + if (!open) setEditingTool(null); + }} + onSave={handleSave} + saving={updateMutation.isPending} + initialTool={editingTool ?? undefined} + /> + + ); +} diff --git a/components/frontend/src/components/label-editor.tsx b/components/frontend/src/components/label-editor.tsx new file mode 100644 index 000000000..0ec0e8a90 --- /dev/null +++ b/components/frontend/src/components/label-editor.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { X, Plus, ChevronDown } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; + +type LabelEditorProps = { + labels: Record; + onChange: (labels: Record) => void; + disabled?: boolean; + suggestions?: string[]; +}; + +const DEFAULT_SUGGESTIONS = ["team", "type", "priority", "feature"]; + +export function LabelEditor({ + labels, + onChange, + disabled = false, + suggestions = DEFAULT_SUGGESTIONS, +}: LabelEditorProps) { + const [inputValue, setInputValue] = useState(""); + const [suggestionsOpen, setSuggestionsOpen] = useState(false); + + const handleRemove = useCallback( + (key: string) => { + const next = { ...labels }; + delete next[key]; + onChange(next); + }, + [labels, onChange] + ); + + const handleAdd = useCallback(() => { + const trimmed = inputValue.trim(); + if (!trimmed) return; + + const colonIdx = trimmed.indexOf(":"); + if (colonIdx <= 0 || colonIdx === trimmed.length - 1) return; + + const key = trimmed.slice(0, colonIdx).trim(); + const value = trimmed.slice(colonIdx + 1).trim(); + if (!key || !value) return; + + onChange({ ...labels, [key]: value }); + setInputValue(""); + }, [inputValue, labels, onChange]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAdd(); + } + }; + + const handleSuggestionClick = (suggestion: string) => { + setInputValue(`${suggestion}:`); + setSuggestionsOpen(false); + }; + + const entries = Object.entries(labels); + + return ( +
+ {/* Existing labels */} + {entries.length > 0 && ( +
+ {entries.map(([key, value]) => ( + + {key} + = + {value} + {!disabled && ( + + )} + + ))} +
+ )} + + {/* Input row */} + {!disabled && ( +
+
+ setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="key:value" + disabled={disabled} + className="pr-8" + /> +
+ + + + + + {suggestions.map((s) => ( + + ))} + + + +
+ )} + + {!disabled && ( +

+ Add labels as key:value pairs. Use the dropdown for common keys. +

+ )} +
+ ); +} diff --git a/components/frontend/src/components/mcp-config-editor.tsx b/components/frontend/src/components/mcp-config-editor.tsx new file mode 100644 index 000000000..f076eefb1 --- /dev/null +++ b/components/frontend/src/components/mcp-config-editor.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Loader2 } from "lucide-react"; +import { useMcpConfig } from "@/services/queries/use-mcp-config"; +import { useHttpTools } from "@/services/queries/use-http-tools"; +import { McpServersTab } from "@/components/mcp-servers-tab"; +import { HttpToolsTab } from "@/components/http-tools-tab"; + +type McpConfigEditorProps = { + projectName: string; +}; + +export function McpConfigEditor({ projectName }: McpConfigEditorProps) { + const { isLoading: mcpLoading } = useMcpConfig(projectName); + const { isLoading: httpLoading } = useHttpTools(projectName); + + if (mcpLoading || httpLoading) { + return ( + + + + + + ); + } + + return ( + + + MCP Servers & HTTP Tools + Configure Model Context Protocol servers and custom HTTP tools for your sessions + + + + + MCP Servers + HTTP Tools + + + + + + + + + + + ); +} diff --git a/components/frontend/src/components/mcp-server-dialog.tsx b/components/frontend/src/components/mcp-server-dialog.tsx new file mode 100644 index 000000000..863a94da3 --- /dev/null +++ b/components/frontend/src/components/mcp-server-dialog.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Plus, Trash2, Loader2, Zap, CheckCircle2, XCircle } from "lucide-react"; +import type { McpServerConfig, McpTestResult } from "@/services/api/mcp-config"; +import { useTestMcpServer } from "@/services/queries/use-mcp-config"; + +type EnvEntry = { key: string; value: string }; + +type McpServerDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (name: string, config: McpServerConfig) => void; + saving: boolean; + projectName: string; + initialName?: string; + initialConfig?: McpServerConfig; +}; + +export function McpServerDialog({ open, onOpenChange, onSave, saving, projectName, initialName, initialConfig }: McpServerDialogProps) { + const [name, setName] = useState(""); + const [command, setCommand] = useState(""); + const [args, setArgs] = useState(""); + const [envEntries, setEnvEntries] = useState([]); + const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'fail'>('idle'); + const [testResult, setTestResult] = useState(null); + const isEditing = !!initialName; + const testMutation = useTestMcpServer(); + + useEffect(() => { + if (open) { + setName(initialName ?? ""); + setCommand(initialConfig?.command ?? ""); + setArgs(initialConfig?.args?.join(", ") ?? ""); + const entries = initialConfig?.env + ? Object.entries(initialConfig.env).map(([key, value]) => ({ key, value })) + : []; + setEnvEntries(entries); + setTestStatus('idle'); + setTestResult(null); + } + }, [open, initialName, initialConfig]); + + const resetTest = () => { + setTestStatus('idle'); + setTestResult(null); + }; + + const buildConfig = (): McpServerConfig => { + const parsedArgs = args + .split(",") + .map((a) => a.trim()) + .filter(Boolean); + const env: Record = {}; + for (const entry of envEntries) { + if (entry.key.trim()) { + env[entry.key.trim()] = entry.value; + } + } + return { command: command.trim(), args: parsedArgs, env }; + }; + + const handleSubmit = () => { + if (!name.trim() || !command.trim()) return; + onSave(name.trim(), buildConfig()); + }; + + const handleTest = () => { + if (!command.trim()) return; + setTestStatus('testing'); + setTestResult(null); + testMutation.mutate( + { projectName, config: buildConfig() }, + { + onSuccess: (result) => { + setTestResult(result); + setTestStatus(result.valid ? 'success' : 'fail'); + }, + onError: (error) => { + setTestResult({ valid: false, error: error instanceof Error ? error.message : 'Test request failed' }); + setTestStatus('fail'); + }, + }, + ); + }; + + const addEnvEntry = () => setEnvEntries([...envEntries, { key: "", value: "" }]); + + const removeEnvEntry = (index: number) => { + setEnvEntries(envEntries.filter((_, i) => i !== index)); + }; + + const updateEnvEntry = (index: number, field: "key" | "value", val: string) => { + setEnvEntries(envEntries.map((e, i) => (i === index ? { ...e, [field]: val } : e))); + }; + + return ( + + + + {isEditing ? "Edit MCP Server" : "Add MCP Server"} + +
+
+ + setName(e.target.value)} placeholder="e.g. my-mcp-server" disabled={isEditing} /> +
+
+ + { setCommand(e.target.value); resetTest(); }} placeholder="e.g. npx" /> +
+
+ + { setArgs(e.target.value); resetTest(); }} placeholder="e.g. -y, @modelcontextprotocol/server-filesystem, /path" /> +
+
+
+ + +
+ {envEntries.map((entry, i) => ( +
+ { updateEnvEntry(i, "key", e.target.value); resetTest(); }} placeholder="KEY" className="flex-1" /> + { updateEnvEntry(i, "value", e.target.value); resetTest(); }} placeholder="value" className="flex-1" /> + +
+ ))} +
+ + + + {testStatus === 'success' && testResult && ( +
+ + Connected{testResult.serverInfo?.name ? ` — ${testResult.serverInfo.name}${testResult.serverInfo.version ? ` v${testResult.serverInfo.version}` : ''}` : ''} +
+ )} + + {testStatus === 'fail' && testResult && ( +
+ + {testResult.error || "Connection failed"} +
+ )} +
+ + + + +
+
+ ); +} diff --git a/components/frontend/src/components/mcp-servers-tab.tsx b/components/frontend/src/components/mcp-servers-tab.tsx new file mode 100644 index 000000000..9f0a96853 --- /dev/null +++ b/components/frontend/src/components/mcp-servers-tab.tsx @@ -0,0 +1,307 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Plus, MoreHorizontal, Pencil, Trash2, Server, Zap, Download, Upload, ChevronDown, AlertCircle } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { successToast, errorToast } from "@/hooks/use-toast"; +import { useMcpConfig, useUpdateMcpConfig, useTestMcpServer } from "@/services/queries/use-mcp-config"; +import { McpServerDialog } from "@/components/mcp-server-dialog"; +import type { McpServerConfig } from "@/services/api/mcp-config"; + +// OpenCode local server format: {type: "local", command: ["cmd", ...args], environment?: {...}} +type OpenCodeServer = { + type: string; + command: string[]; + environment?: Record; + enabled?: boolean; +}; + +function toInternal(servers: Record): Record { + const result: Record = {}; + for (const [name, raw] of Object.entries(servers)) { + if (!raw || typeof raw !== "object") continue; + const srv = raw as Record; + if (Array.isArray(srv.command) && srv.command.every((c) => typeof c === "string")) { + // OpenCode format: command is an array of strings + const [cmd = "", ...args] = srv.command as string[]; + const env = srv.environment && typeof srv.environment === "object" ? (srv.environment as Record) : {}; + result[name] = { command: cmd, args, env }; + } else if (typeof srv.command === "string") { + // Claude Code format: command is a string, args is separate + const args = Array.isArray(srv.args) ? (srv.args as string[]) : []; + const env = srv.env && typeof srv.env === "object" ? (srv.env as Record) : {}; + result[name] = { command: srv.command, args, env }; + } + } + return result; +} + +function toOpenCode(servers: Record): Record { + const result: Record = {}; + for (const [name, srv] of Object.entries(servers)) { + result[name] = { + type: "local", + command: [srv.command, ...srv.args], + ...(Object.keys(srv.env).length > 0 ? { environment: srv.env } : {}), + }; + } + return result; +} + +function downloadJson(data: unknown, filename: string) { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = blobUrl; + a.download = filename; + a.click(); + URL.revokeObjectURL(blobUrl); +} + +type McpServersTabProps = { + projectName: string; +}; + +export function McpServersTab({ projectName }: McpServersTabProps) { + const { data: config, isLoading, error } = useMcpConfig(projectName); + const updateMutation = useUpdateMcpConfig(); + const testMutation = useTestMcpServer(); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingServer, setEditingServer] = useState<{ name: string; config: McpServerConfig } | null>(null); + const fileInputRef = useRef(null); + + const servers = config?.servers ?? {}; + const serverEntries = Object.entries(servers); + + const handleAdd = () => { + setEditingServer(null); + setDialogOpen(true); + }; + + const handleEdit = (name: string, serverConfig: McpServerConfig) => { + setEditingServer({ name, config: serverConfig }); + setDialogOpen(true); + }; + + const handleDelete = (name: string) => { + const updated = { ...servers }; + delete updated[name]; + updateMutation.mutate( + { projectName, config: { servers: updated } }, + { + onSuccess: () => successToast(`Removed MCP server "${name}"`), + onError: () => errorToast("Failed to remove MCP server"), + } + ); + }; + + const handleSave = (name: string, serverConfig: McpServerConfig) => { + const updated = { ...servers, [name]: serverConfig }; + updateMutation.mutate( + { projectName, config: { servers: updated } }, + { + onSuccess: () => { + successToast(editingServer ? `Updated MCP server "${name}"` : `Added MCP server "${name}"`); + setDialogOpen(false); + setEditingServer(null); + }, + onError: () => errorToast("Failed to save MCP server"), + } + ); + }; + + const handleTest = (name: string, srv: McpServerConfig) => { + testMutation.mutate( + { projectName, config: srv }, + { + onSuccess: (result) => { + if (result.valid) { + const info = result.serverInfo; + const detail = info?.name ? `${info.name}${info.version ? ` v${info.version}` : ''}` : 'OK'; + successToast(`Server "${name}" is working — ${detail}`); + } else { + errorToast(`Server "${name}" failed: ${result.error || 'Unknown error'}`); + } + }, + onError: (error) => { + errorToast(`Server "${name}" test error: ${error instanceof Error ? error.message : 'Request failed'}`); + }, + } + ); + }; + + const handleExportClaudeCode = () => { + downloadJson({ mcpServers: servers }, "mcp-servers.json"); + successToast(`Exported ${serverEntries.length} server(s) (Claude Code format)`); + }; + + const handleExportOpenCode = () => { + downloadJson({ mcp: toOpenCode(servers) }, "opencode.json"); + successToast(`Exported ${serverEntries.length} server(s) (OpenCode format)`); + }; + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + const handleImportFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + // Reset so the same file can be re-imported + e.target.value = ""; + try { + const text = await file.text(); + const data = JSON.parse(text); + // Accept: Claude Code {"mcpServers": {...}}, native {"servers": {...}}, OpenCode {"mcp": {...}} + const raw: Record | undefined = data.mcpServers ?? data.servers ?? data.mcp; + if (!raw || typeof raw !== "object") { + errorToast("Invalid MCP config file — must contain 'mcpServers', 'servers', or 'mcp'"); + return; + } + const imported = toInternal(raw); + const merged = { ...servers, ...imported }; + const count = Object.keys(imported).length; + updateMutation.mutate( + { projectName, config: { servers: merged } }, + { + onSuccess: () => successToast(`Imported ${count} server(s)`), + onError: () => errorToast("Failed to import MCP servers"), + } + ); + } catch { + errorToast("Could not parse the selected file as JSON"); + } + }; + + if (isLoading) { + return ( +
+ + + +
+ ); + } + + if (error) { + return ( + + + + Failed to load MCP server configuration: {error instanceof Error ? error.message : "Unknown error"} + + + ); + } + + return ( + <> +
+ + + + + + + Export format + + + Claude Code / Desktop + + + OpenCode + + + + + +
+ + {serverEntries.length === 0 ? ( +
+ +

No MCP servers configured

+

Add an MCP server to extend your session capabilities

+
+ ) : ( + + + + Name + Command + Args + Env + + + + + {serverEntries.map(([name, srv]) => ( + + {name} + {srv.command} + + {srv.args?.length > 0 ? ( + {srv.args.join(", ")} + ) : ( + -- + )} + + + {Object.keys(srv.env ?? {}).length > 0 ? ( + {Object.keys(srv.env).length} vars + ) : ( + -- + )} + + + + + + + + handleTest(name, srv)}> + Test + + handleEdit(name, srv)}> + Edit + + handleDelete(name)} className="text-destructive"> + Delete + + + + + + ))} + +
+ )} + + { + setDialogOpen(open); + if (!open) setEditingServer(null); + }} + onSave={handleSave} + saving={updateMutation.isPending} + projectName={projectName} + initialName={editingServer?.name} + initialConfig={editingServer?.config} + /> + + ); +} diff --git a/components/frontend/src/components/workspace-sections/sessions-section.tsx b/components/frontend/src/components/workspace-sections/sessions-section.tsx index 8685a401a..9d83f6b84 100644 --- a/components/frontend/src/components/workspace-sections/sessions-section.tsx +++ b/components/frontend/src/components/workspace-sections/sessions-section.tsx @@ -1,11 +1,12 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { formatDistanceToNow } from 'date-fns'; -import { Plus, RefreshCw, MoreVertical, Square, Trash2, ArrowRight, Brain, Search, ChevronLeft, ChevronRight, Pencil } from 'lucide-react'; +import { Plus, RefreshCw, MoreVertical, Square, Trash2, ArrowRight, Brain, Search, ChevronLeft, ChevronRight, Pencil, X } from 'lucide-react'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; @@ -28,26 +29,48 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { // Pagination and search state const [searchInput, setSearchInput] = useState(''); const [offset, setOffset] = useState(0); + const [labelFilters, setLabelFilters] = useState>({}); const limit = DEFAULT_PAGE_SIZE; // Debounce search to avoid too many API calls const debouncedSearch = useDebounce(searchInput, 300); - // Reset offset when search changes + // Build labelSelector string from filters + const labelSelector = Object.entries(labelFilters) + .map(([k, v]) => `${k}=${v}`) + .join(',') || undefined; + + // Reset offset when search or label filters change useEffect(() => { setOffset(0); - }, [debouncedSearch]); + }, [debouncedSearch, labelSelector]); + + const addLabelFilter = useCallback((key: string, value: string) => { + setLabelFilters((prev) => ({ ...prev, [key]: value })); + }, []); + + const removeLabelFilter = useCallback((key: string) => { + setLabelFilters((prev) => { + const next = { ...prev }; + delete next[key]; + return next; + }); + }, []); // React Query hooks with pagination const { data: paginatedData, isFetching, refetch, - } = useSessionsPaginated(projectName, { - limit, - offset, - search: debouncedSearch || undefined, - }); + } = useSessionsPaginated( + projectName, + { + limit, + offset, + search: debouncedSearch || undefined, + }, + labelSelector + ); const sessions = paginatedData?.items ?? []; const totalCount = paginatedData?.totalCount ?? 0; @@ -190,19 +213,52 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { className="pl-9" />
+ {/* Active label filters */} + {Object.keys(labelFilters).length > 0 && ( +
+ Filtering by: + {Object.entries(labelFilters).map(([key, value]) => ( + + {key} + = + {value} + + + ))} + +
+ )} - {sessions.length === 0 && !debouncedSearch ? ( + {sessions.length === 0 && !debouncedSearch && Object.keys(labelFilters).length === 0 ? ( - ) : sessions.length === 0 && debouncedSearch ? ( + ) : sessions.length === 0 ? ( ) : ( <> @@ -214,8 +270,8 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { Status Mode Model - Created - Cost + Labels + Created Actions @@ -256,12 +312,30 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { - {session.metadata?.creationTimestamp && - formatDistanceToNow(new Date(session.metadata.creationTimestamp), { addSuffix: true })} + {session.metadata?.labels && Object.keys(session.metadata.labels).length > 0 ? ( +
+ {Object.entries(session.metadata.labels).map(([key, value]) => ( + { + e.preventDefault(); + e.stopPropagation(); + addLabelFilter(key, value); + }} + > + {key}={value} + + ))} +
+ ) : ( + + )}
- {/* total_cost_usd removed from simplified status */} - + {session.metadata?.creationTimestamp && + formatDistanceToNow(new Date(session.metadata.creationTimestamp), { addSuffix: true })} {isActionPending ? ( diff --git a/components/frontend/src/lib/api-route-helpers.ts b/components/frontend/src/lib/api-route-helpers.ts new file mode 100644 index 000000000..0927c554c --- /dev/null +++ b/components/frontend/src/lib/api-route-helpers.ts @@ -0,0 +1,67 @@ +import { BACKEND_URL } from '@/lib/config'; +import { buildForwardHeadersAsync } from '@/lib/auth'; + +/** + * Categorizes an error and returns a structured error response. + */ +function getErrorResponse(error: unknown, operation: 'fetch' | 'update'): { message: string; status: number } { + if (error instanceof TypeError && (error.message.includes('fetch') || error.message.includes('network'))) { + return { message: `Backend service unavailable`, status: 503 }; + } + if (error instanceof Error && error.name === 'AbortError') { + return { message: 'Request timed out', status: 504 }; + } + return { message: `Failed to ${operation} resource`, status: 500 }; +} + +/** + * Logs proxy errors with structured context for debugging. + */ +function logProxyError(method: string, path: string, error: unknown): void { + const errorType = error instanceof Error ? error.constructor.name : typeof error; + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`[proxy] ${method} ${path} failed: [${errorType}] ${errorMessage}`); +} + +/** + * Creates GET and PUT route handlers that proxy requests to the backend. + * @param backendPath - Function that takes the project name and returns the backend URL path. + * @returns Object with GET and PUT handlers following Next.js 15 dynamic route conventions. + */ +export function createProxyRouteHandlers(backendPath: (name: string) => string) { + return { + GET: async (request: Request, { params }: { params: Promise<{ name: string }> }) => { + try { + const { name } = await params; + const headers = await buildForwardHeadersAsync(request); + const response = await fetch(`${BACKEND_URL}${backendPath(name)}`, { headers }); + const text = await response.text(); + return new Response(text, { status: response.status, headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + const path = backendPath('...'); + logProxyError('GET', path, error); + const { message, status } = getErrorResponse(error, 'fetch'); + return Response.json({ error: message }, { status }); + } + }, + PUT: async (request: Request, { params }: { params: Promise<{ name: string }> }) => { + try { + const { name } = await params; + const body = await request.text(); + const headers = await buildForwardHeadersAsync(request); + const response = await fetch(`${BACKEND_URL}${backendPath(name)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...headers }, + body, + }); + const text = await response.text(); + return new Response(text, { status: response.status, headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + const path = backendPath('...'); + logProxyError('PUT', path, error); + const { message, status } = getErrorResponse(error, 'update'); + return Response.json({ error: message }, { status }); + } + }, + }; +} diff --git a/components/frontend/src/services/api/http-tools.ts b/components/frontend/src/services/api/http-tools.ts new file mode 100644 index 000000000..910058573 --- /dev/null +++ b/components/frontend/src/services/api/http-tools.ts @@ -0,0 +1,24 @@ +import { apiClient } from './client'; + +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +export type HttpToolConfig = { + name: string; + description: string; + method: HttpMethod; + endpoint: string; + headers: Record; + params: Record; +}; + +export type HttpToolsData = { + tools: HttpToolConfig[]; +}; + +export async function getHttpTools(projectName: string): Promise { + return apiClient.get(`/projects/${projectName}/http-tools`); +} + +export async function updateHttpTools(projectName: string, data: HttpToolsData): Promise { + await apiClient.put(`/projects/${projectName}/http-tools`, data); +} diff --git a/components/frontend/src/services/api/mcp-config.ts b/components/frontend/src/services/api/mcp-config.ts new file mode 100644 index 000000000..c97e0f18d --- /dev/null +++ b/components/frontend/src/services/api/mcp-config.ts @@ -0,0 +1,35 @@ +import { apiClient } from './client'; + +export type McpServerConfig = { + command: string; + args: string[]; + env: Record; +}; + +export type McpConfigData = { + servers: Record; +}; + +export type McpTestResult = { + valid: boolean; + serverInfo?: { name?: string; version?: string }; + error?: string; +}; + +export async function getMcpConfig(projectName: string): Promise { + return apiClient.get(`/projects/${projectName}/mcp-config`); +} + +export async function updateMcpConfig(projectName: string, config: McpConfigData): Promise { + await apiClient.put(`/projects/${projectName}/mcp-config`, config); +} + +export async function testMcpServer( + projectName: string, + config: McpServerConfig, +): Promise { + return apiClient.post( + `/projects/${projectName}/mcp-config/test`, + config, + ); +} diff --git a/components/frontend/src/services/api/sessions.ts b/components/frontend/src/services/api/sessions.ts index bd55d9d28..d0b6ad1f4 100644 --- a/components/frontend/src/services/api/sessions.ts +++ b/components/frontend/src/services/api/sessions.ts @@ -48,12 +48,14 @@ export type McpStatusResponse = { */ export async function listSessionsPaginated( projectName: string, - params: PaginationParams = {} + params: PaginationParams = {}, + labelSelector?: string ): Promise { const searchParams = new URLSearchParams(); if (params.limit) searchParams.set('limit', params.limit.toString()); if (params.offset) searchParams.set('offset', params.offset.toString()); if (params.search) searchParams.set('search', params.search); + if (labelSelector) searchParams.set('labelSelector', labelSelector); const queryString = searchParams.toString(); const url = queryString diff --git a/components/frontend/src/services/queries/use-http-tools.ts b/components/frontend/src/services/queries/use-http-tools.ts new file mode 100644 index 000000000..5df8812a1 --- /dev/null +++ b/components/frontend/src/services/queries/use-http-tools.ts @@ -0,0 +1,27 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import * as httpToolsApi from '../api/http-tools'; + +export function useHttpTools(projectName: string) { + return useQuery({ + queryKey: ['http-tools', projectName], + queryFn: () => httpToolsApi.getHttpTools(projectName), + enabled: !!projectName, + }); +} + +export function useUpdateHttpTools() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + projectName, + data, + }: { + projectName: string; + data: httpToolsApi.HttpToolsData; + }) => httpToolsApi.updateHttpTools(projectName, data), + onSuccess: (_, { projectName }) => { + queryClient.invalidateQueries({ queryKey: ['http-tools', projectName] }); + }, + }); +} diff --git a/components/frontend/src/services/queries/use-mcp-config.ts b/components/frontend/src/services/queries/use-mcp-config.ts new file mode 100644 index 000000000..05854d219 --- /dev/null +++ b/components/frontend/src/services/queries/use-mcp-config.ts @@ -0,0 +1,39 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import * as mcpConfigApi from '../api/mcp-config'; + +export function useMcpConfig(projectName: string) { + return useQuery({ + queryKey: ['mcp-config', projectName], + queryFn: () => mcpConfigApi.getMcpConfig(projectName), + enabled: !!projectName, + }); +} + +export function useUpdateMcpConfig() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + projectName, + config, + }: { + projectName: string; + config: mcpConfigApi.McpConfigData; + }) => mcpConfigApi.updateMcpConfig(projectName, config), + onSuccess: (_, { projectName }) => { + queryClient.invalidateQueries({ queryKey: ['mcp-config', projectName] }); + }, + }); +} + +export function useTestMcpServer() { + return useMutation({ + mutationFn: ({ + projectName, + config, + }: { + projectName: string; + config: mcpConfigApi.McpServerConfig; + }) => mcpConfigApi.testMcpServer(projectName, config), + }); +} diff --git a/components/frontend/src/services/queries/use-sessions.ts b/components/frontend/src/services/queries/use-sessions.ts index 5dc8f0118..64d42f879 100644 --- a/components/frontend/src/services/queries/use-sessions.ts +++ b/components/frontend/src/services/queries/use-sessions.ts @@ -34,10 +34,14 @@ export const sessionKeys = { /** * Hook to fetch sessions for a project with pagination support */ -export function useSessionsPaginated(projectName: string, params: PaginationParams = {}) { +export function useSessionsPaginated( + projectName: string, + params: PaginationParams = {}, + labelSelector?: string +) { return useQuery({ - queryKey: sessionKeys.list(projectName, params), - queryFn: () => sessionsApi.listSessionsPaginated(projectName, params), + queryKey: [...sessionKeys.list(projectName, params), labelSelector ?? ""] as const, + queryFn: () => sessionsApi.listSessionsPaginated(projectName, params, labelSelector), enabled: !!projectName, placeholderData: keepPreviousData, // Keep previous data while fetching new page }); diff --git a/components/operator/internal/handlers/sessions.go b/components/operator/internal/handlers/sessions.go index b0fa77b2e..d9bc55482 100644 --- a/components/operator/internal/handlers/sessions.go +++ b/components/operator/internal/handlers/sessions.go @@ -1224,6 +1224,39 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error { // Note: No volume mounts needed for runner/integration secrets // All keys are injected as environment variables via EnvFrom above + // Mount MCP config from project ConfigMap if it exists + mcpCM, mcpErr := config.K8sClient.CoreV1().ConfigMaps(sessionNamespace).Get( + context.TODO(), "ambient-mcp-config", v1.GetOptions{}, + ) + if mcpErr != nil && !errors.IsNotFound(mcpErr) { + log.Printf("Warning: failed to fetch MCP ConfigMap for session %s in %s: %v", name, sessionNamespace, mcpErr) + } + if mcpErr == nil && mcpCM != nil { + // Add volume for the ConfigMap + pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{ + Name: "mcp-config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "ambient-mcp-config"}, + Optional: boolPtr(true), + }, + }, + }) + // Mount to runner container + for i := range pod.Spec.Containers { + if pod.Spec.Containers[i].Name == "ambient-code-runner" { + pod.Spec.Containers[i].VolumeMounts = append(pod.Spec.Containers[i].VolumeMounts, corev1.VolumeMount{ + Name: "mcp-config", + MountPath: "/home/user/.mcp.json", + SubPath: "mcp.json", + ReadOnly: true, + }) + log.Printf("Mounted MCP config from ConfigMap for session %s", name) + break + } + } + } + // If ambient-vertex secret was successfully copied, mount it as a volume if ambientVertexSecretCopied { pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{