-
Notifications
You must be signed in to change notification settings - Fork 6
feat(mcp-consent): render inventory stats as an MCP App dashboard #447
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@transcend-io/mcp-server-base': patch | ||
| '@transcend-io/mcp-server-consent': minor | ||
| '@transcend-io/mcp': patch | ||
| --- | ||
|
|
||
| Add an MCP App view to `consent_get_inventory_stats` that renders cookie and data-flow triage counts. The shared MCP App theme gains card, fill, and metric type-scale tokens so KPI cards and progress bars stay legible on dark hosts. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| # Vite-built MCP App views, rebuilt by `pnpm prebuild` | ||
| src/ui/generated/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { | ||
| defineUiResource, | ||
| viewHtml, | ||
| type UiResourceDefinition, | ||
| } from '@transcend-io/mcp-server-base'; | ||
|
|
||
| // Built from src/ui/inventory-stats/ by this package's `prebuild` and inlined here as a | ||
| // string by tsdown's `.html` text loader. Self-contained, because hosts render a | ||
| // view in a sandboxed iframe with no server to fetch anything from. | ||
| import INVENTORY_STATS_APP_HTML from '../ui/generated/inventory-stats.html'; | ||
|
|
||
| /** URI hosts fetch to render the inventory-stats view. */ | ||
| export const INVENTORY_STATS_APP_URI = 'ui://transcend-consent/inventory-stats'; | ||
|
|
||
| /** Cookie and data-flow triage dashboard for `consent_get_inventory_stats`. */ | ||
| export const INVENTORY_STATS_APP_RESOURCE: UiResourceDefinition = defineUiResource({ | ||
| uri: INVENTORY_STATS_APP_URI, | ||
| name: 'Consent inventory triage stats', | ||
| description: 'Interactive dashboard of cookie and data-flow live, needs-review, and junk counts.', | ||
| // Reads from disk instead when TRANSCEND_MCP_DEV_VIEWS is set, so `pnpm mcp:inspect` | ||
| // picks up a view rebuild without restarting the server. | ||
| html: viewHtml({ | ||
| bundled: INVENTORY_STATS_APP_HTML, | ||
| moduleUrl: import.meta.url, | ||
| view: 'inventory-stats', | ||
| }), | ||
| prefersBorder: false, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,38 +1,86 @@ | ||
| import { createToolResult, defineTool, z, type ToolClients } from '@transcend-io/mcp-server-base'; | ||
| import { | ||
| createToolResult, | ||
| defineTool, | ||
| defineToolWithCapabilities, | ||
| McpClientCapability, | ||
| z, | ||
| type ToolClients, | ||
| } from '@transcend-io/mcp-server-base'; | ||
| import { | ||
| COOKIE_STATS, | ||
| DATA_FLOW_STATS, | ||
| type TranscendCliCookieStatsResponse, | ||
| type TranscendCliDataFlowStatsResponse, | ||
| type TranscendTrackerStatsGql, | ||
| } from '@transcend-io/sdk'; | ||
|
|
||
| import { INVENTORY_STATS_APP_RESOURCE } from '../apps/inventory-stats.js'; | ||
| import { resolveAirgapBundleId } from '../resolveAirgapBundleId.js'; | ||
|
|
||
| export const GetInventoryStatsSchema = z.object({}); | ||
| export type GetInventoryStatsInput = z.infer<typeof GetInventoryStatsSchema>; | ||
|
|
||
| export function createConsentGetInventoryStatsTool(clients: ToolClients) { | ||
| /** Cookie and data-flow triage counts returned by {@link createConsentGetInventoryStatsTool}. */ | ||
| export interface InventoryStatsPayload { | ||
| /** Cookie live / needs-review / junk counts */ | ||
| cookies: TranscendTrackerStatsGql; | ||
| /** Data-flow live / needs-review / junk counts */ | ||
| dataFlows: TranscendTrackerStatsGql; | ||
| } | ||
|
|
||
| /** Shared by the baseline tool, the MCP App variant, and the refresh companion. */ | ||
| async function inventoryStatsPayload(clients: ToolClients): Promise<unknown> { | ||
| const airgapBundleId = await resolveAirgapBundleId(clients.graphql); | ||
| const variables = { input: { airgapBundleId } }; | ||
| const [cookieData, dfData] = await Promise.all([ | ||
| clients.graphql.makeRequest<TranscendCliCookieStatsResponse>(COOKIE_STATS, variables), | ||
| clients.graphql.makeRequest<TranscendCliDataFlowStatsResponse>(DATA_FLOW_STATS, variables), | ||
| ]); | ||
| return createToolResult(true, { | ||
| cookies: cookieData.cookieStats, | ||
| dataFlows: dfData.dataFlowStats, | ||
| } satisfies InventoryStatsPayload); | ||
| } | ||
|
|
||
| /** Companion the view calls to refresh itself. Never listed to the model. */ | ||
| function createInventoryStatsRefreshTool(clients: ToolClients) { | ||
| return defineTool({ | ||
| name: 'consent_get_inventory_stats_refresh', | ||
| description: | ||
| 'Re-fetch cookie and data-flow inventory triage counts for the inventory-stats view.', | ||
| category: 'Consent Management', | ||
| readOnly: true, | ||
| annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, | ||
| zodSchema: GetInventoryStatsSchema, | ||
| handler: async () => inventoryStatsPayload(clients), | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Cookie and data-flow inventory triage counts. | ||
| * | ||
| * Renders as an interactive dashboard on hosts that support MCP Apps, and | ||
| * returns plain JSON everywhere else. | ||
| */ | ||
| export function createConsentGetInventoryStatsTool(clients: ToolClients) { | ||
| return defineToolWithCapabilities({ | ||
| name: 'consent_get_inventory_stats', | ||
| description: | ||
| 'Get cookie and data-flow inventory triage counts: live (approved), needs review, and junk. ' + | ||
| 'This is inventory status, not consent analytics — use consent_get_aggregate_analytics or ' + | ||
| 'consent_get_timeseries_analytics for opt-in/out and signal metrics.', | ||
| 'consent_get_timeseries_analytics for opt-in/out and signal metrics. ' + | ||
| 'On hosts that support MCP Apps, renders an interactive triage dashboard.', | ||
| category: 'Consent Management', | ||
| readOnly: true, | ||
| annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, | ||
| zodSchema: GetInventoryStatsSchema, | ||
| handler: async () => { | ||
| const airgapBundleId = await resolveAirgapBundleId(clients.graphql); | ||
| const variables = { input: { airgapBundleId } }; | ||
| const [cookieData, dfData] = await Promise.all([ | ||
| clients.graphql.makeRequest<TranscendCliCookieStatsResponse>(COOKIE_STATS, variables), | ||
| clients.graphql.makeRequest<TranscendCliDataFlowStatsResponse>(DATA_FLOW_STATS, variables), | ||
| ]); | ||
| return createToolResult(true, { | ||
| cookies: cookieData.cookieStats, | ||
| dataFlows: dfData.dataFlowStats, | ||
| }); | ||
| handler: async () => inventoryStatsPayload(clients), | ||
| variants: { | ||
| [McpClientCapability.McpApp]: { | ||
| resource: INVENTORY_STATS_APP_RESOURCE, | ||
| handler: async () => inventoryStatsPayload(clients), | ||
| appOnlyTools: [createInventoryStatsRefreshTool(clients)], | ||
| }, | ||
| }, | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| import { useMcpApp } from '@transcend-io/mcp-server-base/ui'; | ||
|
|
||
| import { Grid } from './components/Grid.tsx'; | ||
| import { Heading } from './components/Heading.tsx'; | ||
| import { MetricCard } from './components/MetricCard.tsx'; | ||
| import { ProgressBar } from './components/ProgressBar.tsx'; | ||
|
|
||
| /** Tracker triage counts returned for cookies or data flows. */ | ||
| interface TrackerStats { | ||
| /** Number of live (approved) items */ | ||
| liveCount?: number; | ||
| /** Number of items needing review */ | ||
| needReviewCount?: number; | ||
| /** Number of junked items */ | ||
| junkCount?: number; | ||
| } | ||
|
|
||
| /** Payload shape returned by `consent_get_inventory_stats` and its refresh companion. */ | ||
| interface InventoryStatsData { | ||
| /** Cookie triage counts */ | ||
| cookies?: TrackerStats; | ||
| /** Data-flow triage counts */ | ||
| dataFlows?: TrackerStats; | ||
| } | ||
|
|
||
| /** Classes shared by every state, so none can drift from the loaded one. */ | ||
| const PANEL = 'flex flex-col gap-4 rounded-lg bg-card-sunken px-4 py-4'; | ||
| const CARD = 'rounded-lg bg-surface-raised px-6 py-5 shadow-sm'; | ||
| const TITLE = 'mb-1 text-heading-md font-semibold text-content'; | ||
| const SUBTITLE = 'text-sm text-content-muted'; | ||
|
|
||
| /** Sum of live + needs-review + junk, treating missing fields as zero. */ | ||
| function totalOf(stats: TrackerStats | undefined): number { | ||
| if (!stats) return 0; | ||
| return (stats.liveCount ?? 0) + (stats.needReviewCount ?? 0) + (stats.junkCount ?? 0); | ||
| } | ||
|
|
||
| /** Triage segments for a ProgressBar from tracker stats. */ | ||
| function triageSegments(stats: TrackerStats | undefined) { | ||
| return [ | ||
| { label: 'Live', value: stats?.liveCount ?? 0, tone: 'success' as const }, | ||
| { label: 'Needs review', value: stats?.needReviewCount ?? 0, tone: 'warning' as const }, | ||
| { label: 'Junk', value: stats?.junkCount ?? 0, tone: 'danger' as const }, | ||
| ]; | ||
| } | ||
|
|
||
| /** | ||
| * Interactive inventory triage dashboard for `consent_get_inventory_stats`. | ||
| * | ||
| * Shows cookie and data-flow live / needs-review / junk breakdowns. Styled only | ||
| * with utilities from `@transcend-io/mcp-server-base/ui/theme.css`. | ||
| */ | ||
| export function InventoryStatsView() { | ||
| const { data, isConnected, connectionError, toolError, isCallingTool, callTool } = | ||
| useMcpApp<InventoryStatsData>({ | ||
| appInfo: { name: 'transcend-consent-inventory-stats', version: '1.0.0' }, | ||
| }); | ||
|
|
||
| if (connectionError) { | ||
| return ( | ||
| <section className={`${CARD} border-l-4 border-l-danger`} role="alert"> | ||
| <h1 className={TITLE}>Could not reach the host</h1> | ||
| <p className={SUBTITLE}>{connectionError.message}</p> | ||
| </section> | ||
| ); | ||
| } | ||
|
|
||
| if (!isConnected) { | ||
| return ( | ||
| <section className={CARD} aria-busy="true"> | ||
| <h1 className={TITLE}>Connecting…</h1> | ||
| <p className={SUBTITLE}>Waiting for the host handshake.</p> | ||
| </section> | ||
| ); | ||
| } | ||
|
|
||
| const cookieTotal = totalOf(data?.cookies); | ||
| const dataFlowTotal = totalOf(data?.dataFlows); | ||
| const needsReviewTotal = | ||
| (data?.cookies?.needReviewCount ?? 0) + (data?.dataFlows?.needReviewCount ?? 0); | ||
|
|
||
| return ( | ||
| <div className={PANEL}> | ||
| <div className="flex flex-wrap items-center justify-between gap-3"> | ||
| <Heading text="Inventory triage" variant="title" /> | ||
| <button | ||
| className="shrink-0 rounded-sm bg-brand px-3.5 py-1.5 text-sm font-medium text-content-inverse transition-colors hover:not-disabled:bg-brand-hovered active:not-disabled:bg-brand-pressed disabled:cursor-default disabled:opacity-60" | ||
| type="button" | ||
| disabled={isCallingTool} | ||
| onClick={() => { | ||
| void callTool('consent_get_inventory_stats_refresh', {}); | ||
| }} | ||
| > | ||
| {isCallingTool ? 'Refreshing…' : 'Refresh'} | ||
| </button> | ||
| </div> | ||
|
|
||
| {toolError ? ( | ||
| <p className="text-sm text-danger" role="alert"> | ||
| {toolError} | ||
| </p> | ||
| ) : null} | ||
|
|
||
| <Grid columns={3}> | ||
| <MetricCard label="Cookies" value={cookieTotal} format="number" /> | ||
| <MetricCard label="Data flows" value={dataFlowTotal} format="number" /> | ||
| <MetricCard | ||
| label="Needs review" | ||
| value={needsReviewTotal} | ||
| format="number" | ||
| note={ | ||
| needsReviewTotal > 0 | ||
| ? { text: 'Across cookies and data flows', tone: 'neutral' } | ||
| : undefined | ||
| } | ||
| /> | ||
| </Grid> | ||
|
|
||
| <Grid columns={1}> | ||
| <ProgressBar label="Cookie triage" segments={triageSegments(data?.cookies)} /> | ||
| <ProgressBar label="Data flow triage" segments={triageSegments(data?.dataFlows)} /> | ||
| </Grid> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import type { ReactNode } from 'react'; | ||
|
|
||
| /** Props for {@link Grid}. */ | ||
| export interface GridProps { | ||
| /** Number of equal columns (1–4) */ | ||
| columns: 1 | 2 | 3 | 4; | ||
| /** Grid children */ | ||
| children?: ReactNode; | ||
| } | ||
|
|
||
| const COLUMN_CLASS: Record<1 | 2 | 3 | 4, string> = { | ||
| 1: 'grid-cols-1', | ||
| 2: 'grid-cols-2', | ||
| 3: 'grid-cols-3', | ||
| 4: 'grid-cols-4', | ||
| }; | ||
|
|
||
| /** | ||
| * Equal-column grid for laying out MetricCards or stacking sections. | ||
| * | ||
| * columns: 1 with vertical gap is the implicit page stack. | ||
| */ | ||
| export function Grid({ columns, children }: GridProps) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you think these UI components are generically usable enough to warrant moving them to a shared package?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah sorry, I asked agent to create the PR but forgot to draft it. It should go to shared components! |
||
| return <div className={`grid gap-3 ${COLUMN_CLASS[columns]}`}>{children}</div>; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| /** Heading size / weight used in dashboard section headers. */ | ||
| export type HeadingVariant = 'eyebrow' | 'title' | 'section'; | ||
|
|
||
| /** Props for {@link Heading}. */ | ||
| export interface HeadingProps { | ||
| /** Display text */ | ||
| text: string; | ||
| /** Visual variant; defaults to title when omitted */ | ||
| variant?: HeadingVariant; | ||
| } | ||
|
|
||
| /** | ||
| * The dashboard sits on a light panel, so headings use the `on-card` family | ||
| * rather than the host-adaptive `content` family — otherwise a dark host paints | ||
| * white text onto that light panel. | ||
| */ | ||
| const VARIANT_CLASS: Record<HeadingVariant, string> = { | ||
| eyebrow: 'text-sm font-semibold tracking-wide text-on-card-muted uppercase', | ||
| title: 'text-heading-md font-semibold text-on-card', | ||
| section: 'text-heading-sm font-semibold text-on-card', | ||
| }; | ||
|
|
||
| /** Dashboard heading without period chips (inventory stats are not time-windowed). */ | ||
| export function Heading({ text, variant = 'title' }: HeadingProps) { | ||
| return <h2 className={VARIANT_CLASS[variant]}>{text}</h2>; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we put this for all mcp packages?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It only needs then when packages have MCP apps
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should have worded that one better. I was thinking of adding it to the root .gitignore with packages/mcp/**/ui/generated.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That would work! I will put it in separate PR so that lakitu team can review it without blocking this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good call!