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
3 changes: 3 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
64 changes: 64 additions & 0 deletions src/env0-service/env0-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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', () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: no case for the zero-configurations path ("No cloud configurations found"). One more buildService([]) + assert.rejects would cover it.

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.

Added in 2fe79f9.

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('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' } };

await service.getCloudResources({ filters });

assert.deepEqual(requests.at(-1)?.data.filters, filters);
assert.equal(requests.length, 1);
});
});
28 changes: 27 additions & 1 deletion src/env0-service/env0-service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -50,16 +51,41 @@ export class Env0Service {
}

async getCloudResources(params: GetCloudResourcesParams): Promise<CloudResourcesResponse> {
const filters = await this.withCloudProvider(params.filters);

return this.env0Client.request<CloudResourcesResponse>({
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<GetCloudResourcesParams['filters']> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Guard checks cloudConfigurationId?.eq only, but the schema still allows cloudConfigurationId.in. An agent that sends in on a multi-provider org gets the "Set filters.cloudProvider.eq..." error even though it did scope the search.

Either check ?.eq || ?.in?.length, or drop in from cloudConfigurationId like you did for cloudProvider. Nit, your call.

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.

Fixed in 18a6e8b by dropping in, same as cloudProvider. The env0 API's own guard reads filters.cloudConfigurationId.eq only, so in alone got a 400 there too. Widening the guard would just have moved the 400 one layer down.

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<object[]> {
return this.env0Client.request({
url: '/mcp/projects',
Expand Down
19 changes: 10 additions & 9 deletions src/mcp/schemas/get-cloud-resources-params-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,19 @@ export const GetCloudResourcesParamsSchema = z.object({
})
.optional(),
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"
),
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(),
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, ' +
Expand Down
Loading