From 6860d031cd89ad8a2da9cbffc67a084accc6cc75 Mon Sep 17 00:00:00 2001 From: Lyu Date: Wed, 15 Apr 2026 02:48:34 -0700 Subject: [PATCH 1/3] add new deployment flow (direct) --- package-lock.json | 12 +- package.json | 4 +- src/shared/tools/deployment.ts | 884 ++++++++++++++++++++++++++------- src/shared/tools/index.ts | 19 +- src/shared/tools/types.ts | 1 + 5 files changed, 713 insertions(+), 207 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2013268..273c270 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@insforge/mcp", - "version": "1.2.9", + "version": "1.2.10-dev.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@insforge/mcp", - "version": "1.2.9", + "version": "1.2.10-dev.3", "license": "Apache-2.0", "dependencies": { - "@insforge/shared-schemas": "1.1.47", + "@insforge/shared-schemas": "1.1.49", "@modelcontextprotocol/sdk": "^1.27.1", "archiver": "^7.0.1", "commander": "^14.0.0", @@ -774,9 +774,9 @@ } }, "node_modules/@insforge/shared-schemas": { - "version": "1.1.47", - "resolved": "https://registry.npmjs.org/@insforge/shared-schemas/-/shared-schemas-1.1.47.tgz", - "integrity": "sha512-lwx7ymV41l7QzqIWkZ81SnZKvudqqy0FchAiBOHp5dea37g/HCMcPxfHV/B89xY4GKLkWjF1P2Nwh58PKv7OHA==", + "version": "1.1.49", + "resolved": "https://registry.npmjs.org/@insforge/shared-schemas/-/shared-schemas-1.1.49.tgz", + "integrity": "sha512-yDv0VXF18orhJheEc7jpmOXPdMM416kH5o/Qf6uP9W9KDXn4/z0G4OMF7o4OgeIlhEVTNnxzVA7F4zi/GZyYDQ==", "license": "Apache-2.0", "dependencies": { "zod": "^3.23.8" diff --git a/package.json b/package.json index bbb368a..238e647 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@insforge/mcp", - "version": "1.2.9", + "version": "1.2.10-dev.3", "description": "MCP (Model Context Protocol) server for Insforge backend-as-a-service", "mcpName": "io.github.InsForge/insforge-mcp", "type": "module", @@ -45,7 +45,7 @@ "server.json" ], "dependencies": { - "@insforge/shared-schemas": "1.1.47", + "@insforge/shared-schemas": "1.1.49", "@modelcontextprotocol/sdk": "^1.27.1", "archiver": "^7.0.1", "commander": "^14.0.0", diff --git a/src/shared/tools/deployment.ts b/src/shared/tools/deployment.ts index 65f35a2..8ef65e9 100644 --- a/src/shared/tools/deployment.ts +++ b/src/shared/tools/deployment.ts @@ -1,49 +1,493 @@ import { z } from 'zod'; import fetch from 'node-fetch'; +import { createHash } from 'crypto'; import { promises as fs, createWriteStream, createReadStream } from 'fs'; import { tmpdir } from 'os'; -import { join } from 'path'; +import { join, relative, sep } from 'path'; import archiver from 'archiver'; import FormData from 'form-data'; import { handleApiResponse, formatSuccessMessage } from '../response-handler.js'; import { - StartDeploymentRequest, - CreateDeploymentResponse, + createDeploymentResponseSchema, + createDirectDeploymentResponseSchema, startDeploymentRequestSchema, } from '@insforge/shared-schemas'; +import type { + CreateDeploymentResponse, + CreateDirectDeploymentResponse, + DeploymentManifestFile, + DeploymentManifestFileEntry, + StartDeploymentRequest, +} from '@insforge/shared-schemas'; import type { RegisterContext } from './types.js'; import { shellEsc } from './utils.js'; -function isCreateDeploymentResponse(obj: unknown): obj is CreateDeploymentResponse { - if (typeof obj !== 'object' || obj === null) { - return false; +const DIRECT_DEPLOYMENT_MIN_VERSION = '2.0.4'; +const DEFAULT_DIRECT_UPLOAD_CONCURRENCY = 8; +const MAX_DIRECT_UPLOAD_CONCURRENCY = 32; +const EXCLUDED_DEPLOYMENT_SEGMENTS = new Set([ + 'node_modules', + '.git', + '.next', + 'dist', + 'build', + '.insforge', +]); + +interface LocalDeploymentFile extends DeploymentManifestFileEntry { + absolutePath: string; +} + +type DirectDeploymentSession = CreateDirectDeploymentResponse; + +class DirectDeploymentUnsupportedError extends Error { + constructor() { + super('Direct deployment endpoints are not available on this backend'); + this.name = 'DirectDeploymentUnsupportedError'; + } +} + +function isAbsoluteSourcePath(sourceDirectory: string): boolean { + return sourceDirectory.startsWith('/') || /^[a-zA-Z]:[/\\]/.test(sourceDirectory); +} + +function supportsDirectDeploymentVersion(backendVersion: string): boolean { + const cleanVersion = backendVersion.replace(/^v/, '').split('-')[0]; + const [major = 0, minor = 0, patch = 0] = cleanVersion.split('.').map(Number); + const [minMajor, minMinor, minPatch] = DIRECT_DEPLOYMENT_MIN_VERSION.split('.').map(Number); + + if (major !== minMajor) return major > minMajor; + if (minor !== minMinor) return minor > minMinor; + return patch >= minPatch; +} + +function shouldExcludeDeploymentPath(normalizedName: string): boolean { + const segments = normalizedName.split('/'); + + if (segments.some((segment) => segment === '.env' || segment.startsWith('.env.'))) { + return true; + } + + if (segments.some((segment) => EXCLUDED_DEPLOYMENT_SEGMENTS.has(segment))) { + return true; + } + + return ( + normalizedName === '.DS_Store' || + normalizedName.endsWith('/.DS_Store') || + normalizedName.endsWith('.log') + ); +} + +function normalizeRelativePath(rootDirectory: string, absolutePath: string): string { + return relative(rootDirectory, absolutePath).split(sep).join('/').replace(/\\/g, '/'); +} + +async function hashFile(filePath: string): Promise<{ sha: string; size: number }> { + const hash = createHash('sha1'); + let size = 0; + + for await (const chunk of createReadStream(filePath)) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + hash.update(buffer); + } + + return { sha: hash.digest('hex'), size }; +} + +function parseCreateDeploymentResponse(obj: unknown): CreateDeploymentResponse { + const result = createDeploymentResponseSchema.safeParse(obj); + if (!result.success) { + throw new Error('Unexpected response format from deployments endpoint'); + } + + return result.data; +} + +function parseCreateDirectDeploymentResponse(obj: unknown): DirectDeploymentSession { + const result = createDirectDeploymentResponseSchema.safeParse(obj); + if (!result.success) { + throw new Error('Unexpected response format from direct deployments endpoint'); + } + + return result.data; +} + +function getDirectUploadConcurrency(): number { + const parsed = Number.parseInt(process.env.INSFORGE_DEPLOY_UPLOAD_CONCURRENCY ?? '', 10); + const requested = + Number.isSafeInteger(parsed) && parsed > 0 ? parsed : DEFAULT_DIRECT_UPLOAD_CONCURRENCY; + return Math.min(requested, MAX_DIRECT_UPLOAD_CONCURRENCY); +} + +async function runWithConcurrency( + items: T[], + concurrency: number, + worker: (item: T, index: number) => Promise +): Promise { + let nextIndex = 0; + + async function runWorker(): Promise { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex++; + await worker(items[index], index); + } + } + + const workerCount = Math.min(concurrency, items.length); + await Promise.all(Array.from({ length: workerCount }, () => runWorker())); +} + +async function collectDeploymentFiles(sourceDirectory: string): Promise { + const files: LocalDeploymentFile[] = []; + + async function walk(currentDirectory: string): Promise { + const entries = await fs.readdir(currentDirectory, { withFileTypes: true }); + entries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + const absolutePath = join(currentDirectory, entry.name); + const normalizedPath = normalizeRelativePath(sourceDirectory, absolutePath); + + if (!normalizedPath || shouldExcludeDeploymentPath(normalizedPath)) { + continue; + } + + if (entry.isDirectory()) { + await walk(absolutePath); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const { sha, size } = await hashFile(absolutePath); + files.push({ + absolutePath, + path: normalizedPath, + sha, + size, + }); + } } - const value = obj as { - id?: unknown; - uploadUrl?: unknown; - uploadFields?: unknown; + await walk(sourceDirectory); + return files; +} + +function buildStartBody(input: { + projectSettings?: StartDeploymentRequest['projectSettings']; + envVars?: StartDeploymentRequest['envVars']; + meta?: StartDeploymentRequest['meta']; +}): StartDeploymentRequest { + const startBody: StartDeploymentRequest = {}; + if (input.projectSettings) startBody.projectSettings = input.projectSettings; + if (input.envVars) startBody.envVars = input.envVars; + if (input.meta) startBody.meta = input.meta; + return startBody; +} + +async function createDirectDeploymentSession( + API_BASE_URL: string, + apiKey: string, + files: DeploymentManifestFileEntry[] +): Promise { + const response = await fetch(`${API_BASE_URL}/api/deployments/direct`, { + method: 'POST', + headers: { + 'x-api-key': apiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ files }), + }); + + if (response.status === 404) { + throw new DirectDeploymentUnsupportedError(); + } + + const result = await handleApiResponse(response); + return parseCreateDirectDeploymentResponse(result); +} + +async function uploadDeploymentFileContent( + API_BASE_URL: string, + apiKey: string, + deploymentId: string, + file: DeploymentManifestFile, + localFile: LocalDeploymentFile +): Promise { + const response = await fetch( + `${API_BASE_URL}/api/deployments/${encodeURIComponent(deploymentId)}/files/${encodeURIComponent(file.fileId)}/content`, + { + method: 'PUT', + headers: { + 'x-api-key': apiKey, + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(localFile.size), + }, + body: createReadStream(localFile.absolutePath), + } + ); + + if (response.status === 404) { + throw new DirectDeploymentUnsupportedError(); + } + + await handleApiResponse(response); +} + +async function startDeployment( + API_BASE_URL: string, + apiKey: string, + deploymentId: string, + startBody: StartDeploymentRequest +): Promise { + const response = await fetch( + `${API_BASE_URL}/api/deployments/${encodeURIComponent(deploymentId)}/start`, + { + method: 'POST', + headers: { + 'x-api-key': apiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(startBody), + } + ); + + return handleApiResponse(response); +} + +async function deployDirect( + API_BASE_URL: string, + apiKey: string, + sourceDirectory: string, + startBody: StartDeploymentRequest +): Promise<{ + deploymentId: string; + fileCount: number; + uploadConcurrency: number; + startResult: unknown; +}> { + const files = await collectDeploymentFiles(sourceDirectory); + if (files.length === 0) { + throw new Error('No deployable files found in source directory after applying exclusions.'); + } + + const manifestFiles = files.map(({ path, sha, size }) => ({ path, sha, size })); + const createResult = await createDirectDeploymentSession(API_BASE_URL, apiKey, manifestFiles); + const deploymentId = String(createResult.id); + const localFileByPath = new Map(files.map((file) => [file.path, file] as const)); + const uploadConcurrency = getDirectUploadConcurrency(); + + await runWithConcurrency(createResult.files, uploadConcurrency, async (file) => { + const localFile = localFileByPath.get(file.path); + if (!localFile) { + throw new Error(`Direct deployment response included unknown file path: ${file.path}`); + } + if (localFile.sha !== file.sha || localFile.size !== file.size) { + throw new Error(`Direct deployment response did not match local file metadata: ${file.path}`); + } + + await uploadDeploymentFileContent(API_BASE_URL, apiKey, deploymentId, file, localFile); + }); + + const startResult = await startDeployment(API_BASE_URL, apiKey, deploymentId, startBody); + return { deploymentId, fileCount: files.length, uploadConcurrency, startResult }; +} + +function buildRemoteDirectUploadInstructions( + API_BASE_URL: string, + sourceDirectory: string +): string { + const escapedDir = shellEsc(sourceDirectory); + const apiBaseUrlJson = JSON.stringify(API_BASE_URL); + + return `Direct deployment upload is available for this backend. + +Please execute the following command locally from a shell that has INSFORGE_API_KEY set, then call the \`start-deployment\` tool with the deployment ID printed by the script. Set \`INSFORGE_DEPLOY_UPLOAD_CONCURRENCY\` if you want to tune parallel uploads; the default is 8 and the maximum is 32. + +\`\`\`bash +cd ${escapedDir} +INSFORGE_API_KEY="\${INSFORGE_API_KEY:?Set INSFORGE_API_KEY to your InsForge API key}" node --input-type=module <<'NODE' +const { createHash } = await import('node:crypto'); +const { createReadStream } = await import('node:fs'); +const fs = await import('node:fs/promises'); +const path = await import('node:path'); + +const API_BASE_URL = ${apiBaseUrlJson}; +const API_KEY = process.env.INSFORGE_API_KEY; +const DEFAULT_UPLOAD_CONCURRENCY = 8; +const MAX_UPLOAD_CONCURRENCY = 32; +const EXCLUDED_SEGMENTS = new Set(['node_modules', '.git', '.next', 'dist', 'build', '.insforge']); + +function shouldExcludeDeploymentPath(normalizedName) { + const segments = normalizedName.split('/'); + if (segments.some((segment) => segment === '.env' || segment.startsWith('.env.'))) return true; + if (segments.some((segment) => EXCLUDED_SEGMENTS.has(segment))) return true; + return normalizedName === '.DS_Store' || normalizedName.endsWith('/.DS_Store') || normalizedName.endsWith('.log'); +} + +async function readJsonResponse(response) { + const text = await response.text(); + let data = null; + if (text) { + try { + data = JSON.parse(text); + } catch { + data = text; + } + } + if (!response.ok) { + const message = data && typeof data === 'object' ? data.message || data.error : null; + throw new Error(message || 'Request failed with status ' + response.status); + } + return data; +} + +async function api(pathname, init = {}) { + const headers = { + 'x-api-key': API_KEY, + ...(init.headers || {}), }; + const response = await fetch(API_BASE_URL + pathname, { ...init, headers }); + return readJsonResponse(response); +} + +function getUploadConcurrency() { + const parsed = Number.parseInt(process.env.INSFORGE_DEPLOY_UPLOAD_CONCURRENCY || '', 10); + const requested = Number.isSafeInteger(parsed) && parsed > 0 ? parsed : DEFAULT_UPLOAD_CONCURRENCY; + return Math.min(requested, MAX_UPLOAD_CONCURRENCY); +} + +async function runWithConcurrency(items, concurrency, worker) { + let nextIndex = 0; - const idOk = - 'id' in value && - (typeof value.id === 'string' || typeof value.id === 'number'); + async function runWorker() { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + await worker(items[index], index); + } + } - const urlOk = - 'uploadUrl' in value && - typeof value.uploadUrl === 'string' && - value.uploadUrl.length > 0; + const workerCount = Math.min(concurrency, items.length); + await Promise.all(Array.from({ length: workerCount }, () => runWorker())); +} + +async function hashFile(filePath) { + const hash = createHash('sha1'); + let size = 0; + for await (const chunk of createReadStream(filePath)) { + size += chunk.length; + hash.update(chunk); + } + return { sha: hash.digest('hex'), size }; +} - const fieldsOk = - 'uploadFields' in value && - typeof value.uploadFields === 'object' && - value.uploadFields !== null; +async function collectFiles(rootDirectory) { + const files = []; + + async function walk(currentDirectory) { + const entries = await fs.readdir(currentDirectory, { withFileTypes: true }); + entries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + const absolutePath = path.join(currentDirectory, entry.name); + const normalizedPath = path.relative(rootDirectory, absolutePath).split(path.sep).join('/').replace(/\\\\/g, '/'); + + if (!normalizedPath || shouldExcludeDeploymentPath(normalizedPath)) continue; + if (entry.isDirectory()) { + await walk(absolutePath); + continue; + } + if (!entry.isFile()) continue; + + const { sha, size } = await hashFile(absolutePath); + files.push({ absolutePath, path: normalizedPath, sha, size }); + } + } - return idOk && urlOk && fieldsOk; + await walk(rootDirectory); + return files; +} + +async function uploadFile(deploymentId, manifestFile, localFile) { + const response = await fetch( + API_BASE_URL + + '/api/deployments/' + + encodeURIComponent(deploymentId) + + '/files/' + + encodeURIComponent(manifestFile.fileId) + + '/content', + { + method: 'PUT', + headers: { + 'x-api-key': API_KEY, + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(localFile.size), + }, + body: createReadStream(localFile.absolutePath), + duplex: 'half', + } + ); + + await readJsonResponse(response); +} + +const rootDirectory = process.cwd(); +const localFiles = await collectFiles(rootDirectory); +if (localFiles.length === 0) throw new Error('No deployable files found after applying exclusions.'); + +const createResult = await api('/api/deployments/direct', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + files: localFiles.map(({ path, sha, size }) => ({ path, sha, size })), + }), +}); + +if (!createResult || !Array.isArray(createResult.files)) { + throw new Error('Direct deployment endpoint returned an unexpected response.'); +} + +const deploymentId = createResult.id; +const localFileByPath = new Map(localFiles.map((file) => [file.path, file])); +const uploadConcurrency = getUploadConcurrency(); +console.log('Created deployment. Deployment ID: ' + deploymentId); + +await runWithConcurrency(createResult.files, uploadConcurrency, async (manifestFile) => { + const localFile = localFileByPath.get(manifestFile.path); + if (!localFile) throw new Error('Backend returned an unknown file path: ' + manifestFile.path); + if (localFile.sha !== manifestFile.sha || localFile.size !== manifestFile.size) { + throw new Error('Backend file metadata mismatch for: ' + manifestFile.path); + } + await uploadFile(deploymentId, manifestFile, localFile); +}); + +console.log('Deployment files uploaded. Deployment ID: ' + deploymentId); +console.log('Uploaded ' + createResult.files.length + ' files through direct deployment proxy with concurrency ' + uploadConcurrency + '.'); +NODE +\`\`\` + +After the script succeeds, call the \`start-deployment\` tool with the printed deployment ID. + +If the upload is interrupted after the deployment ID is printed, query \`system.deployment_files\` with the raw SQL tool for that \`deployment_id\` to inspect \`uploaded_at\`. You can rerun \`create-deployment\` to create a fresh deployment, or upload missing file IDs to \`PUT /api/deployments/:id/files/:fileId/content\` using the queried manifest rows.`; } export function registerDeploymentTools(ctx: RegisterContext): void { - const { API_BASE_URL, isRemote, registerTool, withUsageTracking, getApiKey, addBackgroundContext } = ctx; + const { + API_BASE_URL, + backendVersion, + isRemote, + registerTool, + withUsageTracking, + getApiKey, + addBackgroundContext, + } = ctx; + const supportsDirectDeployment = supportsDirectDeploymentVersion(backendVersion); // -------------------------------------------------- // CONTAINER LOGS TOOLS @@ -53,8 +497,13 @@ export function registerDeploymentTools(ctx: RegisterContext): void { 'get-container-logs', 'Get latest logs from a specific container/service. Use this to help debug problems with your app.', { - apiKey: z.string().optional().describe('API key for authentication (optional if provided via --api_key)'), - source: z.enum(['insforge.logs', 'postgREST.logs', 'postgres.logs', 'function.logs']).describe('Log source to retrieve'), + apiKey: z + .string() + .optional() + .describe('API key for authentication (optional if provided via --api_key)'), + source: z + .enum(['insforge.logs', 'postgREST.logs', 'postgres.logs', 'function.logs']) + .describe('Log source to retrieve'), limit: z.number().optional().default(20).describe('Number of logs to return (default: 20)'), }, withUsageTracking('get-container-logs', async ({ apiKey, source, limit }) => { @@ -78,7 +527,9 @@ export function registerDeploymentTools(ctx: RegisterContext): void { const result = await handleApiResponse(response); return await addBackgroundContext({ - content: [{ type: 'text', text: formatSuccessMessage(`Latest logs from ${source}`, result) }], + content: [ + { type: 'text', text: formatSuccessMessage(`Latest logs from ${source}`, result) }, + ], }); } catch (error) { const errMsg = error instanceof Error ? error.message : 'Unknown error occurred'; @@ -98,23 +549,39 @@ export function registerDeploymentTools(ctx: RegisterContext): void { // Remote mode: prepare deployment and return instructions for agent to execute locally registerTool( 'create-deployment', - 'Prepare a deployment by creating a presigned upload URL. Returns shell commands for the agent to execute locally: zip the source directory and upload to cloud storage. After uploading, call the start-deployment tool to trigger the build.', + 'Prepare a deployment upload. Direct-capable backends return direct file upload commands. Older backends use the legacy zip upload flow. After uploading, call the start-deployment tool to trigger the build.', { - sourceDirectory: z.string().describe('Absolute path to the source directory containing files to deploy (e.g., /Users/name/project). Do not use relative paths like "."'), + sourceDirectory: z + .string() + .describe( + 'Absolute path to the source directory containing files to deploy (e.g., /Users/name/project). Do not use relative paths like "."' + ), }, withUsageTracking('create-deployment', async ({ sourceDirectory }) => { try { - const isAbsolutePath = sourceDirectory.startsWith('/') || /^[a-zA-Z]:[/\\]/.test(sourceDirectory); - if (!isAbsolutePath) { + if (!isAbsoluteSourcePath(sourceDirectory)) { return { - content: [{ - type: 'text' as const, - text: `Error: sourceDirectory must be an absolute path, not a relative path like "${sourceDirectory}". Please provide the full path to the source directory (e.g., /Users/name/project on macOS/Linux or C:\\Users\\name\\project on Windows).`, - }], + content: [ + { + type: 'text' as const, + text: `Error: sourceDirectory must be an absolute path, not a relative path like "${sourceDirectory}". Please provide the full path to the source directory (e.g., /Users/name/project on macOS/Linux or C:\\Users\\name\\project on Windows).`, + }, + ], isError: true, }; } + if (supportsDirectDeployment) { + return { + content: [ + { + type: 'text', + text: buildRemoteDirectUploadInstructions(API_BASE_URL, sourceDirectory), + }, + ], + }; + } + const createResponse = await fetch(`${API_BASE_URL}/api/deployments`, { method: 'POST', headers: { @@ -123,10 +590,9 @@ export function registerDeploymentTools(ctx: RegisterContext): void { }, }); - const createResult = await handleApiResponse(createResponse); - if (!isCreateDeploymentResponse(createResult)) { - throw new Error('Unexpected response format from deployments endpoint'); - } + const createResult = parseCreateDeploymentResponse( + await handleApiResponse(createResponse) + ); const { id: deploymentId, uploadUrl, uploadFields } = createResult; const esc = shellEsc; @@ -144,7 +610,7 @@ Please execute the following commands locally, then call the \`start-deployment\ ## Step 1: Zip the source directory \`\`\`bash cd ${escapedDir} && zip -r ${tmpZip} . \ - -x "node_modules/*" ".git/*" ".next/*" ".env" ".env.local" "dist/*" "build/*" ".DS_Store" "*.log" + -x "node_modules/*" ".git/*" ".next/*" ".env*" "dist/*" "build/*" ".insforge/*" ".DS_Store" "*.log" \`\`\` ## Step 2: Upload the zip file @@ -185,189 +651,221 @@ Run each step in order. If any step fails, do not proceed to the next step.`; deploymentId: z.string().describe('The deployment ID returned by create-deployment'), ...startDeploymentRequestSchema.shape, }, - withUsageTracking('start-deployment', async ({ deploymentId, projectSettings, envVars, meta }) => { - try { - const startBody: StartDeploymentRequest = {}; - if (projectSettings) startBody.projectSettings = projectSettings; - if (envVars) startBody.envVars = envVars; - if (meta) startBody.meta = meta; - - const startResponse = await fetch(`${API_BASE_URL}/api/deployments/${encodeURIComponent(deploymentId)}/start`, { - method: 'POST', - headers: { - 'x-api-key': getApiKey(), - 'Content-Type': 'application/json', - }, - body: JSON.stringify(startBody), - }); - - const startResult = await handleApiResponse(startResponse); - return await addBackgroundContext({ - content: [{ - type: 'text', - text: formatSuccessMessage('Deployment started', startResult) + '\n\nNote: You can check deployment status by querying the system.deployments table.', - }], - }); - } catch (error) { - const errMsg = error instanceof Error ? error.message : 'Unknown error occurred'; - return { - content: [{ type: 'text', text: `Error starting deployment: ${errMsg}` }], - isError: true, - }; + withUsageTracking( + 'start-deployment', + async ({ deploymentId, projectSettings, envVars, meta }) => { + try { + const startBody: StartDeploymentRequest = {}; + if (projectSettings) startBody.projectSettings = projectSettings; + if (envVars) startBody.envVars = envVars; + if (meta) startBody.meta = meta; + + const startResponse = await fetch( + `${API_BASE_URL}/api/deployments/${encodeURIComponent(deploymentId)}/start`, + { + method: 'POST', + headers: { + 'x-api-key': getApiKey(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(startBody), + } + ); + + const startResult = await handleApiResponse(startResponse); + return await addBackgroundContext({ + content: [ + { + type: 'text', + text: + formatSuccessMessage('Deployment started', startResult) + + '\n\nNote: You can check deployment status by querying the system.deployments table.', + }, + ], + }); + } catch (error) { + const errMsg = error instanceof Error ? error.message : 'Unknown error occurred'; + return { + content: [{ type: 'text', text: `Error starting deployment: ${errMsg}` }], + isError: true, + }; + } } - }) + ) ); } else { // Local mode: zip, upload, and trigger deployment directly registerTool( 'create-deployment', - 'Deploy source code from a directory. This tool zips files, uploads to cloud storage, and triggers deployment with optional environment variables and project settings.', + 'Deploy source code from a directory. Uses parallel direct file uploads for direct-capable backends, with legacy zip upload fallback for older projects.', { - sourceDirectory: z.string().describe('Absolute path to the source directory containing files to deploy (e.g., /Users/name/project or C:\\Users\\name\\project). Do not use relative paths like "."'), + sourceDirectory: z + .string() + .describe( + 'Absolute path to the source directory containing files to deploy (e.g., /Users/name/project or C:\\Users\\name\\project). Do not use relative paths like "."' + ), ...startDeploymentRequestSchema.shape, }, - withUsageTracking('create-deployment', async ({ sourceDirectory, projectSettings, envVars, meta }) => { - try { - const isAbsolutePath = sourceDirectory.startsWith('/') || /^[a-zA-Z]:[/\\]/.test(sourceDirectory); - if (!isAbsolutePath) { - return { - content: [{ - type: 'text' as const, - text: `Error: sourceDirectory must be an absolute path, not a relative path like "${sourceDirectory}". Please provide the full path to the source directory (e.g., /Users/name/project on macOS/Linux or C:\\Users\\name\\project on Windows).`, - }], - isError: true, - }; - } - + withUsageTracking( + 'create-deployment', + async ({ sourceDirectory, projectSettings, envVars, meta }) => { try { - const stats = await fs.stat(sourceDirectory); - if (!stats.isDirectory()) { + if (!isAbsoluteSourcePath(sourceDirectory)) { return { - content: [{ - type: 'text' as const, - text: `Error: "${sourceDirectory}" is not a directory. Please provide a path to a directory containing the source code.`, - }], + content: [ + { + type: 'text' as const, + text: `Error: sourceDirectory must be an absolute path, not a relative path like "${sourceDirectory}". Please provide the full path to the source directory (e.g., /Users/name/project on macOS/Linux or C:\\Users\\name\\project on Windows).`, + }, + ], isError: true, }; } - } catch { - return { - content: [{ - type: 'text' as const, - text: `Error: Directory "${sourceDirectory}" does not exist or is not accessible. Please verify the path is correct.`, - }], - isError: true, - }; - } - const resolvedSourceDir = sourceDirectory; + try { + const stats = await fs.stat(sourceDirectory); + if (!stats.isDirectory()) { + return { + content: [ + { + type: 'text' as const, + text: `Error: "${sourceDirectory}" is not a directory. Please provide a path to a directory containing the source code.`, + }, + ], + isError: true, + }; + } + } catch { + return { + content: [ + { + type: 'text' as const, + text: `Error: Directory "${sourceDirectory}" does not exist or is not accessible. Please verify the path is correct.`, + }, + ], + isError: true, + }; + } - const createResponse = await fetch(`${API_BASE_URL}/api/deployments`, { - method: 'POST', - headers: { - 'x-api-key': getApiKey(), - 'Content-Type': 'application/json', - }, - }); + const resolvedSourceDir = sourceDirectory; + const startBody = buildStartBody({ projectSettings, envVars, meta }); + + if (supportsDirectDeployment) { + try { + const { fileCount, uploadConcurrency, startResult } = await deployDirect( + API_BASE_URL, + getApiKey(), + resolvedSourceDir, + startBody + ); + const uploadSummary = `Uploaded ${fileCount} files through direct deployment proxy with concurrency ${uploadConcurrency}.`; + + return await addBackgroundContext({ + content: [ + { + type: 'text', + text: + formatSuccessMessage('Deployment started', startResult) + + `\n\n${uploadSummary}\n\nNote: You can check deployment status by querying the system.deployments table. If file uploads are interrupted, inspect system.deployment_files with the raw SQL tool to see which files are already uploaded before retrying missing files.`, + }, + ], + }); + } catch (error) { + if (!(error instanceof DirectDeploymentUnsupportedError)) { + throw error; + } + // Backend reported no direct deployment endpoints despite its version. + // Fall back to the legacy upload path for compatibility. + } + } - const createResult = await handleApiResponse(createResponse); - if (!isCreateDeploymentResponse(createResult)) { - throw new Error('Unexpected response format from deployments endpoint'); - } - const { id: deploymentId, uploadUrl, uploadFields } = createResult; + const createResponse = await fetch(`${API_BASE_URL}/api/deployments`, { + method: 'POST', + headers: { + 'x-api-key': getApiKey(), + 'Content-Type': 'application/json', + }, + }); - // Write archive to a temp file so we know the size before uploading - // (S3 presigned POSTs require Content-Length; streaming without it fails) - const tmpZipPath = join(tmpdir(), `insforge-deploy-${deploymentId}.zip`); + const createResult = parseCreateDeploymentResponse( + await handleApiResponse(createResponse) + ); + const { id: deploymentId, uploadUrl, uploadFields } = createResult; - try { - await new Promise((resolve, reject) => { - const archive = archiver('zip', { zlib: { level: 9 } }); - const output = createWriteStream(tmpZipPath); - - output.on('close', resolve); - output.on('error', reject); - archive.on('error', reject); - - const excludePatterns = ['node_modules', '.git', '.next', '.env', '.env.local', 'dist', 'build', '.DS_Store']; - - archive.directory(resolvedSourceDir, false, (entry) => { - const normalizedName = entry.name.replace(/\\/g, '/'); - - for (const pattern of excludePatterns) { - if ( - normalizedName.startsWith(pattern + '/') || - normalizedName === pattern || - normalizedName.endsWith('/' + pattern) || - normalizedName.includes('/' + pattern + '/') - ) { - return false; - } - } + // Write the legacy zip to a temp file so we know the size before uploading + // (S3 presigned POSTs require Content-Length; streaming without it fails) + const tmpZipPath = join(tmpdir(), `insforge-deploy-${deploymentId}.zip`); - if (normalizedName.endsWith('.log')) return false; - return entry; - }); + try { + await new Promise((resolve, reject) => { + const archive = archiver('zip', { zlib: { level: 9 } }); + const output = createWriteStream(tmpZipPath); - archive.pipe(output); - archive.finalize(); - }); + output.on('close', resolve); + output.on('error', reject); + archive.on('error', reject); - const { size: zipSize } = await fs.stat(tmpZipPath); + archive.directory(resolvedSourceDir, false, (entry) => { + const normalizedName = entry.name.replace(/\\/g, '/'); + if (shouldExcludeDeploymentPath(normalizedName)) return false; + return entry; + }); - const uploadFormData = new FormData(); - for (const [key, value] of Object.entries(uploadFields)) { - uploadFormData.append(key, value); - } - uploadFormData.append('file', createReadStream(tmpZipPath), { - filename: 'deployment.zip', - contentType: 'application/zip', - knownLength: zipSize, - }); + archive.pipe(output); + archive.finalize(); + }); - const uploadResponse = await fetch(uploadUrl, { - method: 'POST', - body: uploadFormData, - headers: uploadFormData.getHeaders(), - }); + const { size: zipSize } = await fs.stat(tmpZipPath); - if (!uploadResponse.ok) { - const uploadError = await uploadResponse.text(); - throw new Error(`Failed to upload zip file: ${uploadError}`); - } - } finally { - await fs.rm(tmpZipPath, { force: true }).catch(() => undefined); - } + const uploadFormData = new FormData(); + for (const [key, value] of Object.entries(uploadFields)) { + uploadFormData.append(key, value); + } + uploadFormData.append('file', createReadStream(tmpZipPath), { + filename: 'deployment.zip', + contentType: 'application/zip', + knownLength: zipSize, + }); - const startBody: StartDeploymentRequest = {}; - if (projectSettings) startBody.projectSettings = projectSettings; - if (envVars) startBody.envVars = envVars; - if (meta) startBody.meta = meta; + const uploadResponse = await fetch(uploadUrl, { + method: 'POST', + body: uploadFormData, + headers: uploadFormData.getHeaders(), + }); - const startResponse = await fetch(`${API_BASE_URL}/api/deployments/${encodeURIComponent(deploymentId)}/start`, { - method: 'POST', - headers: { - 'x-api-key': getApiKey(), - 'Content-Type': 'application/json', - }, - body: JSON.stringify(startBody), - }); + if (!uploadResponse.ok) { + const uploadError = await uploadResponse.text(); + throw new Error(`Failed to upload zip file: ${uploadError}`); + } + } finally { + await fs.rm(tmpZipPath, { force: true }).catch(() => undefined); + } - const startResult = await handleApiResponse(startResponse); - return await addBackgroundContext({ - content: [{ - type: 'text', - text: formatSuccessMessage('Deployment started', startResult) + '\n\nNote: You can check deployment status by querying the system.deployments table.', - }], - }); - } catch (error) { - const errMsg = error instanceof Error ? error.message : 'Unknown error occurred'; - return { - content: [{ type: 'text', text: `Error creating deployment: ${errMsg}` }], - isError: true, - }; + const startResult = await startDeployment( + API_BASE_URL, + getApiKey(), + deploymentId, + startBody + ); + return await addBackgroundContext({ + content: [ + { + type: 'text', + text: + formatSuccessMessage('Deployment started', startResult) + + '\n\nNote: You can check deployment status by querying the system.deployments table.', + }, + ], + }); + } catch (error) { + const errMsg = error instanceof Error ? error.message : 'Unknown error occurred'; + return { + content: [{ type: 'text', text: `Error creating deployment: ${errMsg}` }], + isError: true, + }; + } } - }) + ) ); } } diff --git a/src/shared/tools/index.ts b/src/shared/tools/index.ts index 28cff22..85da3cc 100644 --- a/src/shared/tools/index.ts +++ b/src/shared/tools/index.ts @@ -131,14 +131,17 @@ async function fetchBackendVersion(apiBaseUrl: string): Promise { throw new Error(`Health check failed with status ${response.status}`); } - const health = await response.json() as HealthCheckResponse; + const health = (await response.json()) as HealthCheckResponse; if (!health.version || typeof health.version !== 'string') { throw new Error('Health check returned invalid version field'); } return health.version; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { - throw new Error(`Health check timed out after 10s — is the backend running at ${apiBaseUrl}?`, { cause: error }); + throw new Error( + `Health check timed out after 10s — is the backend running at ${apiBaseUrl}?`, + { cause: error } + ); } throw error; } finally { @@ -169,7 +172,9 @@ export async function registerInsforgeTools(server: McpServer, config: ToolsConf } catch (error) { const msg = error instanceof Error ? error.message : String(error); console.error(`Failed to fetch backend version: ${msg}`); - throw new Error(`Cannot initialize tools: backend at ${API_BASE_URL} is unreachable. ${msg}`, { cause: error }); + throw new Error(`Cannot initialize tools: backend at ${API_BASE_URL} is unreachable. ${msg}`, { + cause: error, + }); } let toolCount = 0; @@ -187,9 +192,10 @@ export async function registerInsforgeTools(server: McpServer, config: ToolsConf return true; } else { const req = TOOL_VERSION_REQUIREMENTS[toolName]; - const reason = req?.minVersion && compareVersions(backendVersion, req.minVersion) < 0 - ? `requires backend >= ${req.minVersion}` - : `deprecated after backend ${req?.maxVersion}`; + const reason = + req?.minVersion && compareVersions(backendVersion, req.minVersion) < 0 + ? `requires backend >= ${req.minVersion}` + : `deprecated after backend ${req?.maxVersion}`; console.error(`Skipping tool '${toolName}': ${reason} (current: ${backendVersion})`); return false; } @@ -265,6 +271,7 @@ export async function registerInsforgeTools(server: McpServer, config: ToolsConf const ctx: RegisterContext = { API_BASE_URL, + backendVersion, isRemote, registerTool, withUsageTracking, diff --git a/src/shared/tools/types.ts b/src/shared/tools/types.ts index 71ad264..178f03f 100644 --- a/src/shared/tools/types.ts +++ b/src/shared/tools/types.ts @@ -18,6 +18,7 @@ export type AddBackgroundContext = Date: Wed, 15 Apr 2026 22:41:11 -0700 Subject: [PATCH 2/3] update message --- src/shared/tools/deployment.ts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/shared/tools/deployment.ts b/src/shared/tools/deployment.ts index 8ef65e9..33f8dda 100644 --- a/src/shared/tools/deployment.ts +++ b/src/shared/tools/deployment.ts @@ -47,6 +47,14 @@ class DirectDeploymentUnsupportedError extends Error { } } +function isInsforgeCloudApiBaseUrl(apiBaseUrl: string): boolean { + try { + return new URL(apiBaseUrl).hostname.endsWith('.insforge.app'); + } catch { + return false; + } +} + function isAbsoluteSourcePath(sourceDirectory: string): boolean { return sourceDirectory.startsWith('/') || /^[a-zA-Z]:[/\\]/.test(sourceDirectory); } @@ -474,7 +482,7 @@ NODE After the script succeeds, call the \`start-deployment\` tool with the printed deployment ID. -If the upload is interrupted after the deployment ID is printed, query \`system.deployment_files\` with the raw SQL tool for that \`deployment_id\` to inspect \`uploaded_at\`. You can rerun \`create-deployment\` to create a fresh deployment, or upload missing file IDs to \`PUT /api/deployments/:id/files/:fileId/content\` using the queried manifest rows.`; +If the upload is interrupted after the deployment ID is printed, query \`deployments.files\` with the raw SQL tool for that \`deployment_id\` to inspect \`uploaded_at\`. You can rerun \`create-deployment\` to create a fresh deployment, or upload missing file IDs to \`PUT /api/deployments/:id/files/:fileId/content\` using the queried manifest rows.`; } export function registerDeploymentTools(ctx: RegisterContext): void { @@ -679,7 +687,13 @@ Run each step in order. If any step fails, do not proceed to the next step.`; type: 'text', text: formatSuccessMessage('Deployment started', startResult) + - '\n\nNote: You can check deployment status by querying the system.deployments table.', + `\n\n${ + supportsDirectDeployment + ? isInsforgeCloudApiBaseUrl(API_BASE_URL) + ? 'Note: You can check deployment status by querying the deployments.runs table.' + : 'Note: For self-hosted direct deployments, check the result in your Vercel dashboard instead of InsForge deployment logs.' + : 'Note: You can check deployment status by querying the deployments.runs table.' + }`, }, ], }); @@ -766,7 +780,11 @@ Run each step in order. If any step fails, do not proceed to the next step.`; type: 'text', text: formatSuccessMessage('Deployment started', startResult) + - `\n\n${uploadSummary}\n\nNote: You can check deployment status by querying the system.deployments table. If file uploads are interrupted, inspect system.deployment_files with the raw SQL tool to see which files are already uploaded before retrying missing files.`, + `\n\n${uploadSummary}\n\n${ + isInsforgeCloudApiBaseUrl(API_BASE_URL) + ? 'Note: You can check deployment status by querying the deployments.runs table. If file uploads are interrupted, inspect deployments.files with the raw SQL tool to see which files are already uploaded before retrying missing files.' + : 'Note: For self-hosted direct deployments, check the result in your Vercel dashboard instead of InsForge deployment logs. If file uploads are interrupted, inspect deployments.files with the raw SQL tool to see which files are already uploaded before retrying missing files.' + }`, }, ], }); @@ -853,7 +871,7 @@ Run each step in order. If any step fails, do not proceed to the next step.`; type: 'text', text: formatSuccessMessage('Deployment started', startResult) + - '\n\nNote: You can check deployment status by querying the system.deployments table.', + '\n\nNote: You can check deployment status by querying the deployments.runs table.', }, ], }); From 6387e7ecef34035f25c1b74944388ea5e9e6b3f3 Mon Sep 17 00:00:00 2001 From: Lyu Date: Wed, 15 Apr 2026 23:55:03 -0700 Subject: [PATCH 3/3] update version --- package-lock.json | 4 ++-- package.json | 2 +- src/shared/tools/deployment.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 273c270..dd31bbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@insforge/mcp", - "version": "1.2.10-dev.3", + "version": "1.2.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@insforge/mcp", - "version": "1.2.10-dev.3", + "version": "1.2.10", "license": "Apache-2.0", "dependencies": { "@insforge/shared-schemas": "1.1.49", diff --git a/package.json b/package.json index 238e647..47dccfa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@insforge/mcp", - "version": "1.2.10-dev.3", + "version": "1.2.10", "description": "MCP (Model Context Protocol) server for Insforge backend-as-a-service", "mcpName": "io.github.InsForge/insforge-mcp", "type": "module", diff --git a/src/shared/tools/deployment.ts b/src/shared/tools/deployment.ts index 33f8dda..b4ac1e4 100644 --- a/src/shared/tools/deployment.ts +++ b/src/shared/tools/deployment.ts @@ -22,7 +22,7 @@ import type { import type { RegisterContext } from './types.js'; import { shellEsc } from './utils.js'; -const DIRECT_DEPLOYMENT_MIN_VERSION = '2.0.4'; +const DIRECT_DEPLOYMENT_MIN_VERSION = '2.0.6'; const DEFAULT_DIRECT_UPLOAD_CONCURRENCY = 8; const MAX_DIRECT_UPLOAD_CONCURRENCY = 32; const EXCLUDED_DEPLOYMENT_SEGMENTS = new Set([