|
| 1 | +import { z } from "zod"; |
| 2 | +import { type ToolMetadata, type InferSchema } from "xmcp"; |
| 3 | +import { HttpClient, HttpError } from "../core/http-client"; |
| 4 | +import { runCommand, stripAnsiCodes } from "../core/command-runner"; |
| 5 | +import { |
| 6 | + runPreflights, |
| 7 | + requireLocalStackCli, |
| 8 | + requireLocalStackRunning, |
| 9 | + requireProFeature, |
| 10 | +} from "../core/preflight"; |
| 11 | +import { ResponseBuilder } from "../core/response-builder"; |
| 12 | +import { ProFeature } from "../lib/localstack/license-checker"; |
| 13 | + |
| 14 | +export const schema = { |
| 15 | + action: z |
| 16 | + .enum(["list", "install", "uninstall", "available"]) |
| 17 | + .describe( |
| 18 | + "list = installed extensions; install = install an extension; uninstall = remove an extension; available = browse the marketplace/extensions library" |
| 19 | + ), |
| 20 | + name: z |
| 21 | + .string() |
| 22 | + .optional() |
| 23 | + .describe( |
| 24 | + "Extension package name (e.g. 'localstack-extension-typedb' or 'localstack-extension-typedb==1.0.0'). Required for install and uninstall actions." |
| 25 | + ), |
| 26 | + source: z |
| 27 | + .string() |
| 28 | + .optional() |
| 29 | + .describe( |
| 30 | + "Git URL to install from (e.g. 'git+https://github.com/org/repo.git'). Use this instead of name when installing from a repository." |
| 31 | + ), |
| 32 | +}; |
| 33 | + |
| 34 | +export const metadata: ToolMetadata = { |
| 35 | + name: "localstack-extensions", |
| 36 | + description: "Install, uninstall, list, and discover LocalStack Extensions from the marketplace", |
| 37 | + annotations: { |
| 38 | + title: "LocalStack Extensions", |
| 39 | + readOnlyHint: false, |
| 40 | + destructiveHint: false, |
| 41 | + idempotentHint: false, |
| 42 | + }, |
| 43 | +}; |
| 44 | + |
| 45 | +interface MarketplaceExtension { |
| 46 | + name?: string; |
| 47 | + summary?: string; |
| 48 | + description?: string; |
| 49 | + author?: string; |
| 50 | + version?: string; |
| 51 | +} |
| 52 | + |
| 53 | +const AUTH_TOKEN_REQUIRED_MESSAGE = |
| 54 | + "LOCALSTACK_AUTH_TOKEN is not set in your environment. LocalStack Extensions require a valid Auth Token. Please set it and try again."; |
| 55 | + |
| 56 | +export default async function localstackExtensions({ |
| 57 | + action, |
| 58 | + name, |
| 59 | + source, |
| 60 | +}: InferSchema<typeof schema>) { |
| 61 | + const checks = [ |
| 62 | + requireLocalStackCli(), |
| 63 | + requireLocalStackRunning(), |
| 64 | + requireProFeature(ProFeature.EXTENSIONS), |
| 65 | + ]; |
| 66 | + |
| 67 | + const preflightError = await runPreflights(checks); |
| 68 | + if (preflightError) return preflightError; |
| 69 | + |
| 70 | + switch (action) { |
| 71 | + case "list": |
| 72 | + return await handleList(); |
| 73 | + case "install": |
| 74 | + return await handleInstall(name, source); |
| 75 | + case "uninstall": |
| 76 | + return await handleUninstall(name); |
| 77 | + case "available": |
| 78 | + return await handleAvailable(); |
| 79 | + default: |
| 80 | + return ResponseBuilder.error("Unknown action", `Unsupported action: ${action}`); |
| 81 | + } |
| 82 | +} |
| 83 | + |
| 84 | +function requireAuthTokenForCli() { |
| 85 | + if (!process.env.LOCALSTACK_AUTH_TOKEN) { |
| 86 | + return ResponseBuilder.error("Auth Token Required", AUTH_TOKEN_REQUIRED_MESSAGE); |
| 87 | + } |
| 88 | + return null; |
| 89 | +} |
| 90 | + |
| 91 | +function cleanOutput(stdout: string, stderr: string) { |
| 92 | + return { |
| 93 | + stdout: stripAnsiCodes(stdout || "").trim(), |
| 94 | + stderr: stripAnsiCodes(stderr || "").trim(), |
| 95 | + }; |
| 96 | +} |
| 97 | + |
| 98 | +function combineOutput(stdout: string, stderr: string): string { |
| 99 | + return [stdout, stderr].filter((part) => part.trim().length > 0).join("\n").trim(); |
| 100 | +} |
| 101 | + |
| 102 | +async function handleList() { |
| 103 | + const authError = requireAuthTokenForCli(); |
| 104 | + if (authError) return authError; |
| 105 | + |
| 106 | + const cmd = await runCommand("localstack", ["extensions", "list"], { |
| 107 | + env: { ...process.env }, |
| 108 | + }); |
| 109 | + const cleaned = cleanOutput(cmd.stdout, cmd.stderr); |
| 110 | + const combined = combineOutput(cleaned.stdout, cleaned.stderr); |
| 111 | + const combinedLower = combined.toLowerCase(); |
| 112 | + |
| 113 | + if (cmd.exitCode !== 0 && !combined) { |
| 114 | + return ResponseBuilder.error("List Failed", cleaned.stderr || "Failed to list installed extensions."); |
| 115 | + } |
| 116 | + |
| 117 | + const looksEmpty = |
| 118 | + !combined || |
| 119 | + combinedLower.includes("no extensions installed") || |
| 120 | + combinedLower.includes("no extension installed"); |
| 121 | + if (looksEmpty) { |
| 122 | + return ResponseBuilder.markdown( |
| 123 | + "No LocalStack extensions are currently installed.\n\nUse the `available` action to browse the marketplace." |
| 124 | + ); |
| 125 | + } |
| 126 | + |
| 127 | + return ResponseBuilder.markdown(`## Installed LocalStack Extensions\n\n\`\`\`\n${combined}\n\`\`\``); |
| 128 | +} |
| 129 | + |
| 130 | +async function handleInstall(name?: string, source?: string) { |
| 131 | + const authError = requireAuthTokenForCli(); |
| 132 | + if (authError) return authError; |
| 133 | + |
| 134 | + const hasName = !!name; |
| 135 | + const hasSource = !!source; |
| 136 | + if ((hasName && hasSource) || (!hasName && !hasSource)) { |
| 137 | + return ResponseBuilder.error( |
| 138 | + "Invalid Parameters", |
| 139 | + "Provide either `name` or `source` for install, but not both." |
| 140 | + ); |
| 141 | + } |
| 142 | + |
| 143 | + const target = source || name!; |
| 144 | + const cmd = await runCommand("localstack", ["extensions", "install", target], { |
| 145 | + env: { ...process.env }, |
| 146 | + timeout: 120000, |
| 147 | + }); |
| 148 | + const cleaned = cleanOutput(cmd.stdout, cmd.stderr); |
| 149 | + const combined = combineOutput(cleaned.stdout, cleaned.stderr); |
| 150 | + const combinedLower = combined.toLowerCase(); |
| 151 | + |
| 152 | + if (combinedLower.includes("could not resolve package")) { |
| 153 | + return ResponseBuilder.error( |
| 154 | + "Extension Not Found", |
| 155 | + `Could not resolve the extension package '${name || target}'. Please verify it exists on PyPI, or provide a git repository URL using the source parameter.` |
| 156 | + ); |
| 157 | + } |
| 158 | + |
| 159 | + if (combinedLower.includes("no module named 'localstack.pro'")) { |
| 160 | + return ResponseBuilder.error( |
| 161 | + "Auth Token Required", |
| 162 | + "LocalStack Pro modules are not available. Ensure LOCALSTACK_AUTH_TOKEN is set correctly and LocalStack is running with a valid license." |
| 163 | + ); |
| 164 | + } |
| 165 | + |
| 166 | + if ( |
| 167 | + combinedLower.includes("non-zero exit status") || |
| 168 | + combinedLower.includes("returned non-zero") |
| 169 | + ) { |
| 170 | + return ResponseBuilder.error( |
| 171 | + "Install Failed", |
| 172 | + "The extension could not be installed from the provided source. The repository may not contain valid LocalStack extension code. Run the command again with --verbose for more details, or check that the repository contains a proper LocalStack extension." |
| 173 | + ); |
| 174 | + } |
| 175 | + |
| 176 | + const hasSuccessPattern = combinedLower.includes("extension successfully installed"); |
| 177 | + if (cmd.exitCode !== 0 && !hasSuccessPattern) { |
| 178 | + return ResponseBuilder.error("Install Failed", cleaned.stderr || "Extension installation failed."); |
| 179 | + } |
| 180 | + |
| 181 | + if (hasSuccessPattern || cmd.exitCode === 0) { |
| 182 | + const restartCmd = await runCommand("localstack", ["restart"], { timeout: 60000 }); |
| 183 | + const restartCleaned = cleanOutput(restartCmd.stdout, restartCmd.stderr); |
| 184 | + const restartCombined = combineOutput(restartCleaned.stdout, restartCleaned.stderr); |
| 185 | + |
| 186 | + let response = `## Extension Installation Result\n\n\`\`\`\n${combined || "Extension successfully installed."}\n\`\`\`\n\n`; |
| 187 | + response += "LocalStack was restarted to activate the extension."; |
| 188 | + if (restartCombined) { |
| 189 | + response += `\n\n### Restart Output\n\n\`\`\`\n${restartCombined}\n\`\`\``; |
| 190 | + } |
| 191 | + if (restartCmd.exitCode !== 0) { |
| 192 | + response += "\n\n⚠️ Restart command reported an issue. Please verify LocalStack status."; |
| 193 | + } |
| 194 | + return ResponseBuilder.markdown(response); |
| 195 | + } |
| 196 | + |
| 197 | + return ResponseBuilder.error("Install Failed", cleaned.stderr || "Extension installation failed."); |
| 198 | +} |
| 199 | + |
| 200 | +async function handleUninstall(name?: string) { |
| 201 | + const authError = requireAuthTokenForCli(); |
| 202 | + if (authError) return authError; |
| 203 | + |
| 204 | + if (!name) { |
| 205 | + return ResponseBuilder.error( |
| 206 | + "Missing Required Parameter", |
| 207 | + "The `uninstall` action requires the `name` parameter to be specified." |
| 208 | + ); |
| 209 | + } |
| 210 | + |
| 211 | + const cmd = await runCommand("localstack", ["extensions", "uninstall", name], { |
| 212 | + env: { ...process.env }, |
| 213 | + timeout: 60000, |
| 214 | + }); |
| 215 | + const cleaned = cleanOutput(cmd.stdout, cmd.stderr); |
| 216 | + const combined = combineOutput(cleaned.stdout, cleaned.stderr); |
| 217 | + const combinedLower = combined.toLowerCase(); |
| 218 | + |
| 219 | + if (combinedLower.includes("no module named 'localstack.pro'")) { |
| 220 | + return ResponseBuilder.error( |
| 221 | + "Auth Token Required", |
| 222 | + "LocalStack Pro modules are not available. Ensure LOCALSTACK_AUTH_TOKEN is set correctly and LocalStack is running with a valid license." |
| 223 | + ); |
| 224 | + } |
| 225 | + |
| 226 | + const hasSuccessPattern = combinedLower.includes("extension successfully uninstalled"); |
| 227 | + if (cmd.exitCode !== 0 && !hasSuccessPattern) { |
| 228 | + return ResponseBuilder.error("Uninstall Failed", cleaned.stderr || "Extension uninstallation failed."); |
| 229 | + } |
| 230 | + |
| 231 | + if (hasSuccessPattern || cmd.exitCode === 0) { |
| 232 | + const restartCmd = await runCommand("localstack", ["restart"], { timeout: 60000 }); |
| 233 | + const restartCleaned = cleanOutput(restartCmd.stdout, restartCmd.stderr); |
| 234 | + const restartCombined = combineOutput(restartCleaned.stdout, restartCleaned.stderr); |
| 235 | + |
| 236 | + let response = `## Extension Uninstall Result\n\n\`\`\`\n${combined || "Extension successfully uninstalled."}\n\`\`\`\n\n`; |
| 237 | + response += "LocalStack was restarted to apply extension removal."; |
| 238 | + if (restartCombined) { |
| 239 | + response += `\n\n### Restart Output\n\n\`\`\`\n${restartCombined}\n\`\`\``; |
| 240 | + } |
| 241 | + if (restartCmd.exitCode !== 0) { |
| 242 | + response += "\n\n⚠️ Restart command reported an issue. Please verify LocalStack status."; |
| 243 | + } |
| 244 | + return ResponseBuilder.markdown(response); |
| 245 | + } |
| 246 | + |
| 247 | + return ResponseBuilder.error("Uninstall Failed", cleaned.stderr || "Extension uninstallation failed."); |
| 248 | +} |
| 249 | + |
| 250 | +async function handleAvailable() { |
| 251 | + const token = process.env.LOCALSTACK_AUTH_TOKEN; |
| 252 | + if (!token) { |
| 253 | + return ResponseBuilder.error( |
| 254 | + "Authentication Failed", |
| 255 | + "Could not fetch the marketplace. Ensure LOCALSTACK_AUTH_TOKEN is set correctly." |
| 256 | + ); |
| 257 | + } |
| 258 | + |
| 259 | + const encoded = Buffer.from(`:${token}`).toString("base64"); |
| 260 | + const client = new HttpClient(); |
| 261 | + |
| 262 | + try { |
| 263 | + const marketplace = await client.request<MarketplaceExtension[]>( |
| 264 | + "https://api.localstack.cloud/v1/extensions/marketplace", |
| 265 | + { |
| 266 | + method: "GET", |
| 267 | + baseUrl: "", |
| 268 | + headers: { |
| 269 | + Authorization: `Basic ${encoded}`, |
| 270 | + Accept: "application/json", |
| 271 | + }, |
| 272 | + } |
| 273 | + ); |
| 274 | + |
| 275 | + if (!Array.isArray(marketplace)) { |
| 276 | + return ResponseBuilder.error("Marketplace Fetch Failed", "Unexpected marketplace response format."); |
| 277 | + } |
| 278 | + |
| 279 | + const simplified = marketplace.map((item) => ({ |
| 280 | + name: item.name || "unknown-extension", |
| 281 | + summary: item.summary || item.description || "No summary provided.", |
| 282 | + author: item.author || "Unknown", |
| 283 | + version: item.version || "Unknown", |
| 284 | + })); |
| 285 | + |
| 286 | + let markdown = `# LocalStack Extensions Marketplace\n\n${simplified.length} extensions available. Install any with the \`install\` action.\n\n---`; |
| 287 | + for (const extension of simplified) { |
| 288 | + markdown += `\n\n### ${extension.name}\n**Author:** ${extension.author} | **Version:** ${extension.version}\n${extension.summary}\n\n---`; |
| 289 | + } |
| 290 | + |
| 291 | + return ResponseBuilder.markdown(markdown); |
| 292 | + } catch (error) { |
| 293 | + if (error instanceof HttpError && (error.status === 401 || error.status === 403)) { |
| 294 | + return ResponseBuilder.error( |
| 295 | + "Authentication Failed", |
| 296 | + "Could not fetch the marketplace. Ensure LOCALSTACK_AUTH_TOKEN is set correctly." |
| 297 | + ); |
| 298 | + } |
| 299 | + |
| 300 | + const message = error instanceof Error ? error.message : String(error); |
| 301 | + return ResponseBuilder.error("Marketplace Fetch Failed", message); |
| 302 | + } |
| 303 | +} |
0 commit comments