From 50e525cd5f1310015b5dc8e000dfcb027767a1f9 Mon Sep 17 00:00:00 2001 From: Jason Madigan Date: Wed, 2 Sep 2026 17:03:29 +0100 Subject: [PATCH] feat: add MCP Inspector tools and prompts Serve the plugin assets and a narrow same-origin MCP relay from one Go process, resolving gateway targets from Kubernetes resources while keeping OpenShift and MCP credentials separate. Add the MCP Inspector page: gateway selection, MCP session setup, tool discovery and refresh with cursor pagination, schema-driven tool inputs, prompt rendering with a size estimate, manual bearer authentication, and request output aligned with the issue 671 design. Teach the local oinc loop to apply the operator-managed Console proxy contract and document the supported workflow. Signed-off-by: Jason Madigan --- .dockerignore | 7 + Dockerfile | 27 +- Makefile | 5 +- README.md | 24 +- build/suite-router.sh | 2 +- .../templates/configmap.yaml | 32 - .../templates/consoleplugin.yaml | 13 +- .../templates/deployment.yaml | 15 +- cmd/plugin-server/main.go | 479 ++++++++++++ cmd/plugin-server/main_test.go | 185 +++++ console-extensions.json | 28 + ...-16-mcp-inspector-direct-gateway-design.md | 2 +- docs/mcp-inspector.md | 62 ++ docs/overview.md | 1 + e2e/README.md | 7 +- e2e/tests/mcp-inspector.spec.ts | 88 +++ entrypoint.sh | 13 - go.mod | 3 + i18n-scripts/build-i18n.sh | 3 +- install.yaml | 110 +-- .../en/plugin__kuadrant-console-plugin.json | 89 +++ package.json | 3 +- scripts/sync-console-plugin-proxy.sh | 27 + src/components/mcp/MCPCodeBlocks.tsx | 52 ++ src/components/mcp/MCPInspectorOutput.tsx | 83 +++ src/components/mcp/MCPInspectorPage.css | 205 ++++++ src/components/mcp/MCPInspectorPage.test.tsx | 599 +++++++++++++++ src/components/mcp/MCPInspectorPage.tsx | 691 ++++++++++++++++++ src/components/mcp/MCPItemHeader.tsx | 65 ++ src/components/mcp/MCPItemSelect.tsx | 214 ++++++ src/components/mcp/MCPPromptOutput.tsx | 86 +++ src/components/mcp/MCPPromptWorkspace.tsx | 196 +++++ src/components/mcp/MCPToolWorkspace.tsx | 453 ++++++++++++ src/utils/mcp/client.test.ts | 208 ++++++ src/utils/mcp/client.ts | 382 ++++++++++ src/utils/mcp/humanize.ts | 5 + src/utils/mcp/prompts.test.ts | 45 ++ src/utils/mcp/prompts.ts | 17 + src/utils/mcp/serverNames.test.ts | 34 + src/utils/mcp/serverNames.ts | 22 + src/utils/mcp/tokens.ts | 6 + start-local.sh | 18 +- 42 files changed, 4453 insertions(+), 153 deletions(-) create mode 100644 .dockerignore delete mode 100644 charts/openshift-console-plugin/templates/configmap.yaml create mode 100644 cmd/plugin-server/main.go create mode 100644 cmd/plugin-server/main_test.go create mode 100644 docs/mcp-inspector.md create mode 100644 e2e/tests/mcp-inspector.spec.ts delete mode 100755 entrypoint.sh create mode 100644 go.mod create mode 100755 scripts/sync-console-plugin-proxy.sh create mode 100644 src/components/mcp/MCPCodeBlocks.tsx create mode 100644 src/components/mcp/MCPInspectorOutput.tsx create mode 100644 src/components/mcp/MCPInspectorPage.css create mode 100644 src/components/mcp/MCPInspectorPage.test.tsx create mode 100644 src/components/mcp/MCPInspectorPage.tsx create mode 100644 src/components/mcp/MCPItemHeader.tsx create mode 100644 src/components/mcp/MCPItemSelect.tsx create mode 100644 src/components/mcp/MCPPromptOutput.tsx create mode 100644 src/components/mcp/MCPPromptWorkspace.tsx create mode 100644 src/components/mcp/MCPToolWorkspace.tsx create mode 100644 src/utils/mcp/client.test.ts create mode 100644 src/utils/mcp/client.ts create mode 100644 src/utils/mcp/humanize.ts create mode 100644 src/utils/mcp/prompts.test.ts create mode 100644 src/utils/mcp/prompts.ts create mode 100644 src/utils/mcp/serverNames.test.ts create mode 100644 src/utils/mcp/serverNames.ts create mode 100644 src/utils/mcp/tokens.ts 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 2c3e9f9a..51114aab 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 if echo "$CHANGED" | grep -qE "^src/utils/validation\.ts"; then 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..0a8b457a --- /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", "prompts/list", "prompts/get": + 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..b4eb5f71 --- /dev/null +++ b/cmd/plugin-server/main_test.go @@ -0,0 +1,185 @@ +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) + } +} + +func TestAllowedMCPMethods(t *testing.T) { + for _, method := range []string{"initialize", "notifications/initialized", "tools/list", "tools/call", "prompts/list", "prompts/get"} { + if !allowedMCPMethod(method) { + t.Errorf("%q should be relayed", method) + } + } + for _, method := range []string{"", "resources/list", "prompts/delete", "completion/complete", "notifications/cancelled"} { + if allowedMCPMethod(method) { + t.Errorf("%q should be rejected", method) + } + } +} diff --git a/console-extensions.json b/console-extensions.json index c9c1a887..6efbb5ea 100644 --- a/console-extensions.json +++ b/console-extensions.json @@ -662,6 +662,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": { @@ -681,6 +691,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": { @@ -688,5 +708,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..aca7059e --- /dev/null +++ b/docs/mcp-inspector.md @@ -0,0 +1,62 @@ +# 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`, `tools/call`, `prompts/list`, and `prompts/get`), 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. + +## Backend settings + +Environment variables read by the plugin backend (`cmd/plugin-server`): + +- `MCP_PROXY_REQUEST_TIMEOUT`: upstream request timeout as a Go duration. Default `2m`. +- `MCP_PROXY_DIAL_ADDRESS`: `host:port` dialled for every MCP gateway instead of the derived endpoint host. The derived URL and `Host` header are kept. Development only, for clusters where the public gateway host does not resolve from inside the plugin pod; oinc resolves `*.127-0-0-1.sslip.io` to loopback. +- `MCP_PROXY_ALLOW_INSECURE_AUTH`: `true` forwards a bearer token over a plain HTTP listener. Development only. +- `KUBERNETES_INSECURE_SKIP_TLS_VERIFY`: `true` skips verification of the Kubernetes API certificate. Development only. + +## 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. Find a tool by name. The server shown for a tool is the `MCPServerRegistration` whose `spec.prefix` matches the tool name, which needs list access to registrations across namespaces. 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. +8. Switch to **Prompts** to render a prompt template. Pick a prompt, fill its arguments and use **Generate prompt**. The Output card shows the rendered messages with a copy action and a size estimate; the token count is an estimate at four characters per token, not a model tokenizer. Gateways that do not expose prompts show "This gateway does not expose prompts." + +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..f6ef127c --- /dev/null +++ b/e2e/tests/mcp-inspector.spec.ts @@ -0,0 +1,88 @@ +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'; +const integrationPrompt = process.env.MCP_INSPECTOR_E2E_PROMPT || 'toystore_greet'; +const integrationPromptArgumentLabel = process.env.MCP_INSPECTOR_E2E_PROMPT_ARGUMENT_LABEL || ''; +const integrationPromptArgumentValue = process.env.MCP_INSPECTOR_E2E_PROMPT_ARGUMENT_VALUE || 'Ada'; +const integrationPromptOutput = process.env.MCP_INSPECTOR_E2E_PROMPT_OUTPUT || 'Say hi to'; + +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('option', { name: integrationTool }).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(); + + await page.getByRole('tab', { name: 'Prompts' }).click(); + await page.getByLabel('Search prompts').fill(integrationPrompt); + await page.getByRole('option', { name: integrationPrompt }).click(); + if (integrationPromptArgumentLabel) { + await page.getByLabel(integrationPromptArgumentLabel).fill(integrationPromptArgumentValue); + } + await page.getByRole('button', { name: 'Generate prompt' }).click(); + await expect(page.getByText(integrationPromptOutput)).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(/^Token count: ~\d+$/)).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/i18n-scripts/build-i18n.sh b/i18n-scripts/build-i18n.sh index e206270b..7b634011 100755 --- a/i18n-scripts/build-i18n.sh +++ b/i18n-scripts/build-i18n.sh @@ -2,6 +2,7 @@ set -exuo pipefail -FILE_PATTERN="{!(dist|node_modules)/**/*.{js,jsx,ts,tsx,json},*.{js,jsx,ts,tsx,json}}" +# .claude holds local worktrees with their own node_modules +FILE_PATTERN="{!(dist|node_modules|.claude)/**/*.{js,jsx,ts,tsx,json},*.{js,jsx,ts,tsx,json}}" i18next "${FILE_PATTERN}" [-oc] -c "./i18next-parser.config.js" -o "locales/\$LOCALE/\$NAMESPACE.json" 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 c10956f5..f55f6274 100644 --- a/locales/en/plugin__kuadrant-console-plugin.json +++ b/locales/en/plugin__kuadrant-console-plugin.json @@ -1,4 +1,5 @@ { + "({{characters}} characters, estimated at {{perToken}} per token)": "({{characters}} characters, estimated at {{perToken}} per token)", "{{count}} API keys approved successfully_one": "{{count}} API key approved successfully", "{{count}} API keys approved successfully_other": "{{count}} API keys approved successfully", "{{count}} API keys denied successfully_one": "{{count}} API key denied successfully", @@ -7,6 +8,11 @@ "{{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 one of the listed values": "{{field}} must be one of the listed values", + "{{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 +56,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 +133,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 +144,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", @@ -150,7 +160,10 @@ "Choose an existing HTTPRoute": "Choose an existing HTTPRoute", "Choose or create a Gateway": "Choose or create a Gateway", "Choose or create an HTTPRoute": "Choose or create an HTTPRoute", + "Clear fields": "Clear fields", "Clear filters": "Clear filters", + "Clear prompt selection": "Clear prompt selection", + "Clear tool selection": "Clear tool selection", "Client ID": "Client ID", "Close": "Close", "Close wizard": "Close wizard", @@ -169,6 +182,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", @@ -177,7 +196,9 @@ "Controls catalog visibility (Draft = hidden from consumers)": "Controls catalog visibility (Draft = hidden from consumers)", "Copied": "Copied", "Copy": "Copy", + "Copy prompt name": "Copy prompt name", "Copy to clipboard": "Copy to clipboard", + "Copy tool name": "Copy tool name", "Could not determine policy from URL": "Could not determine policy from URL", "Could not load gateways": "Could not load gateways", "Could not load routes": "Could not load routes", @@ -243,6 +264,7 @@ "Deny API Key": "Deny API Key", "Deprecated": "Deprecated", "Description": "Description", + "Destructive": "Destructive", "Details": "Details", "Disabled": "Disabled", "Display Name": "Display Name", @@ -315,6 +337,7 @@ "Enter target HTTPRoute name": "Enter target HTTPRoute name", "Enter the full path to your API spec file": "Enter the full path to your API spec file", "Enter use case": "Enter use case", + "error": "error", "Error": "Error", "Error approving API keys": "Error approving API keys", "Error Code": "Error Code", @@ -357,6 +380,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", @@ -382,6 +406,7 @@ "Filter type": "Filter type", "filters": "filters", "Filters": "Filters", + "Find by name": "Find by name", "Finish": "Finish", "Form": "Form", "Form View": "Form View", @@ -401,6 +426,8 @@ "GatewayClass": "GatewayClass", "Gateways": "Gateways", "Gateways - Traffic Analysis": "Gateways - Traffic Analysis", + "Generate prompt": "Generate prompt", + "Generating prompts creates text templates only and does not execute commands.": "Generating prompts creates text templates only and does not execute commands.", "Geo value to apply to geo endpoints": "Geo value to apply to geo endpoints", "Geography Label (e.g. 'EU')": "Geography Label (e.g. 'EU')", "Get started": "Get started", @@ -419,6 +446,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", @@ -439,10 +467,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", @@ -450,6 +481,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.", @@ -488,6 +521,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...", @@ -500,6 +534,7 @@ "Loading user information...": "Loading user information...", "Loading YAML editor...": "Loading YAML editor...", "Loading...": "Loading...", + "Logs": "Logs", "Manual": "Manual", "match": "match", "matches": "matches", @@ -516,6 +551,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", @@ -523,6 +560,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", @@ -561,6 +601,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", @@ -570,15 +613,20 @@ "No policies are attached to the target HTTPRoute.": "No policies are attached to the target HTTPRoute.", "No policies attached to this HTTPRoute": "No policies attached to this HTTPRoute", "No policies found": "No policies found", + "No prompts": "No prompts", + "No prompts found": "No prompts 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", @@ -589,8 +637,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", @@ -622,6 +673,11 @@ "Press Enter to create \"{{tag}}\"": "Press Enter to create \"{{tag}}\"", "Private host": "Private host", "Programmed": "Programmed", + "Prompt": "Prompt", + "Prompt output": "Prompt output", + "Prompt selector": "Prompt selector", + "Prompts": "Prompts", + "Prompts unavailable": "Prompts unavailable", "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", @@ -636,6 +692,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", @@ -643,6 +700,8 @@ "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 prompts": "Refresh prompts", + "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", @@ -653,8 +712,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", @@ -664,6 +725,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", @@ -693,6 +755,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", @@ -702,7 +766,9 @@ "Search API Product": "Search API Product", "Search by {{filterValue}}...": "Search by {{filterValue}}...", "Search or create tag": "Search or create tag", + "Search prompts": "Search prompts", "Search Tier": "Search Tier", + "Search tools": "Search tools", "Secret model not available": "Secret model not available", "Secret name": "Secret name", "Section": "Section", @@ -718,10 +784,15 @@ "Select a namespace to create a resource": "Select a namespace to create a resource", "Select a namespace to create an API Product": "Select a namespace to create an API Product", "Select a namespace to request an API Key": "Select a namespace to request an API Key", + "Select a prompt": "Select a prompt", + "Select a prompt to generate it.": "Select a prompt to generate it.", "Select a Protocol": "Select a Protocol", "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", @@ -729,11 +800,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", @@ -760,8 +833,12 @@ "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 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", @@ -792,12 +869,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.", @@ -825,8 +904,11 @@ "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 field is required": "This field is required", + "This gateway does not expose prompts.": "This gateway does not expose prompts.", "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 prompt takes no arguments.": "This prompt takes no arguments.", "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.", "Tier": "Tier", @@ -839,10 +921,14 @@ "TLS termination mode. Terminate decrypts TLS at the gateway, Passthrough forwards encrypted traffic.": "TLS termination mode. Terminate decrypts TLS at the gateway, Passthrough forwards encrypted traffic.", "TLSPolicy": "TLSPolicy", "to confirm deletion.": "to confirm deletion.", + "Token count": "Token count", "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", + "Tool selector": "Tool selector", + "Tools": "Tools", "Topology View": "Topology View", "Total": "Total", "Total Gateways": "Total Gateways", @@ -873,6 +959,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", @@ -881,6 +968,8 @@ "View in overview": "View in overview", "View K8s Resource": "View K8s Resource", "Waiting for controller to reconcile...": "Waiting for controller to reconcile...", + "warning": "warning", + "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 ab65dda1..ac4c11eb 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,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/MCPCodeBlocks.tsx b/src/components/mcp/MCPCodeBlocks.tsx new file mode 100644 index 00000000..9ac518e0 --- /dev/null +++ b/src/components/mcp/MCPCodeBlocks.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ClipboardCopyButton, + CodeBlock, + CodeBlockAction, + CodeBlockCode, +} from '@patternfly/react-core'; + +interface MCPCodeBlockProps { + id: string; + text: string; +} + +export const MCPCodeBlock: React.FC = ({ id, text }) => { + const { t } = useTranslation('plugin__kuadrant-console-plugin'); + const [copied, setCopied] = React.useState(false); + + return ( + + { + void navigator.clipboard?.writeText(text); + setCopied(true); + }} + exitDelay={copied ? 1500 : 600} + onTooltipHidden={() => setCopied(false)} + > + {copied ? t('Copied') : t('Copy to clipboard')} + + + } + > + {text} + + ); +}; + +interface MCPJsonBlockProps { + id: string; + value: unknown; +} + +export const MCPJsonBlock: React.FC = ({ id, value }) => ( + +); diff --git a/src/components/mcp/MCPInspectorOutput.tsx b/src/components/mcp/MCPInspectorOutput.tsx new file mode 100644 index 00000000..bdcca14a --- /dev/null +++ b/src/components/mcp/MCPInspectorOutput.tsx @@ -0,0 +1,83 @@ +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'; +import { MCPJsonBlock } from './MCPCodeBlocks'; + +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')} + + {t('JSON-RPC response')} + +
+ ) : ( + {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..8e726f3f --- /dev/null +++ b/src/components/mcp/MCPInspectorPage.css @@ -0,0 +1,205 @@ +.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__stat { + display: inline-flex; + align-items: center; + gap: var(--pf-t--global--spacer--xs, 0.25rem); +} + +.kuadrant-mcp-inspector-page__session-status + > .kuadrant-mcp-inspector-page__stat + + .kuadrant-mcp-inspector-page__stat { + padding-left: var(--pf-t--global--spacer--md, 1rem); + border-left: 1px solid var(--kuadrant-mcp-inspector-border); +} + +.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__section-alert { + 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__selected-item { + margin-top: var(--pf-t--global--spacer--md, 1rem); + padding-top: var(--pf-t--global--spacer--md, 1rem); + border-top: 1px solid var(--kuadrant-mcp-inspector-border); +} + +.kuadrant-mcp-inspector-page__item-header { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: var(--pf-t--global--spacer--md, 1rem); +} + +.kuadrant-mcp-inspector-page__item-server { + color: var(--pf-t--global--text--color--subtle, #6a6e73); + white-space: nowrap; +} + +.kuadrant-mcp-inspector-page__item-description { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--pf-t--global--spacer--sm, 0.5rem); + margin-top: var(--pf-t--global--spacer--sm, 0.5rem); +} + +.kuadrant-mcp-inspector-page__annotations, +.kuadrant-mcp-inspector-page__argument-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__token-estimate { + display: block; + margin-top: var(--pf-t--global--spacer--sm, 0.5rem); +} + +.kuadrant-mcp-inspector-page__console .pf-v6-c-code-block { + margin: var(--pf-t--global--spacer--sm, 0.5rem) 0 var(--pf-t--global--spacer--md, 1rem); +} + +.kuadrant-mcp-inspector-page__console .pf-v6-c-code-block__pre { + max-height: 20rem; + overflow: auto; +} + +.kuadrant-mcp-inspector-page__output pre:not(.pf-v6-c-code-block__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..6b04bda5 --- /dev/null +++ b/src/components/mcp/MCPInspectorPage.test.tsx @@ -0,0 +1,599 @@ +import * as React from 'react'; +import '@testing-library/jest-dom'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MCPGatewayExtension, MCPServerRegistration } from './types'; +import { MCPClient, MCPRpcError, MCPUnauthorizedError } from '../../utils/mcp/client'; + +let mockExtensions: MCPGatewayExtension[] = []; +let mockExtensionsLoaded = true; +let mockRegistrations: MCPServerRegistration[] = []; +let mockToolsCallWithDetails = jest.fn(); +let mockToolsList = jest.fn(); +let mockPromptsList = jest.fn(); +let mockPromptsGetWithDetails = 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: (resource: { groupVersionKind: { kind: string } }) => + resource.groupVersionKind.kind === 'MCPServerRegistration' + ? [mockRegistrations, true, null] + : [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' }], + }, +}; + +const toystoreRegistration: MCPServerRegistration = { + apiVersion: 'mcp.kuadrant.io/v1', + kind: 'MCPServerRegistration', + metadata: { name: 'toystore-mcp-server', namespace: 'toystore' }, + spec: { targetRef: { name: 'mcp-test-server-route' }, prefix: 'toystore_' }, +}; + +const connectToGateway = async () => { + fireEvent.change(screen.getByLabelText('Select an MCP gateway extension'), { + target: { value: 'test-ns/mcp-gateway' }, + }); + await waitFor(() => expect(screen.getByText('Connected')).toBeInTheDocument()); +}; + +const pickTool = (search: string, option: RegExp) => { + fireEvent.change(screen.getByLabelText('Search tools'), { target: { value: search } }); + fireEvent.click(screen.getByRole('option', { name: option })); +}; + +describe('MCPInspectorPage', () => { + beforeEach(() => { + mockExtensions = []; + mockExtensionsLoaded = true; + mockRegistrations = []; + 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'], + }, + }, + ], + }); + mockPromptsList = jest.fn().mockResolvedValue({ + prompts: [ + { + name: 'toystore_greet', + description: 'greet a person by name', + arguments: [{ name: 'name', description: 'who to greet', required: true }], + }, + ], + }); + mockPromptsGetWithDetails = jest.fn().mockResolvedValue({ + result: { messages: [{ role: 'user', content: { type: 'text', text: 'Say hi to Ada' } }] }, + request: { + jsonrpc: '2.0', + id: 4, + method: 'prompts/get', + params: { name: 'toystore_greet', arguments: { name: 'Ada' } }, + }, + response: { + jsonrpc: '2.0', + id: 4, + result: { messages: [{ role: 'user', content: { type: 'text', text: 'Say hi to Ada' } }] }, + }, + status: 200, + statusText: 'OK', + durationMs: 7, + }); + (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, + promptsList: mockPromptsList, + promptsGetWithDetails: mockPromptsGetWithDetails, + })); + }); + + 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.getByText('No authentication')).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('Search tools'), { target: { value: 'toy' } }); + expect(screen.getByRole('option', { name: /toystore_greet/ })).toBeInTheDocument(); + 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()); + + pickTool('greet', /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()); + pickTool('toystore_greet', /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(mockToolsList).toHaveBeenCalledTimes(2)); + fireEvent.change(screen.getByLabelText('Search tools'), { target: { value: 'toystore' } }); + expect(await screen.findByRole('option', { name: /toystore_calculate/ })).toBeInTheDocument(); + expect(MCPClient).toHaveBeenCalledTimes(1); + expect(screen.getByText('1 request')).toBeInTheDocument(); + }); + + it('names the MCP server from its registration prefix and copies the tool name', async () => { + mockRegistrations = [toystoreRegistration]; + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + mockExtensions = [readyExtension]; + render(); + await connectToGateway(); + + fireEvent.change(screen.getByLabelText('Search tools'), { target: { value: 'greet' } }); + const option = screen.getByRole('option', { name: /toystore_greet/ }); + expect(option).toHaveTextContent('toystore-mcp-server'); + fireEvent.click(option); + + expect(screen.getByRole('heading', { name: 'toystore_greet' })).toBeInTheDocument(); + expect(screen.getByText('toystore-mcp-server')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Copy tool name' })); + expect(writeText).toHaveBeenCalledWith('toystore_greet'); + }); + + it('generates a prompt from its arguments and estimates its size', async () => { + mockExtensions = [readyExtension]; + render(); + await connectToGateway(); + + fireEvent.click(screen.getByRole('tab', { name: 'Prompts' })); + expect( + screen.getByText( + 'Generating prompts creates text templates only and does not execute commands.', + ), + ).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('Search prompts'), { target: { value: 'greet' } }); + fireEvent.click(screen.getByRole('option', { name: /toystore_greet/ })); + expect(screen.getByRole('heading', { name: 'toystore_greet' })).toBeInTheDocument(); + expect(screen.getByText('greet a person by name')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Generate prompt' })); + expect(screen.getByText('Name is required')).toBeInTheDocument(); + expect(mockPromptsGetWithDetails).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Ada' } }); + fireEvent.click(screen.getByRole('button', { name: 'Generate prompt' })); + await waitFor(() => + expect(mockPromptsGetWithDetails).toHaveBeenCalledWith('toystore_greet', { name: 'Ada' }), + ); + expect(screen.getByText('Say hi to Ada')).toBeInTheDocument(); + expect(screen.getByText('Token count: ~4')).toBeInTheDocument(); + expect(screen.getByText('200 OK')).toBeInTheDocument(); + expect(screen.getByText('1 request')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Clear fields' })); + expect(screen.getByLabelText('Name')).toHaveValue(''); + }); + + it('keeps a tools-only session when the gateway does not expose prompts', async () => { + mockPromptsList.mockRejectedValue( + new MCPRpcError({ code: -32601, message: 'Method not found' }), + ); + mockExtensions = [readyExtension]; + render(); + await connectToGateway(); + + fireEvent.change(screen.getByLabelText('Search tools'), { target: { value: 'toy' } }); + expect(screen.getByRole('option', { name: /toystore_greet/ })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('tab', { name: 'Prompts' })); + expect(screen.getByText('This gateway does not expose prompts.')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Generate prompt' })).not.toBeInTheDocument(); + }); + + it('keeps the gateway selected last when an earlier connect finishes late', async () => { + const otherExtension: MCPGatewayExtension = { + ...readyExtension, + metadata: { name: 'other-gateway', namespace: 'test-ns' }, + }; + let finishFirst: (sessionId: string) => void = () => undefined; + const firstInitialize = new Promise((resolve) => { + finishFirst = resolve; + }); + const firstToolsList = jest.fn().mockResolvedValue({ tools: [{ name: 'first_tool' }] }); + const firstPromptsList = jest.fn().mockResolvedValue({ prompts: [] }); + (MCPClient as jest.Mock).mockImplementationOnce(() => ({ + initialize: jest.fn().mockReturnValue(firstInitialize), + sendInitialized: jest.fn().mockResolvedValue(undefined), + toolsList: firstToolsList, + promptsList: firstPromptsList, + })); + mockExtensions = [readyExtension, otherExtension]; + render(); + + fireEvent.change(screen.getByLabelText('Select an MCP gateway extension'), { + target: { value: 'test-ns/mcp-gateway' }, + }); + fireEvent.change(screen.getByLabelText('Select an MCP gateway extension'), { + target: { value: 'test-ns/other-gateway' }, + }); + await waitFor(() => expect(screen.getByText('Connected')).toBeInTheDocument()); + + finishFirst('session-stale'); + await waitFor(() => expect(firstPromptsList).toHaveBeenCalled()); + + fireEvent.change(screen.getByLabelText('Search tools'), { target: { value: 'first' } }); + expect(screen.queryByRole('option', { name: /first_tool/ })).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('Search tools'), { target: { value: 'greet' } }); + expect(screen.getByRole('option', { name: /toystore_greet/ })).toBeInTheDocument(); + expect(MCPClient).toHaveBeenLastCalledWith( + '/api/proxy/plugin/kuadrant-console-plugin/backend/api/mcp/v1/mcpgatewayextensions/test-ns/other-gateway', + { token: undefined }, + ); + }); + + it('sends an enum selection with the type the schema declares', async () => { + mockToolsList.mockResolvedValue({ + tools: [ + { + name: 'toystore_toggle', + inputSchema: { + type: 'object', + properties: { enabled: { type: 'boolean', enum: [true, false] } }, + required: ['enabled'], + }, + }, + ], + }); + mockExtensions = [readyExtension]; + render(); + await connectToGateway(); + pickTool('toggle', /toystore_toggle/); + + fireEvent.change(screen.getByLabelText('Enabled'), { target: { value: 'true' } }); + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + + await waitFor(() => + expect(mockToolsCallWithDetails).toHaveBeenCalledWith('toystore_toggle', { enabled: true }), + ); + }); + + it('rejects null for an object input unless the schema is nullable', async () => { + mockToolsList.mockResolvedValue({ + tools: [ + { + name: 'toystore_configure', + inputSchema: { + type: 'object', + properties: { + config: { type: 'object' }, + override: { type: ['object', 'null'] }, + }, + }, + }, + ], + }); + mockExtensions = [readyExtension]; + render(); + await connectToGateway(); + pickTool('configure', /toystore_configure/); + + fireEvent.change(screen.getByLabelText('Config'), { target: { value: 'null' } }); + fireEvent.change(screen.getByLabelText('Override'), { target: { value: 'null' } }); + fireEvent.click(screen.getByRole('button', { name: 'Validate only' })); + expect(screen.getByText('Config must be valid JSON')).toBeInTheDocument(); + expect(screen.queryByText('Override must be valid JSON')).not.toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Config'), { target: { value: '{}' } }); + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + await waitFor(() => + expect(mockToolsCallWithDetails).toHaveBeenCalledWith('toystore_configure', { + config: {}, + override: null, + }), + ); + }); + + it('ignores a tool result that arrives after switching gateway', async () => { + const otherExtension: MCPGatewayExtension = { + ...readyExtension, + metadata: { name: 'other-gateway', namespace: 'test-ns' }, + }; + let finishCall: (exchange: unknown) => void = () => undefined; + mockToolsCallWithDetails.mockReturnValueOnce( + new Promise((resolve) => { + finishCall = resolve; + }), + ); + mockExtensions = [readyExtension, otherExtension]; + render(); + await connectToGateway(); + pickTool('greet', /toystore_greet/); + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Ada' } }); + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + await waitFor(() => expect(mockToolsCallWithDetails).toHaveBeenCalled()); + + fireEvent.change(screen.getByLabelText('Select an MCP gateway extension'), { + target: { value: 'test-ns/other-gateway' }, + }); + await waitFor(() => expect(screen.getByText('Connected')).toBeInTheDocument()); + await act(async () => { + finishCall({ + result: { content: [{ type: 'text', text: 'Stale result' }] }, + request: { jsonrpc: '2.0', id: 3, method: 'tools/call', params: {} }, + response: { jsonrpc: '2.0', id: 3, result: {} }, + status: 200, + statusText: 'OK', + durationMs: 1, + }); + }); + + expect(screen.queryByText('Stale result')).not.toBeInTheDocument(); + expect(screen.queryByText('Success')).not.toBeInTheDocument(); + expect(screen.getByText('0 requests')).toBeInTheDocument(); + }); + + it('treats a null default as no value', async () => { + mockToolsList.mockResolvedValue({ + tools: [ + { + name: 'toystore_note', + inputSchema: { + type: 'object', + properties: { note: { type: ['string', 'null'], default: null } }, + }, + }, + ], + }); + mockExtensions = [readyExtension]; + render(); + await connectToGateway(); + pickTool('note', /toystore_note/); + + expect(screen.getByLabelText('Note')).toHaveValue(''); + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + + await waitFor(() => expect(mockToolsCallWithDetails).toHaveBeenCalledWith('toystore_note', {})); + }); + + it('counts tool errors as warnings and transport failures as errors', async () => { + mockToolsCallWithDetails + .mockResolvedValueOnce({ + result: { isError: true, content: [{ type: 'text', text: 'Tool failed' }] }, + request: { jsonrpc: '2.0', id: 3, method: 'tools/call', params: {} }, + response: { jsonrpc: '2.0', id: 3, result: { isError: true } }, + status: 200, + statusText: 'OK', + durationMs: 5, + }) + .mockRejectedValueOnce(new Error('gateway unreachable')); + mockExtensions = [readyExtension]; + render(); + await connectToGateway(); + pickTool('greet', /toystore_greet/); + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Ada' } }); + + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + await waitFor(() => expect(screen.getByText('1 warning')).toBeInTheDocument()); + expect(screen.getByText('1 request')).toBeInTheDocument(); + expect(screen.getByText('0 errors')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Run tool' })); + await waitFor(() => expect(screen.getByText('1 error')).toBeInTheDocument()); + expect(screen.getByText('2 requests')).toBeInTheDocument(); + expect(screen.getByText('1 warning')).toBeInTheDocument(); + expect(screen.getByText('gateway unreachable')).toBeInTheDocument(); + }); +}); diff --git a/src/components/mcp/MCPInspectorPage.tsx b/src/components/mcp/MCPInspectorPage.tsx new file mode 100644 index 00000000..eb7270b7 --- /dev/null +++ b/src/components/mcp/MCPInspectorPage.tsx @@ -0,0 +1,691 @@ +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, + Icon, +} from '@patternfly/react-core'; +import { + CircleIcon, + ExchangeAltIcon, + ExclamationCircleIcon, + ExclamationTriangleIcon, + LockOpenIcon, + ShieldAltIcon, +} from '@patternfly/react-icons'; +import { + NamespaceBar, + useActiveNamespace, + useK8sWatchResource, +} from '@openshift-console/dynamic-plugin-sdk'; +import { RESOURCES } from '../../utils/resources'; +import { MCPGatewayExtension, MCPServerRegistration } from './types'; +import { + MCPClient, + MCPPrompt, + MCPRpcError, + MCPSessionExpiredError, + MCPUnauthorizedError, + MCPTool, + PromptsGetResult, + ToolsCallResult, + MCPCallExchange, +} from '../../utils/mcp/client'; +import MCPToolWorkspace from './MCPToolWorkspace'; +import MCPInspectorOutput from './MCPInspectorOutput'; +import MCPPromptWorkspace from './MCPPromptWorkspace'; +import MCPPromptOutput from './MCPPromptOutput'; +import { toolServerNameResolver } from '../../utils/mcp/serverNames'; +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}`; + +type PromptSupport = 'ok' | 'unsupported' | 'failed'; + +// a gateway without prompt support answers prompts/list with a json-rpc +// method-not-found. neither that nor a transport failure should block a +// tools-only session, so the outcome is reported instead of thrown. +const listPrompts = async ( + client: MCPClient, +): Promise<{ prompts: MCPPrompt[]; support: PromptSupport; error: string }> => { + try { + const listed = await client.promptsList(); + return { prompts: listed.prompts ?? [], support: 'ok', error: '' }; + } catch (err) { + if (err instanceof MCPRpcError && err.code === -32601) { + return { prompts: [], support: 'unsupported', error: '' }; + } + return { + prompts: [], + support: 'failed', + error: err instanceof Error ? err.message : String(err), + }; + } +}; + +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, + }); + // cluster-wide on purpose: a gateway aggregates registrations from any + // namespace, and the registration prefix is what names the server for a tool. + const [registrations] = useK8sWatchResource({ + groupVersionKind: RESOURCES.MCPServerRegistration.gvk, + isList: true, + }); + const serverNameFor = React.useMemo(() => toolServerNameResolver(registrations), [registrations]); + + 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 [prompts, setPrompts] = React.useState([]); + const [promptSupport, setPromptSupport] = React.useState('ok'); + const [promptError, setPromptError] = React.useState(''); + + const [callExchange, setCallExchange] = React.useState | null>( + null, + ); + const [promptExchange, setPromptExchange] = + React.useState | null>(null); + const [stats, setStats] = React.useState({ requests: 0, warnings: 0, errors: 0 }); + + const [connecting, setConnecting] = React.useState(false); + const [calling, setCalling] = React.useState(false); + const [generating, setGenerating] = React.useState(false); + const [refreshingTools, setRefreshingTools] = React.useState(false); + const [refreshingPrompts, setRefreshingPrompts] = 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 ?? ''; + + // a gateway change or a newer connect invalidates any attempt still in + // flight, so a slow gateway cannot land its session under the one now selected + const connectAttempt = React.useRef(0); + + const openSession = async (inspectorEndpoint: string, bearer?: string) => { + const client = new MCPClient(inspectorEndpoint, { token: bearer || undefined }); + const id = await client.initialize(); + await client.sendInitialized(); + const listed = await client.toolsList(); + const promptState = await listPrompts(client); + return { client, id, tools: listed.tools ?? [], promptState }; + }; + + const handleConnect = async (inspectorEndpoint: string, bearer?: string) => { + if (!inspectorEndpoint) { + return; + } + const attempt = ++connectAttempt.current; + const isCurrent = () => attempt === connectAttempt.current; + setError(''); + setSessionExpired(false); + setTools([]); + setPrompts([]); + setCallExchange(null); + setPromptExchange(null); + setConnecting(true); + try { + const session = await openSession(inspectorEndpoint, bearer); + if (!isCurrent()) { + return; + } + clientRef.current = session.client; + setSessionId(session.id); + setConnected(true); + setTools(session.tools); + setPrompts(session.promptState.prompts); + setPromptSupport(session.promptState.support); + setPromptError(session.promptState.error); + setAuthMode(bearer ? 'bearer' : 'none'); + setAuthChallenge(null); + setAuthRejected(false); + } catch (err) { + if (!isCurrent()) { + return; + } + 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 { + if (isCurrent()) { + setConnecting(false); + } + } + }; + + const handleGatewayChange = (_event: React.FormEvent, value: string) => { + setSelectedKey(value); + connectAttempt.current += 1; + setConnecting(false); + setCalling(false); + setGenerating(false); + setRefreshingTools(false); + setRefreshingPrompts(false); + clientRef.current = null; + setSessionId(null); + setConnected(false); + setAuthMode('none'); + setActiveSection(0); + setTools([]); + setPrompts([]); + setPromptSupport('ok'); + setPromptError(''); + setCallExchange(null); + setPromptExchange(null); + setStats({ requests: 0, warnings: 0, errors: 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()); + }; + + // session expiry is recoverable, anything else the client throws is an error + const recordFailure = (err: unknown) => { + const expired = err instanceof MCPSessionExpiredError; + setStats((current) => ({ + requests: current.requests + 1, + warnings: current.warnings + (expired ? 1 : 0), + errors: current.errors + (expired ? 0 : 1), + })); + }; + + // an operation belongs to the session it started on. once the gateway + // changes, its result, failure and busy flag must not reach the new session. + const runOnSession = async ( + setBusy: (busy: boolean) => void, + operation: (client: MCPClient) => Promise, + onResult: (result: T) => void, + onStart?: () => void, + ) => { + const client = clientRef.current; + if (!client) { + return; + } + const attempt = connectAttempt.current; + const isCurrent = () => attempt === connectAttempt.current; + setError(''); + setSessionExpired(false); + setBusy(true); + onStart?.(); + try { + const result = await operation(client); + if (isCurrent()) { + onResult(result); + } + } catch (err) { + if (!isCurrent()) { + return; + } + recordFailure(err); + if (err instanceof MCPSessionExpiredError) { + setSessionExpired(true); + } else { + setError(err instanceof Error ? err.message : String(err)); + } + } finally { + if (isCurrent()) { + setBusy(false); + } + } + }; + + const handleCall = ( + toolName: string, + args: Record, + metadata?: Record, + ) => + runOnSession( + setCalling, + (client) => + metadata + ? client.toolsCallWithDetails(toolName, args, metadata) + : client.toolsCallWithDetails(toolName, args), + (exchange) => { + setCallExchange(exchange); + // isError is the tool reporting a failed run, not a transport failure + setStats((current) => ({ + ...current, + requests: current.requests + 1, + warnings: current.warnings + (exchange.result.isError ? 1 : 0), + })); + }, + () => setCallExchange(null), + ); + + const handleGenerate = (promptName: string, args: Record) => + runOnSession( + setGenerating, + (client) => client.promptsGetWithDetails(promptName, args), + (exchange) => { + setPromptExchange(exchange); + setStats((current) => ({ ...current, requests: current.requests + 1 })); + }, + () => setPromptExchange(null), + ); + + const handleRefreshPrompts = () => + runOnSession( + setRefreshingPrompts, + (client) => client.promptsList(), + (listed) => { + setPrompts(listed.prompts ?? []); + setPromptSupport('ok'); + setPromptError(''); + setStats((current) => ({ ...current, requests: current.requests + 1 })); + }, + ); + + const handleRefreshTools = () => + runOnSession( + setRefreshingTools, + (client) => client.toolsList(), + (listed) => { + setTools(listed.tools ?? []); + setStats((current) => ({ ...current, requests: current.requests + 1 })); + }, + ); + + 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 === 'bearer' ? ( + + {authMode === 'none' ? t('No authentication') : t('Authenticated')} + + )} +
+ {sessionId && ( + + {t('Session ID')}: {sessionId} + + )} +
+ + + {t('Status')} + +
+ + + + {stats.requests} {stats.requests === 1 ? t('request') : t('requests')} + + + + + {stats.warnings} {stats.warnings === 1 ? t('warning') : t('warnings')} + + + + + {stats.errors} {stats.errors === 1 ? t('error') : 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 && ( + + + + + {promptSupport === 'failed' && ( + + + {promptError} + + + )} + + {promptSupport === 'unsupported' ? ( + + + {t('This gateway does not expose prompts.')} + + + ) : ( + + + + + + + + + )} + + + )} + {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/MCPItemHeader.tsx b/src/components/mcp/MCPItemHeader.tsx new file mode 100644 index 00000000..b4dc9ef2 --- /dev/null +++ b/src/components/mcp/MCPItemHeader.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ClipboardCopyButton, Content, Icon, Title } from '@patternfly/react-core'; +import { ServerIcon } from '@patternfly/react-icons'; + +interface MCPItemHeaderProps { + icon: React.ReactNode; + name: string; + serverName?: string; + description?: string; + copyId: string; + copyLabel: string; +} + +// name, owning server and description of the selected tool or prompt +const MCPItemHeader: React.FC = ({ + icon, + name, + serverName, + description, + copyId, + copyLabel, +}) => { + const { t } = useTranslation('plugin__kuadrant-console-plugin'); + const [copied, setCopied] = React.useState(false); + + React.useEffect(() => setCopied(false), [name]); + + return ( + <> +
+ + <Icon isInline>{icon}</Icon> {name} + + {serverName && ( + + + {' '} + {serverName} + + )} +
+
+ {description || t('No description')} + { + void navigator.clipboard?.writeText(name); + setCopied(true); + }} + exitDelay={copied ? 1500 : 600} + onTooltipHidden={() => setCopied(false)} + > + {copied ? t('Copied') : copyLabel} + +
+ + ); +}; + +export default MCPItemHeader; diff --git a/src/components/mcp/MCPItemSelect.tsx b/src/components/mcp/MCPItemSelect.tsx new file mode 100644 index 00000000..9b2200b3 --- /dev/null +++ b/src/components/mcp/MCPItemSelect.tsx @@ -0,0 +1,214 @@ +import * as React from 'react'; +import { + Button, + MenuToggle, + Select, + SelectList, + SelectOption, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, +} from '@patternfly/react-core'; +import { TimesIcon } from '@patternfly/react-icons'; + +export interface MCPSelectableItem { + name: string; + description?: string; +} + +interface MCPItemSelectProps { + items: MCPSelectableItem[]; + selectedName: string; + onSelect: (name: string) => void; + onClear: () => void; + serverNameFor?: (name: string) => string | undefined; + idPrefix: string; + searchLabel: string; + toggleLabel: string; + clearLabel: string; + placeholder: string; + emptyText: string; +} + +// typeahead over tools or prompts, each option subtitled with its server +const MCPItemSelect: React.FC = ({ + items, + selectedName, + onSelect, + onClear, + serverNameFor, + idPrefix, + searchLabel, + toggleLabel, + clearLabel, + placeholder, + emptyText, +}) => { + const [isOpen, setIsOpen] = React.useState(false); + // inputValue is what the toggle shows, filterValue only what was typed + const [inputValue, setInputValue] = React.useState(selectedName); + const [filterValue, setFilterValue] = React.useState(''); + const [focusedIndex, setFocusedIndex] = React.useState(null); + const inputRef = React.useRef(null); + + React.useEffect(() => { + setInputValue(selectedName); + setFilterValue(''); + setFocusedIndex(null); + }, [selectedName]); + + const term = filterValue.trim().toLowerCase(); + const filtered = items.filter( + (item) => + !term || + item.name.toLowerCase().includes(term) || + (item.description ?? '').toLowerCase().includes(term), + ); + + const choose = (item: MCPSelectableItem) => { + setInputValue(item.name); + setFilterValue(''); + setFocusedIndex(null); + setIsOpen(false); + onSelect(item.name); + }; + + const clear = () => { + setInputValue(''); + setFilterValue(''); + setFocusedIndex(null); + onClear(); + inputRef.current?.focus(); + }; + + const onToggleClick = () => { + setIsOpen((open) => !open); + inputRef.current?.focus(); + }; + + const onInputChange = (_event: React.FormEvent, value: string) => { + setInputValue(value); + setFilterValue(value); + setFocusedIndex(null); + setIsOpen(true); + }; + + const onInputKeyDown = (event: React.KeyboardEvent) => { + switch (event.key) { + case 'Enter': { + event.preventDefault(); + const target = filtered[focusedIndex ?? 0]; + if (isOpen && target) { + choose(target); + } else { + setIsOpen(true); + } + break; + } + case 'ArrowDown': + case 'ArrowUp': { + event.preventDefault(); + setIsOpen(true); + if (filtered.length === 0) { + break; + } + const step = event.key === 'ArrowDown' ? 1 : -1; + setFocusedIndex((current) => { + const start = current ?? (step === 1 ? -1 : filtered.length); + return (start + step + filtered.length) % filtered.length; + }); + break; + } + case 'Escape': + setIsOpen(false); + break; + default: + break; + } + }; + + const toggle = (toggleRef: React.Ref) => ( + + + + {inputValue && ( + + + +
+ + + ) : ( + {t('Select a prompt to generate it.')} + )} + + + ); +}; + +export default MCPPromptWorkspace; diff --git a/src/components/mcp/MCPToolWorkspace.tsx b/src/components/mcp/MCPToolWorkspace.tsx new file mode 100644 index 00000000..f8090b0d --- /dev/null +++ b/src/components/mcp/MCPToolWorkspace.tsx @@ -0,0 +1,453 @@ +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, + TextArea, + TextInput, + Title, + Tooltip, +} from '@patternfly/react-core'; +import { SyncAltIcon, WrenchIcon } from '@patternfly/react-icons'; +import { MCPTool } from '../../utils/mcp/client'; +import { humanize } from '../../utils/mcp/humanize'; +import MCPItemHeader from './MCPItemHeader'; +import MCPItemSelect from './MCPItemSelect'; + +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; + serverNameFor?: (toolName: string) => string | undefined; +} + +type FieldValues = Record; +interface MetadataRow { + id: number; + key: string; + value: string; +} + +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]) => { + // a null default means no value, the same as no default + if (schema.default !== undefined && schema.default !== null) { + 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, + serverNameFor, +}) => { + const { t } = useTranslation('plugin__kuadrant-console-plugin'); + 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 selectTool = (name: string) => { + const tool = tools.find((candidate) => candidate.name === name); + if (!tool) { + return; + } + 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 (propertySchema.enum) { + // the select carries the option as a string; send the schema's own value + const option = propertySchema.enum.find((candidate) => String(candidate) === String(value)); + if (option === undefined) { + errors[name] = t('{{field}} must be one of the listed values', { field: label }); + return; + } + args[name] = option; + 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)); + // typeof null is 'object'; only a nullable schema may send null + const nullable = + Array.isArray(propertySchema.type) && propertySchema.type.includes('null'); + const valid = + parsed === null + ? nullable + : type === 'array' + ? Array.isArray(parsed) + : typeof parsed === 'object' && !Array.isArray(parsed); + if (!valid) { + 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 ( +