@heroku/mcp-server
Version:
Heroku Platform MCP Server
605 lines (600 loc) • 24.9 kB
JavaScript
import { execSync } from 'node:child_process';
import { ValidatorResult } from 'jsonschema';
import { z } from 'zod';
import { readAppJson } from '../utils/read-app-json.js';
import AppService from '../services/app-service.js';
import SourceService from '../services/source-service.js';
import AppSetupService from '../services/app-setup-service.js';
import BuildService from '../services/build-service.js';
import { generateRequestInit } from '../utils/generate-request-init.js';
import { packSources } from '../utils/tarball.js';
import DynoService from '../services/dyno-service.js';
import { RendezvousConnection } from '../services/rendezvous.js';
const appJsonSchema = await import('../utils/app-json.schema.json', { with: { type: 'json' } });
/**
* The DeploymentError class is used when
* an error occurs during the deployment of
* an app.
*/
class DeploymentError extends Error {
appId;
/**
* Constructs a new DeploymentError
*
* @param message The message related to the error
* @param appId The app ID if available
*/
constructor(message, appId) {
super(message);
this.appId = appId;
}
}
/**
* Handles deployment of VSCode workspace projects to Heroku.
*
* This command-based class manages the entire deployment workflow including:
* - Authentication with Heroku
* - Validation of project configuration (app.json)
* - Application creation and deployment via Heroku's AppSetup API
* - Git remote configuration for the new Heroku app
*
* The deployment process uses Heroku's source blob URL approach, which requires
* a publicly accessible tarball of the repository. Local uncommitted changes
* will be included in the deployment unless a blobUri is specified.
*
* Usage:
* ```typescript
* await vscode.commands.executeCommand(DeployToHeroku.COMMAND_ID);
* ```
*
* appSetupService - Service for Heroku app setup operations
* appService - Service for Heroku app management
*
* @see {@link HerokuCommand}
* @see {@link AppSetupService}
* @see {@link AppService}
*/
export class DeployToHeroku extends AbortController {
appService = new AppService('https://api.heroku.com');
sourcesService = new SourceService('https://api.heroku.com');
appSetupService = new AppSetupService('https://api.heroku.com');
buildService = new BuildService('https://api.heroku.com');
dynoService = new DynoService('https://api.heroku.com');
requestInit;
isExistingDeployment = false;
deploymentOptions;
/**
* Deploys the current workspace to Heroku by means
* of the AppSetup apis.
*
* This function orchestrates the following steps:
* 1. Determines if the app.json configuration file exists and is valid
* 2. Determines if the the Procfile exists
* 2. Creates and deploys a new Heroku application
*
* The deployment process is displayed in a progress notification that can be cancelled
* by the user. Upon successful deployment, the new app is added to the git
* remote and the user is notified with options to view the app in the explorer
*
* Note that the AppSetupService requires a URL to download a tarball. If
* the blobUri argument is provided (such as the git repo's archive link) and the
* local branch has uncommitted changes, those changes will not be reflected
* in the deployment (obviously).
*
* Requirements:
* - Valid app.json file in the workspace root
* - Profile must exist in the workspace root
* - Valid Heroku authentication token
*
* @param deploymentOptions details used for the deployment
*
* @throws {Error} If authentication fails or required files are missing
* @throws {Error} If the app.json validation fails
* @throws {Error} If the deployment to Heroku fails
* @see runOperation
* @see deployToHeroku
*
* @returns A promise that resolves when the deployment is complete or rejects if an error occurs during deployment
*/
async run(deploymentOptions = {}) {
this.deploymentOptions = deploymentOptions;
this.requestInit = await generateRequestInit(this.signal);
try {
const app = deploymentOptions.name
? await this.appService.info(deploymentOptions.name, this.requestInit)
: undefined;
this.isExistingDeployment = Boolean(app);
}
catch {
// no-op
}
// rejecting means something went wrong.
let result = null;
try {
result = await this.runOperation();
if (!result) {
this.abort();
const abortMessage = 'Deployment cancelled';
throw new DeploymentError(abortMessage);
}
}
catch (error) {
const message = error.message;
return { errorMessage: message };
}
return result;
}
/**
* Orchestrates the deployment tasks and handles cancellations.
* This function wraps the deployment process into a Promise
* and returns it. The Promise returned from this function
* is expected to be awaited on in a try...catch.
*
* For regular deployments, it performs:
* 1. Validates the app.json configuration file
* 2. Validates the Procfile
* 3. Creates and deploys a new Heroku application
* 4. Adds the new Heroku app to the git remote
* 5. Displays a notification with options to view the app in the explorer
*
* For one-off dynos, it:
* 1. Creates and configures the dyno
* 2. Waits for the dyno to start
* 3. Establishes a rendezvous connection to capture output
* 4. Monitors execution until completion
* 5. Handles automatic cleanup if timeToLive is specified
*
* @returns The deployment process wrapped in a promise
*/
runOperation = async () => {
const { tarballUri } = this.deploymentOptions;
// If command is present, this is a one-off dyno deployment
if ('command' in this.deploymentOptions) {
return this.deployOneOffDyno();
}
// Regular app deployment flow
// app.json is required on an initial deployment using this flow
if (!tarballUri && !this.isExistingDeployment) {
await this.validateAppJson();
}
// We're good to deploy
return this.deployToHeroku();
};
/**
* Builds and sends the payload to Heroku for setting
* up a new app. If a tarballUri is provided, it will be used
* as the source_blob url. Otherwise, the AppSetup service will
* create a new tarball and upload it to the source_blob url created
* by the SourceService.
*
* - If the target argument is provided and this is an App object,
* a new build is created and deployed to the app.
* - If the target argument is not an App object and existing apps are found
* in the workspace, a dialog is presented to ask the user
* where to deploy.
* - If the target argument is not an App object and no existing
* apps are found in the workspace, a new app is created and deployed.
*
* @returns an AppSetup object with the details of the newly setup app
* @throws {DeploymentError} If the deployment fails
*/
async deployToHeroku() {
const { tarballUri, rootUri, appJson } = this.deploymentOptions;
let blobUrl = tarballUri?.toString();
// Create and use an amazon s3 bucket and
// then upload the newly created tarball
// from the local sources if no tarballUri was provided.
if (!blobUrl) {
const generatedAppJson = appJson ? [{ relativePath: './app.json', contents: appJson }] : [];
const tarball = await packSources(rootUri, generatedAppJson);
const { source_blob: sourceBlob } = await this.sourcesService.create(this.requestInit);
blobUrl = sourceBlob.get_url;
try {
const response = await fetch(sourceBlob.put_url, {
method: 'PUT',
body: tarball
});
if (!response.ok) {
const uploadErrorMessage = `Error uploading tarball to S3 bucket. The server responded with: ${response.status} - ${response.statusText}`;
throw new Error(uploadErrorMessage);
}
}
catch (error) {
throw new DeploymentError(error.message);
}
}
// The user has right-clicked on a
// Procfile or app.json or has used
// the deploy to heroku decorator button
// and we have apps in the remote. Ask
// the user where to deploy.
const result = this.isExistingDeployment
? await this.createNewBuildForExistingApp(blobUrl, this.deploymentOptions.name ?? '')
: await this.setupNewApp(blobUrl);
// This is a new app setup and needs a git remote
// added to the workspace.
if (!this.isExistingDeployment && result) {
// Add the new remote to the workspace
const app = await this.appService.info(result.name, this.requestInit);
try {
execSync(`git remote add heroku-${result.name} ${app.git_url}`, { cwd: rootUri });
}
catch {
// Ignore
}
}
return result;
}
/**
* Deploys a one-off dyno to the app and captures its output.
* This method:
* 1. Creates and configures the dyno with the specified settings
* 2. Waits for the dyno to start
* 3. Establishes a rendezvous connection to capture output
* 4. Monitors execution until completion
* 5. Handles automatic cleanup if timeToLive is specified
*
* @returns A promise resolving to an object containing:
* - dyno: The Heroku dyno object
* - output: The complete output from the dyno's execution
* - exitCode: The process exit code (0 for success, non-zero for failure)
* - name: The name of the app the dyno was created in
* - errorMessage: Optional error message if execution failed
* @throws {DeploymentError} If the deployment fails, dyno crashes, or no attach URL is available
* @throws {Error} For general deployment failures with detailed error messages
*/
async deployOneOffDyno() {
const { name, command, size, timeToLive, env, sources } = this.deploymentOptions;
try {
// If sources are provided, pack them and modify the command
let finalCommand = command;
if (sources?.length) {
const commands = [
'TEMP_DIR=$(mktemp -d)',
'cd $TEMP_DIR',
...sources.map((source) => `printf '%s' '${source.contents.replace(/'/g, "'\\''")}' > ${source.relativePath}`),
command,
'cd - > /dev/null',
'rm -rf $TEMP_DIR'
];
finalCommand = commands.join(' && ');
}
// Create and start the one-off dyno
const dyno = await this.dynoService.create(name, {
command: finalCommand,
size: size ?? 'basic',
type: 'run',
// eslint-disable-next-line camelcase
time_to_live: timeToLive,
attach: true,
env: env ?? {},
// eslint-disable-next-line camelcase
force_no_tty: true
}, this.requestInit);
// Connect to the dyno and capture output
const rendezvous = new RendezvousConnection({
uri: new URL(dyno.attach_url),
rejectUnauthorized: true,
showStatus: true,
onStatusChange: (status) => {
// eslint-disable-next-line no-console
console.log(`Dyno status: ${status}`);
}
});
// Capture the output and exit code
const { output, exitCode } = await rendezvous.connect();
// Set up auto-stop if timeToLive is specified
if (timeToLive) {
const timeoutFunc = async () => {
try {
await this.dynoService.stop(name, dyno.id, this.requestInit);
}
catch (error) {
// eslint-disable-next-line no-console
console.warn('Failed to stop dyno:', error);
}
};
setTimeout(() => void timeoutFunc(), timeToLive * 1000);
}
return {
dyno,
output,
exitCode,
name,
errorMessage: exitCode !== 0 ? `Dyno exited with code ${exitCode}` : undefined
};
}
catch (error) {
if (error instanceof DeploymentError) {
throw error;
}
throw new Error(`One-off dyno deployment failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Reads and validates the app.json. If it is invalid,
* the errors are logged and the user is informed that
* the app will not be deployed and the action is aborted.
*
* @returns The app.json as an object or undefined if it is invalid
* @throws {Error} If the app.json is invalid or cannot be read
*/
async validateAppJson() {
const { appJson, rootUri } = this.deploymentOptions;
const readAppJsonResult = await readAppJson(rootUri, appJson ? Buffer.from(appJson) : undefined);
if (readAppJsonResult instanceof ValidatorResult) {
let message = 'invalid app.json\nThe following errors were found in app.json:\n--------------------------------\n';
readAppJsonResult.errors.forEach((error) => {
message += error.stack + '\n';
});
message += '--------------------------------';
throw new Error(message);
}
return readAppJsonResult;
}
/**
* Creates a new build for the given appIdentity.
* This function is used when creating a new build
* for an existing app.
*
* @param blobUrl The url of the blob to sent to the app setup service
* @param appName The name of the app to create the build for
* @returns Build object with the details of the newly created build
* @throws {DeploymentError} If the deployment fails
*/
async createNewBuildForExistingApp(blobUrl, appName) {
const payload = {
// eslint-disable-next-line camelcase
source_blob: {
url: blobUrl,
checksum: null,
version: null,
// eslint-disable-next-line camelcase
version_description: null
}
};
try {
const result = await this.buildService.create(appName, payload, this.requestInit);
let buildOutput = '';
if (result.output_stream_url) {
buildOutput = await this.captureBuildOutput(result.output_stream_url);
}
const info = await this.buildService.info(appName, result.id, this.requestInit);
if (info.status === 'failed') {
throw new DeploymentError(`The request was sent to Heroku successfully but there was a problem with deployment: ${info.status} - ${buildOutput}`, appName);
}
return { ...info, name: appName };
}
catch (error) {
throw new DeploymentError(error.message);
}
}
/**
* Sets up a new app using the AppSetup service and the
* supplied blobUrl.
*
* @param blobUrl The url of the blob to sent to the app setup service
* @returns Build object with the details of the newly setup app
* @throws {DeploymentError} If the deployment fails
*/
async setupNewApp(blobUrl) {
const { name, spaceId, teamId, env, internalRouting } = this.deploymentOptions;
const payload = {
// eslint-disable-next-line camelcase
source_blob: { url: blobUrl },
overrides: { env },
app: {
name: name ?? undefined,
organization: teamId ?? undefined,
space: spaceId ?? undefined
}
};
Reflect.set(payload.app, 'internal_routing', !!internalRouting);
try {
const result = await this.appSetupService.create(payload, this.requestInit);
const info = await this.waitForAppSetup(result);
return {
...info.build,
name: result.app.name,
postDeployOutput: info.postdeploy?.output,
buildOutput: info.build?.output
};
}
catch (error) {
throw new DeploymentError(error.message);
}
}
/**
* Waits for the app setup to complete.
*
* @param result The app setup result
* @returns The app setup result
* @throws {DeploymentError} If the deployment fails
*/
async waitForAppSetup(result) {
let info;
let retriesOnError = 3;
while (retriesOnError > 0) {
try {
info = await this.appSetupService.info(result.id, this.requestInit);
if (info?.build?.output_stream_url ?? info?.status === 'failed') {
break;
}
}
catch {
retriesOnError--;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (!info) {
throw new DeploymentError('The request was sent to Heroku successfully but there was a problem with deployment');
}
if (info?.build?.output_stream_url) {
const buildOutput = await this.captureBuildOutput(info.build.output_stream_url);
info.build.output = buildOutput;
}
while (info.status === 'pending') {
await new Promise((resolve) => setTimeout(resolve, 2000));
info = await this.appSetupService.info(result.id, this.requestInit);
}
if (info.failure_message) {
let errorMessage = `Error: deployment failed with "${info.failure_message}"\n`;
if (info.manifest_errors?.length) {
errorMessage += '-----------------------\n';
info.manifest_errors.forEach((error) => (errorMessage += error + '\n'));
errorMessage += '-----------------------\n';
}
throw new DeploymentError(`The request was sent to Heroku successfully but there was a problem with deployment:\n${errorMessage}`, result.app?.id);
}
return info;
}
/**
* Streams the build output to the console.
*
* @param streamUrl the URL of the stream
* @returns The build output
*/
async captureBuildOutput(streamUrl) {
let output = '';
const stream = await fetch(streamUrl);
const reader = stream.body?.getReader();
while (!this.signal.aborted) {
const { done, value } = await reader.read();
if (done)
break;
if (value.length > 1) {
output += Buffer.from(value).toString();
}
}
return output;
}
}
/**
* Schema for validating Heroku deployment parameters.
* This schema enforces the structure and types of deployment options
* including required fields and optional configurations.
*/
export const deployToHerokuSchema = z
.object({
name: z.string().min(5).max(30).describe('App name for deployment. Creates new app if not exists.'),
rootUri: z.string().min(1).describe('Workspace root directory path.'),
tarballUri: z.string().optional().describe('URL of deployment tarball. Creates from rootUri if not provided.'),
teamId: z.string().optional().describe('Team ID for team deployments.'),
spaceId: z.string().optional().describe('Private space ID for space deployments.'),
internalRouting: z.boolean().optional().describe('Enable internal routing in private spaces.'),
env: z.record(z.string(), z.any()).optional().describe('Environment variables overriding app.json values'),
appJson: z
.string()
.describe(`App.json config for deployment. Must follow schema: ${JSON.stringify(appJsonSchema, null, 0)}`)
})
.strict();
/**
* Registers the deploy_to_heroku tool with the MCP server.
* This tool handles deployment of applications to Heroku using app.json configuration.
* It supports team and private space deployments, environment variable configuration,
* and custom app.json specifications.
*
* @param server - The MCP server instance to register the tool with
*/
export const registerDeployToHerokuTool = (server) => {
server.tool('deploy_to_heroku', 'Use for all deployments. Deploys new/existing apps, with or without teams/spaces, and env vars to Heroku. ' +
'Ask for app name if missing. Requires valid app.json via appJson param.', deployToHerokuSchema.shape, async (options) => {
const deployToHeroku = new DeployToHeroku();
const result = await deployToHeroku.run({
rootUri: options.rootUri,
tarballUri: options.tarballUri,
name: options.name,
teamId: options.teamId,
spaceId: options.spaceId,
internalRouting: options.internalRouting,
env: options.env,
appJson: options.appJson ? new TextEncoder().encode(options.appJson) : undefined
});
if (result?.errorMessage) {
return {
success: false,
message: result.errorMessage,
content: [{ type: 'text', text: result.errorMessage }]
};
}
const successMessage = `Successfully deployed to ${result?.name ?? 'Heroku'}`;
return {
success: true,
message: successMessage,
content: [{ type: 'text', text: successMessage }],
data: result
};
});
};
// Define the schema for deploying to a one-off dyno
export const deployOneOffDynoSchema = z
.object({
name: z.string().min(5).max(30).describe('Target Heroku app name.'),
command: z.string().describe('Command to run in dyno.'),
sources: z
.array(z.object({
relativePath: z.string().describe('Virtual path for tarball entry.'),
contents: z.string().describe('File contents.')
}))
.optional()
.describe('Source files to include in dyno.'),
size: z.string().optional().describe('Dyno size.').default('standard-1x'),
timeToLive: z.number().optional().describe('Dyno lifetime in seconds.').default(3600),
env: z.record(z.string(), z.any()).optional().describe('Dyno environment variables.')
})
.strict();
const execToolSchemaDescription = `
Run code/commands in Heroku one-off dyno with network and filesystem access.
Requirements:
- Show command output
- Use app_info for buildpack detection
- Support shell setup commands
- Use stdout/stderr
Features:
- Network/filesystem access
- Environment variables
- File operations
- Temp directory handling
Usage:
1. Use Heroku runtime
2. Proper syntax/imports
3. Organized code structure
4. Package management:
- Define dependencies
- Minimize external deps
- Prefer native modules
Example package.json:
\`\`\`json
{
"type": "module",
"dependencies": {
"axios": "^1.6.0"
}
}
\`\`\`
`;
/**
* Registers the deploy_one_off_dyno tool with the MCP server.
* This tool handles deployment of one-off dynos to Heroku using the specified command and configuration.
*
* @param server - The MCP server instance to register the tool with
*/
export const registerDeployOneOffDynoTool = (server) => {
server.tool('deploy_one_off_dyno', execToolSchemaDescription, deployOneOffDynoSchema.shape, async (options) => {
const deployToHeroku = new DeployToHeroku();
const result = (await deployToHeroku.run(options));
if (result?.errorMessage) {
return {
success: false,
message: result.errorMessage,
content: [{ type: 'text', text: result.errorMessage }]
};
}
return {
success: true,
message: result.output ?? 'Successfully deployed one-off dyno to Heroku',
content: [{ type: 'text', text: result.output }],
data: result
};
});
};