agentleFS
PostHogKnown publisherPostHog/posthog-foss/.agents/skills/implementing-mcp-ui-apps/SKILL.md

implementing-mcp-ui-apps

Skill727 starsChanged yesterdayMITView on GitHub

Guide for adding MCP UI apps — interactive visualizations that render tool results in MCP clients like Claude Desktop. Use when adding a new detail or list view for an MCP tool, creating view components in products/*/mcp/apps/, or linking tools to UI apps via YAML.

We split this skill into the rules it gives an agent

9 rules. About 0 are common, 2 are distinctive, and 7 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 7

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    When an MCP tool returns structured data that benefits from visual presentation — tables, detail views, charts, status badges, etc.

    line 32
  2. Very specific:

    Place view components in products/{product}/mcp/apps/.

    line 61
  3. Very specific:

    If the custom app exposes a reusable view component, an optional render_ui configuration also adds it to the render-ui umbrella tool.

    line 269
  4. Very specific:

    Add it to the relevant Zod schema (DetailUiAppSchema, ListUiAppSchema, or CustomUiAppSchema) with .optional() if it has a default

    line 312
  5. Very specific:

    Add it to the matching Resolved* interface (ResolvedDetailUiApp, ResolvedListUiApp, or ResolvedCustomUiApp)

    line 314
  6. Very specific:

    Add the default derivation in resolveDetailApp(), resolveListApp(), or resolveCustomApp() in generate-ui-apps.ts

    line 315
  7. Distinctive:

    Use the resolved value in the entry-point or dispatch generator

    line 316
  8. Very specific:

    All schemas use .strict() — unknown keys are rejected at build time, catching typos.

    line 318
  9. Very specific:

    If you change YAML ui_apps sections, run pnpm --filter=@posthog/mcp run generate:ui-apps and commit the result.

    line 366

≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Implementing 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 Quill 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 @posthog/mcp-ui)
    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 @posthog/mcp-ui 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:

FieldDescription
typeMust be 'detail'.
view_propThe 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):

FieldDefaultDescription
app_name"PostHog " + titleCase(key)Display name shown in the MCP client. Example: key error-details → "PostHog Error Details".
descriptiontitleCase(key) + " detail view"Short description for the MCP resource registry.
component_importproducts/{product}/mcp/appsImport path for the view component. Auto-derived from the YAML file's location in the product directory.
data_typePascalCase(key) + "Data"TypeScript type for the tool result. Example: key error-details → ErrorDetailsData.
view_componentPascalCase(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:

FieldDescription
typeMust be 'list'.
detail_toolTool 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:

FieldDefaultDescription
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_labelkebab-to-space of singular keyHuman-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):

FieldDefaultDescription
app_name"PostHog " + titleCase(key)Display name.
descriptiontitleCase(key) + " view"Short description.
component_importproducts/{product}/mcp/appsImport path.
list_data_typePascalCase(singularKey) + "ListData"TypeScript type for the list response. Example: key action-list → ActionListData.
item_data_typePascalCase(singularKey) + "Data"TypeScript type for a single item. Example: key action-list → ActionData.
view_componentPascalCase(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:

FieldDescription
typeMust be 'custom'.
app_nameDisplay name. Required because there's no convention to derive it from (custom apps may not follow naming patterns).
descriptionShort description. Required for the same reason.

Optional render_ui fields:

FieldDescription
component_importImport path for the reusable view component, relative to src/ui-apps/generated.
view_componentReact component name that renders the tool result.
view_propProp name that receives the tool result.
data_typeTypeScript type for the tool result. Omit when the component accepts unknown.
link_propProp name that receives the host's link opener, for a view that opens links.

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:

  1. Add it to the relevant Zod schema (DetailUiAppSchema, ListUiAppSchema, or CustomUiAppSchema) with .optional() if it has a default
  2. Add it to the matching Resolved* interface (ResolvedDetailUiApp, ResolvedListUiApp, or ResolvedCustomUiApp)
  3. Add the default derivation in resolveDetailApp(), resolveListApp(), or resolveCustomApp() in generate-ui-apps.ts
  4. 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.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.