McpTool

Render MCP tool calls with expandable output. Provide part + mcpInfo from parseMcpToolType, use chatStatus to reflect streaming/interrupted state, and defaultOpen to keep output expanded.

Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-react
Examples
Completed output
Pending
Interrupted
Source

The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.

tools/McpTool.tsx
import React, { memo, useMemo } from 'react';
import { Box, Text } from '@mantine/core';
import { CodeBlock } from '../CodeBlock/CodeBlock';
import { Markdown } from '../Markdown/Markdown';
import { ToolRowBase } from '../ToolRowBase/ToolRowBase';
import type { ToolPart } from '../types';
import { cx } from '../utils/cx';
import { fillTemplate } from '../utils/fill-template';
import { useChatLabels } from '../labels/chat-labels';
import { areToolPropsEqual, getPartInput, getToolStatus } from '../utils/format-tool';
import type { JsonSchema } from '../primitives/SchemaView/schema';
import type { McpToolInfo } from './tool-registry';
import { readToolArgs, summarizeToolArgs } from './tool-args';
import { useToolApproval } from '../approvals/approval-context';
import { deriveToolCallState } from './tool-call-state';
import {
  findToolCatalogEntry,
  getToolCatalogTitle,
  useToolPresentation,
} from './tool-presentation';
import {
  clipText,
  DEFAULT_TOOL_OUTPUT_LABELS,
  getToolOutputValue,
  MAX_OUTPUT_CHARS,
  readCallToolResult,
  readResultText,
  readStructuredResult,
  resolveByPartType,
  summarizeToolOutput,
  unwrapToolOutput,
  type ToolOutputLabels,
} from '../rows/tool-output';
import { ToolResultContent } from './ToolResultContent';
import classes from './McpTool.module.css';

export interface McpToolProps {
  /** Tool part in AI SDK v5 shape: `{ type, toolCallId, state, input, output }` */
  part: ToolPart;
  /** Parsed server/tool names, see `parseMcpToolType` */
  mcpInfo: McpToolInfo;
  /** Content rendered at the end of the row, for example `ToolActivity` with elapsed time */
  trailingContent?: React.ReactNode;
  /** Chat status from `useChat()`, used to tell a pending tool from an interrupted one */
  chatStatus?: string;
  /** Initial expanded state of the output panel */
  defaultOpen?: boolean;
  /** Overrides of the default English labels; verb dictionaries are merged with the defaults */
  labels?: Partial<McpToolLabels>;
  /** Class name added to the root element */
  className?: string;
  /** Inline styles added to the root element */
  style?: React.CSSProperties;
}

const PRIORITY_ARGS = [
  'query',
  'question',
  'email',
  'name',
  'id',
  'customer',
  'url',
  'issue',
  'body',
  'summary',
  'title',
];

export interface McpToolLabels extends ToolOutputLabels {
  /** Form of the leading verb of the tool name while the call runs, keyed by that verb: `{ List: 'Listing' }` */
  activeVerbs: Record<string, string>;
  /** Form of the leading verb once the call finished, keyed by that verb: `{ List: 'Listed' }` */
  completedVerbs: Record<string, string>;
  /** Title while the arguments stream in, `{name}` is replaced, `Preparing {name}` by default */
  preparing: string;
  /** Shown for a call interrupted before its result, `{name}` is replaced, `{name} interrupted` by default */
  interrupted: string;
  /** Title of the full arguments in the opened card, `Arguments` by default */
  arguments: string;
  /** Title of the result in the opened quiet row, `Result` by default */
  result: string;
  /** Short state of a failed call in the quiet row, `Error` by default */
  failed: string;
}

const ACTIVE_VERBS: Record<string, string> = {
  List: 'Listing',
  Get: 'Getting',
  Create: 'Creating',
  Update: 'Updating',
  Delete: 'Deleting',
  Search: 'Searching',
  Fetch: 'Fetching',
  Retrieve: 'Retrieving',
  Send: 'Sending',
  Generate: 'Generating',
  Add: 'Adding',
  Remove: 'Removing',
  Modify: 'Modifying',
  Draft: 'Drafting',
  Manage: 'Managing',
  Query: 'Querying',
  Start: 'Starting',
  Set: 'Setting',
  Check: 'Checking',
  Find: 'Finding',
};

const COMPLETED_VERBS: Record<string, string> = {
  List: 'Listed',
  Get: 'Got',
  Create: 'Created',
  Update: 'Updated',
  Delete: 'Deleted',
  Search: 'Searched',
  Fetch: 'Fetched',
  Retrieve: 'Retrieved',
  Send: 'Sent',
  Generate: 'Generated',
  Add: 'Added',
  Remove: 'Removed',
  Modify: 'Modified',
  Draft: 'Drafted',
  Manage: 'Managed',
  Query: 'Queried',
  Start: 'Started',
  Set: 'Set',
  Check: 'Checked',
  Find: 'Found',
};

export const DEFAULT_MCP_TOOL_LABELS: McpToolLabels = {
  activeVerbs: ACTIVE_VERBS,
  completedVerbs: COMPLETED_VERBS,
  preparing: 'Preparing {name}',
  ...DEFAULT_TOOL_OUTPUT_LABELS,
  arguments: 'Arguments',
  result: 'Result',
  failed: 'Error',
  interrupted: '{name} interrupted',
};

function conjugate(info: McpToolInfo, verbs: Record<string, string>): string {
  const words = info.displayName.split(' ');
  const verb = words[0];
  const rest = words.slice(1).join(' ');
  const form = verbs[verb];
  if (!form) {
    return info.displayName;
  }
  return rest ? `${form} ${rest}` : form;
}

function formatMcpArgs(input: unknown): string {
  if (!input || typeof input !== 'object') {
    return '';
  }
  const entries = Object.entries(input as Record<string, unknown>).filter(
    ([, v]) => v !== undefined && v !== null && v !== ''
  );
  if (entries.length === 0) {
    return '';
  }

  const sorted = [...entries].sort(([a], [b]) => {
    const ai = PRIORITY_ARGS.indexOf(a);
    const bi = PRIORITY_ARGS.indexOf(b);
    if (ai !== -1 && bi !== -1) {
      return ai - bi;
    }
    if (ai !== -1) {
      return -1;
    }
    if (bi !== -1) {
      return 1;
    }
    return 0;
  });

  const parts: string[] = [];
  for (const [key, value] of sorted) {
    if (parts.length >= 2) {
      break;
    }
    const val = typeof value === 'string' ? value : JSON.stringify(value);
    const display = val.length > 30 ? `${val.slice(0, 27)}...` : val;
    parts.push(`${key}: ${display}`);
  }
  return parts.join('  ');
}

/** Output of an MCP call as 0.3 read it, kept for existing renderers; see `unwrapToolOutput` */
export function unwrapMcpOutput(output: any): any {
  return output ? unwrapToolOutput(output) : output;
}

/** Backtick fence longer than any backtick run inside the text, at least three characters */
function codeFence(text: string): string {
  let longest = 0;
  for (const match of text.matchAll(/`+/g)) {
    longest = Math.max(longest, match[0].length);
  }
  return '`'.repeat(Math.max(3, longest + 1));
}

function formatOutputForDisplay(output: unknown, schema: JsonSchema | undefined): string {
  const result = readCallToolResult(output);
  const structured = result ? readStructuredResult(result, schema) : output;
  const text =
    structured !== undefined && typeof structured !== 'string'
      ? JSON.stringify(structured, null, 2)
      : result
        ? readResultText(result)
        : String(structured ?? '');
  return clipText(text, MAX_OUTPUT_CHARS);
}

/** Renders `tool-mcp__<server>__<tool>` parts with a verb-conjugated title and JSON output */
export const McpTool = memo(function McpTool({
  part,
  mcpInfo,
  trailingContent,
  chatStatus,
  defaultOpen,
  labels: labelsProp,
  className,
  style,
}: McpToolProps) {
  const status = getToolStatus(part, chatStatus);
  const hostApproval = useToolApproval(part.toolCallId);
  const isWaitingForDecision = Boolean(hostApproval && !hostApproval.outcome);
  const isPending = status.isPending && !isWaitingForDecision;
  const isRejected =
    hostApproval?.outcome?.decision === 'rejected' ||
    deriveToolCallState(part, { chatStatus }) === 'rejected';
  const { isInterrupted } = status;
  const contextLabels = useChatLabels('mcpTool');
  const labels = useMemo(
    () => ({
      ...DEFAULT_MCP_TOOL_LABELS,
      ...contextLabels,
      ...labelsProp,
      activeVerbs: {
        ...DEFAULT_MCP_TOOL_LABELS.activeVerbs,
        ...contextLabels?.activeVerbs,
        ...labelsProp?.activeVerbs,
      },
      completedVerbs: {
        ...DEFAULT_MCP_TOOL_LABELS.completedVerbs,
        ...contextLabels?.completedVerbs,
        ...labelsProp?.completedVerbs,
      },
    }),
    [contextLabels, labelsProp]
  );

  const presentation = useToolPresentation();
  const catalogEntry = findToolCatalogEntry(presentation.catalog, part);
  const result = useMemo(() => readCallToolResult(part.output), [part.output]);
  const isError = part.state === 'output-error' || result?.isError === true;
  const catalogTitle = getToolCatalogTitle(catalogEntry);
  const argsFormatter = resolveByPartType(presentation.args, part.type);
  const outputFormatter = resolveByPartType(presentation.outputs, part.type);
  const isReadable = Boolean(catalogEntry || argsFormatter || outputFormatter);
  const args = useMemo(
    () => (isReadable ? readToolArgs(getPartInput(part)) : {}),
    [isReadable, part]
  );

  const title = useMemo(() => {
    const name = catalogTitle ?? mcpInfo.displayName;
    if (part.state === 'input-streaming') {
      return fillTemplate(labels.preparing, { name });
    }
    if (catalogTitle) {
      return catalogTitle;
    }
    if (isPending) {
      return conjugate(mcpInfo, labels.activeVerbs);
    }
    return conjugate(mcpInfo, labels.completedVerbs);
  }, [part.state, isPending, mcpInfo, labels, catalogTitle]);

  const subtitle = useMemo(() => {
    if (!isReadable) {
      return part.state === 'input-streaming' ? '' : formatMcpArgs(getPartInput(part));
    }
    const summary = summarizeToolArgs(args, {
      schema: catalogEntry?.inputSchema,
      locale: presentation.locale,
    });
    return (
      argsFormatter?.(part, {
        state: isPending ? 'running' : 'done',
        args,
        summary,
        schema: catalogEntry?.inputSchema,
        locale: presentation.locale,
      }) ?? summary
    );
  }, [isReadable, args, catalogEntry, labels, presentation.locale, argsFormatter, part, isPending]);

  const resultLines = useMemo(() => {
    if (
      !isReadable ||
      isPending ||
      isRejected ||
      (part.output === undefined && part.state !== 'output-error')
    ) {
      return null;
    }
    const schema = catalogEntry?.outputSchema;
    const summary = summarizeToolOutput(
      part.state === 'output-error' ? part.errorText : part.output,
      { labels, locale: presentation.locale, schema }
    );
    const formatted = outputFormatter?.(part, {
      state: isError ? 'error' : 'done',
      output: getToolOutputValue(part),
      result: result ?? undefined,
      schema,
      summary,
      locale: presentation.locale,
      labels,
    });
    if (formatted !== null && formatted !== undefined && typeof formatted !== 'string') {
      return formatted;
    }
    const text = typeof formatted === 'string' ? formatted : summary;
    const lines = text.split('\n').filter((line) => line.trim());
    return lines.length > 0 && lines.length <= 4 ? lines : null;
  }, [
    isReadable,
    isPending,
    isRejected,
    isError,
    part,
    result,
    catalogEntry,
    labels,
    presentation.locale,
    outputFormatter,
  ]);

  const displayOutput = useMemo(() => {
    if (!part.output) {
      return null;
    }
    return formatOutputForDisplay(part.output, catalogEntry?.outputSchema);
  }, [part.output, catalogEntry]);

  const codeBlock = useMemo(() => {
    if (!displayOutput || (isReadable && isRejected)) {
      return null;
    }
    const trimmed = displayOutput.trim();
    if (!trimmed) {
      return null;
    }
    const language = trimmed.startsWith('{') || trimmed.startsWith('[') ? 'json' : 'text';
    const fence = codeFence(displayOutput);
    return `${fence}${language}\n${displayOutput}\n${fence}`;
  }, [displayOutput, isReadable, isRejected]);

  const argsJson =
    isReadable && Object.keys(args).length > 0 ? JSON.stringify(args, null, 2) : null;
  const hasMedia = Boolean(result?.content.some((block) => block.type !== 'text'));
  const hasExpandableContent = ((!!codeBlock || hasMedia) && !isPending) || !!argsJson;

  if (isInterrupted && !part.output && !isWaitingForDecision) {
    return (
      <Text component="span" className={cx(classes.interrupted, className)} style={style}>
        {fillTemplate(labels.interrupted, { name: mcpInfo.displayName })}
      </Text>
    );
  }

  return (
    <Box className={cx(classes.root, className)} style={style}>
      <ToolRowBase
        shimmerLabel={title}
        completeLabel={title}
        isAnimating={isPending}
        detail={subtitle || undefined}
        detailLines={isReadable ? 2 : 1}
        trailingContent={trailingContent}
        expandable={hasExpandableContent}
        defaultOpen={defaultOpen}
      >
        {argsJson && (
          <CodeBlock
            code={argsJson}
            language="json"
            title={labels.arguments}
            wrapLines
            className={classes.output}
          />
        )}
        {codeBlock && (
          <Markdown content={codeBlock} className={classes.output} controls={{ code: false }} />
        )}
        {result && hasMedia && (
          <Box className={classes.output}>
            <ToolResultContent result={result} messageId={part.toolCallId ?? mcpInfo.toolName} />
          </Box>
        )}
      </ToolRowBase>
      {resultLines &&
        (Array.isArray(resultLines) ? (
          <div className={classes.result} data-error={isError || undefined}>
            {resultLines.map((line, index) => (
              <div key={index} className={classes.resultLine} data-head={index === 0 || undefined}>
                {line}
              </div>
            ))}
          </div>
        ) : (
          <div className={classes.result}>{resultLines}</div>
        ))}
    </Box>
  );
}, areToolPropsEqual);

McpTool.displayName = 'McpTool';
API reference
Prop
Type
Required
part
ToolPart
Yes
Tool part in AI SDK v5 shape: `{ type, toolCallId, state, input, output }`
mcpInfo
McpToolInfo
Yes
Parsed server/tool names, see `parseMcpToolType`
trailingContent
React.ReactNode
No
Content rendered at the end of the row, for example `ToolActivity` with elapsed time
chatStatus
string
No
Chat status from `useChat()`, used to tell a pending tool from an interrupted one
defaultOpen
boolean
No
Initial expanded state of the output panel
labels
Partial<McpToolLabels>
No
Overrides of the default English labels; verb dictionaries are merged with the defaults
className
string
No
Class name added to the root element
style
React.CSSProperties
No
Inline styles added to the root element