Skip to content
Draft
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
52 changes: 43 additions & 9 deletions src/registry/toolsets/gitops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,40 @@ function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}

function numberField(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key];
return typeof value === "number" ? value : undefined;
}

function gitopsListExtract(...arrayKeys: string[]) {
return (raw: unknown): unknown => {
if (!isRecord(raw)) return raw;

let items: unknown[] | undefined = Array.isArray(raw.items) ? raw.items : undefined;
for (const key of arrayKeys) {
if (items) break;
const value = raw[key];
if (Array.isArray(value)) {
items = value;
}
}

if (!items) return raw;

const pagination = isRecord(raw.pagination) ? raw.pagination : undefined;
const total =
numberField(raw, "total") ??
numberField(raw, "totalItems") ??
numberField(raw, "totalElements") ??
numberField(raw, "itemCount") ??
(pagination ? numberField(pagination, "totalItems") : undefined) ??
(pagination ? numberField(pagination, "total") : undefined) ??
items.length;

return { ...raw, items, total };
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes harness_search, but return { ...raw, items, total } makes the shared registry contract GitOps-specific again. The extractor layer has generally normalized list responses to canonical fields (items, total, plus pagination metadata when needed) rather than preserving transport-specific keys, and the new registry test now hard-codes those raw keys as part of the contract. To stay aligned with Sunil's standards, I think this should normalize to the canonical list shape here and keep any backward-compatibility shim outside the registry layer.

};
}

/**
* Encode `apiextensionsv1.JSON` fields inside a generator's `list.elements`
* and `plugin.input.parameters`. Shared by top-level and nested encoders.
Expand Down Expand Up @@ -208,7 +242,7 @@ export const gitopsToolset: ToolsetDefinition = {
page: "pageIndex",
size: "pageSize",
},
responseExtractor: passthrough,
responseExtractor: gitopsListExtract("agents"),
description: "List GitOps agents",
},
get: {
Expand Down Expand Up @@ -253,7 +287,7 @@ export const gitopsToolset: ToolsetDefinition = {
operationPolicy: { risk: "read", retryPolicy: "safe" },
injectAccountInBody: true,
bodyBuilder: (input) => gitopsListBody(input, { metadataOnly: true }),
responseExtractor: passthrough,
responseExtractor: gitopsListExtract("applications"),
description: "List GitOps applications in the project",
},
get: {
Expand Down Expand Up @@ -581,7 +615,7 @@ export const gitopsToolset: ToolsetDefinition = {
operationPolicy: { risk: "read", retryPolicy: "safe" },
injectAccountInBody: true,
bodyBuilder: (input) => gitopsListBody(input),
responseExtractor: passthrough,
responseExtractor: gitopsListExtract("clusters"),
description: "List GitOps clusters (scope depends on org_id/project_id presence)",
},
get: {
Expand Down Expand Up @@ -626,7 +660,7 @@ export const gitopsToolset: ToolsetDefinition = {
operationPolicy: { risk: "read", retryPolicy: "safe" },
injectAccountInBody: true,
bodyBuilder: (input) => gitopsListBody(input, { repoCredsId: input.repo_creds_id ?? "" }),
responseExtractor: passthrough,
responseExtractor: gitopsListExtract("repositories"),
description: "List GitOps repositories (scope depends on org_id/project_id presence)",
},
get: {
Expand Down Expand Up @@ -675,7 +709,7 @@ export const gitopsToolset: ToolsetDefinition = {
operationPolicy: { risk: "read", retryPolicy: "safe" },
injectAccountInBody: true,
bodyBuilder: (input) => gitopsListBody(input, input.agent_id ? { agentIdentifier: input.agent_id } : {}),
responseExtractor: passthrough,
responseExtractor: gitopsListExtract("applicationsets", "applicationSets"),
emptyOnErrorPatterns: [/agent is not registered/, /never connected/, /Not Implemented/],
description: "List GitOps ApplicationSets",
},
Expand Down Expand Up @@ -875,7 +909,7 @@ export const gitopsToolset: ToolsetDefinition = {
operationPolicy: { risk: "read", retryPolicy: "safe" },
injectAccountInBody: true,
bodyBuilder: (input) => gitopsListBody(input, input.agent_id ? { agentIdentifier: input.agent_id } : {}),
responseExtractor: passthrough,
responseExtractor: gitopsListExtract("repoCreds", "repositoryCredentials", "credentials"),
emptyOnErrorPatterns: [/agent is not registered/, /never connected/, /Not Implemented/],
description: "List GitOps repository credentials",
},
Expand Down Expand Up @@ -918,7 +952,7 @@ export const gitopsToolset: ToolsetDefinition = {
agent_id: "agentIdentifier",
app_name: "appName",
},
responseExtractor: passthrough,
responseExtractor: gitopsListExtract("events"),
description: "List events for a GitOps application",
},
},
Expand Down Expand Up @@ -988,7 +1022,7 @@ export const gitopsToolset: ToolsetDefinition = {
agent_id: "agentIdentifier",
app_name: "appName",
},
responseExtractor: passthrough,
responseExtractor: gitopsListExtract("resources", "managedResources"),
description: "List managed resources for a GitOps application",
},
},
Expand Down Expand Up @@ -1029,7 +1063,7 @@ export const gitopsToolset: ToolsetDefinition = {
group: "request.group",
version: "request.version",
},
responseExtractor: passthrough,
responseExtractor: gitopsListExtract("actions", "resourceActions"),
description: "List available actions for a resource in a GitOps application",
},
},
Expand Down
18 changes: 18 additions & 0 deletions tasks/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,21 @@
- Clarified in README that hosted `https://mcp.harness.io/mcp` is managed and cannot be pointed at Harness0 from Claude/Cursor/Cowork client config; Support must configure hosted MCP for that target environment.
- Updated MCPB manifest descriptions so `HARNESS_BASE_URL` covers private SaaS hosts such as `https://harness0.harness.io`, not just self-managed installs.
- Verified with `pnpm build` and `pnpm docs:check`.

## Slack Bug Triage: GitOps Search Returns Zero (2026-05-08)
- [x] Read the Slack thread and confirm available context
- [x] Trace GitOps list/search request and response handling
- [x] Add a focused failing regression test
- [x] Normalize GitOps list response shapes for search/list consumers
- [x] Run focused tests and typecheck
- [x] Commit, push, open PR, and reply in the Slack thread

### Plan
- Reproduce the report with `harness_search(resource_types=["gitops_application"], org_id, project_id)` against the GitOps list response shape.
- Keep the fix in the GitOps toolset by normalizing list responses to expose `items` while preserving raw API fields.
- Verify the search test plus registry/toolset behavior so scoped explicit list and search use the same data.

### Review
- Root cause: GitOps list APIs returned arrays under API-specific keys such as `applications`, but `harness_search` only counted normalized `items`.
- Added a GitOps list extractor that preserves raw keys while adding `items` and `total` for list/search consumers.
- Verified with focused GitOps search/list regressions, full `pnpm test`, `pnpm typecheck`, and `pnpm build`.
19 changes: 19 additions & 0 deletions tests/registry/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,25 @@ describe("Registry", () => {
});
});

it("normalizes GitOps application list arrays while preserving the raw response key", async () => {
const gitopsRegistry = new Registry(makeConfig({ HARNESS_TOOLSETS: "gitops" }));
const mockRequest = vi.fn().mockResolvedValue({
applications: [{ name: "takeda-gitops-app" }],
total: 1,
});
const client = makeClient(mockRequest);

const result = (await gitopsRegistry.dispatch(client, "gitops_application", "list", {
org_id: "takeda-org",
project_id: "takeda-project",
search_term: "takeda",
})) as { applications: unknown[]; items: unknown[]; total: number };

expect(result.applications[0]).toMatchObject({ name: "takeda-gitops-app" });
expect(result.items[0]).toMatchObject({ name: "takeda-gitops-app" });
expect(result.total).toBe(1);
});

it("builds correct path with path params for a get operation", async () => {
const mockRequest = vi.fn().mockResolvedValue({ data: { identifier: "my-pipeline" } });
const client = makeClient(mockRequest);
Expand Down
42 changes: 42 additions & 0 deletions tests/tools/tool-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,48 @@ describe("harness_search", () => {
expect(data.searched_types).toBe(1);
});

it("finds scoped GitOps applications returned under the API-specific applications key", async () => {
registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "gitops" }));
mockRequest = vi.fn().mockResolvedValue({
applications: [
{
name: "takeda-gitops-app",
orgIdentifier: "takeda-org",
projectIdentifier: "takeda-project",
},
],
total: 1,
});
client = makeClient(mockRequest);
const { registerSearchTool } = await import("../../src/tools/harness-search.js");
server = makeMcpServer();
registerSearchTool(server, registry, client);

const result = await server.call("harness_search", {
query: "takeda",
resource_types: ["gitops_application"],
org_id: "takeda-org",
project_id: "takeda-project",
});

expect(result.isError).toBeUndefined();
const data = parseResult(result) as { total_matches: number; results: Array<{ resource_type: string; items: Array<Record<string, unknown>> }> };
expect(data.total_matches).toBe(1);
expect(data.results[0]).toMatchObject({
resource_type: "gitops_application",
});
expect(String(data.results[0]?.items[0]?.name)).toContain("takeda-gitops-app");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
path: "/gitops/api/v1/applications",
body: expect.objectContaining({
accountIdentifier: "test-account",
orgIdentifier: "takeda-org",
projectIdentifier: "takeda-project",
searchTerm: "takeda",
}),
}));
});

it("gracefully handles search failures for individual types", async () => {
// First call fails, second succeeds
mockRequest
Expand Down
Loading