inspector/clients/web/src/utils/toolUtils.test.ts
cliffhall 8f935adaad fix: address Copilot review round 40 — a required name counts as a field
A schema may require a property it never declares: `{ type: "object",
required: ["token"] }` is legal and the tool plainly takes an argument.
declaresAnyFields counted only `properties`, so an App tool shaped that way —
including nested under a composition — was reported input-free and
auto-invoked with `{}`.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 15:49:54 -04:00

157 lines
4.4 KiB
TypeScript

import { describe, it, expect } from "vitest";
import type { Tool } from "@modelcontextprotocol/client";
import {
findToolByRowKey,
hasInputFields,
resolveDisplayLabel,
toolRowKey,
} from "./toolUtils";
describe("resolveDisplayLabel", () => {
it("returns the title when provided", () => {
expect(resolveDisplayLabel("send_message", "Send Message")).toBe(
"Send Message",
);
});
it("falls back to the name when title is undefined", () => {
expect(resolveDisplayLabel("send_message")).toBe("send_message");
});
it("preserves an empty-string title rather than falling back to the name", () => {
// Empty string is a valid (if unusual) title — title ?? name only falls
// back on undefined / null, not empty string. Document that here.
expect(resolveDisplayLabel("send_message", "")).toBe("");
});
});
describe("hasInputFields", () => {
const baseTool = (inputSchema: Tool["inputSchema"]): Tool => ({
name: "t",
inputSchema,
});
it("returns false when properties is missing", () => {
expect(hasInputFields(baseTool({ type: "object" }))).toBe(false);
});
it("returns false when properties is empty", () => {
expect(hasInputFields(baseTool({ type: "object", properties: {} }))).toBe(
false,
);
});
it("returns true when properties has at least one entry", () => {
expect(
hasInputFields(
baseTool({
type: "object",
properties: { x: { type: "string" } },
}),
),
).toBe(true);
});
});
describe("toolRowKey / findToolByRowKey", () => {
const tool = (name: string): Tool => ({
name,
inputSchema: { type: "object" },
});
// A `tools/list` may legitimately repeat a name (#1957/#2001).
const tools: Tool[] = [
tool("get_weather"),
tool("echo"),
tool("get_weather"),
];
it("distinguishes two tools that share a name", () => {
expect(toolRowKey("get_weather", 0)).not.toBe(toolRowKey("get_weather", 2));
});
it("resolves each duplicate to its own entry", () => {
expect(findToolByRowKey(tools, "0:get_weather")).toBe(tools[0]);
// The second copy — the one a name-based lookup could never reach.
expect(findToolByRowKey(tools, "2:get_weather")).toBe(tools[2]);
});
it("returns undefined for no key", () => {
expect(findToolByRowKey(tools, undefined)).toBeUndefined();
});
it("returns undefined for a key naming no current row", () => {
// A stale selection after the list changed underneath it.
expect(findToolByRowKey(tools, "9:gone")).toBeUndefined();
// Right name, wrong position: still not a match.
expect(findToolByRowKey(tools, "1:get_weather")).toBeUndefined();
});
});
describe("hasInputFields with root composition (#2123)", () => {
const tool = (inputSchema: Tool["inputSchema"]): Tool => ({
name: "t",
inputSchema,
});
it("sees fields declared on a root union branch", () => {
expect(
hasInputFields(
tool({
type: "object",
anyOf: [
{ type: "object", properties: { a: { type: "string" } } },
{ type: "object", properties: { b: { type: "string" } } },
],
}),
),
).toBe(true);
});
it("sees fields declared on a root allOf", () => {
expect(
hasInputFields(
tool({
type: "object",
allOf: [{ type: "object", properties: { a: { type: "string" } } }],
}),
),
).toBe(true);
});
it("sees fields on a union the form declines to flatten", () => {
// Otherwise an App tool with such a schema is treated as input-free and
// invoked immediately with `{}`.
expect(
hasInputFields(
tool({
type: "object",
anyOf: [
{ type: "object", properties: { a: { type: "string" } } },
{ $ref: "#/$defs/Other" },
],
}),
),
).toBe(true);
});
it("counts a $ref member, whose shape is unknown rather than empty", () => {
expect(
hasInputFields(
tool({
type: "object",
anyOf: [{ $ref: "#/$defs/Email" }, { $ref: "#/$defs/Sms" }],
}),
),
).toBe(true);
});
it("counts a required name the schema never declares", () => {
expect(hasInputFields(tool({ type: "object", required: ["token"] }))).toBe(
true,
);
});
it("still reports no fields for a bare object schema", () => {
expect(hasInputFields(tool({ type: "object" }))).toBe(false);
});
});