Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions design/EP-2047-mcp-ui-plugin-registration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# EP-2047: UI Plugins — register MCP server web UIs via RemoteMCPServer.spec.ui

* Issue: [#2047](https://github.com/kagent-dev/kagent/issues/2047)

## Background

Some MCP servers ship their own web UI (e.g. a Kanban board, a dashboard). Today
there is no way to surface that UI inside the kagent console — users must open a
separate URL, losing the kagent theme, namespace context, and navigation chrome.

This EP lets any MCP server that ships a web UI register itself as a first-class
**plugin** in the kagent UI sidebar, framed in an iframe, theme- and
namespace-aware — **without** the UI needing to know it is embedded and **without**
adding a new CRD. Registration is fully declarative: a single `RemoteMCPServer`
resource with a `spec.ui` block is the entire contract.

This EP describes the architecture, the host↔plugin postMessage protocol, and the
reverse-proxy contract.

## Motivation

- Let MCP servers contribute UI surfaces to the kagent console with zero controller
changes and no new CRD.
- Keep the embedding declarative and namespace/theme-aware.
- Provide the foundation for shipped plugins such as `kanban-mcp` (EP-2048).

### Goals

- Add an optional `spec.ui` block to the `RemoteMCPServer` v1alpha2 CRD.
- Backend: list enabled plugins (`GET /api/plugins`) and reverse-proxy each
plugin's web root under `/_p/{pathPrefix}/`, with optional CSS injection.
- UI: a "Plugins" navigation section, a plugin frame page, and a host↔plugin
`postMessage` protocol (context, navigate, resize, badge, title, ready).

### Non-Goals

- A new CRD for plugins (reuse `RemoteMCPServer`).
- Shipping a specific plugin (the Kanban plugin is EP-2048).
- Cross-origin plugin hosting (plugins are proxied same-origin via `/_p/`).

## Implementation Details

### The contract: `RemoteMCPServer.spec.ui` (`RemoteMCPServerUI`)

Defined in `go/api/v1alpha2/remotemcpserver_types.go`; the CRD
(`go/api/config/crd/bases/kagent.dev_remotemcpservers.yaml`, mirrored in
`helm/kagent-crds/templates/`) and `zz_generated.deepcopy.go` are generated from it.

| Field | Type | Default | Validation | Purpose |
|-------|------|---------|------------|---------|
| `enabled` | bool | `false` | — | Opt-in: this server provides a web UI. |
| `pathPrefix` | string | `<name>` | `maxLength=63`, `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` | URL segment for `/_p/{pathPrefix}/`. |
| `displayName` | string | `<name>` | — | Sidebar label. |
| `icon` | string | `puzzle` | — | `lucide-react` icon name. |
| `section` | enum | `RESOURCES` | `OVERVIEW\|AGENTS\|WORKFLOWS\|KNOWLEDGE\|EVALUATIONS\|RESOURCES\|ADMIN\|PLUGINS` | Sidebar section. |
| `defaultPath` | string | — | — | Initial sub-path at plugin root. |
| `injectCSS` | string | — | — | CSS injected into proxied HTML. |

### Backend (`go/core/internal/httpserver`)

- `GET /api/plugins` — `PluginsHandler.HandleListPlugins` lists `RemoteMCPServer`s
across watched namespaces where `spec.ui.enabled`, projects each to
`api.PluginResponse` (`{name, namespace, pathPrefix, displayName, icon, section,
defaultPath}`), applies the defaults, and sorts by `pathPrefix`. Authorized as a
`ToolServer` resource.
- `/_p/{pathPrefix}/*` — `PluginsHandler.HandleProxy` resolves `{pathPrefix}` to its
`RemoteMCPServer`, authorizes against the backing `ToolServer`, derives the target
from the **host** of `spec.url` (proxying to the web root `/`), resolves
`spec.headersFrom` via `RemoteMCPServer.ResolveHeaders`, injects
`spec.ui.injectCSS` into `text/html` responses, and returns `502` when the
upstream is unreachable.
- Wiring (added to the shared `handlers.go`/`server.go`/`httpapi/types.go`): the
`Plugins` handler, the `/api/plugins` route, and the `/_p/{pathPrefix}` proxy
prefix. Only the plugins-related hunks of these shared files are included in this
PR; the MCP-apps hunks belong to EP-2046.

### UI (`ui/src`)

- **Navigation** — `components/sidebars/AppSidebar.tsx` + `AppSidebarNav.tsx` render
a "Plugins" section driven by `useSidebarStatus()`, with live badge updates via the
`kagent:plugin-badge` event. Supporting nav pieces: `NamespaceSelector`,
`StatusIndicator`, `SidebarCollapseButton`, `MobileTopBar`, and the
`sidebar-status-context` / `namespace-context` providers. The root `layout.tsx` is
refactored to a Server Component delegating to a `providers.tsx` client boundary.
- **Plugin list** — `app/actions/plugins.ts` (`getPlugins()` → `GET /api/plugins`,
`checkPluginBackend()` health probe) and the BFF route `app/api/plugins/route.ts`.
- **Plugin frame** — `app/plugins/[name]/[[...path]]/page.tsx` renders
`<iframe src="/_p/{name}{subPath}">`, sandboxed
(`allow-scripts allow-same-origin allow-forms allow-popups`), speaking the
`kagent:*` postMessage protocol: host→plugin `kagent:context` (theme, namespace,
authToken); plugin→host `kagent:navigate | resize | badge | title | ready`.
`app/plugins/page.tsx` lists available plugins.

### Path contract

`getBackendUrl()` ends in `/api`, so the registry is at `/api/plugins`; the reverse
proxy is reached via `getBackendRoot()` (strips `/api`) so it stays at the root
`/_p/...`. (`getBackendRoot` added to `ui/src/lib/utils.ts`.)

### Dependencies

- The sidebar `UserMenu` gains a `variant="sidebar"` rendering used by `AppSidebar`;
that `UserMenu` change ships in this PR. The SSO session-status behavior is
separate (**EP-2045**, #2045) and does not block this PR.

## Test Plan

- **Unit (Go):** `plugins_test.go` covers `HandleListPlugins` projection/defaults
and `HandleProxy` target derivation, header resolution, CSS injection, and 502
handling. `go build ./core/... ./api/...` passes.
- **Unit (UI):** plugin list/health actions; nav rendering from `useSidebarStatus`.
- **e2e / manual:** create a `RemoteMCPServer` with `spec.ui.enabled`; confirm it
appears under "Plugins", the iframe loads via `/_p/{pathPrefix}/`, theme/namespace
propagate, and badge/title updates work.

## Alternatives

- **New `Plugin` CRD** — more moving parts; rejected in favor of reusing
`RemoteMCPServer`, which already models the server identity and auth.
- **Cross-origin iframe to the plugin's own host** — breaks same-origin auth/theme
propagation and complicates CSP; the same-origin `/_p/` proxy avoids this.

## Open Questions

- Should `section` support custom (non-enum) sidebar sections?
- Should plugin health/badges be server-pushed (SSE) rather than polled?
54 changes: 54 additions & 0 deletions go/api/config/crd/bases/kagent.dev_remotemcpservers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,60 @@ spec:
rule: '!(has(self.disableSystemCAs) && self.disableSystemCAs &&
(!has(self.disableVerify) || !self.disableVerify) && (!has(self.caCertSecretRef)
|| size(self.caCertSecretRef) == 0))'
ui:
description: |-
UI defines optional web UI metadata for this MCP server.
When ui.enabled is true, the server's UI is accessible via /_p/{ui.pathPrefix}/ (proxy)
and browser URL /plugins/{ui.pathPrefix} (Next.js wrapper with sidebar + iframe)
properties:
defaultPath:
description: |-
DefaultPath is the initial path to redirect to when the plugin root is loaded.
For example, "/namespaces/kagent" makes the plugin open at that path by default.
type: string
displayName:
description: |-
DisplayName is the human-readable name shown in the sidebar.
Defaults to the RemoteMCPServer name if not specified.
type: string
enabled:
default: false
description: Enabled indicates this MCP server provides a web
UI.
type: boolean
icon:
default: puzzle
description: Icon is a lucide-react icon name (e.g., "kanban",
"git-fork", "database").
type: string
injectCSS:
description: |-
InjectCSS is custom CSS injected into proxied HTML responses to customize the plugin UI.
For example, `[data-testid="navigation-header"] { display: none !important; }` hides the nav.
type: string
pathPrefix:
description: |-
PathPrefix is the URL path segment used for routing: /_p/{pathPrefix}/
Must be a valid URL path segment (lowercase alphanumeric + hyphens).
Defaults to the RemoteMCPServer name if not specified.
maxLength: 63
pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
type: string
section:
default: RESOURCES
description: Section is the sidebar section where this plugin
appears.
enum:
- OVERVIEW
- AGENTS
- WORKFLOWS
- KNOWLEDGE
- EVALUATIONS
- RESOURCES
- ADMIN
- PLUGINS
type: string
type: object
url:
minLength: 1
type: string
Expand Down
21 changes: 21 additions & 0 deletions go/api/httpapi/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,27 @@ type UpdatePromptTemplateRequest struct {
Data map[string]string `json:"data"`
}

// Plugin types

// PluginResponse describes a RemoteMCPServer that exposes a web UI, projected for
// the UI sidebar. Field names match the UI's PluginItem contract.
type PluginResponse struct {
// Name is the RemoteMCPServer metadata name.
Name string `json:"name"`
// Namespace is the RemoteMCPServer namespace.
Namespace string `json:"namespace"`
// PathPrefix is the URL segment used for proxy routing: /_p/{pathPrefix}/.
PathPrefix string `json:"pathPrefix"`
// DisplayName is the human-readable label shown in the sidebar.
DisplayName string `json:"displayName"`
// Icon is a lucide-react icon name.
Icon string `json:"icon"`
// Section is the sidebar section where the plugin appears.
Section string `json:"section"`
// DefaultPath is the initial sub-path to open at the plugin root.
DefaultPath string `json:"defaultPath,omitempty"`
}

// Namespace types

// NamespaceResponse represents a namespace response
Expand Down
49 changes: 49 additions & 0 deletions go/api/v1alpha2/remotemcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,55 @@ type RemoteMCPServerSpec struct {
// no equivalent rule, so a TLS block can sit alongside any baseUrl.
// +optional
TLS *TLSConfig `json:"tls,omitempty"`
// UI defines optional web UI metadata for this MCP server.
// When ui.enabled is true, the server's UI is accessible via /_p/{ui.pathPrefix}/ (proxy)
// and browser URL /plugins/{ui.pathPrefix} (Next.js wrapper with sidebar + iframe)
// +optional
UI *RemoteMCPServerUI `json:"ui,omitempty"`
}

// RemoteMCPServerUI defines optional web UI metadata for a RemoteMCPServer, used by
// the kagent UI to surface the server's embedded web UI as a sidebar plugin and to
// reverse-proxy it under /_p/{pathPrefix}/.
type RemoteMCPServerUI struct {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am thinking if we'd need a separate CRD to configure these plugins? for some reason I don't like the ui field on the RemoteMCPServer crd.

So have a (I am making it up) UIPlugin CRD that has these properties and a ref to the RemoteMCPSErver crd

// Enabled indicates this MCP server provides a web UI.
// +optional
// +kubebuilder:default=false
Enabled bool `json:"enabled,omitempty"`

// PathPrefix is the URL path segment used for routing: /_p/{pathPrefix}/
// Must be a valid URL path segment (lowercase alphanumeric + hyphens).
// Defaults to the RemoteMCPServer name if not specified.
// +optional
// +kubebuilder:validation:MaxLength=63
// +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`
PathPrefix string `json:"pathPrefix,omitempty"`
Comment thread
dimetron marked this conversation as resolved.

// DisplayName is the human-readable name shown in the sidebar.
// Defaults to the RemoteMCPServer name if not specified.
// +optional
DisplayName string `json:"displayName,omitempty"`

// Icon is a lucide-react icon name (e.g., "kanban", "git-fork", "database").
// +optional
// +kubebuilder:default=puzzle
Icon string `json:"icon,omitempty"`

// Section is the sidebar section where this plugin appears.
// +optional
// +kubebuilder:default=RESOURCES
// +kubebuilder:validation:Enum=OVERVIEW;AGENTS;WORKFLOWS;KNOWLEDGE;EVALUATIONS;RESOURCES;ADMIN;PLUGINS

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to clearly define where these section will show up in the UI and start with only a couple of them. Perhaps HEADER, SIDEBAR (left / right?) or CHAT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@peterj all of those are SIDEBAR menu sections only

Section string `json:"section,omitempty"`

// DefaultPath is the initial path to redirect to when the plugin root is loaded.
// For example, "/namespaces/kagent" makes the plugin open at that path by default.
// +optional
DefaultPath string `json:"defaultPath,omitempty"`

// InjectCSS is custom CSS injected into proxied HTML responses to customize the plugin UI.
// For example, `[data-testid="navigation-header"] { display: none !important; }` hides the nav.
// +optional
InjectCSS string `json:"injectCSS,omitempty"`
}

var _ sql.Scanner = (*RemoteMCPServerSpec)(nil)
Expand Down
20 changes: 20 additions & 0 deletions go/api/v1alpha2/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions go/core/internal/httpserver/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type Handlers struct {
CrewAI *CrewAIHandler
CurrentUser *CurrentUserHandler
Substrate *SubstrateHandler
Plugins *PluginsHandler
}

// Base holds common dependencies for all handlers
Expand Down Expand Up @@ -95,5 +96,6 @@ func NewHandlers(
CrewAI: NewCrewAIHandler(base),
CurrentUser: NewCurrentUserHandler(),
Substrate: NewSubstrateHandler(base, substrateAteClient),
Plugins: NewPluginsHandler(base),
}
}
Loading
Loading