
Implementing MCP UI Apps
FreeCreate interactive visualizations for MCP tools.
Free · Opens the source repo
What Implementing MCP UI Apps does
The Implementing MCP UI Apps skill provides a structured guide for developers looking to add interactive UI components to MCP (Multi-Channel Platform) tools. These UI apps are built using React and utilize the Mosaic component library, allowing for rich visualizations that enhance the user experience in MCP clients like Claude Desktop. By following the provided workflow, developers can create detailed and list views that present structured data in a more engaging format than plain text or JSON.
To get started, developers will create view components within their product’s mcp/apps/ directory. The skill outlines the necessary steps to configure these components in the tools.yaml file, linking them to the corresponding tools. The workflow includes generating entry points and building the app using the provided pnpm commands. This structured approach ensures that the UI apps integrate seamlessly with the existing MCP infrastructure.
This skill is particularly useful for teams that are developing new tools or enhancing existing ones within the MCP ecosystem. By leveraging the capabilities of the Mosaic library, developers can create visually appealing and interactive applications that improve data presentation and user interaction. The skill also includes detailed examples of how to implement both detail and list views, making it easier for developers to understand how to structure their components effectively.
Overall, the Implementing MCP UI Apps skill is a valuable resource for developers focused on enhancing the user interface of their MCP tools, ensuring that users can interact with data in a meaningful way.
When to use it
Use this skill when developing new MCP tools or enhancing existing ones that require visual data representation.
When not to use it
This skill may not be suitable for simple text-based tools that do not require visual components.
What you can build with it
Adding a New Tool with UI
When developing a new tool for the MCP, use this skill to create a UI app that presents data visually, enhancing user interaction.
Enhancing Existing Tools
If you have an existing MCP tool that outputs structured data, implement a UI app to improve how this data is displayed to users.
Creating Interactive Dashboards
Leverage the skill to build interactive dashboards that allow users to visualize and interact with data from multiple sources in a cohesive manner.
How to install Implementing MCP UI Apps
View source1. Install with the skills CLI
npx skills add posthog/posthog/implementing-mcp-ui-apps --agent claude-code2. Or install it manually
Download the skill folder and drop it into ~/.claude/skills/ for all projects, or .claude/skills/ to scope it to one repo. Restart Claude Code so it picks up the new skill.
Anthropic's agentic coding CLI, and the reference implementation of Agent Skills. Drop a skill folder into ~/.claude/skills and Claude Code loads it automatically whenever a task matches the skill's description. Claude Code docs
Inside SKILL.md
Written by posthogImplementing MCP UI apps
MCP UI apps are interactive React visualizations that render alongside tool results in MCP clients (e.g. Claude Desktop). They're built with the Mosaic component library and served via Cloudflare Workers Static Assets.
Full reference: services/mcp/CONTRIBUTING.md.
Quick workflow
# 1. Create view components in your product's mcp/apps/ directory
# (see "View components" below)
# 2. Add ui_apps entries to your product's mcp/tools.yaml
# (see "YAML configuration" below)
# 3. Link tools to apps with ui_app: <key> in the tools section
# 4. Generate entry points + registry, then build
pnpm --filter=@posthog/mcp run generate:ui-apps
pnpm --filter=@posthog/mcp run build
When to add a UI app
When an MCP tool returns structured data that benefits from visual presentation — tables, detail views, charts, status badges, etc. Without a UI app, tool results are shown as plain text/JSON in the chat.
Architecture
products/{product}/mcp/
apps/ # React view components (you write these)
EntityView.tsx # Detail view
EntityListView.tsx # List view (uses ListDetailView from Mosaic)
index.ts # Barrel exports
tools.yaml # YAML config: ui_apps + tools
services/mcp/
src/ui-apps/apps/
generated/ # Auto-generated entry points (don't edit)
debug.tsx # Custom/manual entry points
query-results.tsx
src/resources/
ui-apps.generated.ts # Auto-generated: URI constants, UiAppKey, URI_MAP, UI_APPS
ui-apps.ts # Hand-authored: withUiApp(), registerUiAppResources()
scripts/
generate-ui-apps.ts # The generator — reads YAML, writes entry points + registry
yaml-config-schema.ts # Zod schemas for YAML validation (source of truth for field definitions)
View components
Place view components in products/{product}/mcp/apps/.
Detail view — renders a single entity:
import { type ReactElement } from 'react'
import { DescriptionList } from '@posthog/mcp-ui'
import { Card, CardHeader, CardTitle, CardContent } from '@posthog/quill'
export interface MyEntityData {
id: number
name: string
// ... fields from the API response
}
export function MyEntityView({ data }: { data: MyEntityData }): ReactElement {
return (
<Card>
<CardHeader>
<CardTitle>{data.title}</CardTitle>
</CardHeader>
<CardContent>
<DescriptionList items={[{ label: 'ID', value: String(data.id) }]} />
</CardContent>
</Card>
)
}
List view — uses ListDetailView from Mosaic for the list-to-detail state machine:
import { type ReactElement, type ReactNode } from 'react'
import { DataTable, type DataTableColumn, ListDetailView } from '@posthog/mcp-ui'
import { MyEntityView, type MyEntityData } from './MyEntityView'
export interface MyEntityListData {
results: MyEntityData[]
_posthogUrl?: string
}
export interface MyEntityListViewProps {
data: MyEntityListData
onMyEntityClick?: (entity: MyEntityData) => Promise<MyEntityData | null>
}
export function MyEntityListView({ data, onMyEntityClick }: MyEntityListViewProps): ReactElement {
return (
<ListDetailView<MyEntityData>
onItemClick={onMyEntityClick}
backLabel="All entities"
getItemName={(e) => e.name}
renderDetail={(e) => <MyEntityView data={e} />}
renderList={(handleClick) => {
const columns: DataTableColumn<MyEntityData>[] = [
{
key: 'name',
header: 'Name',
sortable: true,
render: (row): ReactNode =>
onMyEntityClick ? (
<button onClick={() => handleClick(row)} className="text-link underline ...">
{row.name}
</button>
) : (
row.name
),
},
]
return (
<div className="p-4">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{data.results.length} entit{data.results.length === 1 ? 'y' : 'ies'}
</span>
</div>
<DataTable<CohortData>
columns={columns}
data={data.results}
pageSize={10}
defaultSort={{ key: 'name', direction: 'asc' }}
emptyMessage="No entities found"
/>
</div>
</div>
)
}}
/>
)
}
Barrel export (index.ts):
export { MyEntityView, type MyEntityData } from './MyEntityView'
export { MyEntityListView, type MyEntityListData, type MyEntityListViewProps } from './MyEntityListView'
YAML configuration
The ui_apps section in products/{product}/mcp/tools.yaml defines UI apps.
Each key becomes the app identifier (used in URIs, constants, and withUiApp calls).
There are three app types: detail, list, and custom.
type: detail — single-entity view
Renders one entity using a view component wrapped in AppWrapper.
Required fields:
| Field | Description |
|---|---|
type | Must be 'detail'. |
view_prop | The React prop name passed to the view component (e.g. data, action, flag). Cannot be derived — must match your component's props. |
Optional fields (derived by convention when omitted):
| Field | Default | Description |
|---|---|---|
app_name | "PostHog " + titleCase(key) | Display name shown in the MCP client. Example: key error-details → "PostHog Error Details". |
description | titleCase(key) + " detail view" | Short description for the MCP resource registry. |
component_import | products/{product}/mcp/apps | Import path for the view component. Auto-derived from the YAML file's location in the product directory. |
data_type | PascalCase(key) + "Data" | TypeScript type for the tool result. Example: key error-details → ErrorDetailsData. |
view_component | PascalCase(key) + "View" | React component name. Example: key error-details → ErrorDetailsView. |
Minimal example:
ui_apps:
action:
type: detail
view_prop: action
Example with overrides (when conventions don't match the actual code):
ui_apps:
llm-costs:
type: detail
view_prop: data
data_type: LLMCostsData # convention would produce LlmCostsData
view_component: LLMCostsView # convention would produce LlmCostsView
type: list — list with drill-down
Renders a list component. When an item is clicked, calls a detail tool via
app.callServerTool() and shows the detail view inline. Falls back to a chat
message if the MCP client doesn't support tool calls from apps.
Required fields:
| Field | Description |
|---|---|
type | Must be 'list'. |
detail_tool | Tool name to call when a list item is clicked (e.g. 'action-get', 'cohorts-retrieve'). Must be a valid tool name defined in the tools section of any YAML file. |
Optional fields with behavioral defaults:
| Field | Default | Description |
|---|---|---|
detail_args | '{ id: item.id }' | JS expression for arguments passed to detail_tool. The variable item refers to the clicked list item. Override when the tool uses a different param name, e.g. '{ flagId: item.id }'. |
item_name_field | 'name' | Field on the item object used for display in loading states and fallback chat messages. Override when items are identified by something other than name, e.g. key for feature flags. |
click_prop | 'on' + PascalCase(singularKey) + 'Click' | Prop name for the click handler passed to the list component. The singular key is derived by stripping the -list suffix. Example: key action-list → onActionClick. Override when your component uses a shorter name, e.g. onFlagClick instead of onFeatureFlagClick. |
entity_label | kebab-to-space of singular key | Human-readable label used in the fallback chat message ("Show me the details for {entity_label} ..."). Example: key error-issue-list → error issue. |
Optional fields with convention defaults (same pattern as detail apps):
| Field | Default | Description |
|---|---|---|
app_name | "PostHog " + titleCase(key) | Display name. |
description | titleCase(key) + " view" | Short description. |
component_import | products/{product}/mcp/apps | Import path. |
list_data_type | PascalCase(singularKey) + "ListData" | TypeScript type for the list response. Example: key action-list → ActionListData. |
item_data_type | PascalCase(singularKey) + "Data" | TypeScript type for a single item. Example: key action-list → ActionData. |
view_component | PascalCase(key) + "View" | React component name. Example: key action-list → ActionListView. |
Minimal example:
ui_apps:
action-list:
type: list
detail_tool: action-get
Example with overrides:
ui_apps:
feature-flag-list:
type: list
detail_tool: feature-flag-get-definition
detail_args: '{ flagId: item.id }' # tool expects flagId, not id
item_name_field: key # flags are identified by key, not name
click_prop: onFlagClick # component uses onFlagClick, not onFeatureFlagClick
type: custom — handwritten entry point
For apps that need fully custom logic (e.g. debug.tsx, query-results.tsx).
The generator does NOT create an entry point — you maintain it manually at
services/mcp/src/ui-apps/apps/{key}.tsx. The registry entry is always generated.
If the custom app exposes a reusable view component, an optional render_ui
configuration also adds it to the render-ui umbrella tool.
Required fields:
| Field | Description |
|---|---|
type | Must be 'custom'. |
app_name | Display name. Required because there's no convention to derive it from (custom apps may not follow naming patterns). |
description | Short description. Required for the same reason. |
Optional render_ui fields:
| Field | Description |
|---|---|
component_import | Import path for the reusable view component, relative to src/ui-apps/generated. |
view_component | React component name that renders the tool result. |
view_prop | Prop name that receives the tool result. |
data_type | TypeScript type for the tool result. Omit when the component accepts unknown. |
Example:
ui_apps:
query-results:
type: custom
app_name: Query Results
description: Interactive visualization for PostHog query results
render_ui:
component_import: ../components/Component
view_component: Component
view_prop: data
Where the schemas live
The Zod schemas that validate these YAML fields live in services/mcp/scripts/yaml-config-schema.ts. Each field has a JSDoc comment explaining its purpose and default.
To add a new field to an app type:
- Add it to the relevant Zod schema (
DetailUiAppSchema,ListUiAppSchema, orCustomUiAppSchema) with.optional()if it has a default - Add it to the matching
Resolved*interface (ResolvedDetailUiApp,ResolvedListUiApp, orResolvedCustomUiApp) - Add the default derivation in
resolveDetailApp(),resolveListApp(), orresolveCustomApp()ingenerate-ui-apps.ts - Use the resolved value in the entry-point or dispatch generator
All schemas use .strict() — unknown keys are rejected at build time, catching typos.
Linking tools to UI apps
In the tools section of the same YAML file, use ui_app to reference a ui_apps key:
tools:
my-entity-get:
operation: my_entities_retrieve
enabled: true
ui_app: my-entity # references ui_apps.my-entity
my-entity-list:
operation: my_entities_list
enabled: true
ui_app: my-entity-list # references ui_apps.my-entity-list
The generator validates that every ui_app value points to a key that exists
in some ui_apps section across all YAML files.
For handwritten tools (not YAML-generated), use withUiApp in TypeScript:
import { withUiApp } from '@/resources/ui-apps'
import { withPostHogUrl, type WithPostHogUrl } from '@/tools/tool-utils'
import type { Context, ToolBase } from '@/tools/types'
type Result = WithPostHogUrl<MyEntityData>
export default (): ToolBase<typeof schema, Result> =>
withUiApp('my-entity', {
name: 'my-entity-get',
schema,
handler: async (context, params) => {
const projectId = await context.stateManager.getProjectId()
const data = await fetchEntity(context, params)
return await withPostHogUrl(context, data, `/my-entities/${data.id}`)
},
})
The appKey parameter is type-checked against the generated UiAppKey union —
invalid keys are compile-time errors.
CI validation
CI checks that generated files are up to date in both ci-mcp.yml and ci-mcp-ui-apps.yml.
If you change YAML ui_apps sections, run pnpm --filter=@posthog/mcp run generate:ui-apps
and commit the result.
Frequently asked questions about Implementing MCP UI Apps
Similar skills
Playwright Component Testing
Test React and Vue components in isolation with Playwright.
Fluent UI Blazor
Integrate Fluent UI components in Blazor applications effortlessly.
Build MCP App
Create interactive UI widgets for MCP servers.
Web Design Reviewer
Identify and fix design issues in websites efficiently.
Markstream Install
Seamlessly integrate Markstream for Markdown rendering.
GSAP & Framer Scroll Animation
Create advanced scroll animations effortlessly.
