Merge pull request #1066 from olaservo/feature/tool-annotations-display

feat(client): display tool annotations in Tools tab
This commit is contained in:
Cliff Hall 2026-02-14 11:37:49 -05:00 • committed by GitHub
commit 5e63f732e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -24,6 +24,7 @@ import {
CompatibilityCallToolResult,
ListToolsResult,
Tool,
ToolAnnotations,
} from "@modelcontextprotocol/sdk/types.js";
import {
Loader2,
@ -56,6 +57,84 @@ import {
const hasMeta = (tool: Tool): tool is Tool & { _meta: unknown } =>
typeof (tool as { _meta?: unknown })._meta !== "undefined";
// Type guard to safely detect the optional annotations field
const hasAnnotations = (
tool: Tool,
): tool is Tool & { annotations: ToolAnnotations } =>
typeof (tool as { annotations?: unknown }).annotations !== "undefined" &&
(tool as { annotations?: unknown }).annotations !== null;
// Helper to render annotation badges
// Shows all 4 annotation values with their state (true/false/implied default)
const AnnotationBadges = ({
annotations,
}: {
annotations: ToolAnnotations | undefined;
}) => {
// Spec defaults: readOnlyHint=false, destructiveHint=true, idempotentHint=false, openWorldHint=true
const getValueAndImplied = (
value: boolean | undefined,
defaultValue: boolean,
): { value: boolean; implied: boolean } => ({
value: value ?? defaultValue,
implied: value === undefined,
});
const readOnly = getValueAndImplied(annotations?.readOnlyHint, false);
const destructive = getValueAndImplied(annotations?.destructiveHint, true);
const idempotent = getValueAndImplied(annotations?.idempotentHint, false);
const openWorld = getValueAndImplied(annotations?.openWorldHint, true);
// Descriptions from MCP spec
const badges = [
{
label: "Read-only",
value: readOnly.value,
implied: readOnly.implied,
description: "Tool does not modify its environment",
},
{
label: "Destructive",
value: destructive.value,
implied: destructive.implied,
description:
"Tool may perform destructive updates (delete/overwrite data)",
},
{
label: "Idempotent",
value: idempotent.value,
implied: idempotent.implied,
description: "Calling repeatedly with same args has no additional effect",
},
{
label: "Open-world",
value: openWorld.value,
implied: openWorld.implied,
description:
"Tool may interact with external entities beyond its local environment",
},
];
return (
<div className="flex flex-wrap gap-1 mt-2">
{badges.map(({ label, value, implied, description }) => (
<span
key={label}
title={`${description}\n\nValue: ${value ? "Yes" : "No"} (${implied ? "implied default" : "explicitly set"})`}
className={cn(
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border",
"bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600",
implied && "border-dashed opacity-60",
!implied && "border-solid",
)}
>
{value ? "✓" : "✗"} {label}
</span>
))}
</div>
);
};
const ToolsTab = ({
tools,
listTools,
@ -217,6 +296,13 @@ const ToolsTab = ({
<p className="text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap max-h-48 overflow-y-auto">
{selectedTool.description}
</p>
<AnnotationBadges
annotations={
hasAnnotations(selectedTool)
? selectedTool.annotations
: undefined
}
/>
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => {
// First resolve any $ref references