evOo-local/packages/adapters/src/lazy-tool-catalog.test.ts
cynarAI ff7e95a081
Find connected-app tools from queries written in words (#209)
* Find connected-app tools from queries written in words

Catalog search matched the query only as one substring, so "search
repositories" or "get file" found nothing for search_repositories and
get_file_contents and the agent concluded GitHub had no tools. Underscores,
hyphens and spaces now compare equal, a query matches when every word starts
a word of the tool, and a miss returns the source's tool names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Bump Expo SDK 57 packages to the patch versions expo install expects

expo install --check now expects @expo/ui 57.0.25, expo 57.0.30 and
expo-router 57.0.28, which turns the mobile typecheck red on every branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-16 20:32:55 +02:00

566 lines
19 KiB
TypeScript

import type { ConnectorTool } from "@rakazo/adapter-kit";
import { describe, expect, it, vi } from "vitest";
import {
assertConnectorToolArgs,
catalogEntries,
catalogGroupLabel,
DIRECT_TOOL_LIMIT,
disambiguateInstalledToolNames,
executeLazyCatalogControl,
formatNameIndexText,
lazyCatalogTools,
loadCatalogEntry,
NAME_INDEX_MAX_NAMES,
prepareCatalogExecuteArguments,
resolveCatalogCall,
SELECTED_SCHEMA_MAX_BYTES,
searchCatalog,
TOOL_SEARCH_LIMIT,
uniquifyInstalledToolName,
} from "./lazy-tool-catalog.js";
function tools(count: number, group = "source"): ConnectorTool[] {
return Array.from({ length: count }, (_, index) => ({
name: `tool_${String(index).padStart(2, "0")}`,
description: "Shared catalog result",
inputSchema: {
type: "object",
properties: { value: { type: "string" } },
required: ["value"],
},
route: {
connectorId: "test",
resourceId: `source-${index}`,
toolName: `tool-${index}`,
catalogGroup: group,
},
}));
}
function multiGroupTools(): ConnectorTool[] {
return [
{
name: "mcp__gmail__send",
description: "Send mail",
inputSchema: { type: "object", properties: { to: { type: "string" } } },
route: {
connectorId: "mcp",
resourceId: "srv-gmail",
toolName: "send",
catalogGroup: "gmail",
},
},
{
name: "mcp__gmail__read",
description: "Read mail",
inputSchema: { type: "object", properties: {} },
route: {
connectorId: "mcp",
resourceId: "srv-gmail",
toolName: "read",
catalogGroup: "gmail",
},
},
{
name: "mcp__gmail__list",
description: "List mail",
inputSchema: { type: "object", properties: {} },
route: {
connectorId: "mcp",
resourceId: "srv-gmail",
toolName: "list",
catalogGroup: "gmail",
},
},
{
name: "mcp__hubspot__list_contacts",
description: "List contacts",
inputSchema: { type: "object", properties: {} },
route: {
connectorId: "mcp",
resourceId: "srv-hubspot",
toolName: "list_contacts",
catalogGroup: "hubspot",
},
},
{
name: "mcp__hubspot__get_deal",
description: "Get deal",
inputSchema: { type: "object", properties: {} },
route: {
connectorId: "mcp",
resourceId: "srv-hubspot",
toolName: "get_deal",
catalogGroup: "hubspot",
},
},
];
}
describe("lazy tool catalog", () => {
it("exposes tools directly at the limit and switches to lazy wrappers above it", () => {
expect(tools(DIRECT_TOOL_LIMIT)).toHaveLength(20);
expect(tools(DIRECT_TOOL_LIMIT + 1)).toHaveLength(21);
expect(lazyCatalogTools("connectors", "mcp", "MCP")).toHaveLength(3);
expect(lazyCatalogTools("installed", "installed", "API").map((tool) => tool.name)).toEqual([
"installed_search_tools",
"installed_load_tool",
"installed_execute_tool",
]);
expect(lazyCatalogTools("connectors", "mcp", "MCP").map((tool) => tool.name)).toEqual([
"connectors_search_tools",
"connectors_load_tool",
"connectors_execute_tool",
]);
expect(lazyCatalogTools("connectors", "mcp", "MCP")[0]?.description).toContain(
"Empty query lists sources",
);
expect(lazyCatalogTools("connectors", "mcp", "MCP")[0]?.inputSchema).not.toMatchObject({
required: expect.arrayContaining(["query"]),
});
});
it("embeds a compact name index in the search description when it fits", () => {
const entries = catalogEntries(multiGroupTools());
const [search] = lazyCatalogTools("connectors", "mcp", "MCP", entries);
expect(search?.description).toContain("gmail:");
expect(search?.description).toContain("list, read, send");
expect(search?.description).toContain("hubspot:");
expect(search?.description).toContain("get_deal, list_contacts");
expect(search?.description).not.toContain("inputSchema");
expect(search?.description).not.toContain("Send mail");
});
it("returns an empty catalog as an empty tool list", () => {
expect(catalogEntries([])).toEqual([]);
expect(searchCatalog([], { query: "anything" })).toEqual({ tools: [] });
});
it("finds tools from a query written in words", () => {
const entries = catalogEntries([
{
name: "search_repositories",
description: "Find GitHub repositories",
inputSchema: { type: "object" },
route: {
connectorId: "test",
resourceId: "gh",
toolName: "search_repositories",
catalogGroup: "GitHub",
},
},
{
name: "get_file_contents",
description: "Read a file from a repository",
inputSchema: { type: "object" },
route: {
connectorId: "test",
resourceId: "gh",
toolName: "get_file_contents",
catalogGroup: "GitHub",
},
},
]);
const names = (query: string) =>
(searchCatalog(entries, { query }) as { tools: Array<{ name: string }> }).tools.map(
(tool) => tool.name,
);
expect(names("search repositories")[0]).toBe("search_repositories");
expect(names("get file")[0]).toBe("get_file_contents");
expect(names("find repositories")).toEqual(["search_repositories"]);
expect(searchCatalog(entries, { query: "cynarAI evOo-local" })).toMatchObject({
tools: [],
index: [{ group: "GitHub", names: ["get_file_contents", "search_repositories"] }],
});
});
it("lists grouped names for an empty query without dumping schemas", () => {
const result = searchCatalog(catalogEntries(multiGroupTools()), {});
expect(result).toEqual({
index: [
{ group: "gmail", names: ["list", "read", "send"] },
{ group: "hubspot", names: ["get_deal", "list_contacts"] },
],
});
expect(JSON.stringify(result)).not.toContain("inputSchema");
expect(JSON.stringify(result)).not.toContain("Send mail");
});
it("expands one group to its names", () => {
expect(searchCatalog(catalogEntries(multiGroupTools()), { group: "gmail" })).toEqual({
group: "gmail",
names: ["list", "read", "send"],
});
expect(searchCatalog(catalogEntries(multiGroupTools()), { group: "missing" })).toEqual({
group: "missing",
names: [],
error: "Unknown or empty group",
});
});
it("keeps case-distinct group labels separate when filtering", () => {
const mixed = [
...tools(2, "Gmail").map((tool, index) => ({
...tool,
name: `Upper_${index}`,
route: { ...tool.route!, toolName: `Upper_${index}` },
})),
...tools(2, "gmail").map((tool, index) => ({
...tool,
name: `lower_${index}`,
route: { ...tool.route!, toolName: `lower_${index}` },
})),
];
const entries = catalogEntries(mixed);
expect(searchCatalog(entries, { group: "Gmail" })).toEqual({
group: "Gmail",
names: ["Upper_0", "Upper_1"],
});
expect(searchCatalog(entries, { group: "gmail" })).toEqual({
group: "gmail",
names: ["lower_0", "lower_1"],
});
const listed = searchCatalog(entries, {});
expect("index" in listed && listed.index).toEqual(
[
{ group: "Gmail", names: ["Upper_0", "Upper_1"] },
{ group: "gmail", names: ["lower_0", "lower_1"] },
].sort((left, right) => left.group.localeCompare(right.group)),
);
});
it("returns a group count when expanding an over-budget source", () => {
const oversized = Array.from({ length: NAME_INDEX_MAX_NAMES + 5 }, (_, index) => ({
name: `tool_${index}`,
description: "x".repeat(40),
inputSchema: { type: "object", properties: { secretShape: { type: "string" } } },
route: {
connectorId: "test",
resourceId: "big",
toolName: `tool_${index}`,
catalogGroup: "huge",
},
}));
const entries = catalogEntries(oversized);
const result = searchCatalog(entries, { group: "huge" });
expect(result).toEqual({
group: "huge",
count: NAME_INDEX_MAX_NAMES + 5,
hint: "Pass query to search within this source.",
});
expect(JSON.stringify(result)).not.toContain("secretShape");
expect(JSON.stringify(result)).not.toContain("tool_0");
const searched = searchCatalog(entries, { group: "huge", query: "tool_0" });
expect("tools" in searched && searched.tools.map((item) => item.name)).toEqual(["tool_0"]);
});
it("returns groups with counts when the name index exceeds budget", () => {
const oversized = Array.from({ length: NAME_INDEX_MAX_NAMES + 5 }, (_, index) => ({
name: `tool_${index}`,
description: "x".repeat(40),
inputSchema: { type: "object", properties: { secretShape: { type: "string" } } },
route: {
connectorId: "test",
resourceId: `src-${index}`,
toolName: `tool_${index}`,
catalogGroup: `group_${Math.floor(index / 50)}`,
},
}));
const entries = catalogEntries(oversized);
const result = searchCatalog(entries, {});
expect(result).toMatchObject({
hint: "Pass group to list names in one source.",
});
expect("groups" in result && result.groups.every((item) => item.count > 0)).toBe(true);
expect("index" in result).toBe(false);
expect(JSON.stringify(result)).not.toContain("secretShape");
const [search] = lazyCatalogTools("connectors", "mcp", "MCP", entries);
expect(search?.description).toContain("Empty query lists sources");
expect(search?.description).not.toContain("tool_0");
});
it("filters query search by group and keeps schema-free hits", () => {
const catalog = tools(25, "alpha").concat(tools(5, "beta"));
const first = searchCatalog(catalogEntries(catalog), {
query: "shared",
group: "beta",
limit: 100,
});
const second = searchCatalog(catalogEntries(catalog), {
query: "shared",
group: "beta",
limit: 100,
});
expect(first).toEqual(second);
expect("tools" in first && first.tools).toHaveLength(5);
expect("tools" in first && first.tools.every((item) => item.name.startsWith("tool_"))).toBe(
true,
);
expect(JSON.stringify(first)).not.toContain("secretShape");
});
it("caps and stably orders schema-free search results", () => {
const catalog = tools(25);
const first = searchCatalog(catalogEntries(catalog), { query: "shared", limit: 100 });
const second = searchCatalog(catalogEntries(catalog), { query: "shared", limit: 100 });
expect("tools" in first && first.tools).toHaveLength(TOOL_SEARCH_LIMIT);
expect(first).toEqual(second);
expect("tools" in first && first.tools.map((item) => item.name)).toEqual(
[...(("tools" in first && first.tools.map((item) => item.name)) || [])].sort((left, right) =>
left.localeCompare(right),
),
);
expect(JSON.stringify(first)).not.toContain("secretShape");
});
it("rejects an untrusted selected schema above the byte ceiling", () => {
const entries = catalogEntries([
{
name: "oversized",
description: "Oversized schema",
inputSchema: { type: "object", description: "x".repeat(SELECTED_SCHEMA_MAX_BYTES) },
},
]);
expect(() => loadCatalogEntry(entries, { id: "oversized" })).toThrow("schema is too large");
});
it("caps load descriptions like search", () => {
const long = "d".repeat(600);
const entries = catalogEntries([
{
name: "verbose",
description: long,
inputSchema: { type: "object", properties: {} },
route: { connectorId: "test", resourceId: "src", toolName: "verbose" },
},
]);
const entry = loadCatalogEntry(entries, { id: "src:verbose" });
expect(entry.tool.description).toHaveLength(600);
const hits = searchCatalog(entries, { query: "verbose" });
expect("tools" in hits && hits.tools[0]?.description).toHaveLength(500);
});
it("loads by exposed tool name as well as catalog id", () => {
const entries = catalogEntries(multiGroupTools());
expect(loadCatalogEntry(entries, { id: "mcp__gmail__send" }).tool.name).toBe(
"mcp__gmail__send",
);
expect(loadCatalogEntry(entries, { id: "srv-gmail:send" }).tool.name).toBe("mcp__gmail__send");
expect(loadCatalogEntry(entries, { id: "send" }).tool.name).toBe("mcp__gmail__send");
expect(() => loadCatalogEntry(entries, { id: "missing" })).toThrow(/unknown/i);
});
it("does not execute args when JSON Schema is unsupported", async () => {
const entries = catalogEntries([
{
name: "exotic",
description: "Unsupported schema",
// unevaluatedProperties is rejected by z.fromJSONSchema
inputSchema: {
type: "object",
properties: { payload: { type: "string" } },
unevaluatedProperties: false,
},
route: { connectorId: "test", resourceId: "src", toolName: "exotic" },
},
]);
const executeResolved = vi.fn(async function* () {
yield { type: "result" as const, data: "executed" };
});
const execute = async () => {
for await (const _event of executeLazyCatalogControl(
{
tool: "wrapper",
args: { id: "src:exotic", arguments: { payload: "raw-bytes", extra: true } },
executionId: "exec",
route: { connectorId: "test", toolName: "__catalog_execute" },
},
entries,
executeResolved,
)) {
// Consume the catalog execution path.
}
};
await expect(execute()).rejects.toThrow();
expect(executeResolved).not.toHaveBeenCalled();
});
it("unwraps arguments nested under parameters and names what is missing", () => {
const entries = catalogEntries(tools(1));
const call = (args: Record<string, unknown>) => ({
tool: "wrapper",
args: { id: "source-0:tool-0", arguments: args },
executionId: "exec",
route: { connectorId: "test", toolName: "__catalog_execute" },
});
expect(resolveCatalogCall(call({ parameters: { value: "v" } }), entries).call.args).toEqual({
value: "v",
});
expect(
resolveCatalogCall(call({ args: '{"arguments":{"value":"v"}}' }), entries).call.args,
).toEqual({ value: "v" });
expect(() => resolveCatalogCall(call({ query: "v" }), entries)).toThrow(
"Required: value. Received: query.",
);
expect(
resolveCatalogCall(
{
tool: "wrapper",
args: { id: "source-0:tool-0", value: "v" },
executionId: "exec",
route: { connectorId: "test", toolName: "__catalog_execute" },
},
entries,
).call.args,
).toEqual({ value: "v" });
});
it("still rejects non-object args and unknown ids", () => {
const entries = catalogEntries(tools(1));
expect(() =>
resolveCatalogCall(
{
tool: "wrapper",
args: { id: "missing", arguments: {} },
executionId: "exec",
route: { connectorId: "test", toolName: "__catalog_execute" },
},
entries,
),
).toThrow("unknown or not authorized");
expect(() =>
resolveCatalogCall(
{
tool: "wrapper",
args: { id: "source-0:tool-0", arguments: ["not-an-object"] },
executionId: "exec",
route: { connectorId: "test", toolName: "__catalog_execute" },
},
entries,
),
).toThrow("must be an object");
});
it("rejects persisted bound args that no longer match the live schema and unwraps aliases", () => {
const schema = {
type: "object",
properties: { value: { type: "string" } },
required: ["value"],
additionalProperties: false,
};
expect(() => assertConnectorToolArgs(schema, { value: "ok" })).not.toThrow();
expect(() => assertConnectorToolArgs(schema, { parameters: { value: "ok" } })).not.toThrow();
expect(() => assertConnectorToolArgs(schema, { input: { value: "ok" } })).not.toThrow();
expect(() => assertConnectorToolArgs(schema, { value: 1 })).toThrow(/invalid/i);
expect(() => assertConnectorToolArgs(schema, {})).toThrow(/invalid/i);
});
it("rejects schemas that combine patternProperties with additionalProperties false", () => {
const schema = {
type: "object",
patternProperties: { "^x-": { type: "string" } },
additionalProperties: false,
};
expect(() => assertConnectorToolArgs(schema, { "x-a": "ok" })).toThrow(
/patternProperties with additionalProperties false/,
);
});
it("uniquifies installed tool names across installs", () => {
expect(uniquifyInstalledToolName("install-A", "delete_item")).toBe(
"installed__install-A__delete_item",
);
expect(uniquifyInstalledToolName("install-B", "delete_item")).toBe(
"installed__install-B__delete_item",
);
});
it("only prefixes installed names when they collide across installs", () => {
const tools = disambiguateInstalledToolNames([
{
name: "unique_op",
description: "Only on A",
inputSchema: { type: "object" },
route: { connectorId: "installed", resourceId: "install-A", toolName: "unique_op" },
},
{
name: "delete_item",
description: "On A",
inputSchema: { type: "object" },
route: { connectorId: "installed", resourceId: "install-A", toolName: "delete_item" },
},
{
name: "delete_item",
description: "On B",
inputSchema: { type: "object" },
route: { connectorId: "installed", resourceId: "install-B", toolName: "delete_item" },
},
]);
expect(tools.map((tool) => tool.name)).toEqual([
"unique_op",
"installed__install-A__delete_item",
"installed__install-B__delete_item",
]);
});
it("labels nameless installs with kind and a short id", () => {
expect(catalogGroupLabel("Gmail", "api", "abcdefghij")).toBe("Gmail");
expect(catalogGroupLabel(" ", "api", "abcdefghij")).toBe("api-abcdefgh");
expect(catalogGroupLabel(undefined, "mcp", "xyz")).toBe("mcp-xyz");
});
it("formats a compact text index for descriptions", () => {
expect(
formatNameIndexText([
{ group: "gmail", names: ["list", "read", "send"] },
{ group: "hubspot", names: ["get_deal", "list_contacts"] },
]),
).toBe("gmail:\n list, read, send\nhubspot:\n get_deal, list_contacts");
});
});
describe("prepareCatalogExecuteArguments", () => {
it("keeps an arguments object as it is", () => {
expect(prepareCatalogExecuteArguments({ id: "a", arguments: { owner: "o" } })).toEqual({
id: "a",
arguments: { owner: "o" },
});
});
it("parses arguments sent as a JSON string", () => {
expect(
prepareCatalogExecuteArguments({ id: "a", arguments: '{"owner":"o","repo":"r"}' }),
).toEqual({
id: "a",
arguments: { owner: "o", repo: "r" },
});
expect(prepareCatalogExecuteArguments({ id: "a", arguments: "" })).toEqual({
id: "a",
arguments: {},
});
});
it("collects arguments flattened next to the id", () => {
expect(prepareCatalogExecuteArguments({ id: "a", owner: "o" })).toEqual({
id: "a",
arguments: { owner: "o" },
});
expect(prepareCatalogExecuteArguments({ id: "a" })).toEqual({ id: "a", arguments: {} });
expect(
prepareCatalogExecuteArguments({ id: "a", arguments: {}, owner: "o", repo: "r" }),
).toEqual({ id: "a", arguments: { owner: "o", repo: "r" } });
});
it("leaves unparseable strings for schema validation to reject", () => {
expect(prepareCatalogExecuteArguments({ id: "a", arguments: "owner=o" })).toEqual({
id: "a",
arguments: "owner=o",
});
});
});