Skip to content
Closed
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
7 changes: 7 additions & 0 deletions src/common/ansi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Strip ANSI escape codes from a string (e.g. [0m[1m color/formatting sequences)
// eslint-disable-next-line no-control-regex
const ANSI_ESCAPE_RE = /\x1B\[[0-9;]*[A-Za-z]|\x1B[@-_]/g;

export function stripAnsi(str: string): string {
return str.replace(ANSI_ESCAPE_RE, '');
}
126 changes: 121 additions & 5 deletions src/env0-service/env0-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ApproveEnvironmentParams } from '../mcp/schemas/approve-environmen
import type { CancelEnvironmentParams } from '../mcp/schemas/cancel-environment-schema';
import type { DeployEnvironmentParams } from '../mcp/schemas/deploy-environment-schema';
import type { GetEnvironmentsParams } from '../mcp/schemas/get-environments-params-schema';
import type { GetDeploymentsParams } from '../mcp/schemas/get-deployments-params-schema';
import type { Env0Config } from './env0-client';
import type Env0Client from './env0-client';
import type { CloudConfiguration } from './models/cloud-configuration';
Expand All @@ -11,6 +12,7 @@ import type { GetCloudResourcesParams } from '../mcp/schemas/get-cloud-resources
import type { GetPlanLogsParams } from '../mcp/schemas/get-plan-logs-params-schema';
import type { GenerateIaCParams } from '../mcp/schemas/generate-iac-schema';
import type { CheckIaCJobStatusParams } from '../mcp/schemas/check-iac-job-status-schema';
import { stripAnsi } from '../common/ansi';

export class Env0Service {
private readonly config: Env0Config;
Expand Down Expand Up @@ -67,12 +69,67 @@ export class Env0Service {
});
}

async getErrorAnalysis(environmentId: string): Promise<object> {
async getDeployments(params: GetDeploymentsParams): Promise<object[]> {
return this.env0Client.request({
url: `/mcp/environments/${environmentId}/error-analysis`
url: `/environments/${params.environmentId}/deployments`,
params: {
limit: params.limit || undefined,
offset: params.offset || undefined,
statuses: params.statuses || undefined
}
});
}

async getDeploymentSteps(deploymentId: string): Promise<object[]> {
return this.env0Client.request({
url: `/deployments/${deploymentId}/steps`
});
}

async getDeploymentStepLog(deploymentId: string, stepName: string): Promise<object> {
const allEvents: object[] = [];
let nextStartTime: string | undefined;
let hasMore = true;

while (hasMore) {
const page = await this.env0Client.request<{
events: object[];
nextStartTime?: string;
hasMoreLogs?: boolean;
}>({
url: `/deployments/${deploymentId}/steps/${encodeURIComponent(stepName)}/log`,
params: nextStartTime ? { startTime: nextStartTime } : undefined
});

if (page.events) {
allEvents.push(...page.events);
}

hasMore = page.hasMoreLogs === true;
nextStartTime = page.nextStartTime;
}

return { events: allEvents, totalEvents: allEvents.length };
}

async getErrorAnalysis(environmentId: string, deploymentId?: string): Promise<object> {
if (!deploymentId) {
return this.env0Client.request({
url: `/mcp/environments/${environmentId}/error-analysis`
});
}

try {
return await this.env0Client.request({
url: `/deployments/${deploymentId}/error-analysis`
});
} catch {
return this.env0Client.request({
url: `/mcp/environments/${environmentId}/error-analysis`
});
}
}

async approveEnvironment({ environmentId }: ApproveEnvironmentParams): Promise<object> {
return this.env0Client.request({
url: `/mcp/environments/${environmentId}/resume`,
Expand Down Expand Up @@ -128,9 +185,68 @@ export class Env0Service {
});
}

async getPlanLogs({ environmentId }: GetPlanLogsParams): Promise<object> {
return this.env0Client.request({
url: `/mcp/environments/${environmentId}/plan/logs`
async getPlanLogs({ environmentId, deploymentId, tail }: GetPlanLogsParams): Promise<object> {
const defaultTail = 50;
const tailCount = tail ?? defaultTail;

let resolvedDeploymentId = deploymentId;
let planSummary: unknown = undefined;

if (!resolvedDeploymentId) {
const env = await this.getEnvironment(environmentId);
const envAny = env as Record<string, unknown>;
const latestId = envAny.latestDeploymentLogId as string | undefined;
if (!latestId) {
return { error: 'No deployments found for this environment' };
}
resolvedDeploymentId = latestId;
// Grab planSummary from the environment's latest deployment log if available
const latestLog = envAny.latestDeploymentLog as Record<string, unknown> | undefined;
if (latestLog?.planSummary) {
planSummary = latestLog.planSummary;
}
}

const steps = await this.getDeploymentSteps(resolvedDeploymentId);
const planStep = (steps as { name: string }[]).find(
s => s.name.toLowerCase() === 'plan' || s.name.toLowerCase().includes('plan')
);

if (!planStep) {
return {
error: 'No plan step found for this deployment',
availableSteps: (steps as { name: string }[]).map(s => s.name)
};
}

const fullLog = (await this.getDeploymentStepLog(resolvedDeploymentId, planStep.name)) as {
events: { message?: string }[];
totalEvents: number;
};

// Strip ANSI escape codes from all event messages
const cleanedEvents = fullLog.events.map(event => {
if (typeof event.message === 'string') {
return { ...event, message: stripAnsi(event.message) };
}
return event;
});

if (fullLog.totalEvents <= tailCount) {
return { events: cleanedEvents, totalEvents: fullLog.totalEvents };
}

const result: Record<string, unknown> = {
events: cleanedEvents.slice(-tailCount),
totalEvents: fullLog.totalEvents,
truncated: true,
showing: `last ${tailCount} of ${fullLog.totalEvents} events (pass a higher tail value to see more)`
};

if (planSummary !== undefined) {
result.planSummary = planSummary;
}

return result;
}
}
6 changes: 6 additions & 0 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerGetCloudConfigurationsTool } from './tools/get-cloud-configurations';
import { registerGetDeploymentsTool } from './tools/get-deployments';
import { registerGetDeploymentStepsTool } from './tools/get-deployment-steps';
import { registerGetDeploymentStepLogTool } from './tools/get-deployment-step-log';
import { registerGetEnvironmentsTool } from './tools/get-environments';
import { Env0Service } from '../env0-service/env0-service';
import { registerGetProjectsTool } from './tools/get-projects';
Expand All @@ -20,6 +23,9 @@ export function createMcpServer(env0Service: Env0Service): McpServer {
});

registerGetCloudConfigurationsTool(server, env0Service);
registerGetDeploymentsTool(server, env0Service);
registerGetDeploymentStepsTool(server, env0Service);
registerGetDeploymentStepLogTool(server, env0Service);
registerGetEnvironmentsTool(server, env0Service);
registerGetProjectsTool(server, env0Service);
registerApproveEnvironmentTool(server, env0Service);
Expand Down
16 changes: 16 additions & 0 deletions src/mcp/schemas/get-deployment-step-log-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import z from 'zod';

export const GetDeploymentStepLogSchema = z.object({
deploymentId: z.string().describe('The ID of the deployment'),
stepName: z
.string()
.describe('The name of the step to get logs for (e.g. "Init", "Plan", "Apply")'),
tail: z
.number()
.int()
.positive()
.optional()
.describe('Return only the last N log events. Defaults to 150. Set higher to see more context.')
});

export type GetDeploymentStepLogParams = z.infer<typeof GetDeploymentStepLogSchema>;
7 changes: 7 additions & 0 deletions src/mcp/schemas/get-deployment-steps-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import z from 'zod';

export const GetDeploymentStepsSchema = z.object({
deploymentId: z.string().describe('The ID of the deployment to get steps for')
});

export type GetDeploymentStepsParams = z.infer<typeof GetDeploymentStepsSchema>;
26 changes: 26 additions & 0 deletions src/mcp/schemas/get-deployments-params-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import z from 'zod';

export const GetDeploymentsParamsSchema = z.object({
environmentId: z.string().describe('The ID of the environment to list deployments for'),
limit: z
.number()
.int()
.positive()
.max(100)
.optional()
.describe('Maximum number of deployments to return (default 10)'),
offset: z
.number()
.int()
.nonnegative()
.optional()
.describe('Number of deployments to skip for pagination'),
statuses: z
.string()
.optional()
.describe(
'Comma-separated deployment statuses to filter by (e.g. "SUCCESS,FAILURE,IN_PROGRESS")'
)
});

export type GetDeploymentsParams = z.infer<typeof GetDeploymentsParamsSchema>;
8 changes: 7 additions & 1 deletion src/mcp/schemas/get-error-analysis-schema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import z from 'zod';

export const GetErrorAnalysisSchema = z.object({
environmentId: z.string().describe('The ID of the environment to get error analysis for')
environmentId: z.string().describe('The ID of the environment to get error analysis for'),
deploymentId: z
.string()
.optional()
.describe(
'The ID of a specific deployment to get error analysis for. If omitted, analyzes the latest deployment.'
)
});

export type GetErrorAnalysisParams = z.infer<typeof GetErrorAnalysisSchema>;
18 changes: 17 additions & 1 deletion src/mcp/schemas/get-plan-logs-params-schema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import { z } from 'zod';

export const GetPlanLogsParamsSchema = z.object({
environmentId: z.string().describe('The ID of the environment to get plan logs for')
environmentId: z.string().describe('The ID of the environment to get plan logs for'),
deploymentId: z
.string()
.optional()
.describe(
'The ID of a specific deployment to get plan logs for. If omitted, returns logs for the latest deployment.'
),
tail: z
.number()
.int()
.positive()
.optional()
.describe(
'Return only the last N log events (the plan summary is always at the end). ' +
'Defaults to 50. Set higher to see more context, e.g. moved blocks or full refresh output. ' +
'Use get-deployment-steps to see all available steps if you need init or apply logs.'
)
});

export type GetPlanLogsParams = z.infer<typeof GetPlanLogsParamsSchema>;
69 changes: 69 additions & 0 deletions src/mcp/tools/get-deployment-step-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Env0Service } from '../../env0-service/env0-service';
import {
type GetDeploymentStepLogParams,
GetDeploymentStepLogSchema
} from '../schemas/get-deployment-step-log-schema';

export function registerGetDeploymentStepLogTool(
server: McpServer,
env0Service: Env0Service
): void {
server.registerTool(
'get-deployment-step-log',
{
title: 'Get Deployment Step Log',
description:
'Get logs for a specific step of a deployment (e.g. Init, Plan, Apply). ' +
'Use get-deployment-steps to discover available step names. ' +
'Returns the last 150 events by default — set tail higher for more context.',
inputSchema: GetDeploymentStepLogSchema.shape
},
async (params: GetDeploymentStepLogParams) => {
try {
const defaultTail = 150;
const tailCount = params.tail ?? defaultTail;

const fullLog = (await env0Service.getDeploymentStepLog(
params.deploymentId,
params.stepName
)) as { events: object[]; totalEvents: number };

if (fullLog.totalEvents <= tailCount) {
return {
content: [
{
type: 'text',
text: JSON.stringify(fullLog)
}
]
};
}

return {
content: [
{
type: 'text',
text: JSON.stringify({
events: fullLog.events.slice(-tailCount),
totalEvents: fullLog.totalEvents,
truncated: true,
showing: `last ${tailCount} of ${fullLog.totalEvents} events (pass a higher tail value to see more)`
})
}
]
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Error fetching step log: ${error instanceof Error ? error.message : 'Unknown error'}`
}
],
isError: true
};
}
}
);
}
Loading