Skip to content

feat(react-ui-base): add mcp-components compound component primitives#2252

Closed
lachieh wants to merge 1 commit intomainfrom
lachlan/tam-1055-mcp-components-compound
Closed

feat(react-ui-base): add mcp-components compound component primitives#2252
lachieh wants to merge 1 commit intomainfrom
lachlan/tam-1055-mcp-components-compound

Conversation

@lachieh
Copy link
Copy Markdown
Contributor

@lachieh lachieh commented Feb 6, 2026

Summary

  • Add unstyled compound component base primitives for MCP prompt and resource buttons
  • Implement Context + Root + sub-components pattern for flexible composition
  • Update ui-registry mcp-components wrapper to use new base primitives

Changes

New Base Components

  • McpPromptButton.Root, .Trigger, .Menu, .List, .Item
  • McpResourceButton.Root, .Trigger, .Menu, .List, .Item, .SearchInput

Features

  • Full context system with useMcpPromptButtonContext/useMcpResourceButtonContext
  • Render function support for flexible customization
  • asChild prop support via @radix-ui/react-slot
  • data-slot attributes for CSS styling hooks
  • Subpath export: @tambo-ai/react-ui-base/mcp-components

Test plan

  • Verify type checks pass
  • Verify existing mcp-components functionality in showcase
  • Test compound component composition patterns

Fixes TAM-1055

🤖 Generated with Claude Code

@charliecreates charliecreates Bot requested a review from CharlieHelps February 6, 2026 19:01
@vercel
Copy link
Copy Markdown

vercel Bot commented Feb 6, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cloud Error Error Feb 9, 2026 11:39pm
showcase Error Error Feb 9, 2026 11:39pm
tambo-docs Error Error Feb 9, 2026 11:39pm

Copy link
Copy Markdown
Contributor

@charliecreates charliecreates Bot left a comment

Choose a reason for hiding this comment

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

The new compound primitives are a solid direction, but a few base-level behaviors are too opinionated or incorrect. Most notably, McpResourceButtonSearchInput renders children into an <input> (invalid HTML) and both roots return null during loading/empty states, limiting composition and causing layout shift. Also, McpPromptButtonRoot likely triggers avoidable fetches by calling the prompt hook with an empty string when nothing is selected. Tightening these areas will make the primitives safer and more reusable.

Additional notes (2)
  • Readability | packages/react-ui-base/src/mcp-components/prompt-button/utils.ts:8-42
    extractPromptText is typed to return string but the caller treats empty string as an error (if (!promptText)). The function currently returns "" when nothing matches, which is fine, but the JSDoc says “Extracted text content joined by newlines” and doesn’t reflect the empty-result behavior.

More importantly, isValidPromptData only validates that messages is an array; extractPromptText then assumes message shapes. That’s ok for “best effort”, but the naming implies stronger validation than is actually performed.

  • Readability | packages/ui-registry/src/components/mcp-components/mcp-components.tsx:42-50
    In the registry wrapper, McpPromptButtonBase.Root is rendered with asChild but wraps its child in an extra <div>.

Because Root ultimately renders a <div> by default (or merges props into the child with Slot), this extra wrapper can interfere with styling hooks, layout, and data-slot targeting (you likely want the data-slot="mcp-prompt-button" to land on the same element your CSS expects). Same pattern is used for McpResourceButtonBase.Root.

Summary of changes

What changed

@tambo-ai/react-ui-base

  • Added a new subpath export @tambo-ai/react-ui-base/mcp-components in package.json exports.
  • Re-exported MCP primitives from the root src/index.ts (both values and types).
  • Introduced a new src/mcp-components/* module implementing unstyled compound component primitives:
    • McpPromptButton.{Root,Trigger,Menu,List,Item}
    • McpResourceButton.{Root,Trigger,Menu,List,Item,SearchInput}
  • Implemented Context + Root + sub-components pattern with:
    • useMcpPromptButtonContext / useOptionalMcpPromptButtonContext
    • useMcpResourceButtonContext / useOptionalMcpResourceButtonContext
  • Added prompt parsing helpers: extractPromptText, isValidPromptData.

@tambo-ai/ui-registry

  • Updated mcp-components registry config to depend on @tambo-ai/react-ui-base.
  • Refactored the registry mcp-components.tsx wrapper to use the new base primitives and contexts, removing duplicated prompt parsing and MCP hook logic.

Comment on lines +33 to +41
const { data: promptList, isLoading } = useTamboMcpPromptList();
const [selectedPromptName, setSelectedPromptName] = React.useState<
string | null
>(null);
const [promptError, setPromptError] = React.useState<string | null>(null);
const { data: promptData, error: fetchError } = useTamboMcpPrompt(
selectedPromptName ?? "",
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

McpPromptButtonRoot calls useTamboMcpPrompt(selectedPromptName ?? "") on every render. If the underlying hook treats an empty string as a real key, this can cause unnecessary fetches or error states whenever nothing is selected.

Even if the hook internally guards, this is an avoidable footgun in a base primitive that will be widely reused.

Suggestion

Consider gating the prompt fetch so it only runs when a valid selection exists. Depending on the @tambo-ai/react/mcp API, that might mean passing an enabled/skip option or lifting the hook behind a conditional wrapper if supported.

Example (if options exist):

const shouldFetch = selectedPromptName != null;
const { data: promptData, error: fetchError } = useTamboMcpPrompt(selectedPromptName ?? "", {
  enabled: shouldFetch,
});

If no options exist, introduce a small helper hook in this module that internally no-ops when selectedPromptName is null, rather than calling the network hook with "".

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

Comment on lines +100 to +103
// Only show if prompts are available (hide during loading and when no prompts)
if (!promptList || promptList.length === 0) {
return null;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The root returns null when promptList is undefined (initial load) or empty. That means the entire compound component disappears during loading, so consumers can’t show a disabled trigger/loading state, and layout may shift.

The comment says “hide during loading”, but this is a base primitive; hiding should be a consumer choice. Also, you’re computing/maintaining selection+error state and effects even though the UI is unmounted, which is awkward for composition.

Suggestion

Don’t hard-return null on !promptList. Prefer always rendering the provider/container and expose isLoading/isEmpty via context so the wrapper decides what to display.

For example:

const hasPrompts = !!promptList && promptList.length > 0;
// ...contextValue includes hasPrompts
return (
  <Provider value={{...contextValue, hasPrompts}}>
    <Comp ...>{children}</Comp>
  </Provider>
);

If you still want the convenience behavior, consider an explicit prop like hideWhenEmpty?: boolean defaulting to false.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

Comment on lines +50 to +72
const Comp = asChild ? Slot : "input";

return (
<Comp
ref={ref}
type="text"
data-slot="mcp-resource-button-search-input"
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSearchQuery(e.target.value)
}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
onKeyDown={(e: React.KeyboardEvent) => {
e.stopPropagation();
if (e.key === "Escape") {
setIsOpen(false);
}
}}
{...rest}
>
{content}
</Comp>
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

McpResourceButtonSearchInput renders an <input> by default, but still renders {content} as children of the input. Inputs are void elements; children are invalid HTML and will be ignored (and can trigger hydration/console warnings in React).

This is a correctness bug in the base primitive.

Suggestion

Do not render children for the default <input> case. If you need to support children for asChild, branch on asChild:

const Comp = asChild ? Slot : "input";

if (!asChild) {
  return (
    <input
      ref={ref}
      type="text"
      data-slot="mcp-resource-button-search-input"
      value={searchQuery}
      onChange={(e) => setSearchQuery(e.target.value)}
      onClick={(e) => e.stopPropagation()}
      onKeyDown={(e) => {
        e.stopPropagation();
        if (e.key === "Escape") setIsOpen(false);
      }}
      {...rest}
    />
  );
}

return (
  <Slot ref={ref} data-slot="mcp-resource-button-search-input" {...rest}>
    {content}
  </Slot>
);

(And consider whether children should be allowed at all for this component if it’s primarily an input.)

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

Comment on lines +85 to +88
// Only show if resources are available
if (!resourceList || resourceList.length === 0) {
return null;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same issue as the prompt root: the resource root returns null when resourceList is undefined (loading) or empty. This prevents consumers from showing a disabled/loading trigger and can cause layout shift.

Additionally, isOpen state is owned by the root. If the root unmounts due to resourceList becoming empty, open-state resets abruptly; that may be surprising in edge cases (e.g., list briefly undefined during refetch).

Suggestion

Avoid returning null from the base root. Always render the provider/container and expose resourceList, isLoading, and a derived isEmpty/hasResources flag.

If you want the old behavior, make it opt-in via a prop (e.g. hideWhenEmpty).

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

Comment on lines +52 to +76
const handleClick = () => {
if (onSelect) {
onSelect();
} else {
onSelectPrompt(prompt.prompt.name);
}
};

const Comp = asChild ? Slot : "div";
const content =
typeof children === "function" ? children(renderProps) : children;

return (
<Comp
ref={ref}
role="menuitem"
tabIndex={0}
data-slot="mcp-prompt-button-item"
onClick={handleClick}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
}}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

handleClick can fire twice when asChild is used and the child also has its own onClick, because Radix Slot merges event handlers (both run). That can lead to double insertion / double selection.

Also, onKeyDown triggers handleClick() on Enter/Space but does not guard against repeated keydown events (key repeat) which can also result in duplicate selection.

For compound primitives, it's safer to compose with user handlers and allow preventing the default selection behavior.

Suggestion

Support composed handlers and allow opting out via event.defaultPrevented, e.g.:

const handleSelect = () => {
  if (onSelect) return onSelect();
  onSelectPrompt(prompt.prompt.name);
};

onClick={(e) => {
  props.onClick?.(e);
  if (!e.defaultPrevented) handleSelect();
}}
onKeyDown={(e) => {
  props.onKeyDown?.(e);
  if (e.defaultPrevented) return;
  if ((e.key === "Enter" || e.key === " ") && !e.repeat) {
    e.preventDefault();
    handleSelect();
  }
}}

(Same applies to the resource item.)

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

Comment on lines +55 to +83
const handleClick = () => {
if (onSelect) {
onSelect();
} else {
onSelectResource(
resource.resource.uri,
resource.resource.name ?? resource.resource.uri,
);
}
};

const Comp = asChild ? Slot : "div";
const content =
typeof children === "function" ? children(renderProps) : children;

return (
<Comp
ref={ref}
role="menuitem"
tabIndex={0}
data-slot="mcp-resource-button-item"
onClick={handleClick}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
}}
{...props}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same issue as the prompt item: when asChild is used, merged handlers can cause the selection behavior to run in addition to consumer-provided onClick/onKeyDown, potentially resulting in double inserts.

Additionally, Space key handling on a div role="menuitem" can be inconsistent across screen readers unless you carefully emulate button behavior; guarding e.repeat helps prevent duplicate inserts.

Suggestion

Compose handlers and honor event.defaultPrevented (and !e.repeat) similar to:

const handleSelect = () => {
  if (onSelect) return onSelect();
  onSelectResource(resource.resource.uri, resource.resource.name ?? resource.resource.uri);
};

onClick={(e) => {
  props.onClick?.(e);
  if (!e.defaultPrevented) handleSelect();
}}
onKeyDown={(e) => {
  props.onKeyDown?.(e);
  if (e.defaultPrevented) return;
  if ((e.key === "Enter" || e.key === " ") && !e.repeat) {
    e.preventDefault();
    handleSelect();
  }
}}

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

@charliecreates charliecreates Bot removed the request for review from CharlieHelps February 6, 2026 19:05
@lachieh lachieh changed the base branch from lachlan/compound-component-refactors-level-0 to main February 9, 2026 22:35
@github-actions github-actions Bot added the area: config Changes to repository configuration files label Feb 9, 2026
@lachieh lachieh changed the base branch from main to lachieh/tam-1057-message-suggestions February 9, 2026 22:38
@lachieh lachieh changed the base branch from lachieh/tam-1057-message-suggestions to lachieh/tam-1047-canvas-space February 9, 2026 22:39
Add unstyled compound component base primitives for MCP prompt and resource
buttons with Context + Root + sub-components pattern.

New components in @tambo-ai/react-ui-base:
- McpPromptButton.Root, .Trigger, .Menu, .List, .Item
- McpResourceButton.Root, .Trigger, .Menu, .List, .Item, .SearchInput

Features:
- Full context system with useMcpPromptButtonContext/useMcpResourceButtonContext
- Render function support for flexible customization
- asChild prop support via @radix-ui/react-slot
- data-slot attributes for CSS styling hooks
- Subpath export: @tambo-ai/react-ui-base/mcp-components

TAM-1055

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@lachieh lachieh force-pushed the lachlan/tam-1055-mcp-components-compound branch from 9e611a9 to 88abd9e Compare February 9, 2026 23:35
@github-actions github-actions Bot added area: api Changes to the API (apps/api) area: web Changes to the web app (apps/web) area: showcase Changes to the showcase app area: cli Changes to the CLI package area: db Changes to the database package area: core Changes to the core package (packages/core) area: backend Changes to the backend package (packages/backend) area: react-sdk Changes to the React SDK area: documentation Improvements or additions to documentation area: github actions Changes to GitHub Actions workflows labels Feb 9, 2026
@lachieh lachieh changed the base branch from lachieh/tam-1047-canvas-space to main February 10, 2026 00:21
@github-actions github-actions Bot removed area: documentation Improvements or additions to documentation area: github actions Changes to GitHub Actions workflows area: react-sdk Changes to the React SDK area: api Changes to the API (apps/api) area: cli Changes to the CLI package area: db Changes to the database package area: showcase Changes to the showcase app area: web Changes to the web app (apps/web) area: core Changes to the core package (packages/core) area: backend Changes to the backend package (packages/backend) area: config Changes to repository configuration files labels Feb 10, 2026
@lachieh lachieh closed this Feb 20, 2026
@lachieh lachieh deleted the lachlan/tam-1055-mcp-components-compound branch February 27, 2026 19:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: react-ui-base Changes to the react-ui-base package (packages/react-ui-base) area: ui change: feat New feature contributor: tambo-team Created by a Tambo team member status: in progress Work is currently being done

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant