diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..c6c4ee23 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.claude +node_modules +dist +coverage +playwright-report +test-results diff --git a/Dockerfile b/Dockerfile index 0c5cc347..33acdbb6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,23 +27,26 @@ RUN test -f ./dist/plugin-manifest.json && \ test -d ./dist/locales && \ echo "All required files are present." -# Stage 2: Runtime image on target architecture -FROM registry.access.redhat.com/ubi9/ubi-minimal:latest +# Stage 2: Build the small asset server and MCP relay on the target architecture. +FROM golang:1.24 AS go-builder + +WORKDIR /usr/src/app +COPY go.mod ./ +COPY cmd/plugin-server ./cmd/plugin-server +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /plugin-server ./cmd/plugin-server -RUN microdnf module enable nginx:1.24 -y && \ - microdnf install -y nginx && \ - microdnf clean all +# Stage 3: Runtime image on target architecture +FROM registry.access.redhat.com/ubi9/ubi-minimal:latest -RUN mkdir -p /var/cache/nginx /var/log/nginx /run && \ - chown -R root:0 /var/cache/nginx /var/log/nginx /run /usr/share/nginx/html && \ - chmod -R g+rwX /var/cache/nginx /var/log/nginx /run && \ - chmod -R g+rX /usr/share/nginx/html +RUN mkdir -p /usr/share/kuadrant-console-plugin && \ + chown -R root:0 /usr/share/kuadrant-console-plugin && \ + chmod -R g+rX /usr/share/kuadrant-console-plugin -COPY --from=builder /usr/src/app/dist/ /usr/share/nginx/html/ -COPY entrypoint.sh /usr/share/nginx/html/entrypoint.sh +COPY --from=builder /usr/src/app/dist/ /usr/share/kuadrant-console-plugin/ +COPY --from=go-builder /plugin-server /usr/local/bin/plugin-server ARG QUAY_IMAGE_EXPIRY="never" LABEL quay.expires-after=${QUAY_IMAGE_EXPIRY} USER 1001 -ENTRYPOINT ["/usr/share/nginx/html/entrypoint.sh"] +ENTRYPOINT ["/usr/local/bin/plugin-server"] diff --git a/Makefile b/Makefile index 17a051da..218f45e3 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,10 @@ -.PHONY: oinc oinc-teardown +.PHONY: oinc oinc-sync-plugin-proxy oinc-teardown oinc: ./start-local.sh +oinc-sync-plugin-proxy: + ./scripts/sync-console-plugin-proxy.sh + oinc-teardown: ./scripts/teardown.sh diff --git a/README.md b/README.md index 9f57d719..a33235fc 100644 --- a/README.md +++ b/README.md @@ -43,15 +43,25 @@ Navigate to and click "Kuadrant" in the left sidebar men [oinc](https://github.com/jasonmadigan/oinc) (OKD in a container) provides a lightweight OpenShift-compatible cluster locally with the console built in. This sets up a full environment with Kuadrant, Istio, cert-manager, and the OpenShift console, with hot reloading for plugin development. -Prerequisites: [oinc](https://github.com/jasonmadigan/oinc), [kubectl](https://kubernetes.io/docs/tasks/tools/), Docker or podman, Node.js. +Prerequisites: [oinc v0.4.6 or newer](https://github.com/jasonmadigan/oinc/releases/tag/v0.4.6), [kubectl](https://kubernetes.io/docs/tasks/tools/), Docker or podman, Node.js. ```bash -make oinc # create cluster + start plugin dev server with hot reload -make oinc-teardown # tear it all down +make oinc # create cluster + start plugin dev server with hot reload +make oinc-sync-plugin-proxy # manually resync an operator-reconciled backend proxy +make oinc-teardown # tear it all down ``` Console runs at http://localhost:9000, plugin at http://localhost:9001. If the cluster already exists, `make oinc` skips setup and just starts the plugin server. +oinc runs Console as a standalone development container, so it does not have +the OpenShift Console operator to consume `ConsolePlugin.spec.proxy`. When the +Kuadrant Operator has reconciled a proxy, `make oinc` automatically translates +it into the standalone Console configuration. Use +`make oinc-sync-plugin-proxy` to resync manually if the backend Service changes +while the development environment is already running. This is development glue +only; the Kuadrant Operator remains the source of truth for production plugin +resources. Set `OINC_BIN` if the required oinc binary is not on `PATH`. + ### Option 3: Docker + VSCode Remote Container Make sure the @@ -89,9 +99,15 @@ docker buildx build --platform linux/amd64,linux/arm64 -t quay.io/kuadrant/conso 2. Run the image: ```bash -docker run -it --rm -d -p 9001:80 quay.io/kuadrant/console-plugin:latest +docker run -it --rm -d -p 9001:9443 \ + -e KUBERNETES_INSECURE_SKIP_TLS_VERIFY=true \ + quay.io/kuadrant/console-plugin:latest ``` +The development flag lets the image serve its static assets outside a pod, +where the Kubernetes service-account CA is not mounted. Do not use it for an +in-cluster deployment. + NOTE: If you have a Mac with Apple silicon, you will need to add the flag `--platform=linux/amd64` when building the image to target the correct platform to run in-cluster. diff --git a/build/suite-router.sh b/build/suite-router.sh index b09170b9..4189639e 100755 --- a/build/suite-router.sh +++ b/build/suite-router.sh @@ -102,7 +102,7 @@ if echo "$CHANGED" | grep -qE "^src/components/(AttachedResources|gateway/Gatewa fi if echo "$CHANGED" | grep -qE "^src/components/mcp/"; then - SPECS="$SPECS mcp-setup-wizard.spec.ts mcp-overview.spec.ts mcp-wizard.spec.ts mcp-resource-pages.spec.ts" + SPECS="$SPECS mcp-setup-wizard.spec.ts mcp-overview.spec.ts mcp-wizard.spec.ts mcp-resource-pages.spec.ts mcp-inspector.spec.ts" fi # Detect test files that changed → run all tags (smoke + nightly) for those files only diff --git a/charts/openshift-console-plugin/templates/configmap.yaml b/charts/openshift-console-plugin/templates/configmap.yaml deleted file mode 100644 index 41ce0f2c..00000000 --- a/charts/openshift-console-plugin/templates/configmap.yaml +++ /dev/null @@ -1,32 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ template "openshift-console-plugin.name" . }} - namespace: {{ .Release.Namespace }} - labels: - {{- include "openshift-console-plugin.labels" . | nindent 4 }} -data: - nginx.conf: | - error_log /dev/stdout info; - events {} - http { - access_log /dev/stdout; - include /etc/nginx/mime.types; - default_type application/octet-stream; - keepalive_timeout 65; - server { - listen {{ .Values.plugin.port }} ssl; - listen [::]:{{ .Values.plugin.port }} ssl; - ssl_certificate /var/cert/tls.crt; - ssl_certificate_key /var/cert/tls.key; - - location / { - root /usr/share/nginx/html; - } - - # Serve config.js from /tmp - location /config.js { - root /tmp; - } - } - } diff --git a/charts/openshift-console-plugin/templates/consoleplugin.yaml b/charts/openshift-console-plugin/templates/consoleplugin.yaml index c70aa50e..86ef29a9 100644 --- a/charts/openshift-console-plugin/templates/consoleplugin.yaml +++ b/charts/openshift-console-plugin/templates/consoleplugin.yaml @@ -7,7 +7,7 @@ metadata: {{- include "openshift-console-plugin.labels" . | nindent 4 }} spec: displayName: {{ default (printf "%s Plugin" (include "openshift-console-plugin.name" .)) .Values.plugin.description }} - i18n: + i18n: loadType: Preload backend: type: Service @@ -15,4 +15,13 @@ spec: name: {{ template "openshift-console-plugin.name" . }} namespace: {{ .Release.Namespace }} port: {{ .Values.plugin.port }} - basePath: {{ .Values.plugin.basePath }} \ No newline at end of file + basePath: {{ .Values.plugin.basePath }} + proxy: + - alias: backend + authorization: UserToken + endpoint: + type: Service + service: + name: {{ template "openshift-console-plugin.name" . }} + namespace: {{ .Release.Namespace }} + port: {{ .Values.plugin.port }} diff --git a/charts/openshift-console-plugin/templates/deployment.yaml b/charts/openshift-console-plugin/templates/deployment.yaml index 46b3a47e..a2234821 100644 --- a/charts/openshift-console-plugin/templates/deployment.yaml +++ b/charts/openshift-console-plugin/templates/deployment.yaml @@ -20,7 +20,8 @@ spec: - name: {{ template "openshift-console-plugin.name" . }} image: {{ required "Plugin image must be specified!" .Values.plugin.image }} ports: - - containerPort: {{ .Values.plugin.port }} + - name: https + containerPort: {{ .Values.plugin.port }} protocol: TCP imagePullPolicy: {{ .Values.plugin.imagePullPolicy }} env: @@ -30,6 +31,10 @@ spec: value: {{ .Values.plugin.topologyConfigMapNamespace | default "kuadrant-system" | quote }} - name: METRICS_WORKLOAD_SUFFIX value: {{ .Values.plugin.metricsWorkloadSuffix | default "-openshift-default" | quote }} + - name: TLS_CERTIFICATE_FILE + value: /var/cert/tls.crt + - name: TLS_KEY_FILE + value: /var/cert/tls.key {{- if and (.Values.plugin.securityContext.enabled) (.Values.plugin.containerSecurityContext) }} securityContext: {{ tpl (toYaml (omit .Values.plugin.containerSecurityContext "enabled")) $ | nindent 12 }} {{- end }} @@ -39,19 +44,11 @@ spec: - name: {{ template "openshift-console-plugin.certificateSecret" . }} readOnly: true mountPath: /var/cert - - name: nginx-conf - readOnly: true - mountPath: /etc/nginx/nginx.conf - subPath: nginx.conf volumes: - name: {{ template "openshift-console-plugin.certificateSecret" . }} secret: secretName: {{ template "openshift-console-plugin.certificateSecret" . }} defaultMode: 420 - - name: nginx-conf - configMap: - name: {{ template "openshift-console-plugin.name" . }} - defaultMode: 420 restartPolicy: Always dnsPolicy: ClusterFirst {{- if and (.Values.plugin.securityContext.enabled) (.Values.plugin.podSecurityContext) }} diff --git a/cmd/plugin-server/main.go b/cmd/plugin-server/main.go new file mode 100644 index 00000000..6073f0f2 --- /dev/null +++ b/cmd/plugin-server/main.go @@ -0,0 +1,479 @@ +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +const ( + mcpProxyPrefix = "/api/mcp/v1/mcpgatewayextensions/" + mcpAuthorizationHeader = "X-Kuadrant-MCP-Authorization" + maxMCPRequestBytes = 1024 * 1024 +) + +type config struct { + listenAddress string + staticDirectory string + tlsCertificateFile string + tlsKeyFile string + kubernetesAPIURL string + kubernetesCAFile string + kubernetesSkipVerify bool + upstreamDialAddress string + allowInsecureMCPAuth bool + requestTimeout time.Duration + topologyConfigMapName string + topologyNamespace string + metricsWorkloadSuffix string +} + +type server struct { + config config + kubernetesHTTP *http.Client + upstreamHTTP *http.Client + logger *slog.Logger +} + +type mcpGatewayExtension struct { + Metadata struct { + Generation int64 `json:"generation"` + } `json:"metadata"` + Spec struct { + PublicHost string `json:"publicHost"` + TargetRef struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + SectionName string `json:"sectionName"` + } `json:"targetRef"` + } `json:"spec"` + Status struct { + Conditions []struct { + Type string `json:"type"` + Status string `json:"status"` + ObservedGeneration int64 `json:"observedGeneration"` + } `json:"conditions"` + } `json:"status"` +} + +type gateway struct { + Spec struct { + Listeners []gatewayListener `json:"listeners"` + } `json:"spec"` +} + +type gatewayListener struct { + Name string `json:"name"` + Hostname string `json:"hostname"` + Protocol string `json:"protocol"` + Port uint32 `json:"port"` +} + +type kubernetesStatus struct { + Message string `json:"message"` +} + +type rpcEnvelope struct { + Method string `json:"method"` +} + +func main() { + cfg, err := loadConfig() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + backend, err := newServer(cfg, logger) + if err != nil { + logger.Error("configure server", "error", err) + os.Exit(1) + } + + handler := backend.routes() + httpServer := &http.Server{ + Addr: cfg.listenAddress, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + isTLS := cfg.tlsCertificateFile != "" && cfg.tlsKeyFile != "" + go func() { + logger.Info("server listening", "address", httpServer.Addr, "tls", isTLS) + var serveErr error + if isTLS { + serveErr = httpServer.ListenAndServeTLS(cfg.tlsCertificateFile, cfg.tlsKeyFile) + } else { + serveErr = httpServer.ListenAndServe() + } + if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + logger.Error("server stopped", "error", serveErr) + stop() + } + }() + + <-ctx.Done() + shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := httpServer.Shutdown(shutdownContext); err != nil { + logger.Error("server shutdown", "error", err) + } +} + +func loadConfig() (config, error) { + requestTimeout, err := time.ParseDuration(env("MCP_PROXY_REQUEST_TIMEOUT", "2m")) + if err != nil { + return config{}, fmt.Errorf("parse MCP_PROXY_REQUEST_TIMEOUT: %w", err) + } + return config{ + listenAddress: env("LISTEN_ADDRESS", ":9443"), + staticDirectory: env("STATIC_DIRECTORY", "/usr/share/kuadrant-console-plugin"), + tlsCertificateFile: os.Getenv("TLS_CERTIFICATE_FILE"), + tlsKeyFile: os.Getenv("TLS_KEY_FILE"), + kubernetesAPIURL: env("KUBERNETES_API_URL", "https://kubernetes.default.svc"), + kubernetesCAFile: env("KUBERNETES_CA_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"), + kubernetesSkipVerify: envBool("KUBERNETES_INSECURE_SKIP_TLS_VERIFY"), + upstreamDialAddress: os.Getenv("MCP_PROXY_DIAL_ADDRESS"), + allowInsecureMCPAuth: envBool("MCP_PROXY_ALLOW_INSECURE_AUTH"), + requestTimeout: requestTimeout, + topologyConfigMapName: env("TOPOLOGY_CONFIGMAP_NAME", "topology"), + topologyNamespace: env("TOPOLOGY_CONFIGMAP_NAMESPACE", "kuadrant-system"), + metricsWorkloadSuffix: env("METRICS_WORKLOAD_SUFFIX", "-openshift-default"), + }, nil +} + +func newServer(cfg config, logger *slog.Logger) (*server, error) { + kubernetesTLS := &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: cfg.kubernetesSkipVerify} // #nosec G402 -- explicit development option + if !cfg.kubernetesSkipVerify { + caPEM, err := os.ReadFile(cfg.kubernetesCAFile) + if err != nil { + return nil, fmt.Errorf("read Kubernetes CA: %w", err) + } + roots, err := x509.SystemCertPool() + if err != nil || roots == nil { + roots = x509.NewCertPool() + } + if !roots.AppendCertsFromPEM(caPEM) { + return nil, errors.New("Kubernetes CA file contains no certificates") + } + kubernetesTLS.RootCAs = roots + } + + upstreamTransport := http.DefaultTransport.(*http.Transport).Clone() + upstreamTransport.TLSClientConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + } + if cfg.upstreamDialAddress != "" { + dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second} + upstreamTransport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { + return dialer.DialContext(ctx, "tcp", cfg.upstreamDialAddress) + } + } + + return &server{ + config: cfg, + kubernetesHTTP: &http.Client{ + Timeout: cfg.requestTimeout, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: kubernetesTLS, + }, + CheckRedirect: rejectRedirect, + }, + upstreamHTTP: &http.Client{ + Timeout: cfg.requestTimeout, + Transport: upstreamTransport, + CheckRedirect: rejectRedirect, + }, + logger: logger, + }, nil +} + +func (s *server) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("GET /config.js", s.serveConfig) + mux.HandleFunc("POST "+mcpProxyPrefix+"{namespace}/{name}", s.proxyMCP) + mux.Handle("/", http.FileServer(http.Dir(s.config.staticDirectory))) + return mux +} + +func (s *server) serveConfig(writer http.ResponseWriter, _ *http.Request) { + value := map[string]string{ + "TOPOLOGY_CONFIGMAP_NAME": s.config.topologyConfigMapName, + "TOPOLOGY_CONFIGMAP_NAMESPACE": s.config.topologyNamespace, + "METRICS_WORKLOAD_SUFFIX": s.config.metricsWorkloadSuffix, + } + encoded, err := json.Marshal(value) + if err != nil { + http.Error(writer, "could not render config", http.StatusInternalServerError) + return + } + writer.Header().Set("Content-Type", "application/javascript; charset=utf-8") + _, _ = fmt.Fprintf(writer, "window.kuadrant_config = %s;\n", encoded) +} + +func (s *server) proxyMCP(writer http.ResponseWriter, request *http.Request) { + userAuthorization := request.Header.Get("Authorization") + if !strings.HasPrefix(userAuthorization, "Bearer ") { + writeJSONError(writer, http.StatusUnauthorized, "OpenShift user authentication is required") + return + } + + request.Body = http.MaxBytesReader(writer, request.Body, maxMCPRequestBytes) + body, err := io.ReadAll(request.Body) + if err != nil { + writeJSONError(writer, http.StatusRequestEntityTooLarge, "MCP request is too large") + return + } + var envelope rpcEnvelope + if err := json.Unmarshal(body, &envelope); err != nil || !allowedMCPMethod(envelope.Method) { + writeJSONError(writer, http.StatusBadRequest, "unsupported MCP request") + return + } + + namespace := request.PathValue("namespace") + name := request.PathValue("name") + endpoint, status, err := s.resolveMCPEndpoint(request.Context(), namespace, name, userAuthorization) + if err != nil { + writeJSONError(writer, status, err.Error()) + return + } + + target, err := url.Parse(endpoint) + if err != nil || target.Host == "" || (target.Scheme != "http" && target.Scheme != "https") || target.User != nil || target.Fragment != "" { + writeJSONError(writer, http.StatusBadGateway, "MCPGatewayExtension has an invalid MCP endpoint") + return + } + mcpAuthorization := request.Header.Get(mcpAuthorizationHeader) + if mcpAuthorization != "" && target.Scheme != "https" && !s.config.allowInsecureMCPAuth { + writeJSONError(writer, http.StatusBadRequest, "refusing to send MCP credentials over an insecure connection") + return + } + + upstreamRequest, err := http.NewRequestWithContext(request.Context(), http.MethodPost, target.String(), strings.NewReader(string(body))) + if err != nil { + writeJSONError(writer, http.StatusBadGateway, "could not create MCP request") + return + } + copyRequestHeader(request.Header, upstreamRequest.Header, "Content-Type") + copyRequestHeader(request.Header, upstreamRequest.Header, "Accept") + copyRequestHeader(request.Header, upstreamRequest.Header, "MCP-Protocol-Version") + copyRequestHeader(request.Header, upstreamRequest.Header, "Mcp-Session-Id") + if mcpAuthorization != "" { + upstreamRequest.Header.Set("Authorization", mcpAuthorization) + } + + upstreamResponse, err := s.upstreamHTTP.Do(upstreamRequest) + if err != nil { + s.logger.Warn("MCP upstream request failed", "namespace", namespace, "name", name, "error", err) + writeJSONError(writer, http.StatusBadGateway, "MCP gateway request failed") + return + } + defer upstreamResponse.Body.Close() + for _, header := range []string{"Content-Type", "Mcp-Session-Id", "MCP-Protocol-Version"} { + copyResponseHeader(upstreamResponse.Header, writer.Header(), header) + } + writer.WriteHeader(upstreamResponse.StatusCode) + _, _ = io.Copy(writer, upstreamResponse.Body) +} + +func (s *server) resolveMCPEndpoint(ctx context.Context, namespace, name, authorization string) (string, int, error) { + baseURL, err := url.Parse(s.config.kubernetesAPIURL) + if err != nil { + return "", http.StatusInternalServerError, errors.New("Kubernetes API URL is invalid") + } + apiBasePath := baseURL.Path + baseURL.Path = filepath.ToSlash(filepath.Join(apiBasePath, "apis/mcp.kuadrant.io/v1/namespaces", namespace, "mcpgatewayextensions", name)) + lookup, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL.String(), nil) + if err != nil { + return "", http.StatusInternalServerError, errors.New("could not create Kubernetes API request") + } + lookup.Header.Set("Authorization", authorization) + lookup.Header.Set("Accept", "application/json") + + response, err := s.kubernetesHTTP.Do(lookup) + if err != nil { + s.logger.Error("Kubernetes API lookup failed", "error", err) + return "", http.StatusBadGateway, errors.New("could not resolve MCP gateway") + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + var status kubernetesStatus + _ = json.NewDecoder(io.LimitReader(response.Body, 64*1024)).Decode(&status) + message := status.Message + if message == "" { + message = "could not access MCPGatewayExtension" + } + return "", response.StatusCode, errors.New(message) + } + + var extension mcpGatewayExtension + if err := json.NewDecoder(io.LimitReader(response.Body, 1024*1024)).Decode(&extension); err != nil { + return "", http.StatusBadGateway, errors.New("Kubernetes API returned an invalid MCPGatewayExtension") + } + ready := false + for _, condition := range extension.Status.Conditions { + if condition.Type == "Ready" && condition.Status == "True" && condition.ObservedGeneration == extension.Metadata.Generation { + ready = true + break + } + } + if !ready { + return "", http.StatusConflict, errors.New("MCPGatewayExtension is not ready") + } + + targetNamespace := extension.Spec.TargetRef.Namespace + if targetNamespace == "" { + targetNamespace = namespace + } + baseURL.Path = filepath.ToSlash(filepath.Join( + apiBasePath, + "apis/gateway.networking.k8s.io/v1/namespaces", + targetNamespace, + "gateways", + extension.Spec.TargetRef.Name, + )) + lookup, err = http.NewRequestWithContext(ctx, http.MethodGet, baseURL.String(), nil) + if err != nil { + return "", http.StatusInternalServerError, errors.New("could not create Gateway API request") + } + lookup.Header.Set("Authorization", authorization) + lookup.Header.Set("Accept", "application/json") + + response, err = s.kubernetesHTTP.Do(lookup) + if err != nil { + s.logger.Error("Gateway API lookup failed", "error", err) + return "", http.StatusBadGateway, errors.New("could not resolve MCP gateway listener") + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + var status kubernetesStatus + _ = json.NewDecoder(io.LimitReader(response.Body, 64*1024)).Decode(&status) + message := status.Message + if message == "" { + message = "could not access the MCP Gateway listener" + } + return "", response.StatusCode, errors.New(message) + } + + var targetGateway gateway + if err := json.NewDecoder(io.LimitReader(response.Body, 1024*1024)).Decode(&targetGateway); err != nil { + return "", http.StatusBadGateway, errors.New("Kubernetes API returned an invalid Gateway") + } + endpoint, err := deriveMCPEndpoint(&extension, &targetGateway) + if err != nil { + return "", http.StatusBadGateway, err + } + return endpoint, http.StatusOK, nil +} + +func deriveMCPEndpoint(extension *mcpGatewayExtension, targetGateway *gateway) (string, error) { + sectionName := extension.Spec.TargetRef.SectionName + for _, listener := range targetGateway.Spec.Listeners { + if listener.Name != sectionName { + continue + } + + host := extension.Spec.PublicHost + if host == "" { + host = listener.Hostname + if strings.HasPrefix(host, "*.") { + host = "mcp" + host[1:] + } + } + if strings.Contains(host, "://") { + return "", errors.New("MCPGatewayExtension has an invalid public host") + } + if hostname, _, err := net.SplitHostPort(host); err == nil { + host = hostname + } + if host == "" || strings.ContainsAny(host, "/?#@") { + return "", errors.New("MCPGatewayExtension has an invalid public host") + } + + scheme := "http" + defaultPort := uint32(80) + switch { + case strings.EqualFold(listener.Protocol, "HTTP"): + case strings.EqualFold(listener.Protocol, "HTTPS"): + scheme = "https" + defaultPort = 443 + default: + return "", errors.New("MCP Gateway listener must use HTTP or HTTPS") + } + if listener.Port == 0 { + return "", errors.New("MCP Gateway listener has an invalid port") + } + + urlHost := host + if listener.Port != defaultPort { + urlHost = net.JoinHostPort(host, strconv.FormatUint(uint64(listener.Port), 10)) + } + return (&url.URL{Scheme: scheme, Host: urlHost, Path: "/mcp"}).String(), nil + } + return "", errors.New("MCPGatewayExtension target listener was not found") +} + +func allowedMCPMethod(method string) bool { + switch method { + case "initialize", "notifications/initialized", "tools/list", "tools/call": + return true + default: + return false + } +} + +func copyRequestHeader(from, to http.Header, name string) { + if value := from.Get(name); value != "" { + to.Set(name, value) + } +} + +func copyResponseHeader(from, to http.Header, name string) { + for _, value := range from.Values(name) { + to.Add(name, value) + } +} + +func writeJSONError(writer http.ResponseWriter, status int, message string) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(status) + _ = json.NewEncoder(writer).Encode(map[string]string{"error": message}) +} + +func rejectRedirect(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse +} + +func env(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} + +func envBool(name string) bool { + value := strings.ToLower(os.Getenv(name)) + return value == "1" || value == "true" || value == "yes" +} diff --git a/cmd/plugin-server/main_test.go b/cmd/plugin-server/main_test.go new file mode 100644 index 00000000..dd5c2e3b --- /dev/null +++ b/cmd/plugin-server/main_test.go @@ -0,0 +1,172 @@ +package main + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestMCPProxyKeepsConsoleAndMCPAuthorizationSeparate(t *testing.T) { + var kubernetesAuthorization string + var kubernetesRequests int + var upstreamAuthorization string + + upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + upstreamAuthorization = request.Header.Get("Authorization") + writer.Header().Set("Content-Type", "application/json") + writer.Header().Set("Mcp-Session-Id", "session-1") + _, _ = io.WriteString(writer, `{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25"}}`) + })) + defer upstream.Close() + + kubernetes := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + kubernetesAuthorization = request.Header.Get("Authorization") + kubernetesRequests++ + writer.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(request.URL.Path, "/mcpgatewayextensions/"): + _ = json.NewEncoder(writer).Encode(map[string]any{ + "metadata": map[string]any{"generation": 4}, + "spec": map[string]any{ + "publicHost": "mcp.example.test", + "targetRef": map[string]any{ + "name": "test-gateway", + "namespace": "gateway-system", + "sectionName": "mcp", + }, + }, + "status": map[string]any{ + "conditions": []map[string]any{{"type": "Ready", "status": "True", "observedGeneration": 4}}, + }, + }) + case strings.Contains(request.URL.Path, "/gateways/"): + _ = json.NewEncoder(writer).Encode(map[string]any{ + "spec": map[string]any{ + "listeners": []map[string]any{{ + "name": "mcp", + "port": 80, + "protocol": "HTTP", + }}, + }, + }) + default: + http.NotFound(writer, request) + } + })) + defer kubernetes.Close() + + backend, err := newServer(config{ + kubernetesAPIURL: kubernetes.URL, + kubernetesSkipVerify: true, + upstreamDialAddress: strings.TrimPrefix(upstream.URL, "http://"), + allowInsecureMCPAuth: true, + requestTimeout: time.Second, + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + + request := httptest.NewRequest( + http.MethodPost, + mcpProxyPrefix+"test-ns/test-extension", + strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`), + ) + request.Header.Set("Authorization", "Bearer openshift-user-token") + request.Header.Set(mcpAuthorizationHeader, "Bearer mcp-gateway-token") + response := httptest.NewRecorder() + + backend.routes().ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + if kubernetesAuthorization != "Bearer openshift-user-token" { + t.Fatalf("Kubernetes Authorization = %q", kubernetesAuthorization) + } + if kubernetesRequests != 2 { + t.Fatalf("Kubernetes requests = %d, want extension and Gateway lookups", kubernetesRequests) + } + if upstreamAuthorization != "Bearer mcp-gateway-token" { + t.Fatalf("MCP upstream Authorization = %q", upstreamAuthorization) + } + if response.Header().Get("Mcp-Session-Id") != "session-1" { + t.Fatalf("MCP session header was not relayed") + } +} + +func TestDeriveMCPEndpoint(t *testing.T) { + tests := []struct { + name string + publicHost string + section string + listener gatewayListener + want string + wantErr string + }{ + { + name: "public host on default HTTP port", + publicHost: "mcp.example.test", + section: "mcp", + listener: gatewayListener{Name: "mcp", Hostname: "ignored.example.test", Protocol: "HTTP", Port: 80}, + want: "http://mcp.example.test/mcp", + }, + { + name: "wildcard listener on non-default HTTPS port", + section: "secure-mcp", + listener: gatewayListener{Name: "secure-mcp", Hostname: "*.example.test", Protocol: "HTTPS", Port: 8443}, + want: "https://mcp.example.test:8443/mcp", + }, + { + name: "missing target listener", + publicHost: "mcp.example.test", + section: "other", + listener: gatewayListener{Name: "mcp", Protocol: "HTTP", Port: 80}, + wantErr: "target listener was not found", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + extension := &mcpGatewayExtension{} + extension.Spec.PublicHost = test.publicHost + extension.Spec.TargetRef.SectionName = test.section + targetGateway := &gateway{} + targetGateway.Spec.Listeners = append(targetGateway.Spec.Listeners, test.listener) + + got, err := deriveMCPEndpoint(extension, targetGateway) + if test.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want %q", err, test.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("endpoint = %q, want %q", got, test.want) + } + }) + } +} + +func TestMCPProxyRequiresConsoleUserToken(t *testing.T) { + backend := &server{logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + request := httptest.NewRequest( + http.MethodPost, + mcpProxyPrefix+"test-ns/test-extension", + strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`), + ) + response := httptest.NewRecorder() + + backend.routes().ServeHTTP(response, request) + + if response.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", response.Code, http.StatusUnauthorized) + } +} diff --git a/console-extensions.json b/console-extensions.json index 7400539e..335ae16b 100644 --- a/console-extensions.json +++ b/console-extensions.json @@ -716,6 +716,16 @@ "section": "kuadrant-mcp-section-admin" } }, + { + "type": "console.navigation/href", + "properties": { + "id": "kuadrant-mcp-inspector-admin", + "name": "%plugin__kuadrant-console-plugin~MCP Inspector%", + "href": "/mcp-inspector", + "perspective": "admin", + "section": "kuadrant-mcp-section-admin" + } + }, { "type": "console.navigation/section", "properties": { @@ -735,6 +745,16 @@ "section": "kuadrant-mcp-section-dev" } }, + { + "type": "console.navigation/href", + "properties": { + "id": "kuadrant-mcp-inspector-dev", + "name": "%plugin__kuadrant-console-plugin~MCP Inspector%", + "href": "/mcp-inspector", + "perspective": "dev", + "section": "kuadrant-mcp-section-dev" + } + }, { "type": "console.page/route", "properties": { @@ -758,5 +778,13 @@ "path": "/kuadrant/mcp/setup-wizard", "component": { "$codeRef": "MCPSetupWizard" } } + }, + { + "type": "console.page/route", + "properties": { + "exact": true, + "path": "/mcp-inspector", + "component": { "$codeRef": "MCPInspectorPage" } + } } ] diff --git a/docs/designs/2026-08-16-mcp-inspector-direct-gateway-design.md b/docs/designs/2026-08-16-mcp-inspector-direct-gateway-design.md index 801ae2c0..1d06282f 100644 --- a/docs/designs/2026-08-16-mcp-inspector-direct-gateway-design.md +++ b/docs/designs/2026-08-16-mcp-inspector-direct-gateway-design.md @@ -1,7 +1,7 @@ # MCP Inspector: Direct Gateway Access via CORS **Date:** 2026-08-16 (rev 7, 2026-08-17) -**Status:** Draft (PoC complete, gateway branches pushed) +**Status:** Superseded by the Console backend relay in [PR #779](https://github.com/Kuadrant/kuadrant-console-plugin/pull/779) and [issue #776](https://github.com/Kuadrant/kuadrant-console-plugin/issues/776). Retained as a record of the direct-browser PoC. **Supersedes:** [PR #674](https://github.com/Kuadrant/kuadrant-console-plugin/pull/674) (MCP client proxy design) **Epic:** [#667](https://github.com/Kuadrant/kuadrant-console-plugin/issues/667) **Issues:** [#671](https://github.com/Kuadrant/kuadrant-console-plugin/issues/671) Tools, [#672](https://github.com/Kuadrant/kuadrant-console-plugin/issues/672) Prompts, [#673](https://github.com/Kuadrant/kuadrant-console-plugin/issues/673) Setup wizard diff --git a/docs/mcp-inspector.md b/docs/mcp-inspector.md new file mode 100644 index 00000000..8c24c591 --- /dev/null +++ b/docs/mcp-inspector.md @@ -0,0 +1,52 @@ +# MCP Inspector + +The MCP Inspector lets an OpenShift Console user inspect and run tools exposed by an MCP Gateway. Browser requests remain on the OpenShift Console origin and pass through the Console plugin backend. For each request, the backend reads the selected `MCPGatewayExtension`, follows its `spec.targetRef` to the Gateway listener, derives the MCP URL, and relays the exchange to that gateway. + +## Prerequisites + +- The `MCPGatewayExtension` must have a current `Ready=True` condition. +- The Kuadrant Operator must deploy the Console plugin backend and reconcile its `ConsolePlugin.spec.proxy` entry with `authorization: UserToken`. +- The Console user must have Kubernetes `get` access to the selected `MCPGatewayExtension` and its referenced Gateway. +- A bearer token supplied for an MCP gateway is only forwarded over HTTPS. The insecure-auth override is for local development only. + +## Proxy and security model + +The UI sends MCP JSON-RPC requests to the same-origin Console path: + +```text +/api/proxy/plugin/kuadrant-console-plugin/backend/api/mcp/v1/mcpgatewayextensions// +``` + +Console supplies the current OpenShift user token to the backend. The backend uses that token only to read the named `MCPGatewayExtension` and its referenced Gateway. It is never sent to the MCP gateway. The backend takes the host from `spec.publicHost`, or from the listener hostname when no override is set. It takes the scheme and port from the referenced listener and uses `/mcp` as the path. This keeps endpoint selection subject to the user's Kubernetes RBAC and avoids maintaining a cluster-wide CSP or CORS allowlist for gateway hosts. + +The backend accepts only the Inspector's current MCP methods (`initialize`, `notifications/initialized`, `tools/list`, and `tools/call`), limits request size, rejects redirects, and relays only the content, protocol, and session headers needed by Streamable HTTP. + +## Authentication + +The inspector first attempts an MCP `initialize` request without a gateway credential. If the gateway returns `401`, the user can paste a bearer token. The browser sends it to the plugin backend in a dedicated header, and the backend translates it to `Authorization: Bearer` only for the selected MCP gateway. + +Bearer tokens and MCP session IDs are held in memory only. OIDC sign-in is not currently supported by the Inspector. + +## Using the inspector + +1. Open **MCP management → MCP Inspector**. +2. Select a Ready MCP gateway extension. The inspector initializes a session and lists its tools. +3. Select or search for a tool. Use the **Refresh tools** icon to run `tools/list` again without reconnecting the session. +4. Fill the schema-generated inputs. Complex object and array inputs accept JSON. +5. Optionally add MCP `_meta` key-value pairs. +6. Use **Validate only** to check the input locally, or **Run tool** to execute it. +7. Inspect the server result, JSON-RPC request and response, HTTP status, and elapsed time in the Output card. + +Changing gateways clears the current token, MCP session, selected tool, output, and session statistics. + +## Live Playwright journey + +The standard smoke test verifies that the inspector opens in Console. A live tool-call journey is available when a Ready development gateway is present: + +```bash +MCP_INSPECTOR_E2E_EXTENSION=mcp-gateway-system/mcp-gateway-extension \ + npx playwright test --config=e2e/playwright.config.ts \ + e2e/tests/mcp-inspector.spec.ts -g "connects to a live gateway" +``` + +The journey defaults to `toystore_greet` with `Name=Ada`. Override `MCP_INSPECTOR_E2E_TOOL`, `MCP_INSPECTOR_E2E_ARGUMENT_LABEL`, and `MCP_INSPECTOR_E2E_ARGUMENT_VALUE` for another development server. diff --git a/docs/overview.md b/docs/overview.md index 28cce70b..bb2fb293 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -22,6 +22,7 @@ The **MCP management** section provides setup and management for MCP (Model Cont - **Overview** - when no MCPGatewayExtensions exist, shows a guided setup wizard for creating MCP infrastructure. Once extensions are created, shows a dashboard with summary cards for MCP Gateways (Total, Healthy, Unhealthy) and MCP Servers (Types, Total, Online, Offline). Includes tables for MCP Gateway Extensions, MCP Servers, Reference Grants, and Policies attached to MCP gateways or servers. Each table has toolbar filters and RBAC-aware create actions. - **MCP Gateway Setup Wizard** - 4-step wizard that walks through selecting or creating a Gateway, HTTPRoute, and MCPGatewayExtension resource. Supports both existing resource selection and inline creation of new resources. Resources are created sequentially in the final verification step, with live status watching for the MCPGatewayExtension Ready condition. +- **MCP Inspector** - connects to a Ready MCPGatewayExtension through the Console plugin backend, lists and refreshes tools, creates inputs from each tool's JSON schema, and displays tool results with JSON-RPC request telemetry. See the [MCP Inspector guide](mcp-inspector.md). Key resources managed on this page: diff --git a/e2e/README.md b/e2e/README.md index 27af1b3f..a35bda65 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -13,7 +13,7 @@ > **Note:** Replace `oinc-linux-amd64` with your platform (e.g., `oinc-darwin-arm64` for Apple Silicon). ```bash -OINC_VERSION="v0.4.3" +OINC_VERSION="v0.4.6" curl -fL -o oinc "https://github.com/jasonmadigan/oinc/releases/download/${OINC_VERSION}/oinc-linux-amd64" chmod +x oinc ./oinc version @@ -65,7 +65,7 @@ npx playwright test --config=e2e/playwright.config.ts e2e/tests/apikey-lifecycle ```bash # Check if cluster is running -oinc list +oinc status # Check if servers are running curl http://localhost:9000 # Console @@ -92,6 +92,7 @@ npx playwright test --config=e2e/playwright.config.ts - `e2e/tests/data-view-regressions.spec.ts` - DataView regressions - `e2e/tests/gateway-crud.spec.ts` - Gateway create, edit, and delete operations - `e2e/tests/httproute-crud.spec.ts` - HTTPRoute create, edit, and delete operations +- `e2e/tests/mcp-inspector.spec.ts` - MCP Inspector smoke and live tool-call journeys - `e2e/tests/mcp-overview.spec.ts` - MCP Overview dashboard - `e2e/tests/mcp-setup-wizard.spec.ts` - MCP Management setup wizard - `e2e/tests/mcp-wizard.spec.ts` - MCP server registration wizard @@ -291,7 +292,7 @@ ls -la test-results/*/test-failed-*.png sudo ./e2e/teardown.sh # Or destroy entire oinc cluster -oinc destroy +oinc delete --force ``` ## Important Notes diff --git a/e2e/tests/mcp-inspector.spec.ts b/e2e/tests/mcp-inspector.spec.ts new file mode 100644 index 00000000..384a3101 --- /dev/null +++ b/e2e/tests/mcp-inspector.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from '@playwright/test'; +import { dismissConsoleTour, spaNavigate, TEST_NAMESPACE } from './helpers'; + +const integrationExtension = process.env.MCP_INSPECTOR_E2E_EXTENSION; +const integrationTool = process.env.MCP_INSPECTOR_E2E_TOOL || 'toystore_greet'; +const integrationArgumentLabel = process.env.MCP_INSPECTOR_E2E_ARGUMENT_LABEL || 'Name'; +const integrationArgumentValue = process.env.MCP_INSPECTOR_E2E_ARGUMENT_VALUE || 'Ada'; + +async function openInspector(page, namespace: string): Promise { + await page.goto(`/k8s/ns/${namespace}`); + await page.waitForLoadState('networkidle'); + await dismissConsoleTour(page); + await spaNavigate(page, '/mcp-inspector'); +} + +test.describe('MCP Inspector', () => { + test('opens from the console and prompts for a gateway', { tag: '@smoke' }, async ({ page }) => { + await openInspector(page, TEST_NAMESPACE); + + await expect(page.getByRole('heading', { name: 'MCP Inspector' })).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByLabel('Select an MCP gateway extension')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'No connection' })).toBeVisible(); + }); + + test('connects to a live gateway and runs a tool', { tag: '@nightly' }, async ({ page }) => { + test.skip( + !integrationExtension, + 'Set MCP_INSPECTOR_E2E_EXTENSION=namespace/name to run the live integration journey.', + ); + const [namespace, extensionName] = integrationExtension!.split('/'); + expect(namespace).toBeTruthy(); + expect(extensionName).toBeTruthy(); + + const cspViolations: string[] = []; + page.on('console', (message) => { + if (message.text().includes('Content Security Policy violation')) { + cspViolations.push(message.text()); + } + }); + + await openInspector(page, namespace); + await page + .getByLabel('Select an MCP gateway extension') + .selectOption(`${namespace}/${extensionName}`); + + await expect(page.getByText('Connected', { exact: true })).toBeVisible({ timeout: 20_000 }); + const refreshToolsButton = page.getByRole('button', { name: 'Refresh tools' }); + await refreshToolsButton.click(); + await expect(refreshToolsButton).toBeEnabled(); + await page.getByLabel('Search tools').fill(integrationTool); + await page.getByRole('button', { name: integrationTool, exact: true }).click(); + await page.getByLabel(integrationArgumentLabel).fill(integrationArgumentValue); + await page.getByRole('button', { name: 'Run tool' }).click(); + + await expect(page.getByText('Success', { exact: true })).toBeVisible({ timeout: 20_000 }); + const summaryTextCenters = await page + .locator('.kuadrant-mcp-inspector-page__request-summary > *') + .evaluateAll((items) => + items.map((item) => { + const range = document.createRange(); + range.selectNodeContents(item); + const rect = range.getBoundingClientRect(); + return rect.top + rect.height / 2; + }), + ); + expect(Math.max(...summaryTextCenters) - Math.min(...summaryTextCenters)).toBeLessThanOrEqual( + 1, + ); + await expect(page.getByRole('heading', { name: 'JSON-RPC response' })).toBeVisible(); + expect(cspViolations).toEqual([]); + }); +}); diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100755 index d1ec4727..00000000 --- a/entrypoint.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -# Inject topology ConfigMap location and metrics configuration -cat < /tmp/config.js -window.kuadrant_config = { - TOPOLOGY_CONFIGMAP_NAME: '${TOPOLOGY_CONFIGMAP_NAME:-topology}', - TOPOLOGY_CONFIGMAP_NAMESPACE: '${TOPOLOGY_CONFIGMAP_NAMESPACE:-kuadrant-system}', - METRICS_WORKLOAD_SUFFIX: '${METRICS_WORKLOAD_SUFFIX:-openshift-default}' -}; -EOF - -# Start Nginx -nginx -g "daemon off;" diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..ecdb4d63 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/Kuadrant/kuadrant-console-plugin + +go 1.24.0 diff --git a/install.yaml b/install.yaml index 2bf37cbe..b0849f64 100644 --- a/install.yaml +++ b/install.yaml @@ -25,36 +25,33 @@ spec: app.kubernetes.io/part-of: kuadrant-console-plugin spec: containers: - - name: kuadrant-console-plugin - image: quay.io/kuadrant/console-plugin:latest - ports: - - containerPort: 9443 - protocol: TCP - imagePullPolicy: Always - env: - - name: TOPOLOGY_CONFIGMAP_NAME - value: topology - - name: TOPOLOGY_CONFIGMAP_NAMESPACE - value: kuadrant-system - - name: METRICS_WORKLOAD_SUFFIX - value: -openshift-default - volumeMounts: - - name: plugin-serving-cert - readOnly: true - mountPath: /var/serving-cert - - name: nginx-conf - readOnly: true - mountPath: /etc/nginx/nginx.conf - subPath: nginx.conf + - name: kuadrant-console-plugin + image: quay.io/kuadrant/console-plugin:latest + ports: + - name: https + containerPort: 9443 + protocol: TCP + imagePullPolicy: Always + env: + - name: TOPOLOGY_CONFIGMAP_NAME + value: topology + - name: TOPOLOGY_CONFIGMAP_NAMESPACE + value: kuadrant-system + - name: METRICS_WORKLOAD_SUFFIX + value: -openshift-default + - name: TLS_CERTIFICATE_FILE + value: /var/serving-cert/tls.crt + - name: TLS_KEY_FILE + value: /var/serving-cert/tls.key + volumeMounts: + - name: plugin-serving-cert + readOnly: true + mountPath: /var/serving-cert volumes: - - name: plugin-serving-cert - secret: - secretName: plugin-serving-cert - defaultMode: 420 - - name: nginx-conf - configMap: - name: nginx-conf - defaultMode: 420 + - name: plugin-serving-cert + secret: + secretName: plugin-serving-cert + defaultMode: 420 restartPolicy: Always dnsPolicy: ClusterFirst strategy: @@ -64,44 +61,6 @@ spec: maxSurge: 25% --- apiVersion: v1 -kind: ConfigMap -metadata: - name: nginx-conf - namespace: kuadrant-system - labels: - app: kuadrant-console-plugin - app.kubernetes.io/component: kuadrant-console-plugin - app.kubernetes.io/instance: kuadrant-console-plugin - app.kubernetes.io/name: kuadrant-console-plugin - app.kubernetes.io/part-of: kuadrant-console-plugin -data: - nginx.conf: | - error_log /dev/stdout; - events {} - http { - access_log /dev/stdout; - include /etc/nginx/mime.types; - default_type application/octet-stream; - keepalive_timeout 65; - - server { - listen 9443 ssl; - listen [::]:9443 ssl; - ssl_certificate /var/serving-cert/tls.crt; - ssl_certificate_key /var/serving-cert/tls.key; - - location / { - root /usr/share/nginx/html; - } - - # Serve config.js from /tmp - location /config.js { - root /tmp; - } - } - } ---- -apiVersion: v1 kind: Service metadata: annotations: @@ -116,10 +75,10 @@ metadata: app.kubernetes.io/part-of: kuadrant-console-plugin spec: ports: - - name: 9443-tcp - protocol: TCP - port: 9443 - targetPort: 9443 + - name: 9443-tcp + protocol: TCP + port: 9443 + targetPort: 9443 selector: app: kuadrant-console-plugin type: ClusterIP @@ -140,3 +99,12 @@ spec: namespace: kuadrant-system port: 9443 basePath: '/' + proxy: + - alias: backend + authorization: UserToken + endpoint: + type: Service + service: + name: kuadrant-console-plugin + namespace: kuadrant-system + port: 9443 diff --git a/locales/en/plugin__kuadrant-console-plugin.json b/locales/en/plugin__kuadrant-console-plugin.json index ddd51c1a..8f580e8d 100644 --- a/locales/en/plugin__kuadrant-console-plugin.json +++ b/locales/en/plugin__kuadrant-console-plugin.json @@ -7,6 +7,10 @@ "{{count}} tags selected_other": "{{count}} tags selected", "{{days}} days ({{date}})": "{{days}} days ({{date}})", "{{days}} days left ({{date}})": "{{days}} days left ({{date}})", + "{{field}} is required": "{{field}} is required", + "{{field}} must be a number": "{{field}} must be a number", + "{{field}} must be an integer": "{{field}} must be an integer", + "{{field}} must be valid JSON": "{{field}} must be valid JSON", "{{limit}} per {{window}}": "{{limit}} per {{window}}", "{{successText}} approved, {{failText}}": "{{successText}} approved, {{failText}}", "{{successText}} denied, {{failText}}": "{{successText}} denied, {{failText}}", @@ -50,6 +54,7 @@ "Add Limit": "Add Limit", "Add listener": "Add listener", "Add match": "Add match", + "Add metadata": "Add metadata", "Add more": "Add more", "Add parent reference": "Add parent reference", "Add Plan": "Add Plan", @@ -126,7 +131,9 @@ "Attached Policies": "Attached Policies", "Attached Resources": "Attached Resources", "Auth": "Auth", + "Authenticated": "Authenticated", "Authentication Methods": "Authentication Methods", + "Authentication required": "Authentication required", "Authorization servers": "Authorization servers", "AuthPolicy": "AuthPolicy", "Auto-generated from product name. Only lowercase, numbers, hyphens, and dots allowed.": "Auto-generated from product name. Only lowercase, numbers, hyphens, and dots allowed.", @@ -135,6 +142,7 @@ "Backend": "Backend", "Backend references": "Backend references", "Backend Services": "Backend Services", + "Bearer token": "Bearer token", "Cancel": "Cancel", "CEL expression that must evaluate to true for this limit to apply": "CEL expression that must evaluate to true for this limit to apply", "CEL expression to match this plan's subscribers": "CEL expression to match this plan's subscribers", @@ -169,6 +177,12 @@ "Confirm Delete": "Confirm Delete", "Confirm resource name": "Confirm resource name", "Conflicted": "Conflicted", + "Connect to a Gateway to view the MCP server tools available.": "Connect to a Gateway to view the MCP server tools available.", + "Connect with bearer token": "Connect with bearer token", + "Connected": "Connected", + "Connecting...": "Connecting...", + "Connection": "Connection", + "Console": "Console", "Contact": "Contact", "Contact Email": "Contact Email", "Contact Slack": "Contact Slack", @@ -243,6 +257,7 @@ "Deny API Key": "Deny API Key", "Deprecated": "Deprecated", "Description": "Description", + "Destructive": "Destructive", "Details": "Details", "Disabled": "Disabled", "Display Name": "Display Name", @@ -355,6 +370,7 @@ "Error updating tags": "Error updating tags", "Error updating version": "Error updating version", "Error: YAML Validation": "Error: YAML Validation", + "errors": "errors", "example.com": "example.com", "Expiration": "Expiration", "Expired": "Expired", @@ -417,6 +433,7 @@ "Health Check": "Health Check", "Healthy": "Healthy", "Healthy Gateways": "Healthy Gateways", + "Held in memory only": "Held in memory only", "here": "here", "Hide for session": "Hide for session", "host": "host", @@ -437,10 +454,13 @@ "HTTPRoutes": "HTTPRoutes", "https://auth.example.com": "https://auth.example.com", "Human-readable name for this protected resource.": "Human-readable name for this protected resource.", + "Idempotent": "Idempotent", "In progress": "In progress", "in the namespace ": "in the namespace ", "inherited from gateway": "inherited from gateway", + "Input is valid": "Input is valid", "Internal": "Internal", + "Invalid bearer token": "Invalid bearer token", "Invalid YAML": "Invalid YAML", "IPAddress": "IPAddress", "Issuer": "Issuer", @@ -448,6 +468,8 @@ "Issuer URL": "Issuer URL", "Issuer: Reference to the issuer for the created certificate. To create an additional Issuer go to": "Issuer: Reference to the issuer for the created certificate. To create an additional Issuer go to", "It indicates the current operational state of the resource and reflects whether its configuration is applied and functioning correctly.": "It indicates the current operational state of the resource and reflects whether its configuration is applied and functioning correctly.", + "JSON-RPC request": "JSON-RPC request", + "JSON-RPC response": "JSON-RPC response", "kebab dropdown toggle": "kebab dropdown toggle", "Key": "Key", "Keys are created without need to be approved.": "Keys are created without need to be approved.", @@ -484,6 +506,7 @@ "Loading API key requests...": "Loading API key requests...", "Loading API Keys...": "Loading API Keys...", "Loading configuration...": "Loading configuration...", + "Loading extensions...": "Loading extensions...", "Loading gateway...": "Loading gateway...", "Loading gateways...": "Loading gateways...", "Loading GRPCRoute...": "Loading GRPCRoute...", @@ -496,6 +519,7 @@ "Loading user information...": "Loading user information...", "Loading YAML editor...": "Loading YAML editor...", "Loading...": "Loading...", + "Logs": "Logs", "Manual": "Manual", "match": "match", "matches": "matches", @@ -512,6 +536,8 @@ "MCP Gateway Setup": "MCP Gateway Setup", "MCP gateway setup wizard": "MCP gateway setup wizard", "MCP Gateways": "MCP Gateways", + "MCP Inspector": "MCP Inspector", + "MCP inspector sections": "MCP inspector sections", "MCP management": "MCP management", "MCP management overview": "MCP management overview", "MCP server is ready": "MCP server is ready", @@ -519,6 +545,9 @@ "MCP Servers": "MCP Servers", "MCPGatewayExtension created successfully": "MCPGatewayExtension created successfully", "MCPServerRegistration created successfully": "MCPServerRegistration created successfully", + "Metadata": "Metadata", + "Metadata key": "Metadata key", + "Metadata value": "Metadata value", "Mirror backend name": "Mirror backend name", "Monthly Limit": "Monthly Limit", "More info": "More info", @@ -554,6 +583,9 @@ "No associated policies found": "No associated policies found", "No associated resources found": "No associated resources found", "No attached resources found": "No attached resources found", + "No authentication": "No authentication", + "No connection": "No connection", + "No description": "No description", "No expiration": "No expiration", "No GRPCRoutes available": "No GRPCRoutes available", "No HTTPRoutes available": "No HTTPRoutes available", @@ -564,14 +596,17 @@ "No policies attached to this HTTPRoute": "No policies attached to this HTTPRoute", "No policies found": "No policies found", "No reference grant needed": "No reference grant needed", + "No results": "No results", "No results found": "No results found", "No rules defined. HTTPRoute will use default routing.": "No rules defined. HTTPRoute will use default routing.", "No tags": "No tags", "No target HTTPRoute configured": "No target HTTPRoute configured", "No target reference": "No target reference", + "No tools found": "No tools found", "None": "None", "None selected": "None selected", "Not allowed by Gateway settings.": "Not allowed by Gateway settings.", + "not reachable": "not reachable", "Not set": "Not set", "Not specified": "Not specified", "OAuth protected resource": "OAuth protected resource", @@ -582,8 +617,11 @@ "OK": "OK", "Online": "Online", "Only HTTPRoute is supported by this Gateway.": "Only HTTPRoute is supported by this Gateway.", + "Open world": "Open world", "OpenAPI Spec URL": "OpenAPI Spec URL", "Optional hostname to match requests. Leave empty to match all hostnames.": "Optional hostname to match requests. Leave empty to match all hostnames.", + "Optional key-value metadata is sent with the MCP tool call.": "Optional key-value metadata is sent with the MCP tool call.", + "Output": "Output", "Override hostnames": "Override hostnames", "Override the public and private hostnames derived from the gateway listener.": "Override the public and private hostnames derived from the gateway listener.", "Overview": "Overview", @@ -611,6 +649,8 @@ "Press Enter to create \"{{tag}}\"": "Press Enter to create \"{{tag}}\"", "Private host": "Private host", "Programmed": "Programmed", + "Prompt inspection is not available yet.": "Prompt inspection is not available yet.", + "Prompts": "Prompts", "Protocol": "Protocol", "Provide a reason for denying this request...": "Provide a reason for denying this request...", "Provide details to request a new API key for accessing API": "Provide details to request a new API key for accessing API", @@ -625,6 +665,7 @@ "RateLimitPolicy": "RateLimitPolicy", "RateLimitPolicy configures rate limiting for your gateway": "RateLimitPolicy configures rate limiting for your gateway", "Rates": "Rates", + "Read only": "Read only", "Reason: ": "Reason: ", "Redirect the request to a different hostname, path, or port.": "Redirect the request to a different hostname, path, or port.", "Redirect type": "Redirect type", @@ -632,6 +673,7 @@ "Reference to an existing secret resource containing DNS provider credentials and configuration": "Reference to an existing secret resource containing DNS provider credentials and configuration", "ReferenceGrant check": "ReferenceGrant check", "ReferenceGrant created successfully": "ReferenceGrant created successfully", + "Refresh tools": "Refresh tools", "Register an internal MCP server by creating an HTTPRoute and server registration": "Register an internal MCP server by creating an HTTPRoute and server registration", "Register MCP server": "Register MCP server", "Register MCP Server": "Register MCP Server", @@ -642,8 +684,10 @@ "Remove custom limit": "Remove custom limit", "Remove label": "Remove label", "Remove listener": "Remove listener", + "Remove metadata": "Remove metadata", "Remove parent reference": "Remove parent reference", "Remove Plan": "Remove Plan", + "request": "request", "Request": "Request", "Request a specific static IP address or hostname for the Gateway. This is optional and used to specify where the Gateway should be accessible.": "Request a specific static IP address or hostname for the Gateway. This is optional and used to specify where the Gateway should be accessible.", "Request API Key": "Request API Key", @@ -653,6 +697,7 @@ "Request Redirect": "Request Redirect", "Requested Time": "Requested Time", "Requester": "Requester", + "requests": "requests", "Requires approval for requesting the API.": "Requires approval for requesting the API.", "Reset Filters": "Reset Filters", "Resolved": "Resolved", @@ -682,6 +727,8 @@ "Rules": "Rules", "Rules define how to route HTTP requests to backend services": "Rules define how to route HTTP requests to backend services", "Rules table": "Rules table", + "Run tool": "Run tool", + "Running tools executes live server-side code and can change your infrastructure.": "Running tools executes live server-side code and can change your infrastructure.", "Same": "Same", "Save": "Save", "Save Limit": "Save Limit", @@ -692,6 +739,7 @@ "Search by {{filterValue}}...": "Search by {{filterValue}}...", "Search or create tag": "Search or create tag", "Search Tier": "Search Tier", + "Search tools": "Search tools", "Secret model not available": "Secret model not available", "Secret name": "Secret name", "Section": "Section", @@ -711,6 +759,9 @@ "Select a route...": "Select a route...", "Select a specific namespace to choose a {{kind}}": "Select a specific namespace to choose a {{kind}}", "Select a specific namespace to choose a Gateway": "Select a specific namespace to choose a Gateway", + "Select a tool": "Select a tool", + "Select a tool to inspect and run it.": "Select a tool to inspect and run it.", + "Select a value...": "Select a value...", "Select Address Type": "Select Address Type", "Select all rows": "Select all rows", "Select Allowed Namespaces": "Select Allowed Namespaces", @@ -718,11 +769,13 @@ "Select an existing gateway or create a new one to handle MCP traffic.": "Select an existing gateway or create a new one to handle MCP traffic.", "Select an existing route or create a new one for the MCP server.": "Select an existing route or create a new one for the MCP server.", "Select an existing route or create a new one to direct traffic to MCP servers.": "Select an existing route or create a new one to direct traffic to MCP servers.", + "Select an extension...": "Select an extension...", "Select an HTTPRoute": "Select an HTTPRoute", "Select an HTTPRoute that defines how traffic reaches your MCP servers.": "Select an HTTPRoute that defines how traffic reaches your MCP servers.", "Select an HTTPRoute that the MCP server will register with.": "Select an HTTPRoute that the MCP server will register with.", "Select an HTTPRoute. APIProduct will be created in the same namespace.": "Select an HTTPRoute. APIProduct will be created in the same namespace.", "Select an Issuer": "Select an Issuer", + "Select an MCP gateway extension": "Select an MCP gateway extension", "Select Certificate Kind": "Select Certificate Kind", "Select ClusterIssuer": "Select ClusterIssuer", "Select date": "Select date", @@ -748,9 +801,14 @@ "Selector": "Selector", "Send a copy of the request to a different backend (for traffic shadowing).": "Send a copy of the request to a different backend (for traffic shadowing).", "Serve OAuth protected resource metadata at /.well-known/oauth-protected-resource.": "Serve OAuth protected resource metadata at /.well-known/oauth-protected-resource.", + "Server": "Server", "Server name": "Server name", + "Server result": "Server result", "Service Name": "Service Name", "Service Port": "Service Port", + "Session expired, reconnect": "Session expired, reconnect", + "Session ID": "Session ID", + "Session logs are not available yet.": "Session logs are not available yet.", "Session storage": "Session storage", "set": "set", "Set": "Set", @@ -781,12 +839,14 @@ "The client ID registered with the OIDC provider": "The client ID registered with the OIDC provider", "The denial reason will apply to all selected requests.": "The denial reason will apply to all selected requests.", "The gateway class used for this Gateway.": "The gateway class used for this Gateway.", + "The gateway rejected this token. Check it and try again.": "The gateway rejected this token. Check it and try again.", "The HTTPRoute that this MCP server registration targets.": "The HTTPRoute that this MCP server registration targets.", "The key will be automatically revoked on this date.": "The key will be automatically revoked on this date.", "The key will not expire.": "The key will not expire.", "The key will remain accessible for future viewing if needed.": "The key will remain accessible for future viewing if needed.", "The Kubernetes namespace where the gateway infrastructure will be deployed.": "The Kubernetes namespace where the gateway infrastructure will be deployed.", "The Kubernetes resource name for this API key": "The Kubernetes resource name for this API key", + "The MCP session is no longer valid. Connect again to start a new session.": "The MCP session is no longer valid. Connect again to start a new session.", "The name of the gateway listener to use for MCP traffic.": "The name of the gateway listener to use for MCP traffic.", "The name of the gateway this extension targets.": "The name of the gateway this extension targets.", "The namespace for the extension. If different from the gateway namespace, a ReferenceGrant will be created.": "The namespace for the extension. If different from the gateway namespace, a ReferenceGrant will be created.", @@ -814,6 +874,7 @@ "This API Product does not have a target HTTPRoute configured.": "This API Product does not have a target HTTPRoute configured.", "This API Product does not have an OpenAPI specification in its status.": "This API Product does not have an OpenAPI specification in its status.", "This is the human-readable name shown in the API catalog": "This is the human-readable name shown in the API catalog", + "This MCP gateway requires authentication. Provide a bearer token for the MCP gateway.": "This MCP gateway requires authentication. Provide a bearer token for the MCP gateway.", "This policy does not declare a spec.targetRef.": "This policy does not declare a spec.targetRef.", "This resource has no related items configured": "This resource has no related items configured", "This view visualizes the relationships and interactions between different resources within your cluster related to Kuadrant, allowing you to explore connections between Gateways, HTTPRoutes and Kuadrant Policies.": "This view visualizes the relationships and interactions between different resources within your cluster related to Kuadrant, allowing you to explore connections between Gateways, HTTPRoutes and Kuadrant Policies.", @@ -829,7 +890,9 @@ "TokenRateLimit": "TokenRateLimit", "TokenRateLimitPolicy": "TokenRateLimitPolicy", "TokenRateLimitPolicy configures token-based rate limiting for your gateway": "TokenRateLimitPolicy configures token-based rate limiting for your gateway", + "Tool call output": "Tool call output", "Tool prefix": "Tool prefix", + "Tools": "Tools", "Topology View": "Topology View", "Total": "Total", "Total Gateways": "Total Gateways", @@ -860,6 +923,7 @@ "Use Redis-based session storage instead of in-memory. The secret must contain a CACHE_CONNECTION_STRING key.": "Use Redis-based session storage instead of in-memory. The secret must contain a CACHE_CONNECTION_STRING key.", "Use YAML view to apply advanced features": "Use YAML view to apply advanced features", "v1": "v1", + "Validate only": "Validate only", "Value": "Value", "Verify configuration": "Verify configuration", "Verify MCP server": "Verify MCP server", @@ -868,6 +932,7 @@ "View in overview": "View in overview", "View K8s Resource": "View K8s Resource", "Waiting for controller to reconcile...": "Waiting for controller to reconcile...", + "warnings": "warnings", "Weekly Limit": "Weekly Limit", "Weight value to apply to weighted endpoints default: 120": "Weight value to apply to weighted endpoints default: 120", "When predicate": "When predicate", diff --git a/package.json b/package.json index e2eb2d62..fb75df5c 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,8 @@ "MCPOverviewPage": "./components/mcp/MCPOverviewPage", "MCPSetupWizard": "./components/mcp/MCPSetupWizard", "MCPGatewayExtensionCreatePage": "./components/mcp/MCPGatewayExtensionCreatePage", - "MCPServerRegistrationCreatePage": "./components/mcp/MCPServerRegistrationCreatePage" + "MCPServerRegistrationCreatePage": "./components/mcp/MCPServerRegistrationCreatePage", + "MCPInspectorPage": "./components/mcp/MCPInspectorPage" }, "dependencies": { "@console/pluginAPI": ">=4.22.0-0" diff --git a/scripts/sync-console-plugin-proxy.sh b/scripts/sync-console-plugin-proxy.sh new file mode 100755 index 00000000..cc767435 --- /dev/null +++ b/scripts/sync-console-plugin-proxy.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Reconfigure OINC's standalone development Console from the ConsolePlugin +# proxy contract reconciled by the Kuadrant Operator. This has no production +# deployment role; a real OpenShift Console operator performs the translation. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# shellcheck source=lib.sh +source "${SCRIPT_DIR}/lib.sh" + +OINC_BIN="${OINC_BIN:-oinc}" +CONSOLE_PORT="${CONSOLE_PORT:-9000}" +PLUGIN_PORT="${PLUGIN_PORT:-9001}" +PLUGIN_NAME=$(node -p "require('${REPO_DIR}/package.json').consolePlugin.name") +RUNTIME=$(detect_runtime) +HOST=$(container_host "${RUNTIME}") + +check_command "${OINC_BIN}" "Install oinc v0.4.6 or newer" + +"${OINC_BIN}" console sync-plugin-proxy "${PLUGIN_NAME}" \ + --console-plugin "${PLUGIN_NAME}=http://${HOST}:${PLUGIN_PORT}" \ + --console-port "${CONSOLE_PORT}" + +log "OINC Console now uses the operator-reconciled plugin proxy; reload the browser" diff --git a/src/components/mcp/MCPInspectorOutput.tsx b/src/components/mcp/MCPInspectorOutput.tsx new file mode 100644 index 00000000..c8c9f4ff --- /dev/null +++ b/src/components/mcp/MCPInspectorOutput.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Card, + CardBody, + CardHeader, + CardTitle, + Content, + Label, + Tab, + Tabs, + TabTitleText, + Title, +} from '@patternfly/react-core'; +import { MCPCallExchange, ToolsCallResult } from '../../utils/mcp/client'; + +interface MCPInspectorOutputProps { + exchange: MCPCallExchange | null; +} + +const renderServerResult = (result: ToolsCallResult): React.ReactNode => { + if (!result.content?.length) { + return
{JSON.stringify(result, null, 2)}
; + } + return result.content.map((content, index) => + content.type === 'text' && content.text ? ( +
{content.text}
+ ) : ( +
{JSON.stringify(content, null, 2)}
+ ), + ); +}; + +const MCPInspectorOutput: React.FC = ({ exchange }) => { + const { t } = useTranslation('plugin__kuadrant-console-plugin'); + const [activeTab, setActiveTab] = React.useState(0); + const succeeded = exchange ? !exchange.result.isError : false; + + return ( + + + {t('Output')} + + + {exchange && ( +
+ + + {exchange.status} {exchange.statusText} + + {exchange.durationMs} ms +
+ )} + setActiveTab(key)} + aria-label={t('Tool call output')} + > + {t('Console')}}> + {exchange ? ( +
+ {t('JSON-RPC request')} +
{JSON.stringify(exchange.request, null, 2)}
+ {t('JSON-RPC response')} +
{JSON.stringify(exchange.response, null, 2)}
+
+ ) : ( + {t('No results')} + )} +
+ {t('Server result')}}> + {exchange ? renderServerResult(exchange.result) : null} + +
+
+
+ ); +}; + +export default MCPInspectorOutput; diff --git a/src/components/mcp/MCPInspectorPage.css b/src/components/mcp/MCPInspectorPage.css new file mode 100644 index 00000000..e7ef753e --- /dev/null +++ b/src/components/mcp/MCPInspectorPage.css @@ -0,0 +1,194 @@ +.kuadrant-mcp-inspector-page { + --kuadrant-mcp-inspector-border: var(--pf-t--global--border--color--default, #d2d2d2); +} + +.kuadrant-mcp-inspector-page__connection-card { + border: 1px solid var(--kuadrant-mcp-inspector-border); + border-radius: var(--pf-t--global--border--radius--large, 12px); + box-shadow: none; +} + +.kuadrant-mcp-inspector-page__connection-card .pf-v6-c-card__body { + padding: var(--pf-t--global--spacer--lg, 1.5rem); +} + +.kuadrant-mcp-inspector-page__connection-segment { + min-height: 5.5rem; + padding: 0 var(--pf-t--global--spacer--xl, 2rem); +} + +.kuadrant-mcp-inspector-page__connection-segment:first-child { + padding-left: 0; +} + +.kuadrant-mcp-inspector-page__connection-segment + + .kuadrant-mcp-inspector-page__connection-segment { + border-left: 1px solid var(--kuadrant-mcp-inspector-border); +} + +.kuadrant-mcp-inspector-page__connection-segment:last-child { + padding-right: 0; +} + +.kuadrant-mcp-inspector-page__connection-segment .pf-v6-c-form__label, +.kuadrant-mcp-inspector-page__segment-title { + display: block; + margin-bottom: var(--pf-t--global--spacer--sm, 0.5rem); + font-weight: var(--pf-t--global--font--weight--body--bold, 700); + text-align: center; +} + +.kuadrant-mcp-inspector-page__endpoint { + display: block; + overflow: hidden; + margin-top: var(--pf-t--global--spacer--xs, 0.25rem); + color: var(--pf-t--global--text--color--subtle, #6a6e73); + text-overflow: ellipsis; + white-space: nowrap; +} + +.kuadrant-mcp-inspector-page__connection-status, +.kuadrant-mcp-inspector-page__session-status, +.kuadrant-mcp-inspector-page__request-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: var(--pf-t--global--spacer--md, 1rem); +} + +.kuadrant-mcp-inspector-page__session-status span + span { + padding-left: var(--pf-t--global--spacer--md, 1rem); + border-left: 1px solid var(--kuadrant-mcp-inspector-border); +} + +.kuadrant-mcp-inspector-page__status-dot { + width: 0.75rem; + height: 0.75rem; + border-radius: 50%; + background: var(--pf-t--global--color--status--danger--default, #c9190b); +} + +.kuadrant-mcp-inspector-page__status-dot.is-connected { + background: var(--pf-t--global--color--status--success--default, #3e8635); +} + +.kuadrant-mcp-inspector-page__session-id { + display: block; + overflow: hidden; + margin-top: var(--pf-t--global--spacer--sm, 0.5rem); + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kuadrant-mcp-inspector-page__section { + padding-top: var(--pf-t--global--spacer--lg, 1.5rem); +} + +.kuadrant-mcp-inspector-page__tool-warning { + max-width: 42rem; + margin: 0 auto; +} + +.kuadrant-mcp-inspector-page__workspace, +.kuadrant-mcp-inspector-page__output { + border: 1px solid var(--kuadrant-mcp-inspector-border); + border-radius: var(--pf-t--global--border--radius--large, 12px); + box-shadow: none; +} + +.kuadrant-mcp-inspector-page__tool-list { + display: flex; + max-height: 10rem; + flex-direction: column; + overflow-y: auto; + margin: var(--pf-t--global--spacer--sm, 0.5rem) 0; + border: 1px solid var(--kuadrant-mcp-inspector-border); + border-radius: var(--pf-t--global--border--radius--small, 4px); +} + +.kuadrant-mcp-inspector-page__tool { + width: 100%; + justify-content: flex-start; + border-radius: 0; + text-align: left; +} + +.kuadrant-mcp-inspector-page__tool.is-selected { + background: var(--pf-t--global--background--color--primary--hover, #f0f0f0); +} + +.kuadrant-mcp-inspector-page__selected-tool { + padding-top: var(--pf-t--global--spacer--md, 1rem); + border-top: 1px solid var(--kuadrant-mcp-inspector-border); +} + +.kuadrant-mcp-inspector-page__annotations, +.kuadrant-mcp-inspector-page__tool-form, +.kuadrant-mcp-inspector-page__metadata { + margin-top: var(--pf-t--global--spacer--md, 1rem); +} + +.kuadrant-mcp-inspector-page__field-error { + color: var(--pf-t--global--color--status--danger--default, #c9190b); +} + +.kuadrant-mcp-inspector-page__metadata-heading, +.kuadrant-mcp-inspector-page__metadata-row, +.kuadrant-mcp-inspector-page__actions { + display: flex; + align-items: center; + gap: var(--pf-t--global--spacer--md, 1rem); +} + +.kuadrant-mcp-inspector-page__metadata-heading { + justify-content: space-between; +} + +.kuadrant-mcp-inspector-page__metadata-row { + margin-top: var(--pf-t--global--spacer--sm, 0.5rem); +} + +.kuadrant-mcp-inspector-page__metadata-row > *:not(:last-child) { + flex: 1; +} + +.kuadrant-mcp-inspector-page__actions { + margin-top: var(--pf-t--global--spacer--lg, 1.5rem); +} + +.kuadrant-mcp-inspector-page__request-summary { + justify-content: flex-start; + margin-bottom: var(--pf-t--global--spacer--sm, 0.5rem); +} + +.kuadrant-mcp-inspector-page__console pre, +.kuadrant-mcp-inspector-page__output pre { + max-height: 20rem; + overflow: auto; + padding: var(--pf-t--global--spacer--md, 1rem); + margin: var(--pf-t--global--spacer--sm, 0.5rem) 0 var(--pf-t--global--spacer--md, 1rem); + border-radius: var(--pf-t--global--border--radius--small, 4px); + background: var(--pf-t--global--background--color--secondary--default, #f5f5f5); + white-space: pre-wrap; + word-break: break-word; +} + +@media (max-width: 768px) { + .kuadrant-mcp-inspector-page__connection-segment { + min-height: auto; + padding: var(--pf-t--global--spacer--md, 1rem) 0; + } + + .kuadrant-mcp-inspector-page__connection-segment + + .kuadrant-mcp-inspector-page__connection-segment { + border-top: 1px solid var(--kuadrant-mcp-inspector-border); + border-left: 0; + } + + .kuadrant-mcp-inspector-page__metadata-row { + align-items: stretch; + flex-direction: column; + } +} diff --git a/src/components/mcp/MCPInspectorPage.test.tsx b/src/components/mcp/MCPInspectorPage.test.tsx new file mode 100644 index 00000000..cd8453d6 --- /dev/null +++ b/src/components/mcp/MCPInspectorPage.test.tsx @@ -0,0 +1,277 @@ +import * as React from 'react'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MCPGatewayExtension } from './types'; +import { MCPClient, MCPUnauthorizedError } from '../../utils/mcp/client'; + +let mockExtensions: MCPGatewayExtension[] = []; +let mockExtensionsLoaded = true; +let mockToolsCallWithDetails = jest.fn(); +let mockToolsList = jest.fn(); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (translated, [name, value]) => translated.replace(`{{${name}}}`, value), + key, + ), + }), +})); + +jest.mock('react-helmet', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock('@openshift-console/dynamic-plugin-sdk', () => ({ + NamespaceBar: () =>
, + useActiveNamespace: () => ['test-ns'], + useK8sWatchResource: () => [mockExtensions, mockExtensionsLoaded, null], +})); + +jest.mock('../../utils/mcp/client', () => ({ + ...jest.requireActual('../../utils/mcp/client'), + MCPClient: jest.fn(), +})); + +import MCPInspectorPage from './MCPInspectorPage'; + +const readyExtension: MCPGatewayExtension = { + apiVersion: 'mcp.kuadrant.io/v1', + kind: 'MCPGatewayExtension', + metadata: { name: 'mcp-gateway', namespace: 'test-ns' }, + spec: { + targetRef: { + name: 'mcp-gateway', + sectionName: 'mcp', + }, + publicHost: 'mcp.example.test', + }, + status: { + conditions: [{ type: 'Ready', status: 'True' }], + }, +}; + +describe('MCPInspectorPage', () => { + beforeEach(() => { + mockExtensions = []; + mockExtensionsLoaded = true; + mockToolsCallWithDetails = jest.fn().mockResolvedValue({ + result: { content: [{ type: 'text', text: 'Hello, Ada!' }] }, + request: { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'toystore_greet', arguments: { name: 'Ada' } }, + }, + response: { + jsonrpc: '2.0', + id: 3, + result: { content: [{ type: 'text', text: 'Hello, Ada!' }] }, + }, + status: 200, + statusText: 'OK', + durationMs: 12, + }); + mockToolsList = jest.fn().mockResolvedValue({ + tools: [ + { + name: 'toystore_greet', + description: 'Say hello', + annotations: { readOnlyHint: true }, + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'The name to greet' }, + }, + required: ['name'], + }, + }, + ], + }); + (MCPClient as jest.Mock).mockReset(); + (MCPClient as jest.Mock).mockImplementation(() => ({ + initialize: jest.fn().mockResolvedValue('session-1'), + sendInitialized: jest.fn().mockResolvedValue(undefined), + toolsList: mockToolsList, + toolsCallWithDetails: mockToolsCallWithDetails, + })); + }); + + it('guides the user to select a gateway before showing inspector tools', () => { + render(); + + expect(screen.getByRole('heading', { name: 'No connection' })).toBeInTheDocument(); + expect( + screen.getByText('Connect to a Gateway to view the MCP server tools available.'), + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Run tool' })).not.toBeInTheDocument(); + }); + + it('connects to a ready gateway and shows its tools workspace', async () => { + mockExtensions = [readyExtension]; + render(); + + fireEvent.change(screen.getByLabelText('Select an MCP gateway extension'), { + target: { value: 'test-ns/mcp-gateway' }, + }); + + await waitFor(() => expect(screen.getByText('Connected')).toBeInTheDocument()); + expect(screen.getByRole('tab', { name: 'Tools' })).toBeInTheDocument(); + expect(screen.getAllByText('toystore_greet').length).toBeGreaterThan(0); + expect(screen.queryByLabelText('Bearer token (optional)')).not.toBeInTheDocument(); + expect(screen.getByText('0 requests')).toBeInTheDocument(); + expect(screen.getByText('0 warnings')).toBeInTheDocument(); + expect(screen.getByText('0 errors')).toBeInTheDocument(); + expect(screen.getByText('No results')).toBeInTheDocument(); + }); + + it('offers an in-memory bearer token after an authentication challenge', async () => { + (MCPClient as jest.Mock).mockImplementationOnce(() => ({ + initialize: jest.fn().mockRejectedValue(new MCPUnauthorizedError()), + })); + mockExtensions = [readyExtension]; + render(); + + fireEvent.change(screen.getByLabelText('Select an MCP gateway extension'), { + target: { value: 'test-ns/mcp-gateway' }, + }); + + const dialog = await screen.findByRole('dialog', { name: 'Authentication required' }); + expect(dialog).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Sign in with OIDC' })).not.toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Bearer token'), { target: { value: 'test-token' } }); + fireEvent.click(screen.getByRole('button', { name: 'Connect with bearer token' })); + + await waitFor(() => expect(screen.getByText('Connected')).toBeInTheDocument()); + expect(MCPClient).toHaveBeenLastCalledWith( + '/api/proxy/plugin/kuadrant-console-plugin/backend/api/mcp/v1/mcpgatewayextensions/test-ns/mcp-gateway', + { token: 'test-token' }, + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('explains when a bearer token is rejected and allows another attempt', async () => { + (MCPClient as jest.Mock) + .mockImplementationOnce(() => ({ + initialize: jest.fn().mockRejectedValue(new MCPUnauthorizedError()), + })) + .mockImplementationOnce(() => ({ + initialize: jest.fn().mockRejectedValue(new MCPUnauthorizedError()), + })); + mockExtensions = [readyExtension]; + render(); + + fireEvent.change(screen.getByLabelText('Select an MCP gateway extension'), { + target: { value: 'test-ns/mcp-gateway' }, + }); + + await screen.findByRole('dialog', { name: 'Authentication required' }); + fireEvent.change(screen.getByLabelText('Bearer token'), { + target: { value: 'incorrect-token' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect with bearer token' })); + + expect(await screen.findByText('Invalid bearer token')).toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: 'Authentication required' })).toBeInTheDocument(); + expect(screen.queryByText('initialize failed (http 401)')).not.toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Bearer token'), { + target: { value: 'correct-token' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect with bearer token' })); + + await waitFor(() => expect(screen.getByText('Connected')).toBeInTheDocument()); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('builds and validates a tool form from its input schema before running it', async () => { + mockExtensions = [readyExtension]; + render(); + + fireEvent.change(screen.getByLabelText('Select an MCP gateway extension'), { + target: { value: 'test-ns/mcp-gateway' }, + }); + await waitFor(() => expect(screen.getByText('Connected')).toBeInTheDocument()); + + fireEvent.change(screen.getByLabelText('Search tools'), { target: { value: 'greet' } }); + fireEvent.click(screen.getByRole('button', { name: /toystore_greet/ })); + + expect(screen.getByRole('heading', { name: 'toystore_greet' })).toBeInTheDocument(); + expect(screen.getByText('Read only')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Validate only' })); + expect(screen.getByText('Name is required')).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Ada' } }); + fireEvent.click(screen.getByRole('button', { name: 'Validate only' })); + expect(screen.getByText('Input is valid')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + await waitFor(() => + expect(mockToolsCallWithDetails).toHaveBeenCalledWith('toystore_greet', { name: 'Ada' }), + ); + }); + + it('sends metadata and presents the server result with request telemetry', async () => { + mockExtensions = [readyExtension]; + render(); + + fireEvent.change(screen.getByLabelText('Select an MCP gateway extension'), { + target: { value: 'test-ns/mcp-gateway' }, + }); + await waitFor(() => expect(screen.getByText('Connected')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /toystore_greet/ })); + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Ada' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add metadata' })); + fireEvent.change(screen.getByLabelText('Metadata key'), { target: { value: 'traceId' } }); + fireEvent.change(screen.getByLabelText('Metadata value'), { + target: { value: 'trace-1' }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + + await waitFor(() => + expect(mockToolsCallWithDetails).toHaveBeenCalledWith( + 'toystore_greet', + { name: 'Ada' }, + { traceId: 'trace-1' }, + ), + ); + expect(screen.getByText('Success')).toBeInTheDocument(); + expect(screen.getByText('200 OK')).toBeInTheDocument(); + expect(screen.getByText('12 ms')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Console' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Server result' })).toBeInTheDocument(); + expect(screen.getAllByText(/Hello, Ada!/).length).toBeGreaterThan(0); + }); + + it('refreshes the tool list without reconnecting the session', async () => { + mockToolsList + .mockResolvedValueOnce({ + tools: [{ name: 'toystore_greet', inputSchema: { type: 'object', properties: {} } }], + }) + .mockResolvedValueOnce({ + tools: [ + { name: 'toystore_greet', inputSchema: { type: 'object', properties: {} } }, + { name: 'toystore_calculate', inputSchema: { type: 'object', properties: {} } }, + ], + }); + mockExtensions = [readyExtension]; + render(); + + fireEvent.change(screen.getByLabelText('Select an MCP gateway extension'), { + target: { value: 'test-ns/mcp-gateway' }, + }); + await waitFor(() => expect(screen.getByText('Connected')).toBeInTheDocument()); + + const refreshButton = screen.getByRole('button', { name: 'Refresh tools' }); + expect(refreshButton).not.toHaveTextContent('Refresh'); + fireEvent.click(refreshButton); + + await waitFor(() => expect(screen.getByText('toystore_calculate')).toBeInTheDocument()); + expect(mockToolsList).toHaveBeenCalledTimes(2); + expect(MCPClient).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/mcp/MCPInspectorPage.tsx b/src/components/mcp/MCPInspectorPage.tsx new file mode 100644 index 00000000..25a22532 --- /dev/null +++ b/src/components/mcp/MCPInspectorPage.tsx @@ -0,0 +1,504 @@ +import * as React from 'react'; +import Helmet from 'react-helmet'; +import { useTranslation } from 'react-i18next'; +import { + PageSection, + Title, + Content, + Form, + FormGroup, + FormSelect, + FormSelectOption, + Button, + Alert, + Stack, + StackItem, + EmptyState, + EmptyStateBody, + Tab, + Tabs, + TabTitleText, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, + TextInput, + Card, + CardBody, + Grid, + GridItem, +} from '@patternfly/react-core'; +import { + NamespaceBar, + useActiveNamespace, + useK8sWatchResource, +} from '@openshift-console/dynamic-plugin-sdk'; +import { RESOURCES } from '../../utils/resources'; +import { MCPGatewayExtension } from './types'; +import { + MCPClient, + MCPSessionExpiredError, + MCPUnauthorizedError, + MCPTool, + ToolsCallResult, + MCPCallExchange, +} from '../../utils/mcp/client'; +import MCPToolWorkspace from './MCPToolWorkspace'; +import MCPInspectorOutput from './MCPInspectorOutput'; +import './MCPInspectorPage.css'; + +const ALL_NS = '#ALL_NS#'; + +const isReady = (ext: MCPGatewayExtension): boolean => + (ext.status?.conditions ?? []).some((c) => c.type === 'Ready' && c.status === 'True'); + +const extKey = (ext: MCPGatewayExtension): string => + `${ext.metadata?.namespace}/${ext.metadata?.name}`; + +const proxyEndpoint = (ext: MCPGatewayExtension): string => + `/api/proxy/plugin/kuadrant-console-plugin/backend/api/mcp/v1/mcpgatewayextensions/${encodeURIComponent( + ext.metadata?.namespace ?? '', + )}/${encodeURIComponent(ext.metadata?.name ?? '')}`; + +const MCPInspectorPage: React.FC = () => { + const { t } = useTranslation('plugin__kuadrant-console-plugin'); + const [activeNamespace] = useActiveNamespace(); + const resolvedNamespace = activeNamespace === ALL_NS ? undefined : activeNamespace; + + const [extensions, extensionsLoaded] = useK8sWatchResource({ + groupVersionKind: RESOURCES.MCPGatewayExtension.gvk, + isList: true, + namespace: resolvedNamespace, + }); + + const [selectedKey, setSelectedKey] = React.useState(''); + const [bearerToken, setBearerToken] = React.useState(''); + const [authChallenge, setAuthChallenge] = React.useState(null); + const [authRejected, setAuthRejected] = React.useState(false); + + // session id lives in react state and on the client instance only, never localStorage. + const clientRef = React.useRef(null); + const [sessionId, setSessionId] = React.useState(null); + const [connected, setConnected] = React.useState(false); + const [authMode, setAuthMode] = React.useState<'none' | 'bearer'>('none'); + const [activeSection, setActiveSection] = React.useState(0); + const [tools, setTools] = React.useState([]); + + const [callExchange, setCallExchange] = React.useState | null>( + null, + ); + const [stats, setStats] = React.useState({ + calls: 0, + failed: 0, + }); + + const [connecting, setConnecting] = React.useState(false); + const [calling, setCalling] = React.useState(false); + const [refreshingTools, setRefreshingTools] = React.useState(false); + const [error, setError] = React.useState(''); + const [sessionExpired, setSessionExpired] = React.useState(false); + + const list = React.useMemo(() => extensions ?? [], [extensions]); + const selected = React.useMemo( + () => list.find((ext) => extKey(ext) === selectedKey), + [list, selectedKey], + ); + const endpoint = selected?.spec.publicHost ?? ''; + + const runSession = async (inspectorEndpoint: string, bearer?: string) => { + setError(''); + setSessionExpired(false); + setTools([]); + setCallExchange(null); + const client = new MCPClient(inspectorEndpoint, { token: bearer || undefined }); + const id = await client.initialize(); + await client.sendInitialized(); + const listed = await client.toolsList(); + clientRef.current = client; + setSessionId(id); + setConnected(true); + setTools(listed.tools ?? []); + }; + + const handleConnect = async (inspectorEndpoint: string, bearer?: string) => { + if (!inspectorEndpoint) { + return; + } + setConnecting(true); + try { + await runSession(inspectorEndpoint, bearer); + setAuthMode(bearer ? 'bearer' : 'none'); + setAuthChallenge(null); + setAuthRejected(false); + } catch (err) { + clientRef.current = null; + setSessionId(null); + setConnected(false); + if (err instanceof MCPUnauthorizedError) { + setError(''); + if (bearer) { + setAuthRejected(true); + } else { + setAuthChallenge(inspectorEndpoint); + setAuthRejected(false); + } + } else { + setError(err instanceof Error ? err.message : String(err)); + } + } finally { + setConnecting(false); + } + }; + + const handleGatewayChange = (_event: React.FormEvent, value: string) => { + setSelectedKey(value); + clientRef.current = null; + setSessionId(null); + setConnected(false); + setAuthMode('none'); + setActiveSection(0); + setTools([]); + setCallExchange(null); + setStats({ calls: 0, failed: 0 }); + setError(''); + setSessionExpired(false); + setAuthChallenge(null); + setBearerToken(''); + setAuthRejected(false); + if (!value) { + return; + } + const extension = list.find((item) => extKey(item) === value); + if (extension && isReady(extension)) { + void handleConnect(proxyEndpoint(extension)); + } + }; + + const handleBearerConnect = () => { + if (!authChallenge || !bearerToken.trim()) { + return; + } + setAuthRejected(false); + void handleConnect(authChallenge, bearerToken.trim()); + }; + + const handleCall = async ( + toolName: string, + args: Record, + metadata?: Record, + ) => { + const client = clientRef.current; + if (!client) { + return; + } + setError(''); + setSessionExpired(false); + setCalling(true); + setCallExchange(null); + try { + const exchange = metadata + ? await client.toolsCallWithDetails(toolName, args, metadata) + : await client.toolsCallWithDetails(toolName, args); + setCallExchange(exchange); + setStats((current) => ({ + calls: current.calls + 1, + failed: current.failed + (exchange.result.isError ? 1 : 0), + })); + } catch (err) { + setStats((current) => ({ + ...current, + calls: current.calls + 1, + failed: current.failed + 1, + })); + if (err instanceof MCPSessionExpiredError) { + setSessionExpired(true); + } else { + setError(err instanceof Error ? err.message : String(err)); + } + } finally { + setCalling(false); + } + }; + + const handleRefreshTools = async () => { + const client = clientRef.current; + if (!client) { + return; + } + setRefreshingTools(true); + setError(''); + setSessionExpired(false); + try { + const listed = await client.toolsList(); + setTools(listed.tools ?? []); + } catch (err) { + if (err instanceof MCPSessionExpiredError) { + setSessionExpired(true); + } else { + setError(err instanceof Error ? err.message : String(err)); + } + } finally { + setRefreshingTools(false); + } + }; + + return ( + <> + + {t('MCP Inspector')} + + + + + + {t('MCP Inspector')} + + + + + + + +
+ + + + {list.map((ext) => { + const reachable = isReady(ext); + const name = `${ext.metadata?.name} (${ext.metadata?.namespace})`; + return ( + + ); + })} + + +
+ {endpoint && ( + + {endpoint} + + )} +
+ + + {t('Connection')} + +
+ + + {connecting + ? t('Connecting...') + : connected + ? t('Connected') + : t('No connection')} + + {connected && ( + + {authMode === 'none' ? t('No authentication') : t('Authenticated')} + + )} +
+ {sessionId && ( + + {t('Session ID')}: {sessionId} + + )} +
+ + + {t('Status')} + +
+ + {stats.calls} {stats.calls === 1 ? t('request') : t('requests')} + + + {sessionExpired ? 1 : 0} {t('warnings')} + + + {stats.failed} {t('errors')} + +
+
+
+
+
+
+ + {!selectedKey && extensionsLoaded && ( + + + + {t('Connect to a Gateway to view the MCP server tools available.')} + + + + )} + + {error && ( + + + {error} + + + )} + + {sessionExpired && ( + + + {t('The MCP session is no longer valid. Connect again to start a new session.')} + + + )} + + {connected && ( + + setActiveSection(key)} + aria-label={t('MCP inspector sections')} + > + {t('Tools')}} /> + {t('Prompts')}} /> + {t('Logs')}} /> + + {activeSection === 0 && ( + + + + + + + + + + + + + + + + )} + {activeSection === 1 && ( + + {t('Prompt inspection is not available yet.')} + + )} + {activeSection === 2 && ( + + {t('Session logs are not available yet.')} + + )} + + )} +
+
+ { + setAuthChallenge(null); + setAuthRejected(false); + }} + variant={ModalVariant.small} + aria-labelledby="mcp-inspector-auth-title" + > + + + + + + {t( + 'This MCP gateway requires authentication. Provide a bearer token for the MCP gateway.', + )} + + + {authRejected && ( + + + {t('The gateway rejected this token. Check it and try again.')} + + + )} + + + { + setBearerToken(value); + setAuthRejected(false); + }} + aria-label={t('Bearer token')} + placeholder={t('Held in memory only')} + /> + + + + + + + + + + + ); +}; + +export default MCPInspectorPage; diff --git a/src/components/mcp/MCPToolWorkspace.tsx b/src/components/mcp/MCPToolWorkspace.tsx new file mode 100644 index 00000000..ac6ac194 --- /dev/null +++ b/src/components/mcp/MCPToolWorkspace.tsx @@ -0,0 +1,444 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Alert, + Button, + Card, + CardBody, + CardHeader, + CardTitle, + Checkbox, + Content, + Form, + FormGroup, + FormSelect, + FormSelectOption, + Label, + LabelGroup, + SearchInput, + TextArea, + TextInput, + Title, + Tooltip, +} from '@patternfly/react-core'; +import { SyncAltIcon } from '@patternfly/react-icons'; +import { MCPTool } from '../../utils/mcp/client'; + +interface JsonSchema { + type?: string | string[]; + title?: string; + description?: string; + default?: unknown; + enum?: unknown[]; + properties?: Record; + required?: string[]; +} + +interface MCPToolWorkspaceProps { + tools: MCPTool[]; + isRunning: boolean; + isRefreshing: boolean; + onRefresh: () => Promise; + onRun: ( + name: string, + args: Record, + metadata?: Record, + ) => Promise; +} + +type FieldValues = Record; +interface MetadataRow { + id: number; + key: string; + value: string; +} + +const humanize = (value: string): string => { + const words = value.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[-_]+/g, ' '); + return words.charAt(0).toUpperCase() + words.slice(1); +}; + +const schemaType = (schema: JsonSchema): string => { + if (Array.isArray(schema.type)) { + return schema.type.find((type) => type !== 'null') ?? 'string'; + } + return schema.type ?? 'string'; +}; + +const initialValues = (tool: MCPTool): FieldValues => { + const properties = (tool.inputSchema as JsonSchema | undefined)?.properties ?? {}; + return Object.entries(properties).reduce((values, [name, schema]) => { + if (schema.default !== undefined) { + values[name] = + typeof schema.default === 'boolean' + ? schema.default + : typeof schema.default === 'string' + ? schema.default + : JSON.stringify(schema.default, null, 2); + } else { + values[name] = schemaType(schema) === 'boolean' ? false : ''; + } + return values; + }, {}); +}; + +const annotationLabels = (tool: MCPTool): string[] => { + const annotations = tool.annotations ?? {}; + return [ + annotations.readOnlyHint ? 'Read only' : '', + annotations.destructiveHint ? 'Destructive' : '', + annotations.idempotentHint ? 'Idempotent' : '', + annotations.openWorldHint ? 'Open world' : '', + ].filter(Boolean); +}; + +const translatedAnnotation = (annotation: string, t: (key: string) => string): string => { + const translations: Record = { + 'Read only': t('Read only'), + Destructive: t('Destructive'), + Idempotent: t('Idempotent'), + 'Open world': t('Open world'), + }; + return translations[annotation] ?? annotation; +}; + +const MCPToolWorkspace: React.FC = ({ + tools, + isRunning, + isRefreshing, + onRefresh, + onRun, +}) => { + const { t } = useTranslation('plugin__kuadrant-console-plugin'); + const [search, setSearch] = React.useState(''); + const [selectedToolName, setSelectedToolName] = React.useState(''); + const [values, setValues] = React.useState({}); + const [fieldErrors, setFieldErrors] = React.useState>({}); + const [validationMessage, setValidationMessage] = React.useState(''); + const [metadataRows, setMetadataRows] = React.useState([]); + const nextMetadataId = React.useRef(1); + + const selectedTool = tools.find((tool) => tool.name === selectedToolName); + const filteredTools = tools.filter((tool) => { + const term = search.trim().toLowerCase(); + return ( + !term || + tool.name.toLowerCase().includes(term) || + (tool.description ?? '').toLowerCase().includes(term) + ); + }); + + const selectTool = (tool: MCPTool) => { + setSelectedToolName(tool.name); + setValues(initialValues(tool)); + setFieldErrors({}); + setValidationMessage(''); + setMetadataRows([]); + }; + + const validate = (): Record | null => { + if (!selectedTool) { + return null; + } + const inputSchema = (selectedTool.inputSchema ?? {}) as JsonSchema; + const properties = inputSchema.properties ?? {}; + const required = new Set(inputSchema.required ?? []); + const errors: Record = {}; + const args: Record = {}; + + Object.entries(properties).forEach(([name, propertySchema]) => { + const value = values[name]; + const label = propertySchema.title || humanize(name); + const type = schemaType(propertySchema); + const isEmpty = value === undefined || value === ''; + + if (required.has(name) && isEmpty) { + errors[name] = t('{{field}} is required', { field: label }); + return; + } + if (isEmpty) { + return; + } + + if (type === 'number' || type === 'integer') { + const parsed = Number(value); + if (!Number.isFinite(parsed) || (type === 'integer' && !Number.isInteger(parsed))) { + errors[name] = + type === 'integer' + ? t('{{field}} must be an integer', { field: label }) + : t('{{field}} must be a number', { field: label }); + return; + } + args[name] = parsed; + return; + } + + if (type === 'object' || type === 'array') { + try { + const parsed = JSON.parse(String(value)); + if ( + (type === 'array' && !Array.isArray(parsed)) || + (type === 'object' && (Array.isArray(parsed) || typeof parsed !== 'object')) + ) { + throw new Error('wrong JSON type'); + } + args[name] = parsed; + } catch { + errors[name] = t('{{field}} must be valid JSON', { field: label }); + } + return; + } + + args[name] = value; + }); + + setFieldErrors(errors); + if (Object.keys(errors).length > 0) { + setValidationMessage(''); + return null; + } + setValidationMessage(t('Input is valid')); + return args; + }; + + const run = async () => { + const args = validate(); + if (args && selectedTool) { + const metadata = metadataRows.reduce>((result, row) => { + if (row.key.trim()) { + result[row.key.trim()] = row.value; + } + return result; + }, {}); + if (Object.keys(metadata).length > 0) { + await onRun(selectedTool.name, args, metadata); + } else { + await onRun(selectedTool.name, args); + } + } + }; + + const addMetadata = () => { + setMetadataRows((current) => [ + ...current, + { id: nextMetadataId.current++, key: '', value: '' }, + ]); + }; + + const updateMetadata = (id: number, field: 'key' | 'value', value: string) => { + setMetadataRows((current) => + current.map((row) => (row.id === id ? { ...row, [field]: value } : row)), + ); + }; + + const renderField = (name: string, propertySchema: JsonSchema) => { + const type = schemaType(propertySchema); + const label = propertySchema.title || humanize(name); + const id = `mcp-tool-argument-${name}`; + const value = values[name] ?? ''; + const setValue = (next: string | boolean) => { + setValues((current) => ({ ...current, [name]: next })); + setFieldErrors((current) => ({ ...current, [name]: '' })); + setValidationMessage(''); + }; + + if (propertySchema.enum) { + return ( + setValue(next)} + aria-label={label} + > + + {propertySchema.enum.map((option) => ( + + ))} + + ); + } + + if (type === 'boolean') { + return ( + setValue(checked)} + aria-label={label} + /> + ); + } + + if (type === 'object' || type === 'array') { + return ( +