Skip to content

Commit 04471dc

Browse files
chelojimenezclaude
andauthored
Add @mcpjam/chat-ui (Tier A read-only transcript renderer) (#2678)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 172694d commit 04471dc

39 files changed

Lines changed: 2441 additions & 73 deletions

.changeset/chat-ui-tier-a.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@mcpjam/chat-ui": minor
3+
---
4+
5+
Introduce `@mcpjam/chat-ui` — a reusable, read-only transcript renderer (Tier
6+
A) for AI SDK `UIMessage`s. Renders text, reasoning, files, sources, JSON/data
7+
parts, approvals-as-state, and tool call/result blocks. Widget-bearing tool
8+
calls render a deterministic placeholder; the package is provider-free (no
9+
Convex/PostHog/inspector/widget-runtime imports, enforced by a build-time
10+
guard). Hosts can inject interactive tool/widget rendering via the
11+
`renderTool`/`renderWidget` seams on `Transcript`.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ mcpjam-inspector/server/routes/apps/**/*.bundled.ts
99
mcpjam-inspector/server/utils/browser-harness/*.generated.ts
1010
cli/dist/
1111
sdk/dist/
12+
chat-ui/dist/

chat-ui/README.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# @mcpjam/chat-ui
2+
3+
A reusable, **read-only transcript renderer** for AI SDK-style chat messages
4+
(`UIMessage`). Renders text, reasoning, files, sources, JSON/data parts,
5+
approvals-as-state, and tool call/result blocks.
6+
7+
This is **Tier A**: it does **not** render MCP Apps widgets. Widget-bearing tool
8+
calls render a deterministic placeholder (or are hidden). It has **zero runtime
9+
imports** from Convex, PostHog, inspector stores/state/contexts, the MCP Apps
10+
renderer, sandbox/iframe code, or widget replay — enforced by
11+
`scripts/check-no-tier-b-imports.mjs`.
12+
13+
## Install
14+
15+
```bash
16+
npm install @mcpjam/chat-ui
17+
```
18+
19+
Peer deps: `react`, `react-dom`, `ai`, `@ai-sdk/react`.
20+
21+
## Usage
22+
23+
```tsx
24+
import { ReadOnlyTranscript } from "@mcpjam/chat-ui";
25+
import "@mcpjam/chat-ui/styles.css";
26+
27+
export function Transcript({ messages }) {
28+
return (
29+
<ReadOnlyTranscript
30+
messages={messages}
31+
model={{ id: "gpt-5", name: "GPT-5", provider: "openai" }}
32+
reasoningDisplayMode="collapsed"
33+
widgetPolicy="placeholder"
34+
themeMode="system"
35+
/>
36+
);
37+
}
38+
```
39+
40+
### `ReadOnlyTranscript` props
41+
42+
| Prop | Type | Default |
43+
| ---------------------- | ----------------------------------------------- | -------------------------------------------------- |
44+
| `messages` | `UIMessage[]` ||
45+
| `model` | `ChatUiModel` | `{ id: "unknown", name: "Unknown", provider: "custom" }` |
46+
| `toolsMetadata` | `Record<string, Record<string, unknown>>` | `{}` |
47+
| `toolServerMap` | `Record<string, string>` | `{}` |
48+
| `toolRenderOverrides` | `Record<string, ToolRenderOverride>` ||
49+
| `themeMode` | `"light" \| "dark" \| "system"` | `"system"` |
50+
| `reasoningDisplayMode` | `"inline" \| "collapsible" \| "collapsed" \| "hidden"` | `"inline"` |
51+
| `widgetPolicy` | `"placeholder" \| "hidden"` | `"placeholder"` |
52+
| `className` | `string` ||
53+
54+
### Host integration (interactive embedders)
55+
56+
`ReadOnlyTranscript` is fully static. Hosts that need interactivity (e.g. the
57+
MCPJam inspector) use the lower-level `Transcript` and inject seams — keeping
58+
the package free of their wiring:
59+
60+
- `renderTool(ctx)` — render your own interactive tool block (save-view,
61+
display-mode controls, etc.) instead of the static `ToolCallPart`.
62+
- `renderWidget(input)` — mount a real widget surface instead of the placeholder.
63+
64+
```tsx
65+
import { Transcript } from "@mcpjam/chat-ui";
66+
67+
<Transcript
68+
messages={messages}
69+
renderWidget={(input) => <MyWidget {...input} />}
70+
renderTool={(ctx) => <MyInteractiveToolPart {...ctx} />}
71+
/>;
72+
```
73+
74+
## Styling
75+
76+
The renderer uses shadcn-style semantic utility classes
77+
(`text-muted-foreground`, `bg-card`, `border-border`, …). Consumers need a
78+
Tailwind v4-compatible utility layer. `@mcpjam/chat-ui/styles.css` ships the
79+
token *values* (scoped to `.mcpjam-chat-ui`) with light/dark defaults; override
80+
any `--token` to theme.
81+
82+
> A fully self-contained compiled CSS bundle (so consumers don't need their own
83+
> Tailwind) is a planned follow-up.
84+
85+
## Scope
86+
87+
Tier A is read-only transcript review. Full MCP Apps widget replay (sandbox
88+
origin, CSP, security review) is a separate Tier B effort.

chat-ui/package.json

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"name": "@mcpjam/chat-ui",
3+
"version": "0.0.0",
4+
"description": "Reusable, read-only transcript renderer for AI SDK chat messages (MCPJam).",
5+
"license": "MIT",
6+
"type": "module",
7+
"main": "dist/index.js",
8+
"module": "dist/index.js",
9+
"types": "dist/index.d.ts",
10+
"files": [
11+
"dist"
12+
],
13+
"exports": {
14+
".": {
15+
"types": "./dist/index.d.ts",
16+
"import": "./dist/index.js",
17+
"default": "./dist/index.js"
18+
},
19+
"./styles.css": "./dist/styles.css"
20+
},
21+
"sideEffects": [
22+
"**/*.css"
23+
],
24+
"scripts": {
25+
"build": "npm run check:tier-b && tsup && node scripts/copy-css.mjs",
26+
"typecheck": "tsc --noEmit",
27+
"test": "npm run check:tier-b && vitest run",
28+
"check:tier-b": "node scripts/check-no-tier-b-imports.mjs",
29+
"prepublishOnly": "npm run build"
30+
},
31+
"peerDependencies": {
32+
"@ai-sdk/react": "^3.0.0",
33+
"ai": "^6.0.0",
34+
"react": "^19.0.0",
35+
"react-dom": "^19.0.0"
36+
},
37+
"dependencies": {
38+
"clsx": "^2.1.1",
39+
"lucide-react": "^0.525.0",
40+
"streamdown": "^2.1.0",
41+
"tailwind-merge": "^3.3.1"
42+
},
43+
"devDependencies": {
44+
"@ai-sdk/react": "^3.0.156",
45+
"@testing-library/jest-dom": "^6.9.1",
46+
"@testing-library/react": "^16.3.1",
47+
"@types/react": "^19",
48+
"@types/react-dom": "^19",
49+
"ai": "^6.0.154",
50+
"jsdom": "^27.4.0",
51+
"react": "19.1.0",
52+
"react-dom": "19.1.0",
53+
"tsup": "^8.3.5",
54+
"typescript": "^5",
55+
"vitest": "^3.2.4"
56+
}
57+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Tier A guard: @mcpjam/chat-ui must stay provider-free and must not pull in
2+
// any inspector wiring, MCP Apps widget runtime, sandbox/iframe, or analytics.
3+
//
4+
// This is the package-local equivalent of the root
5+
// `check:mcp-v1-runtime-imports` script. It fails the build if any forbidden
6+
// module is imported anywhere under src/ (tests included — the renderer must
7+
// be testable without these too).
8+
import { readdirSync, readFileSync, statSync } from "node:fs";
9+
import { dirname, join, relative } from "node:path";
10+
import { fileURLToPath } from "node:url";
11+
12+
const here = dirname(fileURLToPath(import.meta.url));
13+
const srcDir = join(here, "..", "src");
14+
15+
// Each entry is matched against the *module specifier* of every import/export
16+
// `from "..."` and dynamic `import("...")` in the source.
17+
const FORBIDDEN = [
18+
"convex",
19+
"posthog-js",
20+
"@/stores",
21+
"@/state",
22+
"@/contexts",
23+
"@/hooks",
24+
"widget-replay",
25+
"mcp-apps",
26+
"csp-workbench",
27+
"sandboxed-iframe",
28+
"sandbox-proxy",
29+
"@modelcontextprotocol/ext-apps",
30+
"@mcp-ui/client",
31+
"@/lib/client-config",
32+
"@/lib/app-navigation",
33+
"@/lib/host-capabilities",
34+
"@mcpjam/design-system",
35+
];
36+
37+
const SPECIFIER_RE =
38+
/(?:import|export)\s[^;]*?from\s*["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)/g;
39+
40+
function walk(dir) {
41+
const out = [];
42+
for (const name of readdirSync(dir)) {
43+
const full = join(dir, name);
44+
if (statSync(full).isDirectory()) out.push(...walk(full));
45+
else if (/\.(ts|tsx)$/.test(name)) out.push(full);
46+
}
47+
return out;
48+
}
49+
50+
const violations = [];
51+
for (const file of walk(srcDir)) {
52+
const text = readFileSync(file, "utf8");
53+
let m;
54+
while ((m = SPECIFIER_RE.exec(text)) !== null) {
55+
const spec = m[1] ?? m[2];
56+
if (!spec) continue;
57+
const segments = spec.split("/");
58+
for (const bad of FORBIDDEN) {
59+
// Match an exact specifier, a subpath import (`bad/...`), or `bad` as a
60+
// full path segment (so `./widget-replay` and `../mcp-apps/x` are
61+
// caught) — without flagging unrelated names that merely contain the
62+
// substring (e.g. `my-convex-helper`).
63+
if (
64+
spec === bad ||
65+
spec.startsWith(`${bad}/`) ||
66+
segments.includes(bad)
67+
) {
68+
violations.push({ file: relative(srcDir, file), spec, bad });
69+
}
70+
}
71+
}
72+
}
73+
74+
if (violations.length > 0) {
75+
console.error("Tier A import guard FAILED. Forbidden imports found:\n");
76+
for (const v of violations) {
77+
console.error(` ${v.file}: "${v.spec}" (matches "${v.bad}")`);
78+
}
79+
console.error(
80+
"\n@mcpjam/chat-ui (Tier A) must not import provider/widget/inspector modules.",
81+
);
82+
process.exit(1);
83+
}
84+
85+
console.log("Tier A import guard passed: no forbidden imports under src/.");

chat-ui/scripts/copy-css.mjs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copy package-owned CSS into dist so consumers can `import
2+
// "@mcpjam/chat-ui/styles.css"`. tsup only emits the JS/d.ts bundle; the
3+
// stylesheet is shipped as-is.
4+
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
5+
import { dirname, join } from "node:path";
6+
import { fileURLToPath } from "node:url";
7+
8+
const here = dirname(fileURLToPath(import.meta.url));
9+
const root = join(here, "..");
10+
const src = join(root, "src", "styles.css");
11+
const distDir = join(root, "dist");
12+
const dest = join(distDir, "styles.css");
13+
14+
if (!existsSync(src)) {
15+
console.error(`[copy-css] missing source stylesheet: ${src}`);
16+
process.exit(1);
17+
}
18+
mkdirSync(distDir, { recursive: true });
19+
copyFileSync(src, dest);
20+
console.log(`[copy-css] ${src} -> ${dest}`);

chat-ui/src/__tests__/factories.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { UIMessage } from "@ai-sdk/react";
2+
3+
// Minimal UIMessage factories for tests. We cast through `unknown` because the
4+
// renderer only reads `id`, `role`, and `parts` — building the full AI SDK
5+
// message shape in every test would be noise.
6+
7+
export function userText(text: string, id = "u1"): UIMessage {
8+
return {
9+
id,
10+
role: "user",
11+
parts: [{ type: "text", text }],
12+
} as unknown as UIMessage;
13+
}
14+
15+
export function assistantParts(
16+
parts: Array<Record<string, unknown>>,
17+
id = "a1",
18+
): UIMessage {
19+
return { id, role: "assistant", parts } as unknown as UIMessage;
20+
}
21+
22+
export function toolPart(opts: {
23+
toolName: string;
24+
toolCallId?: string;
25+
state?: string;
26+
input?: unknown;
27+
output?: unknown;
28+
errorText?: string;
29+
}): Record<string, unknown> {
30+
return {
31+
type: `tool-${opts.toolName}`,
32+
toolCallId: opts.toolCallId ?? "call-1",
33+
state: opts.state ?? "output-available",
34+
input: opts.input,
35+
output: opts.output,
36+
errorText: opts.errorText,
37+
};
38+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, it, expect } from "vitest";
2+
3+
import { safeStringify } from "../internal/thread-helpers";
4+
import { isSafeImageUrl } from "../internal/safe-external-url";
5+
import {
6+
UIType,
7+
detectUIType,
8+
getUIResourceUri,
9+
isWidgetUiType,
10+
} from "../internal/widget-detection";
11+
12+
describe("safeStringify", () => {
13+
it("always returns a string (even for undefined)", () => {
14+
expect(typeof safeStringify(undefined)).toBe("string");
15+
expect(safeStringify(undefined)).toBe("undefined");
16+
expect(safeStringify({ a: 1 })).toContain("\"a\": 1");
17+
});
18+
});
19+
20+
describe("isSafeImageUrl", () => {
21+
it("allows inline data:image/* and absolute https:", () => {
22+
expect(isSafeImageUrl("data:image/png;base64,AAAA")).toBe(true);
23+
expect(isSafeImageUrl("https://example.com/cat.png")).toBe(true);
24+
});
25+
26+
it("rejects http, javascript:, non-image data:, and junk", () => {
27+
expect(isSafeImageUrl("http://example.com/cat.png")).toBe(false);
28+
expect(isSafeImageUrl("javascript:alert(1)")).toBe(false);
29+
expect(isSafeImageUrl("data:text/html,<script>")).toBe(false);
30+
expect(isSafeImageUrl("")).toBe(false);
31+
expect(isSafeImageUrl(undefined)).toBe(false);
32+
});
33+
});
34+
35+
describe("detectUIType", () => {
36+
it("detects MCP Apps via flat (ui/resourceUri) and nested keys", () => {
37+
expect(detectUIType({ "ui/resourceUri": "ui://x" }, undefined)).toBe(
38+
UIType.MCP_APPS,
39+
);
40+
expect(detectUIType({ ui: { resourceUri: "ui://x" } }, undefined)).toBe(
41+
UIType.MCP_APPS,
42+
);
43+
expect(getUIResourceUri(UIType.MCP_APPS, { "ui/resourceUri": "ui://x" })).toBe(
44+
"ui://x",
45+
);
46+
});
47+
48+
it("only treats a non-empty string openai/outputTemplate as a widget", () => {
49+
// Truthy non-string metadata must NOT classify as a widget.
50+
expect(detectUIType({ "openai/outputTemplate": true }, undefined)).toBeNull();
51+
expect(detectUIType({ "openai/outputTemplate": "" }, undefined)).toBeNull();
52+
const ok = detectUIType({ "openai/outputTemplate": "ui://tmpl" }, undefined);
53+
expect(ok).toBe(UIType.OPENAI_SDK);
54+
expect(isWidgetUiType(ok)).toBe(true);
55+
});
56+
57+
it("returns null for plain tools", () => {
58+
expect(detectUIType({ foo: "bar" }, undefined)).toBeNull();
59+
expect(isWidgetUiType(null)).toBe(false);
60+
});
61+
});

0 commit comments

Comments
 (0)