Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/consent-inventory-stats-app.md
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.
29 changes: 29 additions & 0 deletions packages/mcp/mcp-server-base/src/ui/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,31 @@
--color-line-subtle: var(--color-border-secondary, var(--border-subtle, #ebebef));
--color-focus: var(--color-ring-primary, var(--border-focused, #3e2ebc));

/*
* KPI / metric cards stay on a light surface even when the host is dark.
* Status colors (`text-success`, `text-danger`) are authored for light
* backgrounds, and Claude's dark chrome makes them unreadable — so these
* deliberately skip host background/text variables and use Transcend light
* tokens only.
*/
--color-card: var(--background-default, #ffffff);
--color-card-sunken: var(--background-neutral, #ebebef);
--color-on-card: var(--text, #1e1d28);
--color-on-card-muted: var(--text-subtle, #55535f);
--color-on-card-subtle: var(--text-subtlest, #85838f);
--color-card-line: var(--border-default, #d6d5db);

/*
* Fills for bars and swatches. Separate from the `--color-success` family
* below because those resolve to `--text-*-bold` — near-black green and brown
* that read as mud when painted as a shape rather than set as type.
*/
--color-fill-brand: var(--background-brand-bold, #5f5bf7);
--color-fill-success: var(--background-success-bold, #008200);
--color-fill-warning: var(--background-warning-bold, #c98300);
--color-fill-danger: var(--background-danger-bold, #bb0036);
--color-fill-neutral: var(--background-neutral, #ebebef);

/*
* Brand and status are Transcend's identity, so tokens lead here and the host
* is only the fallback.
Expand Down Expand Up @@ -90,6 +115,10 @@
--text-heading-sm--line-height: var(--font-heading-sm-line-height, 1.5rem);
--text-heading-md: var(--font-heading-md-size, 1.125rem);
--text-heading-md--line-height: var(--font-heading-md-line-height, 1.625rem);
/* Ours: a KPI value has to outrank its own label at a glance, which the
heading sizes above are too close to do. */
--text-metric: 1.75rem;
--text-metric--line-height: 2.125rem;

--radius-sm: var(--border-radius-sm, 6px);
--radius-md: var(--border-radius-md, 8px);
Expand Down
2 changes: 2 additions & 0 deletions packages/mcp/mcp-server-consent/.gitignore
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/

Copy link
Copy Markdown
Member

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?

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.

It only needs then when packages have MCP apps

Copy link
Copy Markdown
Member

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.

@dsklyar dsklyar Aug 19, 2026

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.

That would work! I will put it in separate PR so that lakitu team can review it without blocking this

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good call!

11 changes: 11 additions & 0 deletions packages/mcp/mcp-server-consent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@
"access": "public"
},
"scripts": {
"prebuild": "node ../../../scripts/build-mcp-views.ts",
"build": "tsdown",
"build:ui": "node ../../../scripts/build-mcp-views.ts",
"test": "vitest run --config ../../../vitest.config.mcp.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"typecheck:ui": "tsc -p tsconfig.ui.json --noEmit",
"check:exports": "attw --pack . --ignore-rules cjs-resolves-to-esm",
"check:publint": "publint --level warning --strict --pack pnpm"
},
Expand All @@ -45,10 +48,18 @@
},
"devDependencies": {
"@arethetypeswrong/cli": "catalog:",
"@modelcontextprotocol/ext-apps": "catalog:",
"@transcend-io/design-tokens": "workspace:*",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"publint": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"tailwindcss": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:"
},
"engines": {
Expand Down
28 changes: 28 additions & 0 deletions packages/mcp/mcp-server-consent/src/apps/inventory-stats.ts
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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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?

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.

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>;
}
Loading
Loading