mcp-aws-manager 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,736 +1,1184 @@
1
- #!/usr/bin/env node
2
- "use strict";
3
-
4
- const path = require("node:path");
5
- const { spawn } = require("node:child_process");
6
-
7
- const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
8
- const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
9
- const z4 = require("zod/v4");
10
-
11
- const z = z4.z || z4;
12
- const pkg = require("../package.json");
13
-
14
- const CLI_SCRIPT_PATH = path.join(__dirname, "mcp-aws-manager.js");
15
- const DEFAULT_TIMEOUT_SEC = 300;
16
- const DEFAULT_STDIO_TEXT_LIMIT = 16000;
17
- const DEFAULT_JSON_TEXT_LIMIT = 120000;
18
-
19
- function usageText() {
20
- return [
21
- "mcp-aws-manager-mcp",
22
- "",
23
- "MCP stdio wrapper for the mcp-aws-manager CLI.",
24
- "",
25
- "Usage:",
26
- " mcp-aws-manager-mcp",
27
- " mcp-aws-manager-mcp --help",
28
- "",
29
- "Notes:",
30
- " - This process is an MCP stdio server.",
31
- " - Exposes multi-service AWS inventory and optional runtime tools.",
32
- ""
33
- ].join("\n");
34
- }
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const path = require("node:path");
5
+ const { spawn } = require("node:child_process");
6
+
7
+ const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
8
+ const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
9
+ const z4 = require("zod/v4");
10
+
11
+ const z = z4.z || z4;
12
+ const pkg = require("../package.json");
13
+
14
+ const CLI_SCRIPT_PATH = path.join(__dirname, "mcp-aws-manager.js");
15
+ const DEFAULT_TIMEOUT_SEC = 300;
16
+ const DEFAULT_STDIO_TEXT_LIMIT = 16000;
17
+ const DEFAULT_JSON_TEXT_LIMIT = 120000;
18
+
19
+ function usageText() {
20
+ return [
21
+ "mcp-aws-manager-mcp",
22
+ "",
23
+ "MCP stdio wrapper for the mcp-aws-manager CLI.",
24
+ "",
25
+ "Usage:",
26
+ " mcp-aws-manager-mcp",
27
+ " mcp-aws-manager-mcp --help",
28
+ "",
29
+ "Notes:",
30
+ " - This process is an MCP stdio server.",
31
+ " - Exposes multi-service AWS inventory and optional runtime tools.",
32
+ ""
33
+ ].join("\n");
34
+ }
35
+
36
+ function shouldShowHelp(argv) {
37
+ return argv.includes("-h") || argv.includes("--help");
38
+ }
39
+
40
+ function toCsvArg(values) {
41
+ if (!Array.isArray(values) || !values.length) return null;
42
+ return values.map((v) => String(v).trim()).filter(Boolean).join(",");
43
+ }
44
+
45
+ function truncateText(value, maxLength = DEFAULT_STDIO_TEXT_LIMIT) {
46
+ const text = String(value || "");
47
+ if (text.length <= maxLength) return text;
48
+ const suffix = `\n... [truncated ${text.length - maxLength} chars]`;
49
+ return text.slice(0, maxLength) + suffix;
50
+ }
51
+
52
+ function parseStderr(stderrText) {
53
+ const lines = String(stderrText || "")
54
+ .split(/\r?\n/)
55
+ .map((line) => line.trim())
56
+ .filter(Boolean);
57
+
58
+ const warnings = [];
59
+ const requiredActions = [];
60
+ let summaryLine = null;
61
+
62
+ for (const line of lines) {
63
+ if (line.startsWith("WARNING: ")) {
64
+ warnings.push(line.slice("WARNING: ".length));
65
+ continue;
66
+ }
67
+ if (line.startsWith("Summary: ")) {
68
+ summaryLine = line;
69
+ continue;
70
+ }
71
+ if (line.startsWith("ACTION_REQUIRED: ")) {
72
+ const payload = line.slice("ACTION_REQUIRED: ".length).trim();
73
+ const m = /^\[(.+?)\]\s*(.*)$/.exec(payload);
74
+ requiredActions.push({
75
+ code: m ? m[1] : "UNKNOWN",
76
+ message: m ? m[2] : payload,
77
+ hint: null
78
+ });
79
+ continue;
80
+ }
81
+ if (line.startsWith("ACTION_HINT: ")) {
82
+ if (requiredActions.length) {
83
+ requiredActions[requiredActions.length - 1].hint = line.slice("ACTION_HINT: ".length);
84
+ }
85
+ }
86
+ }
87
+
88
+ return { lines, warnings, requiredActions, summaryLine };
89
+ }
90
+
91
+ function buildCliArgs(input) {
92
+ const args = [CLI_SCRIPT_PATH, "--format", "json"];
93
+
94
+ const profiles = toCsvArg(input.profiles);
95
+ if (profiles) args.push("--profiles", profiles);
96
+
97
+ const regions = toCsvArg(input.regions);
98
+ if (regions) args.push("--regions", regions);
99
+
100
+ const instanceIds = toCsvArg(input.instanceIds);
101
+ if (instanceIds) args.push("--instance-ids", instanceIds);
102
+
103
+ if (input.includeLambda === true) args.push("--include-lambda");
104
+ if (input.includeLambda === false) args.push("--no-include-lambda");
105
+ if (input.includeEc2 === true) args.push("--include-ec2");
106
+ if (input.includeEc2 === false) args.push("--no-ec2");
107
+ if (input.includeAlb === true) args.push("--include-alb");
108
+ if (input.includeAlb === false) args.push("--no-include-alb");
109
+ if (input.includeAsg === true) args.push("--include-asg");
110
+ if (input.includeAsg === false) args.push("--no-include-asg");
111
+ if (input.includeRds === true) args.push("--include-rds");
112
+ if (input.includeRds === false) args.push("--no-include-rds");
113
+ if (input.includeElastiCache === true) args.push("--include-elasticache");
114
+ if (input.includeElastiCache === false) args.push("--no-include-elasticache");
115
+ if (input.includeRoute53 === true) args.push("--include-route53");
116
+ if (input.includeRoute53 === false) args.push("--no-include-route53");
117
+
118
+ if (input.publicOnly) args.push("--public-only");
119
+ if (input.managedOnly) args.push("--managed-only");
120
+
121
+ if (input.autoRemediateSsm) args.push("--auto-remediate-ssm");
122
+ if (input.ssmInstanceProfileName) args.push("--ssm-instance-profile-name", input.ssmInstanceProfileName);
123
+ if (input.ssmInstanceProfileArn) args.push("--ssm-instance-profile-arn", input.ssmInstanceProfileArn);
124
+ if (input.allowReplaceProfile) args.push("--allow-replace-profile");
125
+ if (input.remediationWaitSec != null) args.push("--remediation-wait", String(input.remediationWaitSec));
126
+
127
+ if (input.runtimeSnapshot === false) args.push("--no-runtime-snapshot");
128
+ if (input.runtimeSnapshot === true) args.push("--runtime-snapshot");
129
+ if (input.snapshotTimeoutSec != null) args.push("--snapshot-timeout", String(input.snapshotTimeoutSec));
130
+ if (input.snapshotConcurrency != null) args.push("--snapshot-concurrency", String(input.snapshotConcurrency));
131
+ if (input.snapshotMaxKb != null) args.push("--snapshot-max-kb", String(input.snapshotMaxKb));
132
+
133
+ if (input.manualServerListPath) args.push("--manual-server-list", input.manualServerListPath);
134
+ const pemPaths = toCsvArg(input.pemPaths);
135
+ if (pemPaths) args.push("--pem-paths", pemPaths);
136
+ if (input.sshUser) args.push("--ssh-user", input.sshUser);
137
+ if (input.sshPort != null) args.push("--ssh-port", String(input.sshPort));
138
+ if (input.sshConnectTimeoutSec != null) args.push("--ssh-connect-timeout", String(input.sshConnectTimeoutSec));
139
+ if (input.htmlOutPath) args.push("--html-out", input.htmlOutPath);
140
+ if (input.openHtml) args.push("--open-html");
35
141
 
36
- function shouldShowHelp(argv) {
37
- return argv.includes("-h") || argv.includes("--help");
142
+ if (input.autoSsoLogin === false) args.push("--no-auto-sso-login");
143
+ if (input.autoSsoLogin === true) args.push("--auto-sso-login");
144
+
145
+ if (input.noProgress !== false) args.push("--no-progress");
146
+
147
+ return args;
148
+ }
149
+
150
+ function runDiscoverCli(input) {
151
+ const timeoutSec =
152
+ typeof input.timeoutSec === "number" && Number.isFinite(input.timeoutSec)
153
+ ? input.timeoutSec
154
+ : DEFAULT_TIMEOUT_SEC;
155
+ const timeoutMs = Math.max(1, Math.round(timeoutSec * 1000));
156
+
157
+ return new Promise((resolve) => {
158
+ const child = spawn(process.execPath, buildCliArgs(input), {
159
+ cwd: input.workingDirectory || process.cwd(),
160
+ env: process.env,
161
+ stdio: ["ignore", "pipe", "pipe"]
162
+ });
163
+
164
+ let stdout = "";
165
+ let stderr = "";
166
+ let timedOut = false;
167
+ let exited = false;
168
+
169
+ if (child.stdout) {
170
+ child.stdout.setEncoding("utf8");
171
+ child.stdout.on("data", (chunk) => {
172
+ stdout += chunk;
173
+ });
174
+ }
175
+ if (child.stderr) {
176
+ child.stderr.setEncoding("utf8");
177
+ child.stderr.on("data", (chunk) => {
178
+ stderr += chunk;
179
+ });
180
+ }
181
+
182
+ const timer = setTimeout(() => {
183
+ if (exited) return;
184
+ timedOut = true;
185
+ try { child.kill("SIGTERM"); } catch {}
186
+ setTimeout(() => {
187
+ if (!exited) {
188
+ try { child.kill("SIGKILL"); } catch {}
189
+ }
190
+ }, 3000).unref();
191
+ }, timeoutMs);
192
+ timer.unref();
193
+
194
+ child.on("error", (error) => {
195
+ clearTimeout(timer);
196
+ exited = true;
197
+ resolve({
198
+ ok: false,
199
+ exitCode: null,
200
+ signal: null,
201
+ stdout,
202
+ stderr,
203
+ timedOut,
204
+ spawnError: error && error.message ? error.message : String(error)
205
+ });
206
+ });
207
+
208
+ child.on("close", (code, signal) => {
209
+ clearTimeout(timer);
210
+ exited = true;
211
+ resolve({
212
+ ok: true,
213
+ exitCode: typeof code === "number" ? code : null,
214
+ signal: signal || null,
215
+ stdout,
216
+ stderr,
217
+ timedOut,
218
+ spawnError: null
219
+ });
220
+ });
221
+ });
38
222
  }
39
223
 
40
- function toCsvArg(values) {
41
- if (!Array.isArray(values) || !values.length) return null;
42
- return values.map((v) => String(v).trim()).filter(Boolean).join(",");
224
+ let mutationAwsModules = null;
225
+ function loadMutationAwsModules() {
226
+ if (mutationAwsModules) return mutationAwsModules;
227
+ try {
228
+ const ec2 = require("@aws-sdk/client-ec2");
229
+ const creds = require("@aws-sdk/credential-providers");
230
+ mutationAwsModules = { ec2, creds };
231
+ return mutationAwsModules;
232
+ } catch {
233
+ throw new Error("Missing AWS SDK dependencies. Run: npm install");
234
+ }
43
235
  }
44
236
 
45
- function truncateText(value, maxLength = DEFAULT_STDIO_TEXT_LIMIT) {
46
- const text = String(value || "");
47
- if (text.length <= maxLength) return text;
48
- const suffix = `\n... [truncated ${text.length - maxLength} chars]`;
49
- return text.slice(0, maxLength) + suffix;
237
+ function mutationAwsConfig(profile, region) {
238
+ const { creds } = loadMutationAwsModules();
239
+ const config = { region };
240
+ if (profile) {
241
+ config.credentials = creds.fromIni({ profile });
242
+ }
243
+ return config;
50
244
  }
51
245
 
52
- function parseStderr(stderrText) {
53
- const lines = String(stderrText || "")
54
- .split(/\r?\n/)
55
- .map((line) => line.trim())
56
- .filter(Boolean);
57
-
58
- const warnings = [];
59
- const requiredActions = [];
60
- let summaryLine = null;
61
-
62
- for (const line of lines) {
63
- if (line.startsWith("WARNING: ")) {
64
- warnings.push(line.slice("WARNING: ".length));
65
- continue;
66
- }
67
- if (line.startsWith("Summary: ")) {
68
- summaryLine = line;
69
- continue;
70
- }
71
- if (line.startsWith("ACTION_REQUIRED: ")) {
72
- const payload = line.slice("ACTION_REQUIRED: ".length).trim();
73
- const m = /^\[(.+?)\]\s*(.*)$/.exec(payload);
74
- requiredActions.push({
75
- code: m ? m[1] : "UNKNOWN",
76
- message: m ? m[2] : payload,
77
- hint: null
78
- });
79
- continue;
80
- }
81
- if (line.startsWith("ACTION_HINT: ")) {
82
- if (requiredActions.length) {
83
- requiredActions[requiredActions.length - 1].hint = line.slice("ACTION_HINT: ".length);
84
- }
85
- }
246
+ function mutationErrorMeta(error, profile, region) {
247
+ const detail = `${error && error.name ? error.name : "Error"}: ${error && error.message ? error.message : String(error)}`;
248
+ const text = detail.toLowerCase();
249
+ if (
250
+ text.includes("credential") ||
251
+ text.includes("sso") ||
252
+ text.includes("token") ||
253
+ text.includes("unable to locate credentials")
254
+ ) {
255
+ return {
256
+ code: "AWS_CREDENTIALS_REQUIRED",
257
+ message: `AWS authentication failed for profile=${profile || "default"} region=${region}.`,
258
+ hint: profile
259
+ ? `Run 'aws sso login --profile ${profile}' or configure access key; then retry.`
260
+ : "Configure AWS credentials (SSO or access key) and retry."
261
+ };
86
262
  }
87
-
88
- return { lines, warnings, requiredActions, summaryLine };
263
+ if (text.includes("accessdenied") || text.includes("not authorized")) {
264
+ return {
265
+ code: "IAM_PERMISSION_REQUIRED",
266
+ message: `Missing AWS IAM permission for profile=${profile || "default"} region=${region}.`,
267
+ hint: detail
268
+ };
269
+ }
270
+ return {
271
+ code: "AWS_OPERATION_FAILED",
272
+ message: `AWS operation failed for profile=${profile || "default"} region=${region}.`,
273
+ hint: detail
274
+ };
89
275
  }
90
276
 
91
- function buildCliArgs(input) {
92
- const args = [CLI_SCRIPT_PATH, "--format", "json"];
93
-
94
- const profiles = toCsvArg(input.profiles);
95
- if (profiles) args.push("--profiles", profiles);
96
-
97
- const regions = toCsvArg(input.regions);
98
- if (regions) args.push("--regions", regions);
99
-
100
- const instanceIds = toCsvArg(input.instanceIds);
101
- if (instanceIds) args.push("--instance-ids", instanceIds);
102
-
103
- if (input.includeLambda === true) args.push("--include-lambda");
104
- if (input.includeLambda === false) args.push("--no-include-lambda");
105
- if (input.includeEc2 === true) args.push("--include-ec2");
106
- if (input.includeEc2 === false) args.push("--no-ec2");
107
- if (input.includeAlb === true) args.push("--include-alb");
108
- if (input.includeAlb === false) args.push("--no-include-alb");
109
- if (input.includeAsg === true) args.push("--include-asg");
110
- if (input.includeAsg === false) args.push("--no-include-asg");
111
- if (input.includeRds === true) args.push("--include-rds");
112
- if (input.includeRds === false) args.push("--no-include-rds");
113
- if (input.includeElastiCache === true) args.push("--include-elasticache");
114
- if (input.includeElastiCache === false) args.push("--no-include-elasticache");
115
- if (input.includeRoute53 === true) args.push("--include-route53");
116
- if (input.includeRoute53 === false) args.push("--no-include-route53");
117
-
118
- if (input.publicOnly) args.push("--public-only");
119
- if (input.managedOnly) args.push("--managed-only");
120
-
121
- if (input.autoRemediateSsm) args.push("--auto-remediate-ssm");
122
- if (input.ssmInstanceProfileName) args.push("--ssm-instance-profile-name", input.ssmInstanceProfileName);
123
- if (input.ssmInstanceProfileArn) args.push("--ssm-instance-profile-arn", input.ssmInstanceProfileArn);
124
- if (input.allowReplaceProfile) args.push("--allow-replace-profile");
125
- if (input.remediationWaitSec != null) args.push("--remediation-wait", String(input.remediationWaitSec));
126
-
127
- if (input.runtimeSnapshot === false) args.push("--no-runtime-snapshot");
128
- if (input.runtimeSnapshot === true) args.push("--runtime-snapshot");
129
- if (input.snapshotTimeoutSec != null) args.push("--snapshot-timeout", String(input.snapshotTimeoutSec));
130
- if (input.snapshotConcurrency != null) args.push("--snapshot-concurrency", String(input.snapshotConcurrency));
131
- if (input.snapshotMaxKb != null) args.push("--snapshot-max-kb", String(input.snapshotMaxKb));
132
-
133
- if (input.autoSsoLogin === false) args.push("--no-auto-sso-login");
134
- if (input.autoSsoLogin === true) args.push("--auto-sso-login");
135
-
136
- if (input.noProgress !== false) args.push("--no-progress");
137
-
138
- return args;
277
+ function normalizeProfile(profile) {
278
+ const value = String(profile == null ? "" : profile).trim();
279
+ return value || "default";
139
280
  }
140
281
 
141
- function runDiscoverCli(input) {
142
- const timeoutSec =
143
- typeof input.timeoutSec === "number" && Number.isFinite(input.timeoutSec)
144
- ? input.timeoutSec
145
- : DEFAULT_TIMEOUT_SEC;
146
- const timeoutMs = Math.max(1, Math.round(timeoutSec * 1000));
147
-
148
- return new Promise((resolve) => {
149
- const child = spawn(process.execPath, buildCliArgs(input), {
150
- cwd: input.workingDirectory || process.cwd(),
151
- env: process.env,
152
- stdio: ["ignore", "pipe", "pipe"]
153
- });
154
-
155
- let stdout = "";
156
- let stderr = "";
157
- let timedOut = false;
158
- let exited = false;
282
+ async function runEc2InstanceAction(input, action) {
283
+ const { ec2 } = loadMutationAwsModules();
284
+ const profile = normalizeProfile(input.profile);
285
+ const region = String(input.region || "").trim();
286
+ const instanceIds = Array.isArray(input.instanceIds) ? input.instanceIds.map((v) => String(v).trim()).filter(Boolean) : [];
287
+ if (!region) {
288
+ return { ok: false, error: "region is required", requiredAction: null };
289
+ }
290
+ if (!instanceIds.length) {
291
+ return { ok: false, error: "instanceIds is required", requiredAction: null };
292
+ }
159
293
 
160
- if (child.stdout) {
161
- child.stdout.setEncoding("utf8");
162
- child.stdout.on("data", (chunk) => {
163
- stdout += chunk;
164
- });
165
- }
166
- if (child.stderr) {
167
- child.stderr.setEncoding("utf8");
168
- child.stderr.on("data", (chunk) => {
169
- stderr += chunk;
170
- });
294
+ const client = new ec2.EC2Client(mutationAwsConfig(profile, region));
295
+ try {
296
+ if (action === "start") {
297
+ const output = await client.send(new ec2.StartInstancesCommand({
298
+ InstanceIds: instanceIds,
299
+ DryRun: input.dryRun === true
300
+ }));
301
+ return {
302
+ ok: true,
303
+ action,
304
+ profile,
305
+ region,
306
+ instanceIds,
307
+ response: {
308
+ startingInstances: output && output.StartingInstances ? output.StartingInstances : []
309
+ }
310
+ };
171
311
  }
172
312
 
173
- const timer = setTimeout(() => {
174
- if (exited) return;
175
- timedOut = true;
176
- try { child.kill("SIGTERM"); } catch {}
177
- setTimeout(() => {
178
- if (!exited) {
179
- try { child.kill("SIGKILL"); } catch {}
313
+ if (action === "stop") {
314
+ const output = await client.send(new ec2.StopInstancesCommand({
315
+ InstanceIds: instanceIds,
316
+ Force: input.force === true,
317
+ DryRun: input.dryRun === true
318
+ }));
319
+ return {
320
+ ok: true,
321
+ action,
322
+ profile,
323
+ region,
324
+ instanceIds,
325
+ response: {
326
+ stoppingInstances: output && output.StoppingInstances ? output.StoppingInstances : []
180
327
  }
181
- }, 3000).unref();
182
- }, timeoutMs);
183
- timer.unref();
184
-
185
- child.on("error", (error) => {
186
- clearTimeout(timer);
187
- exited = true;
188
- resolve({
189
- ok: false,
190
- exitCode: null,
191
- signal: null,
192
- stdout,
193
- stderr,
194
- timedOut,
195
- spawnError: error && error.message ? error.message : String(error)
196
- });
197
- });
328
+ };
329
+ }
198
330
 
199
- child.on("close", (code, signal) => {
200
- clearTimeout(timer);
201
- exited = true;
202
- resolve({
331
+ if (action === "reboot") {
332
+ await client.send(new ec2.RebootInstancesCommand({
333
+ InstanceIds: instanceIds,
334
+ DryRun: input.dryRun === true
335
+ }));
336
+ return {
203
337
  ok: true,
204
- exitCode: typeof code === "number" ? code : null,
205
- signal: signal || null,
206
- stdout,
207
- stderr,
208
- timedOut,
209
- spawnError: null
210
- });
211
- });
212
- });
213
- }
214
-
215
- function tryParseJsonArray(text) {
216
- const trimmed = String(text || "").trim();
217
- if (!trimmed) {
218
- return { ok: false, error: "Empty stdout" };
219
- }
220
- try {
221
- const parsed = JSON.parse(trimmed);
222
- if (!Array.isArray(parsed)) {
223
- return { ok: false, error: "CLI JSON output was not an array" };
338
+ action,
339
+ profile,
340
+ region,
341
+ instanceIds,
342
+ response: { rebootRequested: true }
343
+ };
224
344
  }
225
- return { ok: true, value: parsed };
345
+
346
+ return { ok: false, error: `unsupported action: ${action}`, requiredAction: null };
226
347
  } catch (error) {
227
- return { ok: false, error: error && error.message ? error.message : String(error) };
348
+ return {
349
+ ok: false,
350
+ error: error && error.message ? error.message : String(error),
351
+ requiredAction: mutationErrorMeta(error, profile, region)
352
+ };
353
+ } finally {
354
+ client.destroy();
228
355
  }
229
356
  }
230
357
 
231
- function summarizeRecords(records) {
232
- const summary = {
233
- totalRecords: 0,
234
- ec2Records: 0,
235
- lambdaRecords: 0,
236
- albRecords: 0,
237
- targetGroupRecords: 0,
238
- asgRecords: 0,
239
- rdsRecords: 0,
240
- elasticacheRecords: 0,
241
- route53ZoneRecords: 0,
242
- publicIpRecords: 0,
243
- ssmManagedCount: 0,
244
- ssmOnlineCount: 0,
245
- runtimeSnapshotCount: 0,
246
- runtimeSnapshotSuccessCount: 0,
247
- profiles: [],
248
- regions: []
249
- };
250
-
251
- const profileSet = new Set();
252
- const regionSet = new Set();
253
-
254
- for (const item of Array.isArray(records) ? records : []) {
255
- summary.totalRecords += 1;
256
- const resourceType = item && item.resourceType ? String(item.resourceType).toLowerCase() : null;
257
- if (resourceType === "ec2") summary.ec2Records += 1;
258
- if (resourceType === "lambda") summary.lambdaRecords += 1;
259
- if (resourceType === "alb") summary.albRecords += 1;
260
- if (resourceType === "target_group") summary.targetGroupRecords += 1;
261
- if (resourceType === "asg") summary.asgRecords += 1;
262
- if (resourceType === "rds") summary.rdsRecords += 1;
263
- if (resourceType === "elasticache") summary.elasticacheRecords += 1;
264
- if (resourceType === "route53_zone") summary.route53ZoneRecords += 1;
265
- if (item && item.publicIp) summary.publicIpRecords += 1;
266
- if (item && item.ssmManaged === true) summary.ssmManagedCount += 1;
267
- if (item && item.ssmOnline === true) summary.ssmOnlineCount += 1;
268
- if (item && item.runtimeSnapshot) {
269
- summary.runtimeSnapshotCount += 1;
270
- if (item.runtimeSnapshot.status === "Success") {
271
- summary.runtimeSnapshotSuccessCount += 1;
272
- }
273
- }
274
- if (item && item.profile) profileSet.add(String(item.profile));
275
- if (item && item.region) regionSet.add(String(item.region));
358
+ async function applyInstanceProfile(input) {
359
+ const { ec2 } = loadMutationAwsModules();
360
+ const profile = normalizeProfile(input.profile);
361
+ const region = String(input.region || "").trim();
362
+ const instanceId = String(input.instanceId || "").trim();
363
+ if (!region) {
364
+ return { ok: false, error: "region is required", requiredAction: null };
276
365
  }
277
-
278
- summary.profiles = Array.from(profileSet).sort();
279
- summary.regions = Array.from(regionSet).sort();
280
- return summary;
281
- }
282
-
283
- function firstProfileArg(args) {
284
- if (args && Array.isArray(args.profiles) && args.profiles.length > 0) {
285
- return String(args.profiles[0]);
366
+ if (!instanceId) {
367
+ return { ok: false, error: "instanceId is required", requiredAction: null };
286
368
  }
287
- return "default";
288
- }
289
369
 
290
- function inferSsoLoginCommand(action, args) {
291
- const hint = action && action.hint ? String(action.hint) : "";
292
- const matched = /aws sso login --profile\s+[^\s'"]+/i.exec(hint);
293
- if (matched) {
294
- return matched[0];
370
+ const name = input.instanceProfileName ? String(input.instanceProfileName).trim() : "";
371
+ const arn = input.instanceProfileArn ? String(input.instanceProfileArn).trim() : "";
372
+ if (!name && !arn) {
373
+ return { ok: false, error: "instanceProfileName or instanceProfileArn is required", requiredAction: null };
295
374
  }
296
- return `aws sso login --profile ${firstProfileArg(args)}`;
297
- }
375
+ const target = arn ? { Arn: arn } : { Name: name };
298
376
 
299
- function guidanceForAction(action, args) {
300
- const code = action && action.code ? String(action.code) : "UNKNOWN";
301
- const defaultItem = {
302
- code,
303
- title: "Manual action required",
304
- steps: [
305
- action && action.message ? action.message : "A manual action is required.",
306
- action && action.hint ? action.hint : "After completing the action, reply '?熬곣뫁?? to continue."
307
- ],
308
- confirmText: "?브퀗??洹쏆쾸? ?熬곣뫁???濡?듆 '?熬곣뫁?????┑€?????면썒??닔??? ?띠룇?? ??븐슙???怨쀬Ŧ ???吏?????熬곥굥由?뇦猿뗭쪠????덈펲."
309
- };
377
+ const client = new ec2.EC2Client(mutationAwsConfig(profile, region));
378
+ try {
379
+ const associations = await client.send(new ec2.DescribeIamInstanceProfileAssociationsCommand({
380
+ Filters: [{ Name: "instance-id", Values: [instanceId] }]
381
+ }));
382
+ const list = Array.isArray(associations && associations.IamInstanceProfileAssociations)
383
+ ? associations.IamInstanceProfileAssociations
384
+ : [];
385
+ const active = list.find((item) => {
386
+ const state = item && item.State ? String(item.State).toLowerCase() : "";
387
+ return state === "associated" || state === "associating";
388
+ });
310
389
 
311
- switch (code) {
312
- case "SSO_LOGIN_NEEDED":
313
- case "SSO_REAUTH_REQUIRED": {
314
- const cmd = inferSsoLoginCommand(action, args);
390
+ if (!active) {
391
+ const out = await client.send(new ec2.AssociateIamInstanceProfileCommand({
392
+ InstanceId: instanceId,
393
+ IamInstanceProfile: target
394
+ }));
315
395
  return {
316
- code,
317
- title: "AWS SSO login required",
318
- steps: [
319
- `????????????깅쾳 嶺뚮ㅏ援앲??????덈뺄??琉얠돪?? ${cmd}`,
320
- "??곗뒧???? ?筌뤾쑴理?MFA???熬곣뫁???琉얠돪??",
321
- "?熬곣뫁????'?熬곣뫁?????┑€?????면썒??닔???"
322
- ],
323
- confirmText: "SSO ?β돦裕??筌뤾쑴逾???硫명뀬???좊듆 '?熬곣뫁?????┑€?????면썒??닔???"
396
+ ok: true,
397
+ action: "associate",
398
+ profile,
399
+ region,
400
+ instanceId,
401
+ response: {
402
+ associationId: out && out.IamInstanceProfileAssociation ? out.IamInstanceProfileAssociation.AssociationId : null
403
+ }
324
404
  };
325
405
  }
326
- case "AWS_CREDENTIALS_REQUIRED":
327
- return {
328
- code,
329
- title: "AWS credentials required",
330
- steps: [
331
- "??????熬곣뫁夷?熬곣뫗踰????遊꾤춯?밸퉾筌?????깆젧??琉얠돪??(SSO ???裕?access key).",
332
- "SSO??寃밸듆 'aws configure sso --profile <profile>' ???β돦裕??筌뤿굝由?筌뤾쑴??",
333
- "?熬곣뫁????'?熬곣뫁?????┑€?????면썒??닔???"
334
- ],
335
- confirmText: "???遊꾤춯?밸퉾筌????깆젧/?β돦裕??筌뤾쑴逾???硫명뀬???좊듆 '?熬곣뫁?????┑€?????면썒??닔???"
336
- };
337
- case "SET_SSM_INSTANCE_PROFILE":
338
- return {
339
- code,
340
- title: "SSM remediation target missing",
341
- steps: [
342
- "???吏??곌랜踰€?袁ㅻご??????濡?졎嶺?instance profile ???藥????裕?ARN??嶺뚯솘??筌먐삵돵????紐껊퉵??",
343
- "???깅쾳 ?????繞???濡る룎????節띾쐾 ?熬곣뫀堉??琉얠돪?? --ssm-instance-profile-name ???裕?--ssm-instance-profile-arn",
344
- "?熬곣뫁????'?熬곣뫁?????┑€?????면썒??닔???"
345
- ],
346
- confirmText: "?熬곣뫁夷???逾?????⑤챷諭?嶺뚯솘??筌먐삳빳???좊듆 '?熬곣뫁?????┑€?????면썒??닔???"
347
- };
348
- case "SSM_ROLE_OR_AGENT_REQUIRED":
349
- return {
350
- code,
351
- title: "Instance is not SSM managed",
352
- steps: [
353
- "?筌뤾쑬裕??怨룸츩 ?????AmazonSSMManagedInstanceCore???????琉얠돪??",
354
- "SSM Agent?? ???덈콦??怨뚯씩(SSM endpoint/?筌뤿굛????롪퍔?δ빳??띠럾? ?筌먦끆留?筌? ?筌먦끉逾??琉얠돪??",
355
- "?熬곣뫁????'?熬곣뫁?????┑€?????면썒??닔???"
356
- ],
357
- confirmText: "SSM ??㉱€????⑤객臾???브퀗?????덈펲嶺?'?熬곣뫁?????┑€?????면썒??닔???"
358
- };
359
- case "INSTANCE_HAS_PROFILE":
360
- return {
361
- code,
362
- title: "Existing instance profile detected",
363
- steps: [
364
- "?リ옇????筌뤾쑬裕??怨룸츩 ?熬곣뫁夷???逾?????곕????덈펲.",
365
- "??ルㅎ臾?1: ?リ옇????????筌먦끉???SSM 雅?굝??뇡???怨뺣뼺???紐껊퉵??",
366
- "??ルㅎ臾?2: ???吏???€흮?우뮁紐???믨퀡由?춯?allowReplaceProfile=true ??????熬곥굥????덈펲."
367
- ],
368
- confirmText: "??⑤챷????꾩렮維뽬떋???筌먐삳빳???좊듆 '?熬곣뫁?????┑€?????면썒??닔???"
369
- };
370
- case "IAM_PROFILE_ASSOCIATION_FAILED":
371
- case "IAM_PROFILE_REPLACE_FAILED":
372
- return {
373
- code,
374
- title: "Missing IAM permission for remediation",
375
- steps: [
376
- "???덈뺄 ?낅슣?섊뙼??EC2 ?筌뤾쑬裕??怨룸츩 ?熬곣뫁夷???逾???⑤슡????€흮??雅?굝??뇡???遊붋€????筌뤾쑴??",
377
- "?熬곣뫗??雅?굝??뇡? ec2:AssociateIamInstanceProfile, ec2:ReplaceIamInstanceProfileAssociation(??€흮????, iam:PassRole",
378
- "?熬곣뫁????'?熬곣뫁?????┑€?????면썒??닔???"
379
- ],
380
- confirmText: "IAM 雅?굝??뇡??꾩룇瑗?????硫명뀬???좊듆 '?熬곣뫁?????┑€?????면썒??닔???"
381
- };
382
- case "SSM_RUNCOMMAND_PERMISSION_REQUIRED":
383
- return {
384
- code,
385
- title: "Missing SSM RunCommand permission",
386
- steps: [
387
- "???덈뺄 ?낅슣?섊뙼??SSM 嶺뚮ㅏ援앲??雅?굝??뇡???遊붋€????筌뤾쑴??",
388
- "?熬곣뫗??雅?굝??뇡? ssm:SendCommand, ssm:GetCommandInvocation",
389
- "?熬곣뫁????'?熬곣뫁?????┑€?????면썒??닔???"
390
- ],
391
- confirmText: "SSM 雅?굝??뇡??꾩룇瑗?????硫명뀬???좊듆 '?熬곣뫁?????┑€?????면썒??닔???"
392
- };
393
- case "LAMBDA_LIST_PERMISSION_REQUIRED":
394
- return {
395
- code,
396
- title: "Missing Lambda list permission",
397
- steps: [
398
- "??쎈뻬 雅뚯눘猿??Lambda 鈺곌퀬??亦낅슦釉???봔€鈺곌퉲鍮€??덈뼄.",
399
- "?袁⑹뒄 亦낅슦釉? lambda:ListFunctions",
400
- "亦낅슦釉?獄쏆꼷????'??袁⑥┷'??⑦€????젻雅뚯눘苑??"
401
- ],
402
- confirmText: "Lambda 亦낅슦釉?獄쏆꼷?????멸돌筌?'??袁⑥┷'??⑦€????젻雅뚯눘苑??"
403
- };
404
- case "ELBV2_LIST_PERMISSION_REQUIRED":
405
- return {
406
- code,
407
- title: "Missing ELBv2 list permission",
408
- steps: [
409
- "Grant permissions to list load balancers and target groups.",
410
- "Required: elasticloadbalancing:DescribeLoadBalancers and elasticloadbalancing:DescribeTargetGroups.",
411
- "Retry after permission update."
412
- ],
413
- confirmText: "After ELBv2 permission update, reply 'completed' and retry."
414
- };
415
- case "ASG_LIST_PERMISSION_REQUIRED":
416
- return {
417
- code,
418
- title: "Missing Auto Scaling list permission",
419
- steps: [
420
- "Grant permission to read Auto Scaling Groups.",
421
- "Required: autoscaling:DescribeAutoScalingGroups.",
422
- "Retry after permission update."
423
- ],
424
- confirmText: "After Auto Scaling permission update, reply 'completed' and retry."
425
- };
426
- case "RDS_LIST_PERMISSION_REQUIRED":
427
- return {
428
- code,
429
- title: "Missing RDS list permission",
430
- steps: [
431
- "Grant permission to list RDS DB instances.",
432
- "Required: rds:DescribeDBInstances.",
433
- "Retry after permission update."
434
- ],
435
- confirmText: "After RDS permission update, reply 'completed' and retry."
436
- };
437
- case "ELASTICACHE_LIST_PERMISSION_REQUIRED":
438
- return {
439
- code,
440
- title: "Missing ElastiCache list permission",
441
- steps: [
442
- "Grant permission to list ElastiCache clusters.",
443
- "Required: elasticache:DescribeCacheClusters.",
444
- "Retry after permission update."
445
- ],
446
- confirmText: "After ElastiCache permission update, reply 'completed' and retry."
447
- };
448
- case "ROUTE53_LIST_PERMISSION_REQUIRED":
406
+
407
+ if (!input.allowReplaceProfile) {
449
408
  return {
450
- code,
451
- title: "Missing Route53 list permission",
452
- steps: [
453
- "Grant permission to list Route53 hosted zones.",
454
- "Required: route53:ListHostedZones (and route53:ListResourceRecordSets for record counts).",
455
- "Retry after permission update."
456
- ],
457
- confirmText: "After Route53 permission update, reply 'completed' and retry."
409
+ ok: true,
410
+ requiresUserAction: true,
411
+ requiredAction: {
412
+ code: "INSTANCE_HAS_PROFILE",
413
+ message: `Instance ${instanceId} already has an instance profile.`,
414
+ hint: "Set allowReplaceProfile=true to replace existing association."
415
+ },
416
+ action: "skipped-existing-profile",
417
+ profile,
418
+ region,
419
+ instanceId
458
420
  };
459
- default:
460
- return defaultItem;
461
- }
462
- }
463
-
464
- function buildAgentGuidance(requiredActions, toolName, args) {
465
- const items = Array.isArray(requiredActions)
466
- ? requiredActions.map((action) => guidanceForAction(action, args))
467
- : [];
421
+ }
468
422
 
469
- if (!items.length) {
423
+ const out = await client.send(new ec2.ReplaceIamInstanceProfileAssociationCommand({
424
+ AssociationId: active.AssociationId,
425
+ IamInstanceProfile: target
426
+ }));
470
427
  return {
471
- mode: "none",
472
- autoRetryRecommended: false,
473
- retryTool: toolName,
474
- retryArgs: args,
475
- userChecklist: [],
476
- assistantMessageTemplate: "상태 조회가 완료되었습니다."
428
+ ok: true,
429
+ action: "replace",
430
+ profile,
431
+ region,
432
+ instanceId,
433
+ response: {
434
+ associationId: out && out.IamInstanceProfileAssociation ? out.IamInstanceProfileAssociation.AssociationId : active.AssociationId
435
+ }
477
436
  };
437
+ } catch (error) {
438
+ return {
439
+ ok: false,
440
+ error: error && error.message ? error.message : String(error),
441
+ requiredAction: mutationErrorMeta(error, profile, region)
442
+ };
443
+ } finally {
444
+ client.destroy();
478
445
  }
479
-
480
- const firstItem = items[0];
481
- const lines = [];
482
- lines.push("AWS 상태 조회를 계속하려면 아래 조치가 필요합니다.");
483
- lines.push(`1. [${firstItem.code}] ${firstItem.title}`);
484
- for (let i = 0; i < firstItem.steps.length; i += 1) {
485
- lines.push(`${i + 1}. ${firstItem.steps[i]}`);
486
- }
487
- lines.push(firstItem.confirmText);
488
-
489
- return {
490
- mode: "human_in_the_loop",
491
- autoRetryRecommended: true,
492
- retryTool: toolName,
493
- retryArgs: args,
494
- completionTrigger: "사용자가 '완료' 또는 조치 완료 의사를 전달하면 같은 입력으로 재시도",
495
- userChecklist: items,
496
- assistantMessageTemplate: lines.join("\n")
497
- };
498
446
  }
499
447
 
500
- function buildToolTextResponse(payload) {
501
- return truncateText(JSON.stringify(payload, null, 2), DEFAULT_JSON_TEXT_LIMIT);
448
+ function tryParseJsonArray(text) {
449
+ const trimmed = String(text || "").trim();
450
+ if (!trimmed) {
451
+ return { ok: false, error: "Empty stdout" };
452
+ }
453
+ try {
454
+ const parsed = JSON.parse(trimmed);
455
+ if (!Array.isArray(parsed)) {
456
+ return { ok: false, error: "CLI JSON output was not an array" };
457
+ }
458
+ return { ok: true, value: parsed };
459
+ } catch (error) {
460
+ return { ok: false, error: error && error.message ? error.message : String(error) };
461
+ }
462
+ }
463
+
464
+ function summarizeRecords(records) {
465
+ const summary = {
466
+ totalRecords: 0,
467
+ ec2Records: 0,
468
+ lambdaRecords: 0,
469
+ albRecords: 0,
470
+ targetGroupRecords: 0,
471
+ asgRecords: 0,
472
+ rdsRecords: 0,
473
+ elasticacheRecords: 0,
474
+ route53ZoneRecords: 0,
475
+ publicIpRecords: 0,
476
+ ssmManagedCount: 0,
477
+ ssmOnlineCount: 0,
478
+ runtimeSnapshotCount: 0,
479
+ runtimeSnapshotSuccessCount: 0,
480
+ profiles: [],
481
+ regions: []
482
+ };
483
+
484
+ const profileSet = new Set();
485
+ const regionSet = new Set();
486
+
487
+ for (const item of Array.isArray(records) ? records : []) {
488
+ summary.totalRecords += 1;
489
+ const resourceType = item && item.resourceType ? String(item.resourceType).toLowerCase() : null;
490
+ if (resourceType === "ec2") summary.ec2Records += 1;
491
+ if (resourceType === "lambda") summary.lambdaRecords += 1;
492
+ if (resourceType === "alb") summary.albRecords += 1;
493
+ if (resourceType === "target_group") summary.targetGroupRecords += 1;
494
+ if (resourceType === "asg") summary.asgRecords += 1;
495
+ if (resourceType === "rds") summary.rdsRecords += 1;
496
+ if (resourceType === "elasticache") summary.elasticacheRecords += 1;
497
+ if (resourceType === "route53_zone") summary.route53ZoneRecords += 1;
498
+ if (item && item.publicIp) summary.publicIpRecords += 1;
499
+ if (item && item.ssmManaged === true) summary.ssmManagedCount += 1;
500
+ if (item && item.ssmOnline === true) summary.ssmOnlineCount += 1;
501
+ if (item && item.runtimeSnapshot) {
502
+ summary.runtimeSnapshotCount += 1;
503
+ if (item.runtimeSnapshot.status === "Success") {
504
+ summary.runtimeSnapshotSuccessCount += 1;
505
+ }
506
+ }
507
+ if (item && item.profile) profileSet.add(String(item.profile));
508
+ if (item && item.region) regionSet.add(String(item.region));
509
+ }
510
+
511
+ summary.profiles = Array.from(profileSet).sort();
512
+ summary.regions = Array.from(regionSet).sort();
513
+ return summary;
514
+ }
515
+
516
+ function firstProfileArg(args) {
517
+ if (args && Array.isArray(args.profiles) && args.profiles.length > 0) {
518
+ return String(args.profiles[0]);
519
+ }
520
+ return "default";
521
+ }
522
+
523
+ function inferSsoLoginCommand(action, args) {
524
+ const hint = action && action.hint ? String(action.hint) : "";
525
+ const matched = /aws sso login --profile\s+[^\s'"]+/i.exec(hint);
526
+ if (matched) {
527
+ return matched[0];
528
+ }
529
+ return `aws sso login --profile ${firstProfileArg(args)}`;
530
+ }
531
+
532
+ function guidanceForAction(action, args) {
533
+ const code = action && action.code ? String(action.code) : "UNKNOWN";
534
+ const defaultItem = {
535
+ code,
536
+ title: "Manual action required",
537
+ steps: [
538
+ action && action.message ? action.message : "A manual action is required.",
539
+ action && action.hint ? action.hint : "After completing the action, reply '????썹땟?? to continue."
540
+ ],
541
+ confirmText: "??됰슦????잙?猷뉓떋? ????썹땟???????'????썹땟??????維◈€?????筌롫똻??????? ??醫딆┻?? ???됰Ŋ??????Β€?????嶺????????꾣뤃管逾??????泥??????딅젩."
542
+ };
543
+
544
+ switch (code) {
545
+ case "SSO_LOGIN_NEEDED":
546
+ case "SSO_REAUTH_REQUIRED": {
547
+ const cmd = inferSsoLoginCommand(action, args);
548
+ return {
549
+ code,
550
+ title: "AWS SSO login required",
551
+ steps: [
552
+ `Run: ${cmd}`,
553
+ "Complete browser SSO login and MFA.",
554
+ "Alternative: if SSO/access key cannot be used, switch to manual fallback with manualServerListPath (JSON/CSV server list + PEM)."
555
+ ],
556
+ confirmText: "After completing one path (SSO/access key or manual fallback), reply 'completed' and retry."
557
+ };
558
+ }
559
+ case "AWS_CREDENTIALS_REQUIRED":
560
+ return {
561
+ code,
562
+ title: "AWS credentials required",
563
+ steps: [
564
+ "Configure AWS auth first (SSO recommended, or access key).",
565
+ "SSO example: aws configure sso --profile <profile> then aws sso login --profile <profile>.",
566
+ "Alternative: if auth cannot be issued in this environment, switch to manual fallback with manualServerListPath (JSON/CSV server list + PEM)."
567
+ ],
568
+ confirmText: "After completing one path (auth setup or manual fallback), reply 'completed' and retry."
569
+ };
570
+ case "SET_SSM_INSTANCE_PROFILE":
571
+ return {
572
+ code,
573
+ title: "SSM remediation target missing",
574
+ steps: [
575
+ "???嶺???⑤슢?뽫뵓嫄붋€??ш낄??????????鈺곕〕??instance profile ??????????ARN???꿔꺂????癲ル슢怡??щ쨫????嶺뚮ㅎ????",
576
+ "???繹먮굞???????嚥????β뼯援η뙴????壤굿€??좊㎧ ????썹땟????嶺뚮슣堉??? --ssm-instance-profile-name ?????--ssm-instance-profile-arn",
577
+ "????썹땟????'????썹땟??????維◈€?????筌롫똻???????"
578
+ ],
579
+ confirmText: "????썹땟怨⒲뀋???????????쇨덫???꿔꺂????癲ル슢怡??몃춣????ル봾諭?'????썹땟??????維◈€?????筌롫똻???????"
580
+ };
581
+ case "SSM_ROLE_OR_AGENT_REQUIRED":
582
+ return {
583
+ code,
584
+ title: "Instance is not SSM managed",
585
+ steps: [
586
+ "?癲ル슢???吏????ㅿ폍筌??????AmazonSSMManagedInstanceCore???????嶺뚮슣堉???",
587
+ "SSM Agent?? ?????됱뎽????ㅼ뒩??SSM endpoint/?癲ル슢?뤸뤃????嚥▲굧???뚪뜮???醫딆쓧? ?癲ル슢캉??낆춹?癲? ?癲ル슢캉???€??嶺뚮슣堉???",
588
+ "????썹땟????'????썹땟??????維◈€?????筌롫똻???????"
589
+ ],
590
+ confirmText: "SSM ???援온€??????븐뻤?????됰슦????????딅젩??'????썹땟??????維◈€?????筌롫똻???????"
591
+ };
592
+ case "INSTANCE_HAS_PROFILE":
593
+ return {
594
+ code,
595
+ title: "Existing instance profile detected",
596
+ steps: [
597
+ "???뚯?????癲ル슢???吏????ㅿ폍筌?????썹땟怨⒲뀋????????????????딅젩.",
598
+ "????k€??1: ???뚯?????????癲ル슢캉????SSM ???????????ㅻ쿋????嶺뚮ㅎ????",
599
+ "????k€??2: ???嶺???????怨뺤툋嶺???沃섃뫂??먮뎨???allowReplaceProfile=true ?????????꾣뤃??????딅젩."
600
+ ],
601
+ confirmText: "????쇨덫????熬곣뫖?삥납??????癲ル슢怡??몃춣????ル봾諭?'????썹땟??????維◈€?????筌롫똻???????"
602
+ };
603
+ case "IAM_PROFILE_ASSOCIATION_FAILED":
604
+ case "IAM_PROFILE_REPLACE_FAILED":
605
+ return {
606
+ code,
607
+ title: "Missing IAM permission for remediation",
608
+ steps: [
609
+ "?????덊떀 ???녿뮝???낆뇢??EC2 ?癲ル슢???吏????ㅿ폍筌?????썹땟怨⒲뀋?????????쇰뮚???????????????????낇뀘?????癲ル슢????",
610
+ "????썹땟????????? ec2:AssociateIamInstanceProfile, ec2:ReplaceIamInstanceProfileAssociation(????????, iam:PassRole",
611
+ "????썹땟????'????썹땟??????維◈€?????筌롫똻???????"
612
+ ],
613
+ confirmText: "IAM ????????熬곣뫖利??????嶺뚮∥梨?????ル봾諭?'????썹땟??????維◈€?????筌롫똻???????"
614
+ };
615
+ case "SSM_RUNCOMMAND_PERMISSION_REQUIRED":
616
+ return {
617
+ code,
618
+ title: "Missing SSM RunCommand permission",
619
+ steps: [
620
+ "?????덊떀 ???녿뮝???낆뇢??SSM ?꿔꺂??琉몃쨨??????????????낇뀘?????癲ル슢????",
621
+ "????썹땟????????? ssm:SendCommand, ssm:GetCommandInvocation",
622
+ "????썹땟????'????썹땟??????維◈€?????筌롫똻???????"
623
+ ],
624
+ confirmText: "SSM ????????熬곣뫖利??????嶺뚮∥梨?????ル봾諭?'????썹땟??????維◈€?????筌롫똻???????"
625
+ };
626
+ case "LAMBDA_LIST_PERMISSION_REQUIRED":
627
+ return {
628
+ code,
629
+ title: "Missing Lambda list permission",
630
+ steps: [
631
+ "????덈틖 ??낆뒩??딅쇊??Lambda ?釉뚰€?????援???????딅텑??釉뚰€?轅대쑏?????덊렡.",
632
+ "??ш끽維????援???? lambda:ListFunctions",
633
+ "??援?????袁⑸즵?????'???ш끽維??????뫢€???????뜏??????"
634
+ ],
635
+ confirmText: "Lambda ??援?????袁⑸즵??????筌롫챶猷롳┼?'???ш끽維??????뫢€???????뜏??????"
636
+ };
637
+ case "ELBV2_LIST_PERMISSION_REQUIRED":
638
+ return {
639
+ code,
640
+ title: "Missing ELBv2 list permission",
641
+ steps: [
642
+ "Grant permissions to list load balancers and target groups.",
643
+ "Required: elasticloadbalancing:DescribeLoadBalancers and elasticloadbalancing:DescribeTargetGroups.",
644
+ "Retry after permission update."
645
+ ],
646
+ confirmText: "After ELBv2 permission update, reply 'completed' and retry."
647
+ };
648
+ case "ASG_LIST_PERMISSION_REQUIRED":
649
+ return {
650
+ code,
651
+ title: "Missing Auto Scaling list permission",
652
+ steps: [
653
+ "Grant permission to read Auto Scaling Groups.",
654
+ "Required: autoscaling:DescribeAutoScalingGroups.",
655
+ "Retry after permission update."
656
+ ],
657
+ confirmText: "After Auto Scaling permission update, reply 'completed' and retry."
658
+ };
659
+ case "RDS_LIST_PERMISSION_REQUIRED":
660
+ return {
661
+ code,
662
+ title: "Missing RDS list permission",
663
+ steps: [
664
+ "Grant permission to list RDS DB instances.",
665
+ "Required: rds:DescribeDBInstances.",
666
+ "Retry after permission update."
667
+ ],
668
+ confirmText: "After RDS permission update, reply 'completed' and retry."
669
+ };
670
+ case "ELASTICACHE_LIST_PERMISSION_REQUIRED":
671
+ return {
672
+ code,
673
+ title: "Missing ElastiCache list permission",
674
+ steps: [
675
+ "Grant permission to list ElastiCache clusters.",
676
+ "Required: elasticache:DescribeCacheClusters.",
677
+ "Retry after permission update."
678
+ ],
679
+ confirmText: "After ElastiCache permission update, reply 'completed' and retry."
680
+ };
681
+ case "ROUTE53_LIST_PERMISSION_REQUIRED":
682
+ return {
683
+ code,
684
+ title: "Missing Route53 list permission",
685
+ steps: [
686
+ "Grant permission to list Route53 hosted zones.",
687
+ "Required: route53:ListHostedZones (and route53:ListResourceRecordSets for record counts).",
688
+ "Retry after permission update."
689
+ ],
690
+ confirmText: "After Route53 permission update, reply 'completed' and retry."
691
+ };
692
+ case "MANUAL_SERVER_LIST_EMPTY":
693
+ return {
694
+ code,
695
+ title: "Manual server list is empty",
696
+ steps: [
697
+ "Provide at least one server row in JSON or CSV format.",
698
+ "Minimum fields: host/publicIp/privateIp, optional pemPath/keyName.",
699
+ "Retry after updating the file."
700
+ ],
701
+ confirmText: "After updating the server list file, reply 'completed' and retry."
702
+ };
703
+ case "MANUAL_SERVER_HOST_REQUIRED":
704
+ return {
705
+ code,
706
+ title: "Server host is required",
707
+ steps: [
708
+ "At least one of host/publicIp/privateIp/publicDns is required per server row.",
709
+ "Update the manual server list row(s) that are missing host info.",
710
+ "Retry with the same manual list path."
711
+ ],
712
+ confirmText: "After fixing host fields, reply 'completed' and retry."
713
+ };
714
+ case "PEM_KEY_NOT_FOUND":
715
+ return {
716
+ code,
717
+ title: "PEM key file not found",
718
+ steps: [
719
+ "Check pemPath in server rows or --pem-paths input.",
720
+ "Use absolute paths or valid relative paths from the working directory.",
721
+ "Retry after fixing key paths."
722
+ ],
723
+ confirmText: "After fixing PEM paths, reply 'completed' and retry."
724
+ };
725
+ case "PEM_MAPPING_REQUIRED":
726
+ return {
727
+ code,
728
+ title: "PEM mapping required",
729
+ steps: [
730
+ "Set pemPath per server row, or pass --pem-paths.",
731
+ "If multiple PEM files are used, include keyName per server for deterministic mapping.",
732
+ "Retry after mapping PEM keys."
733
+ ],
734
+ confirmText: "After PEM mapping is configured, reply 'completed' and retry."
735
+ };
736
+ case "SSH_CLIENT_NOT_FOUND":
737
+ return {
738
+ code,
739
+ title: "SSH client missing",
740
+ steps: [
741
+ "Install OpenSSH client on this machine.",
742
+ "Verify with: ssh -V",
743
+ "Retry after SSH is available."
744
+ ],
745
+ confirmText: "After OpenSSH is installed, reply 'completed' and retry."
746
+ };
747
+ case "SSH_AUTH_OR_CONNECT_FAILED":
748
+ return {
749
+ code,
750
+ title: "SSH authentication or connectivity failed",
751
+ steps: [
752
+ "Verify security group/NACL, host reachability, and target SSH port.",
753
+ "Check SSH user and PEM key permissions.",
754
+ "Retry after connectivity/auth fix."
755
+ ],
756
+ confirmText: "After fixing SSH access, reply 'completed' and retry."
757
+ };
758
+ default:
759
+ return defaultItem;
760
+ }
761
+ }
762
+
763
+ function buildAgentGuidance(requiredActions, toolName, args) {
764
+ const items = Array.isArray(requiredActions)
765
+ ? requiredActions.map((action) => guidanceForAction(action, args))
766
+ : [];
767
+
768
+ if (!items.length) {
769
+ return {
770
+ mode: "none",
771
+ autoRetryRecommended: false,
772
+ retryTool: toolName,
773
+ retryArgs: args,
774
+ userChecklist: [],
775
+ assistantMessageTemplate: "?怨밴묶 鈺곌퀬?뜹첎? ?袁⑥┷??뤿???щ빍??"
776
+ };
777
+ }
778
+
779
+ const firstItem = items[0];
780
+ const lines = [];
781
+ lines.push("AWS ?怨밴묶 鈺곌퀬?띄몴??④쑴???롮젻筌??袁⑥삋 鈺곌퀣?귛첎? ?袁⑹뒄??몃빍??");
782
+ lines.push(`1. [${firstItem.code}] ${firstItem.title}`);
783
+ for (let i = 0; i < firstItem.steps.length; i += 1) {
784
+ lines.push(`${i + 1}. ${firstItem.steps[i]}`);
785
+ }
786
+ lines.push(firstItem.confirmText);
787
+
788
+ return {
789
+ mode: "human_in_the_loop",
790
+ autoRetryRecommended: true,
791
+ retryTool: toolName,
792
+ retryArgs: args,
793
+ completionTrigger: "User replies that the required action is completed.",
794
+ userChecklist: items,
795
+ assistantMessageTemplate: lines.join("\n")
796
+ };
797
+ }
798
+
799
+ function buildToolTextResponse(payload) {
800
+ return truncateText(JSON.stringify(payload, null, 2), DEFAULT_JSON_TEXT_LIMIT);
801
+ }
802
+
803
+ function toolSchema() {
804
+ return {
805
+ profiles: z.array(z.string().min(1)).optional().describe("Optional AWS profiles."),
806
+ regions: z.array(z.string().min(1)).optional().describe("Optional AWS regions."),
807
+ instanceIds: z.array(z.string().min(1)).optional().describe("Optional EC2 instance ids."),
808
+ includeLambda: z.boolean().optional().describe("If true, include Lambda inventory."),
809
+ includeEc2: z.boolean().optional().describe("If false, skip EC2 inventory."),
810
+ includeAlb: z.boolean().optional().describe("If true, include ALB/NLB and target group inventory."),
811
+ includeAsg: z.boolean().optional().describe("If true, include Auto Scaling Group inventory."),
812
+ includeRds: z.boolean().optional().describe("If true, include RDS DB instance inventory."),
813
+ includeElastiCache: z.boolean().optional().describe("If true, include ElastiCache cluster inventory."),
814
+ includeRoute53: z.boolean().optional().describe("If true, include Route53 hosted zone inventory."),
815
+ publicOnly: z.boolean().optional().describe("If true, include only public IPv4 instances."),
816
+ managedOnly: z.boolean().optional().describe("If true, include only SSM-managed instances."),
817
+ autoRemediateSsm: z.boolean().optional().describe("If true, try attaching/replacing instance profile for unmanaged instances."),
818
+ ssmInstanceProfileName: z.string().min(1).optional().describe("Instance profile name used for remediation."),
819
+ ssmInstanceProfileArn: z.string().min(1).optional().describe("Instance profile ARN used for remediation."),
820
+ allowReplaceProfile: z.boolean().optional().describe("Allow replacement when instance already has a profile."),
821
+ remediationWaitSec: z.number().positive().optional().describe("Seconds to wait before post-remediation SSM recheck."),
822
+ runtimeSnapshot: z.boolean().optional().describe("Enable/disable runtime snapshot collection."),
823
+ snapshotTimeoutSec: z.number().positive().optional().describe("SSM command timeout in seconds."),
824
+ snapshotConcurrency: z.number().int().positive().optional().describe("Parallel workers for runtime snapshots."),
825
+ snapshotMaxKb: z.number().int().positive().optional().describe("Max runtime snapshot output size per instance in KB."),
826
+ manualServerListPath: z.string().min(1).optional().describe("JSON/CSV manual server list path (fallback when AWS auth is unavailable)."),
827
+ pemPaths: z.array(z.string().min(1)).optional().describe("Optional PEM key paths for SSH runtime snapshot in manual mode."),
828
+ sshUser: z.string().min(1).optional().describe("Default SSH username for manual mode (default: ec2-user)."),
829
+ sshPort: z.number().int().positive().optional().describe("Default SSH port for manual mode (default: 22)."),
830
+ sshConnectTimeoutSec: z.number().positive().optional().describe("SSH connect timeout seconds (default: 8)."),
831
+ htmlOutPath: z.string().min(1).optional().describe("Optional HTML GUI report output path."),
832
+ openHtml: z.boolean().optional().describe("If true, attempt to open the generated HTML report."),
833
+ autoSsoLogin: z.boolean().optional().describe("Enable/disable automatic aws sso login retry."),
834
+ noProgress: z.boolean().optional().describe("Suppress CLI progress logs."),
835
+ timeoutSec: z.number().positive().max(3600).optional().describe("Wrapper timeout for CLI process."),
836
+ workingDirectory: z.string().min(1).optional().describe("Optional working directory for subprocess."),
837
+ maxRecords: z.number().int().positive().max(10000).optional().describe("Optional max records in MCP response."),
838
+ includeStderr: z.boolean().optional().describe("Include stderr logs in response.")
839
+ };
840
+ }
841
+
842
+ function registerDiscoverTool(server, name, title, description) {
843
+ server.registerTool(
844
+ name,
845
+ {
846
+ title,
847
+ description,
848
+ annotations: {
849
+ readOnlyHint: true,
850
+ destructiveHint: false,
851
+ idempotentHint: true,
852
+ openWorldHint: true
853
+ },
854
+ inputSchema: toolSchema()
855
+ },
856
+ async (args) => {
857
+ const startedAt = new Date().toISOString();
858
+ const cliResult = await runDiscoverCli(args);
859
+ const logInfo = parseStderr(cliResult.stderr);
860
+
861
+ if (cliResult.spawnError) {
862
+ return {
863
+ content: [{ type: "text", text: `Failed to start CLI subprocess: ${cliResult.spawnError}` }],
864
+ isError: true
865
+ };
866
+ }
867
+
868
+ if (cliResult.timedOut) {
869
+ return {
870
+ content: [
871
+ {
872
+ type: "text",
873
+ text: buildToolTextResponse({
874
+ ok: false,
875
+ error: "CLI process timed out",
876
+ startedAt,
877
+ timeoutSec: typeof args.timeoutSec === "number" ? args.timeoutSec : DEFAULT_TIMEOUT_SEC,
878
+ exitCode: cliResult.exitCode,
879
+ signal: cliResult.signal,
880
+ stderr: truncateText(cliResult.stderr)
881
+ })
882
+ }
883
+ ],
884
+ isError: true
885
+ };
886
+ }
887
+
888
+ const parsed = tryParseJsonArray(cliResult.stdout);
889
+ if (!parsed.ok) {
890
+ return {
891
+ content: [
892
+ {
893
+ type: "text",
894
+ text: buildToolTextResponse({
895
+ ok: false,
896
+ error: "Failed to parse CLI JSON output",
897
+ parseError: parsed.error,
898
+ exitCode: cliResult.exitCode,
899
+ signal: cliResult.signal,
900
+ stderr: truncateText(cliResult.stderr),
901
+ stdout: truncateText(cliResult.stdout)
902
+ })
903
+ }
904
+ ],
905
+ isError: true
906
+ };
907
+ }
908
+
909
+ const allRecords = parsed.value;
910
+ const maxRecords =
911
+ typeof args.maxRecords === "number" && Number.isFinite(args.maxRecords)
912
+ ? args.maxRecords
913
+ : null;
914
+ const records = maxRecords && allRecords.length > maxRecords ? allRecords.slice(0, maxRecords) : allRecords;
915
+
916
+ const acceptedExitCodes = new Set([0, 2, 3]);
917
+ const requiresUserAction = logInfo.requiredActions.length > 0 || cliResult.exitCode === 3;
918
+ const guidance = buildAgentGuidance(logInfo.requiredActions, name, args);
919
+
920
+ const response = {
921
+ ok: acceptedExitCodes.has(cliResult.exitCode),
922
+ startedAt,
923
+ finishedAt: new Date().toISOString(),
924
+ exitCode: cliResult.exitCode,
925
+ signal: cliResult.signal,
926
+ requiresUserAction,
927
+ requiredActions: logInfo.requiredActions,
928
+ guidance,
929
+ summary: {
930
+ ...summarizeRecords(allRecords),
931
+ returnedRecords: records.length,
932
+ truncated: records.length !== allRecords.length,
933
+ warningCount: logInfo.warnings.length,
934
+ summaryLine: logInfo.summaryLine
935
+ },
936
+ records
937
+ };
938
+
939
+ if (args.includeStderr) {
940
+ response.stderr = truncateText(cliResult.stderr);
941
+ }
942
+
943
+ if (!acceptedExitCodes.has(cliResult.exitCode)) {
944
+ return {
945
+ content: [{ type: "text", text: buildToolTextResponse({ ...response, ok: false, stderr: truncateText(cliResult.stderr) }) }],
946
+ isError: true
947
+ };
948
+ }
949
+
950
+ return {
951
+ content: [{ type: "text", text: buildToolTextResponse(response) }],
952
+ isError: false
953
+ };
954
+ }
955
+ );
502
956
  }
503
957
 
504
- function toolSchema() {
958
+ function ec2ActionSchema(extra = {}) {
505
959
  return {
506
- profiles: z.array(z.string().min(1)).optional().describe("Optional AWS profiles."),
507
- regions: z.array(z.string().min(1)).optional().describe("Optional AWS regions."),
508
- instanceIds: z.array(z.string().min(1)).optional().describe("Optional EC2 instance ids."),
509
- includeLambda: z.boolean().optional().describe("If true, include Lambda inventory."),
510
- includeEc2: z.boolean().optional().describe("If false, skip EC2 inventory."),
511
- includeAlb: z.boolean().optional().describe("If true, include ALB/NLB and target group inventory."),
512
- includeAsg: z.boolean().optional().describe("If true, include Auto Scaling Group inventory."),
513
- includeRds: z.boolean().optional().describe("If true, include RDS DB instance inventory."),
514
- includeElastiCache: z.boolean().optional().describe("If true, include ElastiCache cluster inventory."),
515
- includeRoute53: z.boolean().optional().describe("If true, include Route53 hosted zone inventory."),
516
- publicOnly: z.boolean().optional().describe("If true, include only public IPv4 instances."),
517
- managedOnly: z.boolean().optional().describe("If true, include only SSM-managed instances."),
518
- autoRemediateSsm: z.boolean().optional().describe("If true, try attaching/replacing instance profile for unmanaged instances."),
519
- ssmInstanceProfileName: z.string().min(1).optional().describe("Instance profile name used for remediation."),
520
- ssmInstanceProfileArn: z.string().min(1).optional().describe("Instance profile ARN used for remediation."),
521
- allowReplaceProfile: z.boolean().optional().describe("Allow replacement when instance already has a profile."),
522
- remediationWaitSec: z.number().positive().optional().describe("Seconds to wait before post-remediation SSM recheck."),
523
- runtimeSnapshot: z.boolean().optional().describe("Enable/disable runtime snapshot collection."),
524
- snapshotTimeoutSec: z.number().positive().optional().describe("SSM command timeout in seconds."),
525
- snapshotConcurrency: z.number().int().positive().optional().describe("Parallel workers for runtime snapshots."),
526
- snapshotMaxKb: z.number().int().positive().optional().describe("Max runtime snapshot output size per instance in KB."),
527
- autoSsoLogin: z.boolean().optional().describe("Enable/disable automatic aws sso login retry."),
528
- noProgress: z.boolean().optional().describe("Suppress CLI progress logs."),
529
- timeoutSec: z.number().positive().max(3600).optional().describe("Wrapper timeout for CLI process."),
530
- workingDirectory: z.string().min(1).optional().describe("Optional working directory for subprocess."),
531
- maxRecords: z.number().int().positive().max(10000).optional().describe("Optional max records in MCP response."),
532
- includeStderr: z.boolean().optional().describe("Include stderr logs in response.")
960
+ profile: z.string().min(1).optional().describe("AWS profile name (default: default)."),
961
+ region: z.string().min(1).describe("AWS region, e.g. ap-southeast-1."),
962
+ instanceIds: z.array(z.string().min(1)).min(1).describe("Target EC2 instance ids."),
963
+ dryRun: z.boolean().optional().describe("If true, AWS DryRun is used where supported."),
964
+ ...extra
533
965
  };
534
966
  }
535
967
 
536
- function registerDiscoverTool(server, name, title, description) {
968
+ function registerEc2MutationTool(server, name, title, description, action, schema = ec2ActionSchema()) {
537
969
  server.registerTool(
538
970
  name,
539
971
  {
540
972
  title,
541
973
  description,
542
974
  annotations: {
543
- readOnlyHint: true,
544
- destructiveHint: false,
545
- idempotentHint: true,
975
+ readOnlyHint: false,
976
+ destructiveHint: true,
977
+ idempotentHint: false,
546
978
  openWorldHint: true
547
979
  },
548
- inputSchema: toolSchema()
980
+ inputSchema: schema
549
981
  },
550
982
  async (args) => {
551
983
  const startedAt = new Date().toISOString();
552
- const cliResult = await runDiscoverCli(args);
553
- const logInfo = parseStderr(cliResult.stderr);
554
-
555
- if (cliResult.spawnError) {
556
- return {
557
- content: [{ type: "text", text: `Failed to start CLI subprocess: ${cliResult.spawnError}` }],
558
- isError: true
984
+ const result = await runEc2InstanceAction(args, action);
985
+ if (!result.ok) {
986
+ const payload = {
987
+ ok: false,
988
+ action,
989
+ startedAt,
990
+ finishedAt: new Date().toISOString(),
991
+ error: result.error,
992
+ requiredActions: result.requiredAction ? [result.requiredAction] : []
559
993
  };
560
- }
561
-
562
- if (cliResult.timedOut) {
563
- return {
564
- content: [
565
- {
566
- type: "text",
567
- text: buildToolTextResponse({
568
- ok: false,
569
- error: "CLI process timed out",
570
- startedAt,
571
- timeoutSec: typeof args.timeoutSec === "number" ? args.timeoutSec : DEFAULT_TIMEOUT_SEC,
572
- exitCode: cliResult.exitCode,
573
- signal: cliResult.signal,
574
- stderr: truncateText(cliResult.stderr)
575
- })
576
- }
577
- ],
578
- isError: true
579
- };
580
- }
581
-
582
- const parsed = tryParseJsonArray(cliResult.stdout);
583
- if (!parsed.ok) {
584
994
  return {
585
- content: [
586
- {
587
- type: "text",
588
- text: buildToolTextResponse({
589
- ok: false,
590
- error: "Failed to parse CLI JSON output",
591
- parseError: parsed.error,
592
- exitCode: cliResult.exitCode,
593
- signal: cliResult.signal,
594
- stderr: truncateText(cliResult.stderr),
595
- stdout: truncateText(cliResult.stdout)
596
- })
597
- }
598
- ],
995
+ content: [{ type: "text", text: buildToolTextResponse(payload) }],
599
996
  isError: true
600
997
  };
601
998
  }
602
-
603
- const allRecords = parsed.value;
604
- const maxRecords =
605
- typeof args.maxRecords === "number" && Number.isFinite(args.maxRecords)
606
- ? args.maxRecords
607
- : null;
608
- const records = maxRecords && allRecords.length > maxRecords ? allRecords.slice(0, maxRecords) : allRecords;
609
-
610
- const acceptedExitCodes = new Set([0, 2, 3]);
611
- const requiresUserAction = logInfo.requiredActions.length > 0 || cliResult.exitCode === 3;
612
- const guidance = buildAgentGuidance(logInfo.requiredActions, name, args);
613
-
614
- const response = {
615
- ok: acceptedExitCodes.has(cliResult.exitCode),
616
- startedAt,
617
- finishedAt: new Date().toISOString(),
618
- exitCode: cliResult.exitCode,
619
- signal: cliResult.signal,
620
- requiresUserAction,
621
- requiredActions: logInfo.requiredActions,
622
- guidance,
623
- summary: {
624
- ...summarizeRecords(allRecords),
625
- returnedRecords: records.length,
626
- truncated: records.length !== allRecords.length,
627
- warningCount: logInfo.warnings.length,
628
- summaryLine: logInfo.summaryLine
629
- },
630
- records
999
+ return {
1000
+ content: [{
1001
+ type: "text",
1002
+ text: buildToolTextResponse({
1003
+ ok: true,
1004
+ action,
1005
+ startedAt,
1006
+ finishedAt: new Date().toISOString(),
1007
+ ...result
1008
+ })
1009
+ }],
1010
+ isError: false
631
1011
  };
1012
+ }
1013
+ );
1014
+ }
632
1015
 
633
- if (args.includeStderr) {
634
- response.stderr = truncateText(cliResult.stderr);
1016
+ function registerEc2ProfileTool(server) {
1017
+ server.registerTool(
1018
+ "ec2_apply_instance_profile",
1019
+ {
1020
+ title: "Apply EC2 IAM Instance Profile",
1021
+ description: "Associates or replaces IAM instance profile on one EC2 instance.",
1022
+ annotations: {
1023
+ readOnlyHint: false,
1024
+ destructiveHint: true,
1025
+ idempotentHint: false,
1026
+ openWorldHint: true
1027
+ },
1028
+ inputSchema: {
1029
+ profile: z.string().min(1).optional().describe("AWS profile name (default: default)."),
1030
+ region: z.string().min(1).describe("AWS region, e.g. ap-southeast-1."),
1031
+ instanceId: z.string().min(1).describe("Target EC2 instance id."),
1032
+ instanceProfileName: z.string().min(1).optional().describe("Target instance profile name."),
1033
+ instanceProfileArn: z.string().min(1).optional().describe("Target instance profile ARN."),
1034
+ allowReplaceProfile: z.boolean().optional().describe("If true, existing association can be replaced.")
635
1035
  }
636
-
637
- if (!acceptedExitCodes.has(cliResult.exitCode)) {
1036
+ },
1037
+ async (args) => {
1038
+ const startedAt = new Date().toISOString();
1039
+ const result = await applyInstanceProfile(args);
1040
+ if (!result.ok) {
1041
+ const payload = {
1042
+ ok: false,
1043
+ action: "ec2_apply_instance_profile",
1044
+ startedAt,
1045
+ finishedAt: new Date().toISOString(),
1046
+ error: result.error,
1047
+ requiredActions: result.requiredAction ? [result.requiredAction] : []
1048
+ };
638
1049
  return {
639
- content: [{ type: "text", text: buildToolTextResponse({ ...response, ok: false, stderr: truncateText(cliResult.stderr) }) }],
1050
+ content: [{ type: "text", text: buildToolTextResponse(payload) }],
640
1051
  isError: true
641
1052
  };
642
1053
  }
643
-
644
1054
  return {
645
- content: [{ type: "text", text: buildToolTextResponse(response) }],
1055
+ content: [{
1056
+ type: "text",
1057
+ text: buildToolTextResponse({
1058
+ ok: true,
1059
+ action: "ec2_apply_instance_profile",
1060
+ startedAt,
1061
+ finishedAt: new Date().toISOString(),
1062
+ ...result
1063
+ })
1064
+ }],
646
1065
  isError: false
647
1066
  };
648
1067
  }
649
1068
  );
650
- }
1069
+ }
651
1070
 
652
1071
  async function registerTools(server) {
653
1072
  registerDiscoverTool(
654
1073
  server,
655
1074
  "discover_ec2_with_ssm",
656
- "Discover AWS Inventory (multi-service + SSM runtime)",
657
- "Runs mcp-aws-manager and returns inventory across EC2/Lambda/ALB/ASG/RDS/ElastiCache/Route53 with optional SSM runtime snapshots."
1075
+ "Discover AWS Inventory (multi-service + SSM runtime)",
1076
+ "Runs mcp-aws-manager and returns inventory across EC2/Lambda/ALB/ASG/RDS/ElastiCache/Route53 with optional runtime snapshots (SSM or manual-server-list + PEM SSH)."
658
1077
  );
659
1078
 
660
- server.registerTool(
661
- "mcp_aws_discover_cli_help",
662
- {
663
- title: "Show CLI Help",
664
- description: "Returns the local mcp-aws-manager CLI usage text.",
665
- annotations: {
666
- readOnlyHint: true,
667
- destructiveHint: false,
668
- idempotentHint: true,
669
- openWorldHint: false
670
- }
671
- },
672
- async () => {
673
- const child = await new Promise((resolve) => {
674
- const proc = spawn(process.execPath, [CLI_SCRIPT_PATH, "--help"], {
675
- cwd: process.cwd(),
676
- env: process.env,
677
- stdio: ["ignore", "pipe", "pipe"]
678
- });
679
-
680
- let stdout = "";
681
- let stderr = "";
682
- if (proc.stdout) {
683
- proc.stdout.setEncoding("utf8");
684
- proc.stdout.on("data", (c) => { stdout += c; });
685
- }
686
- if (proc.stderr) {
687
- proc.stderr.setEncoding("utf8");
688
- proc.stderr.on("data", (c) => { stderr += c; });
689
- }
690
-
691
- proc.on("close", (code, signal) => {
692
- resolve({ code: code == null ? null : code, signal, stdout, stderr });
693
- });
694
- proc.on("error", (error) => {
695
- resolve({ code: null, signal: null, stdout, stderr: String(error && error.message ? error.message : error) });
696
- });
697
- });
698
-
699
- const payload = {
700
- ok: child.code === 0,
701
- exitCode: child.code,
702
- signal: child.signal || null,
703
- stdout: truncateText(child.stdout, DEFAULT_JSON_TEXT_LIMIT),
704
- stderr: truncateText(child.stderr)
705
- };
706
-
707
- return {
708
- content: [{ type: "text", text: buildToolTextResponse(payload) }],
709
- isError: child.code !== 0
710
- };
711
- }
1079
+ registerEc2MutationTool(
1080
+ server,
1081
+ "ec2_start_instances",
1082
+ "Start EC2 Instances",
1083
+ "Starts EC2 instances in a region.",
1084
+ "start"
712
1085
  );
713
- }
714
-
715
- async function main() {
716
- if (shouldShowHelp(process.argv.slice(2))) {
717
- process.stdout.write(usageText());
718
- return;
719
- }
720
1086
 
721
- const server = new McpServer({
722
- name: "mcp-aws-manager",
723
- version: pkg.version
724
- });
1087
+ registerEc2MutationTool(
1088
+ server,
1089
+ "ec2_stop_instances",
1090
+ "Stop EC2 Instances",
1091
+ "Stops EC2 instances in a region (supports force).",
1092
+ "stop",
1093
+ ec2ActionSchema({
1094
+ force: z.boolean().optional().describe("If true, force stop is requested.")
1095
+ })
1096
+ );
725
1097
 
726
- await registerTools(server);
1098
+ registerEc2MutationTool(
1099
+ server,
1100
+ "ec2_reboot_instances",
1101
+ "Reboot EC2 Instances",
1102
+ "Reboots EC2 instances in a region.",
1103
+ "reboot"
1104
+ );
727
1105
 
728
- const transport = new StdioServerTransport();
729
- await server.connect(transport);
730
- }
1106
+ registerEc2ProfileTool(server);
731
1107
 
732
- main().catch((error) => {
733
- const message = error && error.stack ? error.stack : String(error);
734
- process.stderr.write(`mcp-aws-manager-mcp startup error: ${message}\n`);
735
- process.exitCode = 1;
1108
+ server.registerTool(
1109
+ "mcp_aws_discover_cli_help",
1110
+ {
1111
+ title: "Show CLI Help",
1112
+ description: "Returns the local mcp-aws-manager CLI usage text.",
1113
+ annotations: {
1114
+ readOnlyHint: true,
1115
+ destructiveHint: false,
1116
+ idempotentHint: true,
1117
+ openWorldHint: false
1118
+ }
1119
+ },
1120
+ async () => {
1121
+ const child = await new Promise((resolve) => {
1122
+ const proc = spawn(process.execPath, [CLI_SCRIPT_PATH, "--help"], {
1123
+ cwd: process.cwd(),
1124
+ env: process.env,
1125
+ stdio: ["ignore", "pipe", "pipe"]
1126
+ });
1127
+
1128
+ let stdout = "";
1129
+ let stderr = "";
1130
+ if (proc.stdout) {
1131
+ proc.stdout.setEncoding("utf8");
1132
+ proc.stdout.on("data", (c) => { stdout += c; });
1133
+ }
1134
+ if (proc.stderr) {
1135
+ proc.stderr.setEncoding("utf8");
1136
+ proc.stderr.on("data", (c) => { stderr += c; });
1137
+ }
1138
+
1139
+ proc.on("close", (code, signal) => {
1140
+ resolve({ code: code == null ? null : code, signal, stdout, stderr });
1141
+ });
1142
+ proc.on("error", (error) => {
1143
+ resolve({ code: null, signal: null, stdout, stderr: String(error && error.message ? error.message : error) });
1144
+ });
1145
+ });
1146
+
1147
+ const payload = {
1148
+ ok: child.code === 0,
1149
+ exitCode: child.code,
1150
+ signal: child.signal || null,
1151
+ stdout: truncateText(child.stdout, DEFAULT_JSON_TEXT_LIMIT),
1152
+ stderr: truncateText(child.stderr)
1153
+ };
1154
+
1155
+ return {
1156
+ content: [{ type: "text", text: buildToolTextResponse(payload) }],
1157
+ isError: child.code !== 0
1158
+ };
1159
+ }
1160
+ );
1161
+ }
1162
+
1163
+ async function main() {
1164
+ if (shouldShowHelp(process.argv.slice(2))) {
1165
+ process.stdout.write(usageText());
1166
+ return;
1167
+ }
1168
+
1169
+ const server = new McpServer({
1170
+ name: "mcp-aws-manager",
1171
+ version: pkg.version
1172
+ });
1173
+
1174
+ await registerTools(server);
1175
+
1176
+ const transport = new StdioServerTransport();
1177
+ await server.connect(transport);
1178
+ }
1179
+
1180
+ main().catch((error) => {
1181
+ const message = error && error.stack ? error.stack : String(error);
1182
+ process.stderr.write(`mcp-aws-manager-mcp startup error: ${message}\n`);
1183
+ process.exitCode = 1;
736
1184
  });