diff --git a/Makefile b/Makefile index 7ff4a81dc..f921c7853 100644 --- a/Makefile +++ b/Makefile @@ -60,6 +60,10 @@ build-all-platforms: clean tidy format lint ## Build the project for all platfor test: ## Run the tests go test -count=1 -v ./... +.PHONY: test-browser +test-browser: ## Run browser-based tests (requires Chromium, auto-downloaded by Rod on first run) + go test -count=1 -v -tags browser ./pkg/mcpapps/... + .PHONY: test-update-snapshots test-update-snapshots: ## Update test snapshots for toolset tests UPDATE_TOOLSETS_JSON=1 go test -count=1 -v ./pkg/mcp @@ -167,5 +171,24 @@ local-env-teardown: ## Tear down the local Kind cluster print-git-tag-version: ## Print the GIT_TAG_VERSION @echo $(GIT_TAG_VERSION) +##@ MCP Apps + +HTM_VERSION ?= 3.1.1 +CHART_JS_VERSION ?= 4.4.8 +PRISM_VERSION ?= 1.30.0 +MCP_APPS_VENDOR_DIR ?= pkg/mcpapps/vendor + +.PHONY: vendor-js +vendor-js: ## Download vendored JavaScript dependencies for MCP Apps + @mkdir -p $(MCP_APPS_VENDOR_DIR) + curl -sL "https://cdn.jsdelivr.net/npm/htm@$(HTM_VERSION)/preact/standalone.umd.js" \ + -o $(MCP_APPS_VENDOR_DIR)/htm-preact-standalone.umd.js + curl -sL "https://cdn.jsdelivr.net/npm/chart.js@$(CHART_JS_VERSION)/dist/chart.umd.min.js" \ + -o $(MCP_APPS_VENDOR_DIR)/chart.umd.min.js + curl -sL "https://cdn.jsdelivr.net/npm/prismjs@$(PRISM_VERSION)/components/prism-core.min.js" \ + -o $(MCP_APPS_VENDOR_DIR)/prism-core.min.js + curl -sL "https://cdn.jsdelivr.net/npm/prismjs@$(PRISM_VERSION)/components/prism-yaml.min.js" \ + -o $(MCP_APPS_VENDOR_DIR)/prism-yaml.min.js + # Include build configuration files -include build/*.mk diff --git a/docs/README.md b/docs/README.md index 4994aef29..d09c6b4a8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,6 +31,7 @@ Living documentation for implemented and planned features: | Spec | Description | Status | |------|-------------|--------| +| **[MCP Apps](specs/mcp-apps.wip.md)** | Interactive HTML-based UIs for tool results via MCP Apps extension | In Progress | | **[Validation](specs/validation.md)** | Pre-execution validation layer (resource existence, schema, RBAC) | Implemented | ## Advanced Topics diff --git a/docs/specs/mcp-apps.wip.md b/docs/specs/mcp-apps.wip.md new file mode 100644 index 000000000..ffa1e8205 --- /dev/null +++ b/docs/specs/mcp-apps.wip.md @@ -0,0 +1,1770 @@ +# MCP Apps + +MCP Apps enables tools to return interactive HTML-based UIs rendered in sandboxed iframes by MCP hosts. This document describes the architecture, design decisions, and configuration for the MCP Apps integration. + +**Issue**: [containers/kubernetes-mcp-server#753](https://github.com/containers/kubernetes-mcp-server/issues/753) + +## Table of Contents + +- [1. What Are MCP Apps](#1-what-are-mcp-apps) +- [2. Go-SDK Support](#2-go-sdk-support) +- [3. Styling and CSS Isolation](#3-styling-and-css-isolation) +- [4. Multiple Widgets and Tool Call Accumulation](#4-multiple-widgets-and-tool-call-accumulation) +- [5. Frontend Stack Decision](#5-frontend-stack-decision) +- [6. MCP postMessage Protocol](#6-mcp-postmessage-protocol) +- [7. Configuration: Opt-In Feature](#7-configuration-opt-in-feature) +- [8. Architecture](#8-architecture) +- [9. Output Layer](#9-output-layer) +- [10. Per-Tool Resource URIs and Dual-Flow Viewer](#10-per-tool-resource-uris-and-dual-flow-viewer) +- [11. structuredContent Object Wrapping](#11-structuredcontent-object-wrapping) +- [12. Model Context Visibility: `content` vs `structuredContent`](#12-model-context-visibility-content-vs-structuredcontent) +- [13. Known Compatibility Notes](#13-known-compatibility-notes) +- [14. YAML Syntax Highlighting](#14-yaml-syntax-highlighting) +- [Appendix A: Research and Development Journal](#appendix-a-research-and-development-journal) + +--- + +## 1. What Are MCP Apps + +MCP Apps is the first official extension to the Model Context Protocol (spec version 2026-01-26). +It enables MCP tools to return interactive HTML-based UIs rendered in sandboxed iframes by the +MCP host (VS Code, Claude Desktop, ChatGPT, etc.). + +The flow: + +1. **Tool definition** declares `_meta.ui.resourceUri` pointing to a `ui://` resource +2. **Model calls the tool** — host executes it on the server +3. **Host fetches the UI resource** — gets HTML via MCP `resources/read` +4. **Host renders in sandboxed iframe** — passes tool data via `postMessage` +5. **Bidirectional communication** — app can call server tools via JSON-RPC over `postMessage` + +Extension identifier: `io.modelcontextprotocol/ui` + +Supported hosts: VS Code (Insiders and Stable), Claude (web/desktop), ChatGPT, Goose, Postman, MCPJam. + +Key specification sources: +- [MCP Apps Specification (2026-01-26)](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx) +- [MCP Apps Documentation](https://modelcontextprotocol.io/docs/extensions/apps) +- [ext-apps SDK Repository](https://github.com/modelcontextprotocol/ext-apps) + +## 2. Go-SDK Support + +The go-sdk (v1.4.0) added an `Extensions` field to both `ServerCapabilities` and +`ClientCapabilities` per SEP-2133. + +API surface: + +```go +// On ServerCapabilities +Extensions map[string]any `json:"extensions,omitempty"` +func (c *ServerCapabilities) AddExtension(name string, settings map[string]any) + +// On ClientCapabilities +Extensions map[string]any `json:"extensions,omitempty"` +func (c *ClientCapabilities) AddExtension(name string, settings map[string]any) +``` + +Both `AddExtension` methods normalize `nil` settings to `map[string]any{}` (spec requires +an object, not null). + +### Usage for MCP Apps capability negotiation + +**Server declares support** (in `initialize` response): +```json +{ + "capabilities": { + "extensions": { + "io.modelcontextprotocol/ui": {} + } + } +} +``` + +**Client declares support** (in `initialize` request): +```json +{ + "capabilities": { + "extensions": { + "io.modelcontextprotocol/ui": { + "mimeTypes": ["text/html;profile=mcp-app"] + } + } + } +} +``` + +In Go (`pkg/mcp/mcp.go`): +```go +caps.AddExtension("io.modelcontextprotocol/ui", nil) +``` + +## 3. Styling and CSS Isolation + +### Spec guarantees + +**CSS isolation is not a concern.** The MCP Apps specification mandates that all app views +run inside **sandboxed iframes**. This provides complete CSS isolation by design: + +- Views have no access to the host's DOM, cookies, or storage +- All communication passes through `postMessage` +- App CSS cannot leak into the host; host CSS cannot reach the app +- Each app runs in its own isolated context + +This means Shadow DOM, `@scope`, `all: initial`, and other CSS isolation techniques are +**unnecessary** for MCP Apps. + +### Theming + +The viewer applies host theme and CSS variables per the MCP Apps spec. + +#### How theming works (per spec) + +The host does **not** inject CSS variables directly into the iframe's DOM. Instead: + +1. Host sends `hostContext.theme` (`"dark"` or `"light"`) and optionally + `hostContext.styles.variables` (a `Record` of CSS custom properties) + via the `ui/initialize` response. +2. The **view is responsible** for applying these — calling + `document.documentElement.style.colorScheme = theme` and iterating + `styles.variables` with `style.setProperty(key, value)`. +3. Theme updates are communicated via `ui/notifications/host-context-changed`. + +This is implemented in `viewer/app.js` via `applyTheme()` and `applyStyleVariables()`, +called from both the `initialize()` handler and the `host-context-changed` handler. + +#### CSS variable names (spec-defined) + +The spec defines ~65 CSS custom properties across these categories: + +- **Background**: `--color-background-primary`, `-secondary`, `-tertiary`, `-inverse`, + `-ghost`, `-info`, `-danger`, `-success`, `-warning`, `-disabled` +- **Text**: `--color-text-primary`, `-secondary`, `-tertiary`, etc. +- **Border**: `--color-border-primary`, `-secondary`, etc. +- **Ring/Focus**: `--color-ring-primary`, `-secondary`, etc. +- **Typography**: `--font-sans`, `--font-mono`, `--font-weight-*`, `--font-text-*-size`, + `--font-heading-*-size`, line heights +- **Border radius**: `--border-radius-xs` through `-full` +- **Shadows**: `--shadow-hairline`, `-sm`, `-md`, `-lg` + +Spacing is intentionally excluded (layouts break when spacing varies). + +#### Fallback strategy: `light-dark()` CSS function + +Since hosts may provide inconsistent variable subsets (or none at all — MCP Inspector +only provides `theme`, not `styles.variables`), all CSS uses `var()` with `light-dark()` +fallbacks: + +```css +body { + background: var(--color-background-primary, light-dark(#ffffff, #1a1a1a)); + color: var(--color-text-primary, light-dark(#1a1a2e, #e5e7eb)); +} +``` + +The `light-dark()` CSS function selects the correct value based on the `color-scheme` +property set by `applyTheme()`. This means: +- If the host provides CSS variables → those are used +- If not → `light-dark()` picks the right fallback based on the theme + +For Chart.js (which uses canvas, not CSS), colors are read from +`document.documentElement.getAttribute('data-theme')` at chart creation time. + +#### Real-time theme switching + +Real-time theme changes on already-rendered content require the host to send +`host-context-changed` notifications — this is the host's responsibility per the spec. +CSS-only elements (tables, text, borders) update automatically via `light-dark()` when +`color-scheme` changes. Canvas-based elements (Chart.js) are created with the correct +theme at render time but don't live-update without a re-render. + +**MCP Inspector limitation**: The Inspector computes `hostContext` once with +`useMemo([], [])` and does not send `host-context-changed` on theme toggle. +This is a known limitation of the Inspector, not something the viewer can work around +from inside a sandboxed iframe. + +The `prefersBorder` metadata allows apps to request border/background treatment from the host. + +### Known issues + +- Different hosts provide inconsistent CSS variable subsets + ([ext-apps #382](https://github.com/modelcontextprotocol/ext-apps/issues/382)) +- Platform-specific styling pressure — Claude, ChatGPT, VS Code each have their own guidelines + ([ext-apps #467](https://github.com/modelcontextprotocol/ext-apps/issues/467)) +- `autoResize: true` causes layout bugs including infinite growth loops + ([ext-apps #502](https://github.com/modelcontextprotocol/ext-apps/issues/502)) +- MCP Inspector does not send `host-context-changed` on theme toggle (computed once) + +### Recommendation + +- Always provide CSS variable fallback values using `light-dark()` for theme awareness +- Treat all host-provided variables as optional +- Use `autoResize: false` + explicit height + manual `sendSizeChanged()` for predictable sizing +- Set `prefersBorder` explicitly + +### Default Content Security Policy + +When `ui.csp` is omitted, the default CSP is: +``` +default-src 'none'; +script-src 'self' 'unsafe-inline'; +style-src 'self' 'unsafe-inline'; +img-src 'self' data:; +media-src 'self' data:; +connect-src 'none'; +``` + +This means: +- Inline ` + + +``` + +--- + +## Appendix A: Research and Development Journal + +> **Note**: This appendix contains research context, implementation status tracking, development +> history, and other material from the original research document. It is preserved here for +> reference during PR review and can be removed once the spec and implementation are finalized. + +### A.1 PoC Reference (openshift-mcp-server PR #143) + +[PR #143](https://github.com/openshift/openshift-mcp-server/pull/143) is a rough PoC +implementing a Pods Top dashboard with MCP Apps. + +#### What it does + +- Adds a `mcp-app/` directory with a Vite + TypeScript frontend build +- Uses `vite-plugin-singlefile` to produce a single HTML with all JS/CSS inlined +- Bundles the HTML into Go binary via `//go:embed` +- Uses `@modelcontextprotocol/ext-apps` SDK (v1.0.0) for host communication +- Implements column sorting, refresh via `app.callServerTool()`, dark/light theme support + +#### Architecture (3 layers) + +1. **Go embed package** (`mcp-app/mcpapp.go`): + - `//go:embed dist/pods-top-app.html` + - `Resources()` returns a registry of all app resources + - `ToolMeta(resourceURI)` helper generates `_meta` map + - MIME type: `text/html;profile=mcp-app` + - Resource URI: `ui://kubernetes-mcp-server/pods-top.html` + +2. **MCP resource registration** (`pkg/mcp/mcp.go`): + - Iterates `mcpapp.Resources()` and registers each as an MCP resource + - Enables `Resources` capability (was `nil` before) + +3. **Tool linkage** (`pkg/toolsets/core/pods.go`): + - `pods_top` tool gets `Meta: mcpapp.ToolMeta(mcpapp.PodsTopResourceURI)` + - Tool handler returns both text and `structuredContent` + +#### Concerns noted + +- **Build step required**: `npm install && vite build` as dependency of `make build` +- **Committed build artifacts**: `dist/pods-top-app.html` in git (should be .gitignored) +- **innerHTML without sanitization**: XSS risk if pod names contain HTML-special characters +- **SDK size**: `@modelcontextprotocol/ext-apps` is 314 KB (mostly Zod validation) +- **Tight Go/TypeScript coupling**: Matching struct/interface with no shared schema + +#### Patterns reused in our implementation + +- `//go:embed` for bundling HTML into Go binary +- `ToolMeta()` helper for consistent `_meta` structure +- Dual content return (text + structured) for backward compatibility +- Theme support via CSS custom property fallbacks + +#### Patterns intentionally avoided + +- **No build step**: We inline JS directly, no Vite/npm required +- **No ext-apps SDK**: Custom ~80-line protocol implementation instead of 314 KB SDK +- **Per-tool resource URIs** (not per-tool HTML files): Single generic viewer template, + but each tool gets its own `ui://` resource URI with the tool name injected into the HTML + via `window.__mcpToolName`. This enables the dual-flow viewer (see [Section 10](#10-per-tool-resource-uris-and-dual-flow-viewer)). + +### A.2 Implementation Status + +#### Completed — Phase 1: Infrastructure + Configuration + +| Feature | Location | Status | +|---------|----------|--------| +| `AppsEnabled bool` config field | `pkg/config/config.go` | Done | +| `IsAppsEnabled()` getter | `pkg/config/config.go` | Done | +| `--apps` CLI flag | `pkg/kubernetes-mcp-server/cmd/root.go` | Done | +| Extension registration | `pkg/mcp/mcp.go` | Done — `caps.AddExtension("io.modelcontextprotocol/ui", nil)` | +| Resources capability | `pkg/mcp/mcp.go` | Done — `caps.Resources = &mcp.ResourceCapabilities{}` | +| Vendor JS files | `pkg/mcpapps/vendor/` | Done — htm-preact + Chart.js committed | +| Makefile `vendor-js` target | `Makefile` | Done — downloads htm@3.1.1 + chart.js@4.4.8 | + +#### Completed — Phase 2: Viewer + Pod Tools + +| Feature | Location | Status | +|---------|----------|--------| +| MCP postMessage protocol | `viewer/protocol.js` (~80 lines) | Done — exposes `window.mcpProtocol` | +| Preact components | `viewer/components.js` (~150 lines) | Done — SortableTable, TableView, MetricsTable (data-driven), GenericView | +| App root + routing | `viewer/app.js` (~80 lines) | Done — dual-flow: spec-compliant `tool-result` + fallback `tool-input` → `serverTools` | +| CSS with theme fallbacks | `viewer/style.css` (~60 lines) | Done — includes `.chart-container` | +| HTML shell + assembly | `viewer/viewer.html` (~38 lines) | Done — `INJECT_*` placeholders including `INJECT_TOOL_NAME` | +| Chart.js bar chart | `viewer/components.js` — `MetricsTable` | Done — data-driven: reads columns, chart config, items from self-describing structured content | +| `pods_top` structured content | `pkg/toolsets/core/pods.go` | Done — `extractPodsTopStructured()` returns self-describing `map[string]any` with columns, chart, items | + +#### Completed — Phase 3: Output Layer + Per-Tool URIs + All List Tools + +| Feature | Location | Status | +|---------|----------|--------| +| Per-tool resource URIs | `pkg/mcpapps/mcpapps.go` | Done — `ViewerHTMLForTool(toolName)`, `ToolMetaForTool(toolName)`, `ToolResourceURI(toolName)` | +| Per-tool resource registration | `pkg/mcp/mcp.go` — `registerMCPAppResources(toolNames)` | Done — called from `reloadToolsets()`, one `ui://` resource per enabled tool | +| Centralized Meta injection | `pkg/mcp/tool_mutator.go` — `WithAppsMeta()` | Done — injects per-tool `_meta.ui.resourceUri` | +| `PrintObjStructured` on Output interface | `pkg/output/output.go` | Done — generic structured extraction for both YAML and Table output | +| `PrintResult` struct | `pkg/output/output.go` | Done — holds `Text` + `Structured` | +| `tableToStructured` helper | `pkg/output/output.go` | Done — converts `metav1.Table` → `[]map[string]any` using column definitions | +| `NewToolCallResultFull` constructor | `pkg/api/toolsets.go` | Done — `(text, structured, err)` for explicit text + structured | +| `structuredContent` object wrapping | `pkg/mcp/mcp.go` — `ensureStructuredObject()` | Done — wraps slice/array in `{"items": [...]}` for MCP spec compliance | +| Viewer unwraps `items` envelope | `viewer/app.js` | Done — extracts `structured.items` for plain wrappers only (preserves self-describing objects) | +| `namespaces_list` structured content | `pkg/toolsets/core/namespaces.go` | Done — uses `PrintObjStructured` + `NewToolCallResultFull` | +| `projects_list` structured content | `pkg/toolsets/core/namespaces.go` | Done — same pattern | +| `pods_list` / `pods_list_in_namespace` structured content | `pkg/toolsets/core/pods.go` | Done — uses `PrintObjStructured`, removed `extractPodListStructured` | +| `resources_list` structured content | `pkg/toolsets/core/resources.go` | Done — uses `PrintObjStructured` + `NewToolCallResultFull` | +| Hooks violation fix | `viewer/components.js` — `TableView` | Done — moved `useMemo` before guard clause | +| Typed nil interface fix | `pkg/output/output.go` — `PrintObjStructured` | Done — explicit nil check before assigning to `any` | +| Tests | `pkg/mcpapps/mcpapps_test.go` | Done — 19 tests covering per-tool HTML, embeds, placeholders, constants | +| Tests | `pkg/mcp/text_result_test.go` | Done — tests for array wrapping and map pass-through | +| Tests | `pkg/output/output_test.go` | Done — tests for `PrintObjStructured` (YAML and Table), `tableToStructured` | +| Tests | `pkg/api/toolsets_test.go` | Done — tests for `NewToolCallResultFull` | + +#### Completed — Phase 3.5: Self-Describing Metrics Structured Content + +| Feature | Location | Status | +|---------|----------|--------| +| Self-describing `pods_top` structured content | `pkg/toolsets/core/pods.go` | Done — `extractPodsTopStructured()` returns `map[string]any` with `columns`, `chart`, `items` | +| Self-describing `nodes_top` structured content | `pkg/toolsets/core/nodes.go` | Done — `extractNodesTopStructured()` with CPU/memory values and percentage utilization | +| Data-driven `MetricsTable` component | `viewer/components.js` | Done — reads columns, chart config, items from self-describing data; generic `parseUnit()` helper | +| Shape-based routing (replaces tool-name routing) | `viewer/app.js` | Done — detects `structured.chart && structured.columns && Array.isArray(structured.items)` | +| Selective `items` unwrapping | `viewer/app.js` | Done — only unwraps when `Object.keys(raw).length === 1` (plain wrapper) | +| Tests | `pkg/mcp/pods_top_test.go` | Done — structured content assertions for columns, chart, items | +| Tests | `pkg/mcp/nodes_top_test.go` | Done — structured content assertions for self-describing shape | + +#### Completed — Phase 4: YAML Syntax Highlighting + +| Feature | Location | Status | +|---------|----------|--------| +| Vendor Prism.js core + YAML grammar | `pkg/mcpapps/vendor/prism-core.min.js`, `prism-yaml.min.js` | Done — `make vendor-js` downloads prismjs@1.30.0 | +| Prism.js theme CSS (`light-dark()`) | `viewer/style.css` | Done — `.token.atrule`, `.token.string`, etc. with `light-dark()` fallbacks | +| `YamlView` component | `viewer/components.js` | Done — `Prism.highlight()` + `dangerouslySetInnerHTML` with `useMemo` | +| YAML detection + routing | `viewer/app.js` | Done — `looksLikeYaml()` heuristic + route to `YamlView` | +| HTML assembly (`viewer.html`) | `viewer/viewer.html` | Done — `INJECT_VENDOR_PRISM_CORE/YAML` placeholders, `window.Prism = { manual: true }` | +| Embed + assembly (`mcpapps.go`) | `pkg/mcpapps/mcpapps.go` | Done — Prism placeholder replacements in `buildBaseHTML()` | +| Tests for Prism embed/placeholder | `pkg/mcpapps/mcpapps_test.go` | Done — tests for Prism placeholders and vendor file embedding | + +#### Pending — Phase 5: Polish + +| Feature | Status | +|---------|--------| +| Structured content for remaining non-list tool handlers | Not started | +| Refresh via `callServerTool()` | Not started | +| Compact height management via `sendSizeChanged()` | Not started | +| Filtering and pagination for table views | Not started | +| Edge case handling (empty results, large datasets) | Not started | +| User-facing documentation (see below) | Not started | + +**Documentation requirements** (must be done before merging to main): + +- `docs/configuration.md`: Add `apps_enabled` to the configuration reference. +- `docs/configuration.md` or dedicated `docs/apps.md`: Must clearly explain that + `structuredContent` is **not** added to model context — only `content` is (see + [Section 12](#12-model-context-visibility-content-vs-structuredcontent)). Users need + to understand that enabling MCP Apps does not change what the LLM sees; it only adds + visual rendering in supporting hosts. If their MCP host misbehaves (feeds + `structuredContent` to the model), the `--apps` / `apps_enabled = false` flag is the + escape hatch. + +#### Pre-existing infrastructure (unchanged) + +| Feature | Location | +|---------|----------| +| `Tool.Meta map[string]any` | `pkg/api/toolsets.go` | +| Meta → go-sdk conversion | `pkg/mcp/gosdk.go` — `Meta: mcp.Meta(tool.Tool.Meta)` | +| `ToolCallResult.StructuredContent` | `pkg/api/toolsets.go` | +| `NewToolCallResultStructured()` | `pkg/api/toolsets.go` | +| `NewStructuredResult()` | `pkg/mcp/mcp.go` | +| Go-SDK v1.4.0 with Extensions | `go.mod` | + +### A.3 Commit History + +1. `989144b` — feat: add MCP Apps scaffolding with research, config, and vendor libs (Phase 1) +2. `4093abb` — feat: add MCP Apps viewer with Preact rendering and Chart.js (Phase 2) +3. `c3b2f48` — feat: add per-tool resource URIs, self-describing metrics, and dual-flow viewer (Phase 3) +4. `ce472e8` — fix: register MCP App resources before tools for VS Code compatibility +5. `518c3bf` — test: ensure no error when writing test harness and viewer HTML +6. `eadbcf8` — chore(docs): move MCP Apps research to specs as feature specification +7. `01d45c3` — docs(specs): add YAML syntax highlighting spec and tool rendering matrix +8. `e3e37da` — feat: add YAML syntax highlighting with Prism.js for MCP Apps viewer (Phase 4) + +### A.4 End-to-End Verification + +The feature has been verified working end-to-end with MCP Inspector: with `--apps` enabled, +tools expose per-tool `ui://kubernetes-mcp-server/tool/{toolName}` resources. The viewer +renders in a sandboxed iframe and supports both the spec-compliant flow (receiving `tool-result` +from the host) and the fallback flow (calling the tool via `serverTools` when only `tool-input` +is received). Array-typed `structuredContent` is correctly wrapped in `{"items": [...]}` to +satisfy the MCP spec requirement that `structuredContent` must be a JSON object. + +### A.5 Output Layer Problem Analysis + +#### Data flow before refinement (pods_list example) + +``` +K8s API → runtime.Unstructured + ├── PrintObj(ret) → text string (YAML or Table) ← for LLM/Content + └── extractPodListStructured(ret) → []map[string]any ← for MCP Apps/StructuredContent + (manually walks same object again) +``` + +The handler then manually assembled the result: +```go +text, textErr := params.ListOutput.PrintObj(ret) +if structured := extractPodListStructured(ret); structured != nil { + return &api.ToolCallResult{Content: text, StructuredContent: structured, Error: textErr}, nil +} +return api.NewToolCallResult(text, textErr), nil +``` + +#### Three distinct problems solved + +1. **`PrintObj` returns text only** — `PrintObj(obj runtime.Unstructured) (string, error)`. + The Kubernetes object is already being processed (YAML marshal or Table extraction), + but the structured data is thrown away and must be re-extracted separately. + +2. **No constructor for "text + structured"** — `NewToolCallResult` is text-only, + `NewToolCallResultStructured` is structured-only (auto-serializes structured to JSON + for Content, losing human-readable formatting). The pods handlers work around this + by constructing `ToolCallResult` literally. + +3. **Per-tool extraction functions don't scale** — `extractPodListStructured()` and + `extractPodsTopStructured()` manually walk the Kubernetes object to pluck specific + fields. Every tool would need its own extraction function. + +#### What the refinement eliminated + +- **All `extract*Structured()` functions** for standard Kubernetes list operations — + `extractPodListStructured()` removed. The `Output` implementation handles extraction generically. + +- **Manual `ToolCallResult` literal construction** — replaced by `NewToolCallResultFull`. + +- **Per-tool structured content logic in toolset files** — the output layer handles it. + +#### Tool patterns before and after + +| Pattern | Tools | How they produce results | +|---------|-------|------------------------| +| Text-only via `PrintObj` | namespaces_list, projects_list, most list tools | `NewToolCallResult(params.ListOutput.PrintObj(ret))` | +| Text-only via custom formatting | events_list, helm_list | `NewToolCallResult(customFormat, nil)` | +| Text-only hardcoded | pods_get, pods_delete, pods_log | `NewToolCallResult("message", nil)` | +| Text + structured (manual) | pods_list, pods_list_in_namespace | `PrintObj` + `extractPodListStructured` + literal struct | +| Custom text + self-describing structured | pods_top, nodes_top | `TopCmdPrinter` + `extractPodsTopStructured`/`extractNodesTopStructured` → `map[string]any` with columns, chart, items | + +### A.6 Files Modified Per Feature + +#### Output layer refinement + +| File | Changes | +|------|---------| +| `pkg/output/output.go` | Added `PrintResult` struct, `PrintObjStructured` to interface, implemented for yaml and table, added `tableToStructured` helper | +| `pkg/output/output_test.go` | Tests for `PrintObjStructured` (YAML and Table), `tableToStructured`, typed nil guard | +| `pkg/api/toolsets.go` | Added `NewToolCallResultFull(text, structured, err)` constructor | +| `pkg/api/toolsets_test.go` | Tests for `NewToolCallResultFull` | +| `pkg/toolsets/core/pods.go` | Simplified handlers to use `PrintObjStructured` + `NewToolCallResultFull`, removed `extractPodListStructured` | +| `pkg/toolsets/core/namespaces.go` | Switched from `PrintObj` → `PrintObjStructured` + `NewToolCallResultFull` | +| `pkg/toolsets/core/resources.go` | Switched from `PrintObj` → `PrintObjStructured` + `NewToolCallResultFull` | +| `pkg/mcpapps/viewer/components.js` | Generalized `TableView` to render any column set, fixed hooks violation | + +#### Per-tool resource URIs + +| File | Changes | +|------|---------| +| `pkg/mcpapps/mcpapps.go` | Replaced `ViewerHTML()` → `ViewerHTMLForTool(toolName)`, `ToolMeta()` → `ToolMetaForTool(toolName)`, added `ToolResourceURI(toolName)`, `sync.Map` for per-tool caching | +| `pkg/mcpapps/mcpapps_test.go` | Rewritten for per-tool API: 19 tests | +| `pkg/mcpapps/viewer/viewer.html` | Added `INJECT_TOOL_NAME` placeholder | +| `pkg/mcpapps/viewer/app.js` | Dual-flow: `tool-result` (spec) + `tool-input` → `serverTools` (fallback) | +| `pkg/mcpapps/viewer/protocol.js` | Added responses to unhandled requests, improved logging | +| `pkg/mcp/mcp.go` | Changed `registerMCPAppResources()` to per-tool, moved to `reloadToolsets()` | +| `pkg/mcp/tool_mutator.go` | Updated `WithAppsMeta()` to use `ToolMetaForTool(tool.Tool.Name)` | + +#### structuredContent object wrapping + +| File | Changes | +|------|---------| +| `pkg/mcp/mcp.go` | Added `ensureStructuredObject()`, called from `NewStructuredResult()` | +| `pkg/mcp/text_result_test.go` | Tests for array wrapping and map pass-through | +| `pkg/mcpapps/viewer/app.js` | Unwraps `{"items": [...]}` envelope | + +#### VS Code compatibility fix + +| File | Changes | +|------|---------| +| `pkg/mcp/mcp.go` | Moved `registerMCPAppResources()` before `reloadItems` for tools | +| `pkg/mcpapps/mcpapps.go` | Added legacy flat `"ui/resourceUri"` key to `ToolMetaForTool()` | +| `pkg/mcpapps/mcpapps_test.go` | Tests for both nested and legacy `_meta` keys | + +### A.7 Frontend Browser Testing + +Browser tests are **foundational infrastructure** — the test harness and first tests should be +set up alongside the initial viewer implementation, not deferred to a later phase. Each feature +(table rendering, metrics charts, theming, sorting) gets its browser tests written as part of +the same phase that implements the feature. + +#### Why Browser Tests Are Needed + +The MCP Apps viewer is a Preact-based SPA served as a self-contained HTML blob (~220KB with +vendored JS). It communicates with its parent frame via **postMessage JSON-RPC**, not HTTP. +Go unit tests (`mcpapps_test.go`) can verify HTML assembly and resource URIs, but they cannot +test rendering, component routing, user interactions, or the postMessage protocol flow. Only +a real browser can validate that the viewer works end-to-end. + +#### Library Choice: Rod (go-rod/rod) + +| | | +|---|---| +| **Repository** | [github.com/go-rod/rod](https://github.com/go-rod/rod) | +| **Stars** | ~6,700+ | +| **Actively maintained** | Yes (Feb 2026) | +| **Pure Go** | Yes (no Node.js, no Java) | +| **Browser** | Auto-downloads pinned Chromium version | + +**Why Rod:** + +1. **Auto-downloads the exact Chromium version** — no CI setup, no version mismatch. + Each Rod release pins a specific Chromium + DevTools protocol version. +2. **Auto-wait on all interactions** — ideal for Preact async rendering + Chart.js canvas. +3. **Thread-safe** — works with Go test parallelism. +4. **`Must` prefix convention** maps naturally to test code (panics on error). +5. **First-class iframe support** — critical since MCP Apps run inside sandboxed iframes. +6. **Zero transitive Go dependencies** — only adds `github.com/go-rod/rod` to `go.mod`. +7. **CI-friendly** — headless by default, works on `ubuntu-latest` GitHub Actions out of the box. + +```go +browser := rod.New().MustConnect() +page := browser.MustPage("file:///tmp/test-harness.html").MustWaitStable() +iframe := page.MustElement("iframe").MustFrame() +rows := iframe.MustElements("table tbody tr") +``` + +**Safe alternative: [chromedp](https://github.com/chromedp/chromedp)** (~11,500 stars) — +most popular Go browser library, battle-tested, pure Go with zero dependencies. Trade-offs: +more verbose DSL-like API, no auto-wait (manual `chromedp.WaitVisible` required), requires +system Chrome or Docker image (no auto-download). + +#### Test Architecture: Wrapper Page with iframe + +The viewer communicates via `window.parent.postMessage()` JSON-RPC. In production, the parent +is the MCP host (Claude Desktop, VS Code, etc.). In tests, we **simulate the MCP host** using +a wrapper HTML page that embeds the viewer in an ` + + +``` + +#### Test Flow + +``` +1. Go test calls mcpapps.ViewerHTMLForTool("pods_list") +2. Go test generates harness HTML with viewer embedded in + +` + +type BrowserSuite struct { + suite.Suite + browser *rod.Browser + server *httptest.Server +} + +func (s *BrowserSuite) SetupSuite() { + mux := http.NewServeMux() + mux.HandleFunc("/harness", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, err := fmt.Fprint(w, testHarnessHTML) + s.Require().NoError(err) + }) + mux.HandleFunc("/viewer.html", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, err := fmt.Fprint(w, ViewerHTMLForTool("test_tool")) + s.Require().NoError(err) + }) + s.server = httptest.NewServer(mux) + s.browser = rod.New().MustConnect() +} + +func (s *BrowserSuite) TearDownSuite() { + if s.browser != nil { + s.browser.MustClose() + } + if s.server != nil { + s.server.Close() + } +} + +// openViewer opens a new browser page with the test harness and returns +// the harness page and the viewer iframe. The viewer has completed the +// MCP protocol handshake and is showing "Waiting for tool result..." when +// this method returns. +func (s *BrowserSuite) openViewer() (*rod.Page, *rod.Page) { + page := s.browser.MustPage(s.server.URL + "/harness") + frame := page.MustElement("#viewer").MustFrame() + frame.MustElementR(".status", "Waiting for tool result") + return page, frame +} + +// containsFold asserts that actual contains expected, case-insensitively. +// Header text is CSS text-transform: uppercase, so MustText() returns +// uppercased text while the source labels may be mixed-case. +func (s *BrowserSuite) containsFold(actual, expected string) { + s.T().Helper() + s.True(strings.Contains(strings.ToLower(actual), strings.ToLower(expected)), + "%q does not contain %q (case-insensitive)", actual, expected) +} + +// screenshotsDir is the output directory for visual captures. +// Located under _output/ which is already gitignored. +const screenshotsDir = "_output/screenshots" + +// screenshot captures the viewer iframe as a PNG and saves it to _output/screenshots/.png. +func (s *BrowserSuite) screenshot(page *rod.Page, name string) { + s.T().Helper() + dir := filepath.Join(findRepoRoot(s.T()), screenshotsDir) + s.Require().NoError(os.MkdirAll(dir, 0o755)) + data, err := page.Screenshot(true, &proto.PageCaptureScreenshot{ + Format: proto.PageCaptureScreenshotFormatPng, + }) + s.Require().NoError(err) + path := filepath.Join(dir, name+".png") + s.Require().NoError(os.WriteFile(path, data, 0o644)) + s.T().Logf("screenshot saved: %s", path) +} + +// findRepoRoot walks up from the test binary's working directory to find go.mod. +func findRepoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("could not find repo root (go.mod)") + } + dir = parent + } +} + +func (s *BrowserSuite) TestProtocolHandshake() { + s.Run("viewer shows ready state after initialization", func() { + page, frame := s.openViewer() + defer page.MustClose() + el := frame.MustElement(".status") + s.Equal("Waiting for tool result...", el.MustText()) + }) +} + +func (s *BrowserSuite) TestTableView() { + s.Run("renders table with correct number of rows", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + structuredContent: { + items: [ + {name: "pod-1", namespace: "default"}, + {name: "pod-2", namespace: "kube-system"}, + {name: "pod-3", namespace: "default"} + ] + } + })`) + frame.MustElement("table") + rows := frame.MustElements("table tbody tr") + s.Len(rows, 3) + }) + + s.Run("renders correct column headers from data keys", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + structuredContent: { + items: [{name: "pod-1", namespace: "default"}] + } + })`) + headers := frame.MustElements("table thead th") + s.Len(headers, 2) + s.containsFold(headers[0].MustText(), "name") + s.containsFold(headers[1].MustText(), "namespace") + }) + + s.Run("renders correct cell values", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + structuredContent: { + items: [{name: "my-pod", namespace: "production"}] + } + })`) + frame.MustElement("table") + cells := frame.MustElements("table tbody td") + s.Equal("my-pod", cells[0].MustText()) + s.Equal("production", cells[1].MustText()) + }) + + s.Run("displays item count for multiple items", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + structuredContent: { + items: [{name: "a"}, {name: "b"}, {name: "c"}] + } + })`) + count := frame.MustElement(".count") + s.Equal("3 items", count.MustText()) + }) + + s.Run("displays singular item count", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + structuredContent: { + items: [{name: "only-one"}] + } + })`) + count := frame.MustElement(".count") + s.Equal("1 item", count.MustText()) + }) +} + +func (s *BrowserSuite) TestTableSorting() { + s.Run("sorts ascending on first header click", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + structuredContent: { + items: [ + {name: "charlie"}, + {name: "alpha"}, + {name: "bravo"} + ] + } + })`) + frame.MustElement("table thead th").MustClick() + frame.MustElementR(".sort-arrow", "\u25B2") + rows := frame.MustElements("table tbody tr") + s.Equal("alpha", rows[0].MustElement("td").MustText()) + s.Equal("bravo", rows[1].MustElement("td").MustText()) + s.Equal("charlie", rows[2].MustElement("td").MustText()) + }) + + s.Run("sorts descending on second header click", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + structuredContent: { + items: [ + {name: "charlie"}, + {name: "alpha"}, + {name: "bravo"} + ] + } + })`) + th := frame.MustElement("table thead th") + th.MustClick() + frame.MustElementR(".sort-arrow", "\u25B2") + th.MustClick() + frame.MustElementR(".sort-arrow", "\u25BC") + rows := frame.MustElements("table tbody tr") + s.Equal("charlie", rows[0].MustElement("td").MustText()) + s.Equal("bravo", rows[1].MustElement("td").MustText()) + s.Equal("alpha", rows[2].MustElement("td").MustText()) + }) +} + +func (s *BrowserSuite) TestMetricsTable() { + s.Run("renders chart canvas and data table", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + structuredContent: { + columns: [ + {key: "name", label: "Pod"}, + {key: "cpu", label: "CPU"}, + {key: "memory", label: "Memory"} + ], + chart: { + labelKey: "name", + datasets: [ + {key: "cpu", label: "CPU (millicores)", unit: "cpu", axis: "left"}, + {key: "memory", label: "Memory (MiB)", unit: "memory", axis: "right"} + ] + }, + items: [ + {name: "pod-1", cpu: "100m", memory: "128Mi"}, + {name: "pod-2", cpu: "250m", memory: "256Mi"} + ] + } + })`) + frame.MustElement("canvas") + rows := frame.MustElements("table tbody tr") + s.Len(rows, 2) + }) + + s.Run("renders metrics table with custom column headers", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + structuredContent: { + columns: [ + {key: "name", label: "Pod Name"}, + {key: "cpu", label: "CPU Usage"} + ], + chart: { + labelKey: "name", + datasets: [{key: "cpu", label: "CPU", unit: "cpu", axis: "left"}] + }, + items: [{name: "pod-1", cpu: "100m"}] + } + })`) + headers := frame.MustElements("table thead th") + s.containsFold(headers[0].MustText(), "Pod Name") + s.containsFold(headers[1].MustText(), "CPU Usage") + }) +} + +func (s *BrowserSuite) TestGenericView() { + s.Run("renders JSON for non-array structured content", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + structuredContent: {key: "value", nested: {a: 1}} + })`) + pre := frame.MustElement("pre.raw") + text := pre.MustText() + s.Contains(text, "key") + s.Contains(text, "value") + s.Contains(text, "nested") + }) + + s.Run("renders text when no structured content", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + content: [{type: "text", text: "Hello World"}] + })`) + pre := frame.MustElement("pre.raw") + s.Equal("Hello World", pre.MustText()) + }) +} + +func (s *BrowserSuite) TestItemsUnwrapping() { + s.Run("unwraps items-only wrapper to array for TableView", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + structuredContent: { + items: [{name: "pod-1"}, {name: "pod-2"}] + } + })`) + frame.MustElement("table") + rows := frame.MustElements("table tbody tr") + s.Len(rows, 2) + }) + + s.Run("does not unwrap when items coexists with other keys", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + structuredContent: { + items: [{name: "pod-1"}], + extra: "should prevent unwrapping" + } + })`) + // Without chart+columns, this falls through to GenericView + pre := frame.MustElement("pre.raw") + text := pre.MustText() + s.Contains(text, "items") + s.Contains(text, "extra") + }) +} + +func (s *BrowserSuite) TestDataRouting() { + s.Run("routes chart+columns+items to MetricsTable", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + structuredContent: { + columns: [{key: "name", label: "Name"}], + chart: { + labelKey: "name", + datasets: [{key: "value", label: "Value", unit: "cpu", axis: "left"}] + }, + items: [{name: "a", value: "100m"}] + } + })`) + frame.MustElement("canvas") + frame.MustElement("table") + }) + + s.Run("routes items-only to TableView not MetricsTable", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + structuredContent: { + items: [{name: "pod-1"}] + } + })`) + frame.MustElement("table") + canvases := frame.MustElements("canvas") + s.Len(canvases, 0, "TableView should not render a canvas") + }) + + s.Run("routes plain object to GenericView", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + structuredContent: {message: "hello"} + })`) + pre := frame.MustElement("pre.raw") + s.Contains(pre.MustText(), "hello") + }) + + s.Run("routes text-only content to GenericView", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + content: [{type: "text", text: "raw output"}] + })`) + pre := frame.MustElement("pre.raw") + s.Equal("raw output", pre.MustText()) + }) +} + +func (s *BrowserSuite) TestSelfDescribingMetrics() { + s.Run("renders metrics with realistic pods_top data", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + structuredContent: { + columns: [ + {key: "namespace", label: "Namespace"}, + {key: "name", label: "Pod"}, + {key: "cpu", label: "CPU"}, + {key: "memory", label: "Memory"} + ], + chart: { + labelKey: "name", + datasets: [ + {key: "cpu", label: "CPU (millicores)", unit: "cpu", axis: "left"}, + {key: "memory", label: "Memory (MiB)", unit: "memory", axis: "right"} + ] + }, + items: [ + {namespace: "default", name: "nginx-1", cpu: "100m", memory: "128Mi"}, + {namespace: "default", name: "redis-1", cpu: "50m", memory: "256Mi"}, + {namespace: "kube-system", name: "coredns", cpu: "10m", memory: "32Mi"} + ] + } + })`) + frame.MustElement("canvas") + rows := frame.MustElements("table tbody tr") + s.Len(rows, 3) + // Verify raw unit strings appear in table cells + cells := frame.MustElements("table tbody td") + cellTexts := make([]string, len(cells)) + for i, c := range cells { + cellTexts[i] = c.MustText() + } + s.Contains(cellTexts, "100m") + s.Contains(cellTexts, "128Mi") + s.Contains(cellTexts, "256Mi") + }) + + s.Run("renders metrics with single dataset on left axis", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + structuredContent: { + columns: [{key: "name", label: "Node"}, {key: "cpu", label: "CPU"}], + chart: { + labelKey: "name", + datasets: [{key: "cpu", label: "CPU (millicores)", unit: "cpu", axis: "left"}] + }, + items: [{name: "node-1", cpu: "500m"}, {name: "node-2", cpu: "1200m"}] + } + })`) + frame.MustElement("canvas") + rows := frame.MustElements("table tbody tr") + s.Len(rows, 2) + }) + + s.Run("table columns use labels from self-describing metadata", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + structuredContent: { + columns: [ + {key: "name", label: "Node"}, + {key: "cpu", label: "CPU (cores)"}, + {key: "memory", label: "Memory (bytes)"}, + {key: "cpu_pct", label: "CPU%"}, + {key: "mem_pct", label: "Memory%"} + ], + chart: { + labelKey: "name", + datasets: [{key: "cpu", label: "CPU", unit: "cpu", axis: "left"}] + }, + items: [{name: "node-1", cpu: "500m", memory: "2048Mi", cpu_pct: "25%", mem_pct: "50%"}] + } + })`) + headers := frame.MustElements("table thead th") + s.Len(headers, 5) + s.containsFold(headers[0].MustText(), "Node") + s.containsFold(headers[1].MustText(), "CPU (cores)") + s.containsFold(headers[2].MustText(), "Memory (bytes)") + s.containsFold(headers[3].MustText(), "CPU%") + s.containsFold(headers[4].MustText(), "Memory%") + }) +} + +func (s *BrowserSuite) TestThemeApplication() { + s.Run("applies light theme from host context", func() { + page, frame := s.openViewer() + defer page.MustClose() + theme := frame.MustEval(`() => document.documentElement.getAttribute('data-theme')`).Str() + s.Equal("light", theme) + }) + + s.Run("sets colorScheme style property", func() { + page, frame := s.openViewer() + defer page.MustClose() + colorScheme := frame.MustEval(`() => document.documentElement.style.colorScheme`).Str() + s.Equal("light", colorScheme) + }) + + s.Run("applies dark theme via host-context-changed notification", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendNotification('ui/notifications/host-context-changed', {theme: 'dark'})`) + frame.MustWait(`() => document.documentElement.getAttribute('data-theme') === 'dark'`) + theme := frame.MustEval(`() => document.documentElement.getAttribute('data-theme')`).Str() + s.Equal("dark", theme) + colorScheme := frame.MustEval(`() => document.documentElement.style.colorScheme`).Str() + s.Equal("dark", colorScheme) + }) + + s.Run("applies CSS custom properties from host styles", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendNotification('ui/notifications/host-context-changed', { + styles: { + variables: { + '--color-background-primary': '#ff0000', + '--color-text-primary': '#00ff00' + } + } + })`) + frame.MustWait(`() => document.documentElement.style.getPropertyValue('--color-background-primary') === '#ff0000'`) + bg := frame.MustEval(`() => document.documentElement.style.getPropertyValue('--color-background-primary')`).Str() + s.Equal("#ff0000", bg) + text := frame.MustEval(`() => document.documentElement.style.getPropertyValue('--color-text-primary')`).Str() + s.Equal("#00ff00", text) + }) +} + +func (s *BrowserSuite) TestYamlViewXSS() { + s.Run("HTML tags in YAML values are escaped", func() { + page, frame := s.openViewer() + defer page.MustClose() + page.MustEval(`() => window.sendToolResult({ + content: [{type: "text", text: "apiVersion: v1\nkind: Pod\nmetadata:\n annotations:\n note: "}] + })`) + pre := frame.MustElement("pre.raw.yaml") + // The raw text should show the angle brackets as visible characters, not execute them + text := pre.MustText() + s.Contains(text, "") + // The innerHTML must NOT contain an actual "}] + })`) + frame.MustElement("pre.raw.yaml") + inner := frame.MustEval(`() => document.querySelector('pre.raw.yaml').innerHTML`).Str() + s.NotContains(inner, " + + + + + + + + + + + + + + + + + + + + + + diff --git a/pkg/output/output.go b/pkg/output/output.go index 433584d04..ece18f88f 100644 --- a/pkg/output/output.go +++ b/pkg/output/output.go @@ -33,7 +33,7 @@ type Output interface { AsTable() bool // PrintObj prints the given object as a string. PrintObj(obj runtime.Unstructured) (string, error) - // PrintObjStructured prints the given object and also extracts structured data. + // PrintObjStructured prints the given object and also extracts structured data for MCP Apps UI rendering. PrintObjStructured(obj runtime.Unstructured) (*PrintResult, error) } @@ -162,11 +162,13 @@ func tableToStructured(t *metav1.Table) []map[string]any { item[col.Name] = row.Cells[ci] } } - // Add namespace from the embedded object metadata if available - if row.Object.Object != nil { - if u, ok := row.Object.Object.(*unstructured.Unstructured); ok { - if ns := u.GetNamespace(); ns != "" { - item["Namespace"] = ns + // Add namespace from the embedded object metadata if not already present from column definitions + if _, hasNS := item["Namespace"]; !hasNS { + if row.Object.Object != nil { + if u, ok := row.Object.Object.(*unstructured.Unstructured); ok { + if ns := u.GetNamespace(); ns != "" { + item["Namespace"] = ns + } } } } diff --git a/pkg/output/output_test.go b/pkg/output/output_test.go index 3a8ff94c3..c71d97276 100644 --- a/pkg/output/output_test.go +++ b/pkg/output/output_test.go @@ -188,6 +188,51 @@ func (s *OutputSuite) TestTableToStructured() { _, hasNs := result[0]["Namespace"] s.False(hasNs, "expected no Namespace key for cluster-scoped resource") }) + s.Run("returns nil for table with no rows", func() { + t := &metav1.Table{ + ColumnDefinitions: []metav1.TableColumnDefinition{{Name: "Name"}}, + } + result := tableToStructured(t) + s.Nil(result) + }) + s.Run("does not duplicate Namespace from embedded object when column exists", func() { + t := &metav1.Table{ + ColumnDefinitions: []metav1.TableColumnDefinition{ + {Name: "Name"}, + {Name: "Namespace"}, + }, + Rows: []metav1.TableRow{{ + Cells: []any{"my-pod", "from-column"}, + Object: runtime.RawExtension{ + Object: &unstructured.Unstructured{Object: map[string]any{ + "metadata": map[string]any{"namespace": "from-object"}, + }}, + }, + }}, + } + result := tableToStructured(t) + s.Require().Len(result, 1) + s.Equal("from-column", result[0]["Namespace"], "column value should take precedence over embedded object") + }) + s.Run("adds Namespace from embedded object when no column exists", func() { + t := &metav1.Table{ + ColumnDefinitions: []metav1.TableColumnDefinition{ + {Name: "Name"}, + }, + Rows: []metav1.TableRow{{ + Cells: []any{"my-pod"}, + Object: runtime.RawExtension{ + Object: &unstructured.Unstructured{Object: map[string]any{ + "metadata": map[string]any{"namespace": "default"}, + }}, + }, + }}, + } + result := tableToStructured(t) + s.Require().Len(result, 1) + s.Equal("my-pod", result[0]["Name"]) + s.Equal("default", result[0]["Namespace"]) + }) } func TestOutput(t *testing.T) { diff --git a/pkg/toolsets/core/namespaces.go b/pkg/toolsets/core/namespaces.go index 1538cbe0e..c6db75145 100644 --- a/pkg/toolsets/core/namespaces.go +++ b/pkg/toolsets/core/namespaces.go @@ -53,7 +53,11 @@ func namespacesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to list namespaces: %w", err)), nil } - return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil + result, err := params.ListOutput.PrintObjStructured(ret) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list namespaces: %w", err)), nil + } + return api.NewToolCallResultFull(result.Text, result.Structured, nil), nil } func projectsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { @@ -61,5 +65,9 @@ func projectsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to list projects: %w", err)), nil } - return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil + result, err := params.ListOutput.PrintObjStructured(ret) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list projects: %w", err)), nil + } + return api.NewToolCallResultFull(result.Text, result.Structured, nil), nil } diff --git a/pkg/toolsets/core/nodes.go b/pkg/toolsets/core/nodes.go index 4c5a3d99e..411ac18a2 100644 --- a/pkg/toolsets/core/nodes.go +++ b/pkg/toolsets/core/nodes.go @@ -10,6 +10,7 @@ import ( "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/kubectl/pkg/metricsutil" + "k8s.io/metrics/pkg/apis/metrics" "k8s.io/utils/ptr" "github.com/containers/kubernetes-mcp-server/pkg/api" @@ -177,5 +178,47 @@ func nodesTop(params api.ToolHandlerParams) (*api.ToolCallResult, error) { return api.NewToolCallResult("", fmt.Errorf("failed to print node metrics: %w", err)), nil } - return api.NewToolCallResult(buf.String(), nil), nil + return api.NewToolCallResultFull(buf.String(), extractNodesTopStructured(nodeMetrics, availableResources), nil), nil +} + +func extractNodesTopStructured(nodeMetrics *metrics.NodeMetricsList, available map[string]v1.ResourceList) map[string]any { + if nodeMetrics == nil || len(nodeMetrics.Items) == 0 { + return nil + } + items := make([]map[string]any, 0, len(nodeMetrics.Items)) + for _, nm := range nodeMetrics.Items { + cpuUsage := nm.Usage.Cpu().MilliValue() + memUsage := nm.Usage.Memory().Value() + item := map[string]any{ + "name": nm.Name, + "cpu": fmt.Sprintf("%dm", cpuUsage), + "memory": fmt.Sprintf("%dMi", memUsage/(1024*1024)), + } + if res, ok := available[nm.Name]; ok { + if allocCPU, ok := res[v1.ResourceCPU]; ok && allocCPU.MilliValue() > 0 { + item["cpuPercent"] = fmt.Sprintf("%d%%", cpuUsage*100/allocCPU.MilliValue()) + } + if allocMem, ok := res[v1.ResourceMemory]; ok && allocMem.Value() > 0 { + item["memPercent"] = fmt.Sprintf("%d%%", memUsage*100/allocMem.Value()) + } + } + items = append(items, item) + } + return map[string]any{ + "columns": []map[string]string{ + {"key": "name", "label": "Node"}, + {"key": "cpu", "label": "CPU"}, + {"key": "cpuPercent", "label": "CPU%"}, + {"key": "memory", "label": "Memory"}, + {"key": "memPercent", "label": "Memory%"}, + }, + "chart": map[string]any{ + "labelKey": "name", + "datasets": []map[string]string{ + {"key": "cpu", "label": "CPU (millicores)", "unit": "cpu", "axis": "left"}, + {"key": "memory", "label": "Memory (MiB)", "unit": "memory", "axis": "right"}, + }, + }, + "items": items, + } } diff --git a/pkg/toolsets/core/pods.go b/pkg/toolsets/core/pods.go index 09e4e959a..9fbbb2dd1 100644 --- a/pkg/toolsets/core/pods.go +++ b/pkg/toolsets/core/pods.go @@ -7,6 +7,7 @@ import ( "github.com/google/jsonschema-go/jsonschema" "k8s.io/kubectl/pkg/metricsutil" + "k8s.io/metrics/pkg/apis/metrics" "k8s.io/utils/ptr" "github.com/containers/kubernetes-mcp-server/pkg/api" @@ -275,7 +276,11 @@ func podsListInAllNamespaces(params api.ToolHandlerParams) (*api.ToolCallResult, if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to list pods in all namespaces: %w", err)), nil } - return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil + result, err := params.ListOutput.PrintObjStructured(ret) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list pods in all namespaces: %w", err)), nil + } + return api.NewToolCallResultFull(result.Text, result.Structured, nil), nil } func podsListInNamespace(params api.ToolHandlerParams) (*api.ToolCallResult, error) { @@ -298,7 +303,11 @@ func podsListInNamespace(params api.ToolHandlerParams) (*api.ToolCallResult, err if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to list pods in namespace %s: %w", ns, err)), nil } - return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil + result, err := params.ListOutput.PrintObjStructured(ret) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list pods in namespace %s: %w", ns, err)), nil + } + return api.NewToolCallResultFull(result.Text, result.Structured, nil), nil } func podsGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) { @@ -357,7 +366,7 @@ func podsTop(params api.ToolHandlerParams) (*api.ToolCallResult, error) { if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to get pods top: %w", err)), nil } - return api.NewToolCallResult(buf.String(), nil), nil + return api.NewToolCallResultFull(buf.String(), extractPodsTopStructured(ret), nil), nil } func podsExec(params api.ToolHandlerParams) (*api.ToolCallResult, error) { @@ -458,3 +467,43 @@ func podsRun(params api.ToolHandlerParams) (*api.ToolCallResult, error) { } return api.NewToolCallResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil } + +func extractPodsTopStructured(podMetrics *metrics.PodMetricsList) map[string]any { + if podMetrics == nil || len(podMetrics.Items) == 0 { + return nil + } + items := make([]map[string]any, 0, len(podMetrics.Items)) + for _, pm := range podMetrics.Items { + var cpuTotal, memTotal int64 + for _, c := range pm.Containers { + if cpu, ok := c.Usage["cpu"]; ok { + cpuTotal += cpu.MilliValue() + } + if mem, ok := c.Usage["memory"]; ok { + memTotal += mem.Value() + } + } + items = append(items, map[string]any{ + "name": pm.Name, + "namespace": pm.Namespace, + "cpu": fmt.Sprintf("%dm", cpuTotal), + "memory": fmt.Sprintf("%dMi", memTotal/(1024*1024)), + }) + } + return map[string]any{ + "columns": []map[string]string{ + {"key": "namespace", "label": "Namespace"}, + {"key": "name", "label": "Pod"}, + {"key": "cpu", "label": "CPU"}, + {"key": "memory", "label": "Memory"}, + }, + "chart": map[string]any{ + "labelKey": "name", + "datasets": []map[string]string{ + {"key": "cpu", "label": "CPU (millicores)", "unit": "cpu", "axis": "left"}, + {"key": "memory", "label": "Memory (MiB)", "unit": "memory", "axis": "right"}, + }, + }, + "items": items, + } +} diff --git a/pkg/toolsets/core/resources.go b/pkg/toolsets/core/resources.go index cfefdd396..bc36c61c7 100644 --- a/pkg/toolsets/core/resources.go +++ b/pkg/toolsets/core/resources.go @@ -225,7 +225,11 @@ func resourcesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to list resources: %w", err)), nil } - return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil + result, err := params.ListOutput.PrintObjStructured(ret) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list resources: %w", err)), nil + } + return api.NewToolCallResultFull(result.Text, result.Structured, nil), nil } func resourcesGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) {