From 1dda008c06319d5ad5acd89178474fe545e95970 Mon Sep 17 00:00:00 2001 From: Liran Farage <83922349+liranfarage89@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:03:14 +0300 Subject: [PATCH 1/4] fix: Resolve the cloud provider when a resource search omits it The env0 API rejects a cloud resource search that has neither cloudConfigurationId nor cloudProvider, and agents send neither about eight times a day. When both are missing we now look up the organization's cloud configurations: one provider means we fill it in, several means the agent gets a message naming them, none means the organization has no Cloud Compass data yet. Also dropped cloudProvider.in from the tool schema. The API only reads .eq, so an agent that used .in always got a 400. Co-Authored-By: Claude Opus 5 --- .github/workflows/lint.yml | 3 + package.json | 2 +- src/env0-service/env0-service.test.ts | 55 +++++++++++++++++++ src/env0-service/env0-service.ts | 28 +++++++++- .../get-cloud-resources-params-schema.ts | 10 ++-- 5 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 src/env0-service/env0-service.test.ts diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f30bd8b..f4b5c96 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -30,5 +30,8 @@ jobs: - name: Check formatting run: npm run format:check + - name: Run tests + run: npm test + - name: Type check run: npm run type-check diff --git a/package.json b/package.json index a97115c..947d07c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "tsc", "start": "tsx src/cli.ts", "dev": "tsx watch src/cli.ts", - "test": "npm test", + "test": "tsx --test src/**/*.test.ts", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", diff --git a/src/env0-service/env0-service.test.ts b/src/env0-service/env0-service.test.ts new file mode 100644 index 0000000..83641e5 --- /dev/null +++ b/src/env0-service/env0-service.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { AxiosRequestConfig } from 'axios'; +import type Env0Client from './env0-client'; +import type { CloudConfiguration } from './models/cloud-configuration'; +import { Env0Service } from './env0-service'; + +const config = { + organizationId: 'org-1', + apiUrl: 'https://api.env0.com', + apiKeyId: 'id', + apiKeySecret: 'secret' +}; + +const buildService = ( + providers: CloudConfiguration['provider'][] +): { service: Env0Service; requests: AxiosRequestConfig[] } => { + const requests: AxiosRequestConfig[] = []; + const client = { + request: async (request: AxiosRequestConfig) => { + requests.push(request); + return request.url === '/mcp/cloud/configurations' + ? providers.map(provider => ({ provider })) + : { resources: [], total: 0 }; + } + } as unknown as Env0Client; + + return { service: new Env0Service(config, client), requests }; +}; + +describe('getCloudResources', () => { + it('fills in the organization cloud provider when the search has no provider and no configuration', async () => { + const { service, requests } = buildService(['GCP']); + + await service.getCloudResources({ filters: {} }); + + assert.deepEqual(requests.at(-1)?.data.filters, { cloudProvider: { eq: 'GCP' } }); + }); + + it('asks for a provider when the organization has more than one', async () => { + const { service } = buildService(['AWS', 'GCP']); + + await assert.rejects(service.getCloudResources({ filters: {} }), /AWS, GCP/); + }); + + it('keeps the search as is when it already has a configuration ID', async () => { + const { service, requests } = buildService(['AWS']); + const filters = { cloudConfigurationId: { eq: 'config-1' } }; + + await service.getCloudResources({ filters }); + + assert.deepEqual(requests.at(-1)?.data.filters, filters); + assert.equal(requests.length, 1); + }); +}); diff --git a/src/env0-service/env0-service.ts b/src/env0-service/env0-service.ts index 2f84767..a36709f 100644 --- a/src/env0-service/env0-service.ts +++ b/src/env0-service/env0-service.ts @@ -1,3 +1,4 @@ +import _ from 'lodash'; import type { AbortEnvironmentParams } from '../mcp/schemas/abort-environment-schema'; import type { ApproveEnvironmentParams } from '../mcp/schemas/approve-environment-schema'; import type { CancelEnvironmentParams } from '../mcp/schemas/cancel-environment-schema'; @@ -50,16 +51,41 @@ export class Env0Service { } async getCloudResources(params: GetCloudResourcesParams): Promise { + const filters = await this.withCloudProvider(params.filters); + return this.env0Client.request({ url: '/mcp/cloud/resources', method: 'POST', data: { organizationId: this.config.organizationId || undefined, - ...params + ...params, + filters } }); } + // The API rejects a search that has neither cloudConfigurationId nor cloudProvider, and callers often send neither. + private async withCloudProvider( + filters: GetCloudResourcesParams['filters'] + ): Promise { + if (filters.cloudConfigurationId?.eq || filters.cloudProvider?.eq) return filters; + + const providers = _.uniq((await this.getCloudConfigurations()).map(({ provider }) => provider)); + const [provider] = providers; + + if (!provider) { + throw new Error('No cloud configurations found for this organization.'); + } + + if (providers.length > 1) { + throw new Error( + `Set filters.cloudProvider.eq to one of: ${providers.join(', ')}, or set filters.cloudConfigurationId.eq.` + ); + } + + return { ...filters, cloudProvider: { eq: provider } }; + } + async getProjects(): Promise { return this.env0Client.request({ url: '/mcp/projects', diff --git a/src/mcp/schemas/get-cloud-resources-params-schema.ts b/src/mcp/schemas/get-cloud-resources-params-schema.ts index ce28ed3..b179655 100644 --- a/src/mcp/schemas/get-cloud-resources-params-schema.ts +++ b/src/mcp/schemas/get-cloud-resources-params-schema.ts @@ -29,16 +29,14 @@ export const GetCloudResourcesParamsSchema = z.object({ filters: z.object({ cloudConfigurationId: optionalEQPattern.describe( 'The cloud configuration ID, can be found using the Cloud Configurations tool. ' + - "It's required that you provide either a configuration ID or a cloud provider" + 'Scopes the search to a single cloud configuration' ), cloudProvider: z - .object({ - eq: cloudProviderEnum.optional(), - in: z.array(cloudProviderEnum).optional() - }) + .object({ eq: cloudProviderEnum }) .optional() .describe( - "The cloud provider ID. It's required that you provide either a configuration ID or a cloud provider" + 'The cloud provider to search in. Defaults to the only provider the organization has, ' + + 'so it is only needed when the organization has more than one and no configuration ID is given' ), managementType: optionalEQPattern.describe( 'An Optional filter for a specific IaC management type, ' + From ff31aaad4a87e3d6cbc453f4ec986caa5af4ec92 Mon Sep 17 00:00:00 2001 From: Liran Farage <83922349+liranfarage89@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:38:19 +0300 Subject: [PATCH 2/4] fix: Quote the test glob so Node expands it, not sh --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 947d07c..cad324d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "tsc", "start": "tsx src/cli.ts", "dev": "tsx watch src/cli.ts", - "test": "tsx --test src/**/*.test.ts", + "test": "tsx --test 'src/**/*.test.ts'", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", From 18a6e8bdad1a50db68ff466e0e1db99aff76d69e Mon Sep 17 00:00:00 2001 From: Liran Farage <83922349+liranfarage89@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:38:42 +0300 Subject: [PATCH 3/4] fix: Drop in from the cloudConfigurationId filter, the API only reads eq --- src/mcp/schemas/get-cloud-resources-params-schema.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/mcp/schemas/get-cloud-resources-params-schema.ts b/src/mcp/schemas/get-cloud-resources-params-schema.ts index b179655..00e3dae 100644 --- a/src/mcp/schemas/get-cloud-resources-params-schema.ts +++ b/src/mcp/schemas/get-cloud-resources-params-schema.ts @@ -27,10 +27,13 @@ export const GetCloudResourcesParamsSchema = z.object({ }) .optional(), filters: z.object({ - cloudConfigurationId: optionalEQPattern.describe( - 'The cloud configuration ID, can be found using the Cloud Configurations tool. ' + - 'Scopes the search to a single cloud configuration' - ), + cloudConfigurationId: z + .object({ eq: z.string() }) + .optional() + .describe( + 'The cloud configuration ID, can be found using the Cloud Configurations tool. ' + + 'Scopes the search to a single cloud configuration' + ), cloudProvider: z .object({ eq: cloudProviderEnum }) .optional() From 2fe79f98057415731a1f4c3629be8c809218c412 Mon Sep 17 00:00:00 2001 From: Liran Farage <83922349+liranfarage89@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:39:22 +0300 Subject: [PATCH 4/4] test: Cover the organization with no cloud configurations --- src/env0-service/env0-service.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/env0-service/env0-service.test.ts b/src/env0-service/env0-service.test.ts index 83641e5..7bc8219 100644 --- a/src/env0-service/env0-service.test.ts +++ b/src/env0-service/env0-service.test.ts @@ -43,6 +43,15 @@ describe('getCloudResources', () => { await assert.rejects(service.getCloudResources({ filters: {} }), /AWS, GCP/); }); + it('says so when the organization has no cloud configurations', async () => { + const { service } = buildService([]); + + await assert.rejects( + service.getCloudResources({ filters: {} }), + /No cloud configurations found/ + ); + }); + it('keeps the search as is when it already has a configuration ID', async () => { const { service, requests } = buildService(['AWS']); const filters = { cloudConfigurationId: { eq: 'config-1' } };