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 6ad0c888..d24f2b77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ RUN yarn config set --home enableGlobalCache true WORKDIR /usr/src/app COPY package.json yarn.lock .yarnrc.yml ./ +COPY .yarn/patches ./.yarn/patches RUN YARN_ENABLE_SCRIPTS=false yarn install --immutable COPY . . @@ -26,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 3288d51a..6b281a40 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Based on https://github.com/openshift/console-plugin-template ## Screenshots ![Overview](docs/images/overview.gif) + ## Running - Target a running OCP with `oc login` @@ -46,11 +47,20 @@ Prerequisites: [oinc](https://github.com/jasonmadigan/oinc), [kubectl](https://k ```bash make oinc # create cluster + start plugin dev server with hot reload +make oinc-sync-plugin-proxy # sync an operator-reconciled backend proxy into dev Console 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`. After a +development Kuadrant Operator reconciles a plugin backend proxy, run +`make oinc-sync-plugin-proxy` once (and again if the backend Service changes). +This target is development glue only; the Kuadrant Operator remains the source +of truth for production plugin resources. Set `OINC_BIN` if the development +OINC binary is not on `PATH`. + ### Option 3: Docker + VSCode Remote Container Make sure the @@ -88,9 +98,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. @@ -176,13 +192,14 @@ Update `settings.json` (File > Preferences > Settings): ```json "editor.formatOnSave": true ``` + ## Version matrix | kuadrant-console-plugin version | PatternFly version | Openshift console version | Dynamic Plugin SDK | -|---------------------------------|--------------------|---------------------------|--------------------| -| v0.0.3 - v0.0.18 | 5 | v4.17.x | v1.6.0 | -| TBD | 5 | v4.18.x | v1.8.0 | -| TBD | 6 | v4.19.x | TBD | +| ------------------------------- | ------------------ | ------------------------- | ------------------ | +| v0.0.3 - v0.0.18 | 5 | v4.17.x | v1.6.0 | +| TBD | 5 | v4.18.x | v1.8.0 | +| TBD | 6 | v4.19.x | TBD | Openshift console is configured to share modules with its dynamic plugins (console plugins). For more information on versions and changes to the shared modules, please see the shared modules [documentation](https://www.npmjs.com/package/@openshift-console/dynamic-plugin-sdk?activeTab=readme) @@ -236,4 +253,3 @@ Deletions are confirmed interactively in batches of 25. ## Troubleshooting For troubleshooting common issues, please see [TROUBLESHOOTING.md](TROUBLESHOOTING.md). - diff --git a/build/suite-router.sh b/build/suite-router.sh index 314250a7..0a086d20 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" + SPECS="$SPECS mcp-setup-wizard.spec.ts mcp-overview.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..7ac2af79 --- /dev/null +++ b/cmd/plugin-server/main.go @@ -0,0 +1,495 @@ +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 + devListenAddress string + staticDirectory string + tlsCertificateFile string + tlsKeyFile string + kubernetesAPIURL string + kubernetesCAFile string + kubernetesSkipVerify bool + upstreamSkipVerify 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() + servers := []*http.Server{{ + Addr: cfg.listenAddress, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + }} + if cfg.devListenAddress != "" { + servers = append(servers, &http.Server{ + Addr: cfg.devListenAddress, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + }) + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + for index, httpServer := range servers { + isTLS := index == 0 && 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() + for _, httpServer := range servers { + 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"), + devListenAddress: os.Getenv("DEV_LISTEN_ADDRESS"), + 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"), + upstreamSkipVerify: envBool("MCP_PROXY_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, + InsecureSkipVerify: cfg.upstreamSkipVerify, // #nosec G402 -- explicit development option + } + 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", "WWW-Authenticate"} { + 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 79318263..267096cf 100644 --- a/console-extensions.json +++ b/console-extensions.json @@ -659,6 +659,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": { @@ -678,6 +688,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": { @@ -701,5 +721,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/mcp-inspector.md b/docs/mcp-inspector.md new file mode 100644 index 00000000..391abbe4 --- /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 protocol, session, content, and authentication-challenge headers needed by Streamable HTTP. + +## Authentication + +The inspector first attempts an MCP `initialize` request without a gateway credential. If the gateway returns a `401` challenge, 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..f0d4d995 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -22,14 +22,15 @@ 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: -| Resource | API Group | Purpose | -|-|-|-| -| `MCPGatewayExtension` | `mcp.kuadrant.io/v1` | Extends a Gateway with MCP capabilities (public host, OAuth, session store) | -| `MCPServerRegistration` | `mcp.kuadrant.io/v1` | Registers an MCP server behind an HTTPRoute with prefix routing | -| `ReferenceGrant` | `gateway.networking.k8s.io/v1beta1` | Allows cross-namespace references between MCPGatewayExtensions and Gateways | +| Resource | API Group | Purpose | +| ----------------------- | ----------------------------------- | --------------------------------------------------------------------------- | +| `MCPGatewayExtension` | `mcp.kuadrant.io/v1` | Extends a Gateway with MCP capabilities (public host, OAuth, session store) | +| `MCPServerRegistration` | `mcp.kuadrant.io/v1` | Registers an MCP server behind an HTTPRoute with prefix routing | +| `ReferenceGrant` | `gateway.networking.k8s.io/v1beta1` | Allows cross-namespace references between MCPGatewayExtensions and Gateways | MCP Gateways are identified by finding Gateway resources that have an MCPGatewayExtension targeting them via `spec.targetRef`. The summary cards compute health based on the Gateway's `Accepted` and `Programmed` conditions, and server readiness based on the `Ready` condition. diff --git a/e2e/README.md b/e2e/README.md index 79f52316..64036f5f 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -9,6 +9,7 @@ ## Installation ### Install oinc + ```bash OINC_VERSION="v0.4.3" curl -fL -o oinc "https://github.com/jasonmadigan/oinc/releases/download/${OINC_VERSION}/oinc-linux-amd64" @@ -18,6 +19,7 @@ sudo mv oinc /usr/local/bin/ ``` ### Install Playwright browsers + ```bash npx playwright install chromium --with-deps # If you get sudo errors, install without system deps: @@ -56,7 +58,7 @@ npx playwright test e2e/tests/apikey-lifecycle.spec.ts --config=e2e/playwright.c ```bash # Check if cluster is running -oinc list +oinc status # Check if servers are running curl http://localhost:9000 # Console @@ -79,6 +81,13 @@ npx playwright test --config=e2e/playwright.config.ts - `e2e/tests/apiproduct-overview-tab.spec.ts` - API product overview tab - `e2e/tests/apiproduct-rbac.spec.ts` - API product RBAC - `e2e/tests/api-product-list.spec.ts` - API product list page +- `e2e/tests/attached-tab.spec.ts` - attached-policy tab behaviour +- `e2e/tests/data-view-regressions.spec.ts` - shared data-view regressions +- `e2e/tests/gateway-crud.spec.ts` - Gateway CRUD workflows +- `e2e/tests/httproute-crud.spec.ts` - HTTPRoute CRUD workflows +- `e2e/tests/mcp-inspector.spec.ts` - MCP Inspector smoke and live tool-call journeys +- `e2e/tests/mcp-overview.spec.ts` - MCP management overview +- `e2e/tests/mcp-setup-wizard.spec.ts` - MCP setup wizard - `e2e/tests/overview.spec.ts` - Overview dashboard cards, stats, and navigation - `e2e/tests/policy-forms.spec.ts` - Policy creation forms (DNS, TLS, Auth, RateLimit, etc.) - `e2e/tests/rbac.spec.ts` - RBAC permission tests @@ -100,14 +109,17 @@ run executes every spec in `e2e/tests/`. ## Troubleshooting ### Tests fail with "Cannot navigate to invalid URL" + - Make sure you use `--config=e2e/playwright.config.ts` - Check that console is running: `curl http://localhost:9000` ### Tests timeout looking for elements + - Check that plugin dev server is running: `curl http://localhost:9001` - Check test screenshots in `test-results/` directory ### API key not approved automatically + - Check if Kuadrant controller is running: ```bash kubectl get pods -n kuadrant-system @@ -118,6 +130,7 @@ run executes every spec in `e2e/tests/`. ``` ### View test results + ```bash # Open HTML report npx playwright show-report @@ -133,7 +146,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 3da8d99c..7f57c69f 100644 --- a/locales/en/plugin__kuadrant-console-plugin.json +++ b/locales/en/plugin__kuadrant-console-plugin.json @@ -845,5 +845,69 @@ "You do not have permission to view Policies": "You do not have permission to view Policies", "You do not have permission to view Policy Topology": "You do not have permission to view Policy Topology", "You do not have permission to view Reference grants": "You do not have permission to view Reference grants", - "You do not have permission to view this resource": "You do not have permission to view this resource" -} \ No newline at end of file + "You do not have permission to view this resource": "You do not have permission to view this resource", + "MCP Inspector": "MCP Inspector", + "Select an MCP gateway extension": "Select an MCP gateway extension", + "Loading extensions...": "Loading extensions...", + "Select an extension...": "Select an extension...", + "not reachable": "not reachable", + "Held in memory only": "Held in memory only", + "Session ID": "Session ID", + "Session expired, reconnect": "Session expired, reconnect", + "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.", + "Select a tool": "Select a tool", + "Add metadata": "Add metadata", + "Authenticated": "Authenticated", + "Authentication required": "Authentication required", + "Bearer token": "Bearer token", + "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", + "Destructive": "Destructive", + "errors": "errors", + "Gateway": "Gateway", + "Idempotent": "Idempotent", + "Input is valid": "Input is valid", + "JSON-RPC request": "JSON-RPC request", + "JSON-RPC response": "JSON-RPC response", + "Logs": "Logs", + "MCP inspector sections": "MCP inspector sections", + "Metadata": "Metadata", + "Metadata key": "Metadata key", + "Metadata value": "Metadata value", + "No authentication": "No authentication", + "No connection": "No connection", + "No description": "No description", + "No results": "No results", + "No tools found": "No tools found", + "Open world": "Open world", + "Optional key-value metadata is sent with the MCP tool call.": "Optional key-value metadata is sent with the MCP tool call.", + "Output": "Output", + "Prompt inspection is not available yet.": "Prompt inspection is not available yet.", + "Prompts": "Prompts", + "Read only": "Read only", + "Refresh tools": "Refresh tools", + "Remove metadata": "Remove metadata", + "request": "request", + "requests": "requests", + "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.", + "Search tools": "Search tools", + "Select a tool to inspect and run it.": "Select a tool to inspect and run it.", + "Select a value...": "Select a value...", + "Server": "Server", + "Server result": "Server result", + "Session logs are not available yet.": "Session logs are not available yet.", + "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.", + "Tool call output": "Tool call output", + "Tools": "Tools", + "Validate only": "Validate only", + "warnings": "warnings", + "{{field}} is required": "{{field}} is required", + "{{field}} must be an integer": "{{field}} must be an integer", + "{{field}} must be a number": "{{field}} must be a number", + "{{field}} must be valid JSON": "{{field}} must be valid JSON" +} diff --git a/package.json b/package.json index febc765f..2d25ed7d 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,8 @@ "GatewaySingleOverview": "./components/gateway/GatewaySingleOverview", "HTTPRouteSingleOverview": "./components/httproute/HTTPRouteSingleOverview", "MCPOverviewPage": "./components/mcp/MCPOverviewPage", - "MCPSetupWizard": "./components/mcp/MCPSetupWizard" + "MCPSetupWizard": "./components/mcp/MCPSetupWizard", + "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..a8dd701e --- /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 an OINC build with console sync-plugin-proxy support" + +"${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..2c7cbe68 --- /dev/null +++ b/src/components/mcp/MCPInspectorPage.test.tsx @@ -0,0 +1,255 @@ +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({ + result: { + protocolVersion: '2025-11-25', + serverInfo: { name: 'test-server', version: '1.0.0' }, + }, + sessionId: '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( + 'Bearer resource_metadata="https://mcp.example.test/.well-known/oauth-protected-resource/mcp"', + ), + ), + })); + 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('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..e8804b4d --- /dev/null +++ b/src/components/mcp/MCPInspectorPage.tsx @@ -0,0 +1,494 @@ +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<{ + proxyEndpoint: string; + selectedKey: string; + wwwAuthenticate: string | null; + } | null>(null); + + // 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, + succeeded: 0, + failed: 0, + totalDurationMs: 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 { sessionId: 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, + selectedExtensionKey = selectedKey, + bearer?: string, + ) => { + if (!inspectorEndpoint) { + return; + } + setConnecting(true); + try { + await runSession(inspectorEndpoint, bearer); + setAuthMode(bearer ? 'bearer' : 'none'); + setAuthChallenge(null); + } catch (err) { + clientRef.current = null; + setSessionId(null); + setConnected(false); + if (err instanceof MCPUnauthorizedError && !bearer) { + setError(''); + setAuthChallenge({ + proxyEndpoint: inspectorEndpoint, + selectedKey: selectedExtensionKey, + wwwAuthenticate: err.wwwAuthenticate, + }); + } 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, succeeded: 0, failed: 0, totalDurationMs: 0 }); + setError(''); + setSessionExpired(false); + setAuthChallenge(null); + setBearerToken(''); + if (!value) { + return; + } + const extension = list.find((item) => extKey(item) === value); + if (extension && isReady(extension)) { + void handleConnect(proxyEndpoint(extension), value); + } + }; + + const handleBearerConnect = () => { + if (!authChallenge || !bearerToken.trim()) { + return; + } + void handleConnect(authChallenge.proxyEndpoint, authChallenge.selectedKey, 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); + const startedAt = Date.now(); + try { + const exchange = metadata + ? await client.toolsCallWithDetails(toolName, args, metadata) + : await client.toolsCallWithDetails(toolName, args); + setCallExchange(exchange); + setStats((current) => ({ + calls: current.calls + 1, + succeeded: current.succeeded + (exchange.result.isError ? 0 : 1), + failed: current.failed + (exchange.result.isError ? 1 : 0), + totalDurationMs: current.totalDurationMs + exchange.durationMs, + })); + } catch (err) { + setStats((current) => ({ + ...current, + calls: current.calls + 1, + failed: current.failed + 1, + totalDurationMs: current.totalDurationMs + (Date.now() - startedAt), + })); + 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)} + variant={ModalVariant.small} + aria-labelledby="mcp-inspector-auth-title" + > + + + + + + {t( + 'This MCP gateway requires authentication. Provide a bearer token for the MCP gateway.', + )} + + + + + setBearerToken(value)} + 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..fa14c6d4 --- /dev/null +++ b/src/components/mcp/MCPToolWorkspace.tsx @@ -0,0 +1,450 @@ +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[]; + items?: JsonSchema; +} + +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 => { + switch (annotation) { + case 'Read only': + return t('Read only'); + case 'Destructive': + return t('Destructive'); + case 'Idempotent': + return t('Idempotent'); + case 'Open world': + return t('Open world'); + default: + return 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 ( +