mcp-aws-manager 0.1.0 → 0.2.0

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.
@@ -20,17 +20,15 @@ function usageText() {
20
20
  return [
21
21
  "mcp-aws-manager-mcp",
22
22
  "",
23
- "MCP stdio wrapper for the mcp-aws-manager CLI.",
24
- "Starts an MCP server that exposes a discover tool for AI clients.",
23
+ "MCP stdio wrapper for the mcp-aws-manager CLI (SSM-only).",
25
24
  "",
26
25
  "Usage:",
27
26
  " mcp-aws-manager-mcp",
28
27
  " mcp-aws-manager-mcp --help",
29
28
  "",
30
29
  "Notes:",
31
- " - This process is an MCP stdio server. Do not run it interactively unless your client",
32
- " speaks MCP over stdio (Claude Desktop, Cursor, Cline, etc.).",
33
- " - The discover tool mirrors the CLI PEM resolution rules (arg/env/cwd auto-discovery).",
30
+ " - This process is an MCP stdio server.",
31
+ " - Exposes SSM inventory/runtime snapshot discovery tools.",
34
32
  ""
35
33
  ].join("\n");
36
34
  }
@@ -40,17 +38,13 @@ function shouldShowHelp(argv) {
40
38
  }
41
39
 
42
40
  function toCsvArg(values) {
43
- if (!Array.isArray(values) || !values.length) {
44
- return null;
45
- }
41
+ if (!Array.isArray(values) || !values.length) return null;
46
42
  return values.map((v) => String(v).trim()).filter(Boolean).join(",");
47
43
  }
48
44
 
49
45
  function truncateText(value, maxLength = DEFAULT_STDIO_TEXT_LIMIT) {
50
46
  const text = String(value || "");
51
- if (text.length <= maxLength) {
52
- return text;
53
- }
47
+ if (text.length <= maxLength) return text;
54
48
  const suffix = `\n... [truncated ${text.length - maxLength} chars]`;
55
49
  return text.slice(0, maxLength) + suffix;
56
50
  }
@@ -61,60 +55,70 @@ function parseStderr(stderrText) {
61
55
  .map((line) => line.trim())
62
56
  .filter(Boolean);
63
57
 
64
- const warnings = lines.filter((line) => line.startsWith("WARNING: "));
65
- const summaryLine = lines.find((line) => line.startsWith("Summary: ")) || null;
58
+ const warnings = [];
59
+ const requiredActions = [];
60
+ let summaryLine = null;
66
61
 
67
- return {
68
- lines,
69
- warnings,
70
- summaryLine
71
- };
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 };
72
89
  }
73
90
 
74
91
  function buildCliArgs(input) {
75
92
  const args = [CLI_SCRIPT_PATH, "--format", "json"];
76
93
 
77
- const pemPaths = toCsvArg(input.pemPaths);
78
- if (pemPaths) {
79
- args.push("--pem-path", pemPaths);
80
- } else if (input.pemPath) {
81
- args.push("--pem-path", input.pemPath);
82
- }
83
-
84
94
  const profiles = toCsvArg(input.profiles);
85
- if (profiles) {
86
- args.push("--profiles", profiles);
87
- }
95
+ if (profiles) args.push("--profiles", profiles);
88
96
 
89
97
  const regions = toCsvArg(input.regions);
90
- if (regions) {
91
- args.push("--regions", regions);
92
- }
98
+ if (regions) args.push("--regions", regions);
93
99
 
94
- if (input.keyName) {
95
- args.push("--key-name", input.keyName);
96
- }
100
+ const instanceIds = toCsvArg(input.instanceIds);
101
+ if (instanceIds) args.push("--instance-ids", instanceIds);
97
102
 
98
- if (input.sshCheck) {
99
- args.push("--ssh-check");
100
- }
103
+ if (input.publicOnly) args.push("--public-only");
104
+ if (input.managedOnly) args.push("--managed-only");
101
105
 
102
- const sshUsernames = toCsvArg(input.sshUsernames);
103
- if (sshUsernames) {
104
- args.push("--ssh-usernames", sshUsernames);
105
- }
106
+ if (input.autoRemediateSsm) args.push("--auto-remediate-ssm");
107
+ if (input.ssmInstanceProfileName) args.push("--ssm-instance-profile-name", input.ssmInstanceProfileName);
108
+ if (input.ssmInstanceProfileArn) args.push("--ssm-instance-profile-arn", input.ssmInstanceProfileArn);
109
+ if (input.allowReplaceProfile) args.push("--allow-replace-profile");
110
+ if (input.remediationWaitSec != null) args.push("--remediation-wait", String(input.remediationWaitSec));
106
111
 
107
- if (input.sshTimeoutSec != null) {
108
- args.push("--ssh-timeout", String(input.sshTimeoutSec));
109
- }
112
+ if (input.runtimeSnapshot === false) args.push("--no-runtime-snapshot");
113
+ if (input.runtimeSnapshot === true) args.push("--runtime-snapshot");
114
+ if (input.snapshotTimeoutSec != null) args.push("--snapshot-timeout", String(input.snapshotTimeoutSec));
115
+ if (input.snapshotConcurrency != null) args.push("--snapshot-concurrency", String(input.snapshotConcurrency));
116
+ if (input.snapshotMaxKb != null) args.push("--snapshot-max-kb", String(input.snapshotMaxKb));
110
117
 
111
- if (input.matchedOnly) {
112
- args.push("--matched-only");
113
- }
118
+ if (input.autoSsoLogin === false) args.push("--no-auto-sso-login");
119
+ if (input.autoSsoLogin === true) args.push("--auto-sso-login");
114
120
 
115
- if (input.noProgress !== false) {
116
- args.push("--no-progress");
117
- }
121
+ if (input.noProgress !== false) args.push("--no-progress");
118
122
 
119
123
  return args;
120
124
  }
@@ -144,7 +148,6 @@ function runDiscoverCli(input) {
144
148
  stdout += chunk;
145
149
  });
146
150
  }
147
-
148
151
  if (child.stderr) {
149
152
  child.stderr.setEncoding("utf8");
150
153
  child.stderr.on("data", (chunk) => {
@@ -153,18 +156,12 @@ function runDiscoverCli(input) {
153
156
  }
154
157
 
155
158
  const timer = setTimeout(() => {
156
- if (exited) {
157
- return;
158
- }
159
+ if (exited) return;
159
160
  timedOut = true;
160
- try {
161
- child.kill("SIGTERM");
162
- } catch {}
161
+ try { child.kill("SIGTERM"); } catch {}
163
162
  setTimeout(() => {
164
163
  if (!exited) {
165
- try {
166
- child.kill("SIGKILL");
167
- } catch {}
164
+ try { child.kill("SIGKILL"); } catch {}
168
165
  }
169
166
  }, 3000).unref();
170
167
  }, timeoutMs);
@@ -198,7 +195,7 @@ function runDiscoverCli(input) {
198
195
  });
199
196
  });
200
197
  });
201
- }
198
+ }
202
199
 
203
200
  function tryParseJsonArray(text) {
204
201
  const trimmed = String(text || "").trim();
@@ -212,140 +209,87 @@ function tryParseJsonArray(text) {
212
209
  }
213
210
  return { ok: true, value: parsed };
214
211
  } catch (error) {
215
- return {
216
- ok: false,
217
- error: error && error.message ? error.message : String(error)
218
- };
212
+ return { ok: false, error: error && error.message ? error.message : String(error) };
219
213
  }
220
214
  }
221
215
 
222
216
  function summarizeRecords(records) {
223
217
  const summary = {
224
- totalRecords: Array.isArray(records) ? records.length : 0,
225
- matchedCount: 0,
226
- sshCheckedCount: 0,
227
- sshReachableCount: 0,
218
+ totalRecords: 0,
219
+ publicIpRecords: 0,
220
+ ssmManagedCount: 0,
221
+ ssmOnlineCount: 0,
222
+ runtimeSnapshotCount: 0,
223
+ runtimeSnapshotSuccessCount: 0,
228
224
  profiles: [],
229
225
  regions: []
230
226
  };
231
227
 
232
- const profiles = new Set();
233
- const regions = new Set();
228
+ const profileSet = new Set();
229
+ const regionSet = new Set();
234
230
 
235
231
  for (const item of Array.isArray(records) ? records : []) {
236
- if (item && item.keyFingerprintMatch === true) {
237
- summary.matchedCount += 1;
238
- }
239
- if (item && item.sshReachable !== null && item.sshReachable !== undefined) {
240
- summary.sshCheckedCount += 1;
241
- }
242
- if (item && item.sshReachable === true) {
243
- summary.sshReachableCount += 1;
244
- }
245
- if (item && item.profile) {
246
- profiles.add(String(item.profile));
247
- }
248
- if (item && item.region) {
249
- regions.add(String(item.region));
232
+ summary.totalRecords += 1;
233
+ if (item && item.publicIp) summary.publicIpRecords += 1;
234
+ if (item && item.ssmManaged === true) summary.ssmManagedCount += 1;
235
+ if (item && item.ssmOnline === true) summary.ssmOnlineCount += 1;
236
+ if (item && item.runtimeSnapshot) {
237
+ summary.runtimeSnapshotCount += 1;
238
+ if (item.runtimeSnapshot.status === "Success") {
239
+ summary.runtimeSnapshotSuccessCount += 1;
240
+ }
250
241
  }
242
+ if (item && item.profile) profileSet.add(String(item.profile));
243
+ if (item && item.region) regionSet.add(String(item.region));
251
244
  }
252
245
 
253
- summary.profiles = Array.from(profiles).sort();
254
- summary.regions = Array.from(regions).sort();
246
+ summary.profiles = Array.from(profileSet).sort();
247
+ summary.regions = Array.from(regionSet).sort();
255
248
  return summary;
256
249
  }
257
250
 
258
251
  function buildToolTextResponse(payload) {
259
- const text = JSON.stringify(payload, null, 2);
260
- return truncateText(text, DEFAULT_JSON_TEXT_LIMIT);
252
+ return truncateText(JSON.stringify(payload, null, 2), DEFAULT_JSON_TEXT_LIMIT);
261
253
  }
262
254
 
263
- async function registerTools(server) {
255
+ function toolSchema() {
256
+ return {
257
+ profiles: z.array(z.string().min(1)).optional().describe("Optional AWS profiles."),
258
+ regions: z.array(z.string().min(1)).optional().describe("Optional AWS regions."),
259
+ instanceIds: z.array(z.string().min(1)).optional().describe("Optional EC2 instance ids."),
260
+ publicOnly: z.boolean().optional().describe("If true, include only public IPv4 instances."),
261
+ managedOnly: z.boolean().optional().describe("If true, include only SSM-managed instances."),
262
+ autoRemediateSsm: z.boolean().optional().describe("If true, try attaching/replacing instance profile for unmanaged instances."),
263
+ ssmInstanceProfileName: z.string().min(1).optional().describe("Instance profile name used for remediation."),
264
+ ssmInstanceProfileArn: z.string().min(1).optional().describe("Instance profile ARN used for remediation."),
265
+ allowReplaceProfile: z.boolean().optional().describe("Allow replacement when instance already has a profile."),
266
+ remediationWaitSec: z.number().positive().optional().describe("Seconds to wait before post-remediation SSM recheck."),
267
+ runtimeSnapshot: z.boolean().optional().describe("Enable/disable runtime snapshot collection."),
268
+ snapshotTimeoutSec: z.number().positive().optional().describe("SSM command timeout in seconds."),
269
+ snapshotConcurrency: z.number().int().positive().optional().describe("Parallel workers for runtime snapshots."),
270
+ snapshotMaxKb: z.number().int().positive().optional().describe("Max runtime snapshot output size per instance in KB."),
271
+ autoSsoLogin: z.boolean().optional().describe("Enable/disable automatic aws sso login retry."),
272
+ noProgress: z.boolean().optional().describe("Suppress CLI progress logs."),
273
+ timeoutSec: z.number().positive().max(3600).optional().describe("Wrapper timeout for CLI process."),
274
+ workingDirectory: z.string().min(1).optional().describe("Optional working directory for subprocess."),
275
+ maxRecords: z.number().int().positive().max(10000).optional().describe("Optional max records in MCP response."),
276
+ includeStderr: z.boolean().optional().describe("Include stderr logs in response.")
277
+ };
278
+ }
279
+
280
+ function registerDiscoverTool(server, name, title, description) {
264
281
  server.registerTool(
265
- "discover_public_ec2_with_pem",
282
+ name,
266
283
  {
267
- title: "Discover EC2 Public IP Inventory (PEM-based)",
268
- description:
269
- "Runs the local mcp-aws-manager CLI and returns EC2 instances with public IPv4 addresses, including PEM fingerprint matching and optional SSH reachability checks.",
284
+ title,
285
+ description,
270
286
  annotations: {
271
287
  readOnlyHint: true,
272
288
  destructiveHint: false,
273
289
  idempotentHint: true,
274
290
  openWorldHint: true
275
291
  },
276
- inputSchema: {
277
- pemPaths: z
278
- .array(z.string().min(1))
279
- .optional()
280
- .describe(
281
- "Optional list of local PEM private key file paths. Preferred when multiple keys should be used."
282
- ),
283
- pemPath: z
284
- .string()
285
- .min(1)
286
- .optional()
287
- .describe(
288
- "Optional single PEM path. Ignored when pemPaths is provided. If omitted, CLI auto-discovers .pem files in workingDirectory/current directory."
289
- ),
290
- profiles: z
291
- .array(z.string().min(1))
292
- .optional()
293
- .describe("Optional AWS profile names to scan."),
294
- regions: z
295
- .array(z.string().min(1))
296
- .optional()
297
- .describe("Optional AWS regions to scan."),
298
- keyName: z
299
- .string()
300
- .min(1)
301
- .optional()
302
- .describe("Optional EC2 KeyPair name fallback for matching."),
303
- sshCheck: z
304
- .boolean()
305
- .optional()
306
- .describe("If true, attempt SSH authentication checks."),
307
- sshUsernames: z
308
- .array(z.string().min(1))
309
- .optional()
310
- .describe("Optional SSH usernames to try when sshCheck=true."),
311
- sshTimeoutSec: z
312
- .number()
313
- .positive()
314
- .optional()
315
- .describe("SSH timeout in seconds."),
316
- matchedOnly: z
317
- .boolean()
318
- .optional()
319
- .describe("If true, return only records matched to the PEM."),
320
- noProgress: z
321
- .boolean()
322
- .optional()
323
- .describe("Suppress CLI step progress logs (defaults to true in the MCP wrapper)."),
324
- timeoutSec: z
325
- .number()
326
- .positive()
327
- .max(3600)
328
- .optional()
329
- .describe("Wrapper timeout for the CLI process (default 300s)."),
330
- workingDirectory: z
331
- .string()
332
- .min(1)
333
- .optional()
334
- .describe(
335
- "Optional working directory. Used for relative PEM path resolution and auto .pem discovery when pemPath/pemPaths are omitted."
336
- ),
337
- maxRecords: z
338
- .number()
339
- .int()
340
- .positive()
341
- .max(10000)
342
- .optional()
343
- .describe("Optional max number of records to include in the MCP response."),
344
- includeStderr: z
345
- .boolean()
346
- .optional()
347
- .describe("If true, include CLI stderr logs in the response (truncated).")
348
- }
292
+ inputSchema: toolSchema()
349
293
  },
350
294
  async (args) => {
351
295
  const startedAt = new Date().toISOString();
@@ -354,12 +298,7 @@ async function registerTools(server) {
354
298
 
355
299
  if (cliResult.spawnError) {
356
300
  return {
357
- content: [
358
- {
359
- type: "text",
360
- text: `Failed to start CLI subprocess: ${cliResult.spawnError}`
361
- }
362
- ],
301
+ content: [{ type: "text", text: `Failed to start CLI subprocess: ${cliResult.spawnError}` }],
363
302
  isError: true
364
303
  };
365
304
  }
@@ -373,8 +312,7 @@ async function registerTools(server) {
373
312
  ok: false,
374
313
  error: "CLI process timed out",
375
314
  startedAt,
376
- timeoutSec:
377
- typeof args.timeoutSec === "number" ? args.timeoutSec : DEFAULT_TIMEOUT_SEC,
315
+ timeoutSec: typeof args.timeoutSec === "number" ? args.timeoutSec : DEFAULT_TIMEOUT_SEC,
378
316
  exitCode: cliResult.exitCode,
379
317
  signal: cliResult.signal,
380
318
  stderr: truncateText(cliResult.stderr)
@@ -411,17 +349,19 @@ async function registerTools(server) {
411
349
  typeof args.maxRecords === "number" && Number.isFinite(args.maxRecords)
412
350
  ? args.maxRecords
413
351
  : null;
414
- const records =
415
- maxRecords && allRecords.length > maxRecords
416
- ? allRecords.slice(0, maxRecords)
417
- : allRecords;
352
+ const records = maxRecords && allRecords.length > maxRecords ? allRecords.slice(0, maxRecords) : allRecords;
353
+
354
+ const acceptedExitCodes = new Set([0, 2, 3]);
355
+ const requiresUserAction = logInfo.requiredActions.length > 0 || cliResult.exitCode === 3;
418
356
 
419
357
  const response = {
420
- ok: cliResult.exitCode === 0 || cliResult.exitCode === 2,
358
+ ok: acceptedExitCodes.has(cliResult.exitCode),
421
359
  startedAt,
422
360
  finishedAt: new Date().toISOString(),
423
361
  exitCode: cliResult.exitCode,
424
362
  signal: cliResult.signal,
363
+ requiresUserAction,
364
+ requiredActions: logInfo.requiredActions,
425
365
  summary: {
426
366
  ...summarizeRecords(allRecords),
427
367
  returnedRecords: records.length,
@@ -436,39 +376,41 @@ async function registerTools(server) {
436
376
  response.stderr = truncateText(cliResult.stderr);
437
377
  }
438
378
 
439
- if (cliResult.exitCode !== 0 && cliResult.exitCode !== 2) {
379
+ if (!acceptedExitCodes.has(cliResult.exitCode)) {
440
380
  return {
441
- content: [
442
- {
443
- type: "text",
444
- text: buildToolTextResponse({
445
- ...response,
446
- ok: false,
447
- stderr: truncateText(cliResult.stderr)
448
- })
449
- }
450
- ],
381
+ content: [{ type: "text", text: buildToolTextResponse({ ...response, ok: false, stderr: truncateText(cliResult.stderr) }) }],
451
382
  isError: true
452
383
  };
453
384
  }
454
385
 
455
386
  return {
456
- content: [
457
- {
458
- type: "text",
459
- text: buildToolTextResponse(response)
460
- }
461
- ]
387
+ content: [{ type: "text", text: buildToolTextResponse(response) }],
388
+ isError: false
462
389
  };
463
390
  }
464
391
  );
392
+ }
393
+
394
+ async function registerTools(server) {
395
+ registerDiscoverTool(
396
+ server,
397
+ "discover_ec2_with_ssm",
398
+ "Discover EC2 + SSM Inventory",
399
+ "Runs mcp-aws-manager in SSM-only mode and returns EC2 inventory with SSM management/online status and optional runtime snapshots."
400
+ );
401
+
402
+ registerDiscoverTool(
403
+ server,
404
+ "discover_public_ec2_with_pem",
405
+ "Discover EC2 + SSM Inventory (compat alias)",
406
+ "Compatibility alias. Internally runs the same SSM-only discovery flow."
407
+ );
465
408
 
466
409
  server.registerTool(
467
410
  "mcp_aws_discover_cli_help",
468
411
  {
469
412
  title: "Show CLI Help",
470
- description:
471
- "Returns the local mcp-aws-manager CLI usage text for reference inside AI clients.",
413
+ description: "Returns the local mcp-aws-manager CLI usage text.",
472
414
  annotations: {
473
415
  readOnlyHint: true,
474
416
  destructiveHint: false,
@@ -483,30 +425,27 @@ async function registerTools(server) {
483
425
  env: process.env,
484
426
  stdio: ["ignore", "pipe", "pipe"]
485
427
  });
428
+
486
429
  let stdout = "";
487
430
  let stderr = "";
488
431
  if (proc.stdout) {
489
432
  proc.stdout.setEncoding("utf8");
490
- proc.stdout.on("data", (c) => (stdout += c));
433
+ proc.stdout.on("data", (c) => { stdout += c; });
491
434
  }
492
435
  if (proc.stderr) {
493
436
  proc.stderr.setEncoding("utf8");
494
- proc.stderr.on("data", (c) => (stderr += c));
437
+ proc.stderr.on("data", (c) => { stderr += c; });
495
438
  }
496
- proc.on("close", (code, signal) =>
497
- resolve({ code: code == null ? null : code, signal, stdout, stderr })
498
- );
499
- proc.on("error", (error) =>
500
- resolve({
501
- code: null,
502
- signal: null,
503
- stdout,
504
- stderr: String(error && error.message ? error.message : error)
505
- })
506
- );
439
+
440
+ proc.on("close", (code, signal) => {
441
+ resolve({ code: code == null ? null : code, signal, stdout, stderr });
442
+ });
443
+ proc.on("error", (error) => {
444
+ resolve({ code: null, signal: null, stdout, stderr: String(error && error.message ? error.message : error) });
445
+ });
507
446
  });
508
447
 
509
- const result = {
448
+ const payload = {
510
449
  ok: child.code === 0,
511
450
  exitCode: child.code,
512
451
  signal: child.signal || null,
@@ -515,12 +454,7 @@ async function registerTools(server) {
515
454
  };
516
455
 
517
456
  return {
518
- content: [
519
- {
520
- type: "text",
521
- text: buildToolTextResponse(result)
522
- }
523
- ],
457
+ content: [{ type: "text", text: buildToolTextResponse(payload) }],
524
458
  isError: child.code !== 0
525
459
  };
526
460
  }
@@ -539,6 +473,7 @@ async function main() {
539
473
  });
540
474
 
541
475
  await registerTools(server);
476
+
542
477
  const transport = new StdioServerTransport();
543
478
  await server.connect(transport);
544
479
  }
@@ -547,4 +482,4 @@ main().catch((error) => {
547
482
  const message = error && error.stack ? error.stack : String(error);
548
483
  process.stderr.write(`mcp-aws-manager-mcp startup error: ${message}\n`);
549
484
  process.exitCode = 1;
550
- });
485
+ });