Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
67 changes: 66 additions & 1 deletion apps/mcp-ui-poc/client/src/components/chat-interface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { Message } from "../types/virtual-dom";
import {
StructuredDomRenderer,
type StructuredDomContent,
type Intent,
} from "./structured-dom-renderer";

export function ChatInterface() {
Expand Down Expand Up @@ -159,6 +160,67 @@ export function ChatInterface() {
}
}

// Handle intents emitted from UI components
async function handleIntentEmit(intent: Intent) {
console.log("🎯 Intent received:", intent);

// Combine description + structured payload for Claude
const message = `${intent.description}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We don't have to show a message to the user. It's just here for DX


[Intent Context]
Type: ${intent.type}
Payload: ${JSON.stringify(intent.payload, null, 2)}`;

console.log("💬 Forwarding intent to Claude:", message);

// Add user message to chat (show cleaner version to user)
const userMessage: Message = {
role: "user",
content: `[Intent: ${intent.type}] ${intent.description}`,
};
setMessages((prev) => [...prev, userMessage]);

setIsLoading(true);

try {
// Build message history for context
const messageHistory = messages.map((msg) => ({
role: msg.role,
content: msg.content || "",
}));

// Send to Claude with full description + payload
const { text, uiResources } = await claudeClient.sendMessage(message, {
uiToolsEnabled,
commerceToolsEnabled,
messageHistory,
});

// Add Claude's response
setMessages((prev) => [
...prev,
{
role: "assistant",
content: text,
uiResources,
},
]);

console.log("✅ Intent processed successfully");
} catch (error) {
console.error("❌ Error handling intent:", error);
setMessages((prev) => [
...prev,
{
role: "assistant",
content: `Error: ${(error as Error).message}`,
},
]);
} finally {
setIsLoading(false);
}
}

// Show initialization error if present
if (initError) {
return (
Expand Down Expand Up @@ -305,7 +367,10 @@ export function ChatInterface() {
console.log("✨ Using StructuredDomRenderer");
return (
<Box key={i} marginTop="300">
<StructuredDomRenderer content={parsedContent} />
<StructuredDomRenderer
content={parsedContent}
onIntentEmit={handleIntentEmit}
/>
</Box>
);
} catch (error) {
Expand Down
33 changes: 29 additions & 4 deletions apps/mcp-ui-poc/client/src/components/structured-dom-renderer.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
import React from "react";
import { componentMap } from "./nimbus-library";

/**
* Intent structure matching server-side definition
*/
export interface Intent {
type: string;
description: string;
payload: Record<string, unknown>;
}

/**
* Types matching server-side definitions
*/
export interface ElementDefinition {
tagName: string;
attributes?: Record<string, string | boolean | number | undefined>;
children?: (ElementDefinition | string)[];
events?: {
onPress?: Intent;
};
}

export interface StructuredDomContent {
Expand All @@ -21,7 +33,8 @@ export interface StructuredDomContent {
*/
function renderElement(
element: ElementDefinition,
index: number = 0
index: number = 0,
onIntentEmit?: (intent: Intent) => void
): React.ReactNode {
// Find the corresponding Nimbus component
const Component = componentMap[element.tagName];
Expand All @@ -32,14 +45,23 @@ function renderElement(
}

// ✅ Pass attributes directly - server sends camelCase React props
const props = element.attributes || {};
const props: Record<string, unknown> = { ...(element.attributes || {}) };

// Attach event handlers if present
if (element.events?.onPress && onIntentEmit) {
const intent = element.events.onPress;
props.onPress = () => {
console.log("🎯 Intent emitted:", intent);
onIntentEmit(intent);
};
}

// Render children (can be text or nested elements)
const children = element.children?.map((child, childIndex) => {
if (typeof child === "string") {
return child;
}
return renderElement(child, childIndex);
return renderElement(child, childIndex, onIntentEmit);
});

return (
Expand All @@ -57,11 +79,14 @@ function renderElement(
* - ✅ Secure: No code execution (new Function())
* - ✅ Debuggable: Data structures instead of code strings
* - ✅ Performance: Direct React rendering, no script parsing
* - ✅ Intent support: Type-safe event handlers for user interactions
*/
export function StructuredDomRenderer({
content,
onIntentEmit,
}: {
content: StructuredDomContent;
onIntentEmit?: (intent: Intent) => void;
}) {
return <>{renderElement(content.element)}</>;
return <>{renderElement(content.element, 0, onIntentEmit)}</>;
}
6 changes: 5 additions & 1 deletion apps/mcp-ui-poc/server/src/elements/button.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ElementDefinition } from "../types/remote-dom.js";
import type { ElementDefinition, Intent } from "../types/remote-dom.js";

export interface ButtonElementArgs {
label: string;
Expand All @@ -8,6 +8,8 @@ export interface ButtonElementArgs {
isDisabled?: boolean;
type?: "button" | "submit" | "reset";
ariaLabel?: string;
/** Optional intent to emit when button is pressed */
intent?: Intent;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@ByronDWall It is powerful to give the agent complete control over the intent.

One of the issues I've been grappling with lately is how much control we exert over these UI templates when it comes to statefulness, that is, what priority data do we attach to intent handlers (product name, id, sku, etc)? I don't know that we can make this call in all cases, when we as developers won't have full conversation context. So, I chose to experiment by leaning into the inverse of this to give agents more control (continued below)

}

/**
Expand All @@ -23,6 +25,7 @@ export function buildButtonElement(args: ButtonElementArgs): ElementDefinition {
isDisabled = false,
type = "button",
ariaLabel,
intent,
} = args;

return {
Expand All @@ -37,5 +40,6 @@ export function buildButtonElement(args: ButtonElementArgs): ElementDefinition {
"aria-label": ariaLabel,
},
children: [label],
events: intent ? { onPress: intent } : undefined,
};
}
52 changes: 50 additions & 2 deletions apps/mcp-ui-poc/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ function registerTools(server: McpServer) {
{
title: "Create Product Card",
description:
"Creates a product card UI component with name, price, description, image, and stock status using Nimbus design system components.",
"Creates a product card UI component with name, price, description, image, stock status, and an action button.",
inputSchema: z.object({
productId: z
.string()
Expand All @@ -310,6 +310,32 @@ function registerTools(server: McpServer) {
.boolean()
.optional()
.describe("Whether the product is in stock (default: true)"),
buttonLabel: z
.string()
.describe(
"Label text for the action button. Choose based on the intent type and user context."
),
buttonIntent: z
.object({
type: z
.string()
.describe(
"Intent type identifier in underscore_case. Choose based on what makes sense for the user's query context."
),
description: z
.string()
.describe(
"Human-readable description of what the user wants to do when clicking this button. Consider the current conversation context and what logical next step."
),
payload: z
.record(z.any())
.describe(
"Structured data payload for this intent. Include all relevant entity information."
),
})
.describe(
"Intent configuration for the product card's action button. You decide what this button should do based on the user's query context and next logical steps."
),
Comment on lines +318 to +343
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@ByronDWall Here, the agent has complete control over the type of the intent, description, and payload. This has a few benefits:

  1. The agent already has user query and conversation context available to it, so it's more informed than we are
  2. The agent can suggest next steps for a flow, which is a powerful way to steer users in novel directions
  3. Reduced client/server coupling to predefined intents, reducing our maintenance burden

This also has a few drawbacks:

  1. Our templating becomes less deterministic
  2. Few guards against hallucinations (but I remain positive here, as I believe the agent should be able to recover with sufficient type, description, and payload content)

As with most things, we can use the best of both worlds. With this strategy, we can offer a dynamic avenue for agent-steered suggestion. For templates that require a high level of determinism, we can create hardcoded intents with stricter data boundaries.

}),
},
async (args) => {
Expand Down Expand Up @@ -650,7 +676,7 @@ function registerTools(server: McpServer) {
{
title: "Create Button",
description:
"Creates a button UI component using Nimbus design system. Supports HTML form submission types.",
"Creates a button UI component using Nimbus design system. Supports HTML form submission types and optional intent emission for interactive workflows.",
inputSchema: z.object({
label: z.string().describe("Button label text"),
variant: z
Expand Down Expand Up @@ -678,6 +704,28 @@ function registerTools(server: McpServer) {
.describe(
"Accessible label for the button (overrides visible label for screen readers)"
),
intent: z
.object({
type: z
.string()
.describe(
"Intent type identifier in underscore_case. Choose a name that clearly describes the user's desired action."
),
description: z
.string()
.describe(
"Human-readable description of what the user wants to do when clicking this button. Write as if the user is speaking. Be specific and include relevant context from the current interaction."
),
payload: z
.record(z.any())
.describe(
"Structured data payload containing relevant information for the agent to act on the intent. Include IDs, names, current state, or any other data the agent might need to fulfill the user's request."
),
})
.optional()
.describe(
"Optional intent to emit when button is pressed. When provided, clicking the button sends this intent to the agent, allowing for dynamic, context-aware interactions. Use this for buttons that should trigger the agent to take further action."
),
}),
},
async (args) => {
Expand Down
14 changes: 11 additions & 3 deletions apps/mcp-ui-poc/server/src/tools/product-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export interface ProductCardArgs {
description?: string;
imageUrl?: string;
inStock?: boolean;
buttonLabel: string;
buttonIntent: {
type: string;
description: string;
payload: Record<string, unknown>;
};
}

export function createProductCard(args: ProductCardArgs) {
Expand All @@ -28,6 +34,8 @@ export function createProductCard(args: ProductCardArgs) {
description = "",
imageUrl,
inStock = true,
buttonLabel,
buttonIntent,
} = args;

// ✅ Build structured product card using element builders
Expand Down Expand Up @@ -75,12 +83,12 @@ export function createProductCard(args: ProductCardArgs) {
content: description,
}),
buildButtonElement({
label: "Add to Cart",
variant: "solid",
label: buttonLabel,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Along with the agent steering the intent, the agent also chooses the button text

variant: "outline",
colorPalette: "primary",
width: "full",
isDisabled: !inStock,
type: "button",
intent: buttonIntent,
}),
],
});
Expand Down
25 changes: 25 additions & 0 deletions apps/mcp-ui-poc/server/src/types/remote-dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@
* Remote DOM type definitions for MCP-UI server
*/

/**
* Intent structure for user actions that bubble up from components.
* The description field is human-readable text that Claude interprets directly.
*/
export interface Intent {
/** Intent type identifier (e.g., "view_details", "add_to_cart") */
type: string;
/**
* Human-readable description of what the user wants to do.
* This is what Claude will see and interpret.
* Should be written as if the user is speaking to Claude.
*/
description: string;
/**
* Structured payload with intent-specific data.
* Claude can use this for tool calls or additional context.
*/
payload: Record<string, unknown>;
}

/**
* Structured element definition for type-safe UI serialization
*/
Expand All @@ -12,6 +32,11 @@ export interface ElementDefinition {
attributes?: Record<string, string | boolean | number | undefined>;
/** Child elements (can be nested ElementDefinitions or text nodes) */
children?: (ElementDefinition | string)[];
/** Event handlers for user interactions */
events?: {
/** Press event (for buttons) - emits an intent */
onPress?: Intent;
};
}

// Re-export from index for convenience
Expand Down