Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions shared/glean-dev-docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## [3.4.2](https://github.com/gleanwork/agent-plugins/compare/v3.4.1...v3.4.2) (2026-09-03)

### Bug Fixes

* sync plugin changelogs after generation ([b7df133](https://github.com/gleanwork/agent-plugins/commit/b7df133796df5a8a25a0cde95d40a2197331f45a))

### Documentation

* **plugins:** update Glean plugin description ([f4a3e23](https://github.com/gleanwork/agent-plugins/commit/f4a3e23000efc50cfdea75dd3c11da06b26beb71))

## [3.4.1](https://github.com/gleanwork/agent-plugins/compare/v3.4.0...v3.4.1) (2026-09-02)

### Documentation
Expand Down
10 changes: 10 additions & 0 deletions shared/glean/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## [3.4.2](https://github.com/gleanwork/agent-plugins/compare/v3.4.1...v3.4.2) (2026-09-03)

### Bug Fixes

* sync plugin changelogs after generation ([b7df133](https://github.com/gleanwork/agent-plugins/commit/b7df133796df5a8a25a0cde95d40a2197331f45a))

### Documentation

* **plugins:** update Glean plugin description ([f4a3e23](https://github.com/gleanwork/agent-plugins/commit/f4a3e23000efc50cfdea75dd3c11da06b26beb71))

## [3.4.1](https://github.com/gleanwork/agent-plugins/compare/v3.4.0...v3.4.1) (2026-09-02)

### Documentation
Expand Down
35 changes: 32 additions & 3 deletions shared/glean/mcp/src/tools/find-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ import { callRemoteTool } from "../remote-client.js";
import { writeSkillsToDisk, formatAvailableSkillsPrompt } from "../skill-writer.js";
import type { SkillsMap } from "../types.js";

// ponytail: the remote exposes either the new or the legacy name during the
// rename rollout. Resolve once per client via tools/list (the client is a
// process singleton, so this is one extra round trip per process).
// Drop this and hardcode "find_skills_and_tools" once every host has upgraded.
const REMOTE_NAMES = ["find_skills_and_tools", "find_skills"] as const;
const resolvedRemoteName = new WeakMap<Client, string>();

async function resolveRemoteName(client: Client): Promise<string> {
const cached = resolvedRemoteName.get(client);
if (cached) return cached;
const { tools } = await client.listTools();
const names = new Set(tools.map((t) => t.name));
const name = REMOTE_NAMES.find((n) => names.has(n)) ?? REMOTE_NAMES[0];
resolvedRemoteName.set(client, name);
return name;
}

export async function handleFindSkills(
remoteClient: Client,
skillsBaseDir: string,
Expand All @@ -15,21 +32,33 @@ export async function handleFindSkills(
toolArgs.queries = [args.query];
}

const result = await callRemoteTool(remoteClient, "find_skills", toolArgs);
const remoteName = await resolveRemoteName(remoteClient);
const result = await callRemoteTool(remoteClient, remoteName, toolArgs);

const textContent = result.content.find((c) => c.type === "text");
if (!textContent || textContent.type !== "text") {
return "<available_skills />";
}

if (result.isError) {
throw new Error(textContent.text || "find_skills failed");
throw new Error(textContent.text || `${remoteName} failed`);
}

// ponytail: lazy-disclosure surfaces (/mcp/default, skill-pack servers)
// answer with an XML index and expect read_skill_files; the plugin only
// speaks the gateway/proxy JSON map. Name the misconfiguration instead of
// surfacing "Unexpected token '<'".
if (textContent.text.trimStart().startsWith("<")) {
throw new Error(
`${remoteName} returned a lazy-disclosure XML index instead of the skills JSON map; ` +
"point the plugin at the /mcp/gateway/proxy route (check GLEAN_MCP_SERVER_URL).",
);
}

const parsed = JSON.parse(textContent.text) as { skills?: SkillsMap };
if (!parsed.skills || typeof parsed.skills !== "object") {
console.error(
`find_skills: unexpected response shape, keys: ${Object.keys(parsed).join(", ")}`,
`${remoteName}: unexpected response shape, keys: ${Object.keys(parsed).join(", ")}`,
);
return "<available_skills />";
}
Expand Down
47 changes: 42 additions & 5 deletions shared/glean/mcp/tests/find-skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ import os from "node:os";
import { handleFindSkills } from "../src/tools/find-skills.js";
import type { SkillsMap } from "../src/types.js";

function createMockClient(skills: SkillsMap) {
const listTools = (...names: string[]) =>
vi.fn().mockResolvedValue({ tools: names.map((name) => ({ name })) });

function createMockClient(skills: SkillsMap, remoteName = "find_skills_and_tools") {
return {
listTools: listTools(remoteName),
callTool: vi.fn().mockResolvedValue({
content: [
{
Expand All @@ -32,7 +36,7 @@ describe("handleFindSkills", () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});

it("calls find_skills and writes skill files", async () => {
it("calls find_skills_and_tools and writes skill files", async () => {
const mockClient = createMockClient({
"search-jira": {
"SKILL.md":
Expand All @@ -50,7 +54,7 @@ describe("handleFindSkills", () => {

expect(mockClient.callTool).toHaveBeenCalledWith(
expect.objectContaining({
name: "find_skills",
name: "find_skills_and_tools",
arguments: {},
}),
expect.objectContaining({ timeout: expect.any(Number) }),
Expand All @@ -75,7 +79,7 @@ describe("handleFindSkills", () => {

expect(mockClient.callTool).toHaveBeenCalledWith(
expect.objectContaining({
name: "find_skills",
name: "find_skills_and_tools",
arguments: { queries: ["create a calendar event"] },
}),
expect.objectContaining({ timeout: expect.any(Number) }),
Expand All @@ -91,7 +95,7 @@ describe("handleFindSkills", () => {

expect(mockClient.callTool).toHaveBeenCalledWith(
expect.objectContaining({
name: "find_skills",
name: "find_skills_and_tools",
arguments: { queries: ["search emails", "create calendar event"] },
}),
expect.objectContaining({ timeout: expect.any(Number) }),
Expand All @@ -100,6 +104,7 @@ describe("handleFindSkills", () => {

it("returns empty XML when response has no skills field", async () => {
const mockClient = {
listTools: listTools("find_skills_and_tools"),
callTool: vi.fn().mockResolvedValue({
content: [{ type: "text", text: JSON.stringify({ unexpected: true }) }],
}),
Expand All @@ -120,6 +125,7 @@ describe("handleFindSkills", () => {

it("handles missing text content gracefully", async () => {
const mockClient = {
listTools: listTools("find_skills_and_tools"),
callTool: vi.fn().mockResolvedValue({ content: [] }),
close: vi.fn(),
} as any;
Expand All @@ -131,6 +137,7 @@ describe("handleFindSkills", () => {

it("throws with upstream message when find_skills returns an error", async () => {
const mockClient = {
listTools: listTools("find_skills"),
callTool: vi.fn().mockResolvedValue({
content: [{ type: "text", text: "backend unavailable" }],
isError: true,
Expand All @@ -142,4 +149,34 @@ describe("handleFindSkills", () => {
handleFindSkills(mockClient, tmpDir, {}),
).rejects.toThrow("backend unavailable");
});

it("names the route misconfiguration when the remote answers with the lazy XML index", async () => {
const mockClient = {
listTools: listTools("find_skills_and_tools"),
callTool: vi.fn().mockResolvedValue({
content: [{ type: "text", text: "<available_skills>\n</available_skills>" }],
}),
close: vi.fn(),
} as any;

await expect(handleFindSkills(mockClient, tmpDir, {})).rejects.toThrow(
/gateway\/proxy/,
);
});

it("falls back to legacy find_skills when the host has not been renamed", async () => {
const mockClient = createMockClient({}, "find_skills");
await handleFindSkills(mockClient, tmpDir, {});
expect(mockClient.callTool).toHaveBeenCalledWith(
expect.objectContaining({ name: "find_skills" }),
expect.anything(),
);
});

it("resolves the remote name once per client", async () => {
const mockClient = createMockClient({});
await handleFindSkills(mockClient, tmpDir, {});
await handleFindSkills(mockClient, tmpDir, {});
expect(mockClient.listTools).toHaveBeenCalledTimes(1);
});
});