mcp-aws-manager 0.1.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.
@@ -0,0 +1,550 @@
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
+ "Starts an MCP server that exposes a discover tool for AI clients.",
25
+ "",
26
+ "Usage:",
27
+ " mcp-aws-manager-mcp",
28
+ " mcp-aws-manager-mcp --help",
29
+ "",
30
+ "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).",
34
+ ""
35
+ ].join("\n");
36
+ }
37
+
38
+ function shouldShowHelp(argv) {
39
+ return argv.includes("-h") || argv.includes("--help");
40
+ }
41
+
42
+ function toCsvArg(values) {
43
+ if (!Array.isArray(values) || !values.length) {
44
+ return null;
45
+ }
46
+ return values.map((v) => String(v).trim()).filter(Boolean).join(",");
47
+ }
48
+
49
+ function truncateText(value, maxLength = DEFAULT_STDIO_TEXT_LIMIT) {
50
+ const text = String(value || "");
51
+ if (text.length <= maxLength) {
52
+ return text;
53
+ }
54
+ const suffix = `\n... [truncated ${text.length - maxLength} chars]`;
55
+ return text.slice(0, maxLength) + suffix;
56
+ }
57
+
58
+ function parseStderr(stderrText) {
59
+ const lines = String(stderrText || "")
60
+ .split(/\r?\n/)
61
+ .map((line) => line.trim())
62
+ .filter(Boolean);
63
+
64
+ const warnings = lines.filter((line) => line.startsWith("WARNING: "));
65
+ const summaryLine = lines.find((line) => line.startsWith("Summary: ")) || null;
66
+
67
+ return {
68
+ lines,
69
+ warnings,
70
+ summaryLine
71
+ };
72
+ }
73
+
74
+ function buildCliArgs(input) {
75
+ const args = [CLI_SCRIPT_PATH, "--format", "json"];
76
+
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
+ const profiles = toCsvArg(input.profiles);
85
+ if (profiles) {
86
+ args.push("--profiles", profiles);
87
+ }
88
+
89
+ const regions = toCsvArg(input.regions);
90
+ if (regions) {
91
+ args.push("--regions", regions);
92
+ }
93
+
94
+ if (input.keyName) {
95
+ args.push("--key-name", input.keyName);
96
+ }
97
+
98
+ if (input.sshCheck) {
99
+ args.push("--ssh-check");
100
+ }
101
+
102
+ const sshUsernames = toCsvArg(input.sshUsernames);
103
+ if (sshUsernames) {
104
+ args.push("--ssh-usernames", sshUsernames);
105
+ }
106
+
107
+ if (input.sshTimeoutSec != null) {
108
+ args.push("--ssh-timeout", String(input.sshTimeoutSec));
109
+ }
110
+
111
+ if (input.matchedOnly) {
112
+ args.push("--matched-only");
113
+ }
114
+
115
+ if (input.noProgress !== false) {
116
+ args.push("--no-progress");
117
+ }
118
+
119
+ return args;
120
+ }
121
+
122
+ function runDiscoverCli(input) {
123
+ const timeoutSec =
124
+ typeof input.timeoutSec === "number" && Number.isFinite(input.timeoutSec)
125
+ ? input.timeoutSec
126
+ : DEFAULT_TIMEOUT_SEC;
127
+ const timeoutMs = Math.max(1, Math.round(timeoutSec * 1000));
128
+
129
+ return new Promise((resolve) => {
130
+ const child = spawn(process.execPath, buildCliArgs(input), {
131
+ cwd: input.workingDirectory || process.cwd(),
132
+ env: process.env,
133
+ stdio: ["ignore", "pipe", "pipe"]
134
+ });
135
+
136
+ let stdout = "";
137
+ let stderr = "";
138
+ let timedOut = false;
139
+ let exited = false;
140
+
141
+ if (child.stdout) {
142
+ child.stdout.setEncoding("utf8");
143
+ child.stdout.on("data", (chunk) => {
144
+ stdout += chunk;
145
+ });
146
+ }
147
+
148
+ if (child.stderr) {
149
+ child.stderr.setEncoding("utf8");
150
+ child.stderr.on("data", (chunk) => {
151
+ stderr += chunk;
152
+ });
153
+ }
154
+
155
+ const timer = setTimeout(() => {
156
+ if (exited) {
157
+ return;
158
+ }
159
+ timedOut = true;
160
+ try {
161
+ child.kill("SIGTERM");
162
+ } catch {}
163
+ setTimeout(() => {
164
+ if (!exited) {
165
+ try {
166
+ child.kill("SIGKILL");
167
+ } catch {}
168
+ }
169
+ }, 3000).unref();
170
+ }, timeoutMs);
171
+ timer.unref();
172
+
173
+ child.on("error", (error) => {
174
+ clearTimeout(timer);
175
+ exited = true;
176
+ resolve({
177
+ ok: false,
178
+ exitCode: null,
179
+ signal: null,
180
+ stdout,
181
+ stderr,
182
+ timedOut,
183
+ spawnError: error && error.message ? error.message : String(error)
184
+ });
185
+ });
186
+
187
+ child.on("close", (code, signal) => {
188
+ clearTimeout(timer);
189
+ exited = true;
190
+ resolve({
191
+ ok: true,
192
+ exitCode: typeof code === "number" ? code : null,
193
+ signal: signal || null,
194
+ stdout,
195
+ stderr,
196
+ timedOut,
197
+ spawnError: null
198
+ });
199
+ });
200
+ });
201
+ }
202
+
203
+ function tryParseJsonArray(text) {
204
+ const trimmed = String(text || "").trim();
205
+ if (!trimmed) {
206
+ return { ok: false, error: "Empty stdout" };
207
+ }
208
+ try {
209
+ const parsed = JSON.parse(trimmed);
210
+ if (!Array.isArray(parsed)) {
211
+ return { ok: false, error: "CLI JSON output was not an array" };
212
+ }
213
+ return { ok: true, value: parsed };
214
+ } catch (error) {
215
+ return {
216
+ ok: false,
217
+ error: error && error.message ? error.message : String(error)
218
+ };
219
+ }
220
+ }
221
+
222
+ function summarizeRecords(records) {
223
+ const summary = {
224
+ totalRecords: Array.isArray(records) ? records.length : 0,
225
+ matchedCount: 0,
226
+ sshCheckedCount: 0,
227
+ sshReachableCount: 0,
228
+ profiles: [],
229
+ regions: []
230
+ };
231
+
232
+ const profiles = new Set();
233
+ const regions = new Set();
234
+
235
+ 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));
250
+ }
251
+ }
252
+
253
+ summary.profiles = Array.from(profiles).sort();
254
+ summary.regions = Array.from(regions).sort();
255
+ return summary;
256
+ }
257
+
258
+ function buildToolTextResponse(payload) {
259
+ const text = JSON.stringify(payload, null, 2);
260
+ return truncateText(text, DEFAULT_JSON_TEXT_LIMIT);
261
+ }
262
+
263
+ async function registerTools(server) {
264
+ server.registerTool(
265
+ "discover_public_ec2_with_pem",
266
+ {
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.",
270
+ annotations: {
271
+ readOnlyHint: true,
272
+ destructiveHint: false,
273
+ idempotentHint: true,
274
+ openWorldHint: true
275
+ },
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
+ }
349
+ },
350
+ async (args) => {
351
+ const startedAt = new Date().toISOString();
352
+ const cliResult = await runDiscoverCli(args);
353
+ const logInfo = parseStderr(cliResult.stderr);
354
+
355
+ if (cliResult.spawnError) {
356
+ return {
357
+ content: [
358
+ {
359
+ type: "text",
360
+ text: `Failed to start CLI subprocess: ${cliResult.spawnError}`
361
+ }
362
+ ],
363
+ isError: true
364
+ };
365
+ }
366
+
367
+ if (cliResult.timedOut) {
368
+ return {
369
+ content: [
370
+ {
371
+ type: "text",
372
+ text: buildToolTextResponse({
373
+ ok: false,
374
+ error: "CLI process timed out",
375
+ startedAt,
376
+ timeoutSec:
377
+ typeof args.timeoutSec === "number" ? args.timeoutSec : DEFAULT_TIMEOUT_SEC,
378
+ exitCode: cliResult.exitCode,
379
+ signal: cliResult.signal,
380
+ stderr: truncateText(cliResult.stderr)
381
+ })
382
+ }
383
+ ],
384
+ isError: true
385
+ };
386
+ }
387
+
388
+ const parsed = tryParseJsonArray(cliResult.stdout);
389
+ if (!parsed.ok) {
390
+ return {
391
+ content: [
392
+ {
393
+ type: "text",
394
+ text: buildToolTextResponse({
395
+ ok: false,
396
+ error: "Failed to parse CLI JSON output",
397
+ parseError: parsed.error,
398
+ exitCode: cliResult.exitCode,
399
+ signal: cliResult.signal,
400
+ stderr: truncateText(cliResult.stderr),
401
+ stdout: truncateText(cliResult.stdout)
402
+ })
403
+ }
404
+ ],
405
+ isError: true
406
+ };
407
+ }
408
+
409
+ const allRecords = parsed.value;
410
+ const maxRecords =
411
+ typeof args.maxRecords === "number" && Number.isFinite(args.maxRecords)
412
+ ? args.maxRecords
413
+ : null;
414
+ const records =
415
+ maxRecords && allRecords.length > maxRecords
416
+ ? allRecords.slice(0, maxRecords)
417
+ : allRecords;
418
+
419
+ const response = {
420
+ ok: cliResult.exitCode === 0 || cliResult.exitCode === 2,
421
+ startedAt,
422
+ finishedAt: new Date().toISOString(),
423
+ exitCode: cliResult.exitCode,
424
+ signal: cliResult.signal,
425
+ summary: {
426
+ ...summarizeRecords(allRecords),
427
+ returnedRecords: records.length,
428
+ truncated: records.length !== allRecords.length,
429
+ warningCount: logInfo.warnings.length,
430
+ summaryLine: logInfo.summaryLine
431
+ },
432
+ records
433
+ };
434
+
435
+ if (args.includeStderr) {
436
+ response.stderr = truncateText(cliResult.stderr);
437
+ }
438
+
439
+ if (cliResult.exitCode !== 0 && cliResult.exitCode !== 2) {
440
+ return {
441
+ content: [
442
+ {
443
+ type: "text",
444
+ text: buildToolTextResponse({
445
+ ...response,
446
+ ok: false,
447
+ stderr: truncateText(cliResult.stderr)
448
+ })
449
+ }
450
+ ],
451
+ isError: true
452
+ };
453
+ }
454
+
455
+ return {
456
+ content: [
457
+ {
458
+ type: "text",
459
+ text: buildToolTextResponse(response)
460
+ }
461
+ ]
462
+ };
463
+ }
464
+ );
465
+
466
+ server.registerTool(
467
+ "mcp_aws_discover_cli_help",
468
+ {
469
+ title: "Show CLI Help",
470
+ description:
471
+ "Returns the local mcp-aws-manager CLI usage text for reference inside AI clients.",
472
+ annotations: {
473
+ readOnlyHint: true,
474
+ destructiveHint: false,
475
+ idempotentHint: true,
476
+ openWorldHint: false
477
+ }
478
+ },
479
+ async () => {
480
+ const child = await new Promise((resolve) => {
481
+ const proc = spawn(process.execPath, [CLI_SCRIPT_PATH, "--help"], {
482
+ cwd: process.cwd(),
483
+ env: process.env,
484
+ stdio: ["ignore", "pipe", "pipe"]
485
+ });
486
+ let stdout = "";
487
+ let stderr = "";
488
+ if (proc.stdout) {
489
+ proc.stdout.setEncoding("utf8");
490
+ proc.stdout.on("data", (c) => (stdout += c));
491
+ }
492
+ if (proc.stderr) {
493
+ proc.stderr.setEncoding("utf8");
494
+ proc.stderr.on("data", (c) => (stderr += c));
495
+ }
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
+ );
507
+ });
508
+
509
+ const result = {
510
+ ok: child.code === 0,
511
+ exitCode: child.code,
512
+ signal: child.signal || null,
513
+ stdout: truncateText(child.stdout, DEFAULT_JSON_TEXT_LIMIT),
514
+ stderr: truncateText(child.stderr)
515
+ };
516
+
517
+ return {
518
+ content: [
519
+ {
520
+ type: "text",
521
+ text: buildToolTextResponse(result)
522
+ }
523
+ ],
524
+ isError: child.code !== 0
525
+ };
526
+ }
527
+ );
528
+ }
529
+
530
+ async function main() {
531
+ if (shouldShowHelp(process.argv.slice(2))) {
532
+ process.stdout.write(usageText());
533
+ return;
534
+ }
535
+
536
+ const server = new McpServer({
537
+ name: "mcp-aws-manager",
538
+ version: pkg.version
539
+ });
540
+
541
+ await registerTools(server);
542
+ const transport = new StdioServerTransport();
543
+ await server.connect(transport);
544
+ }
545
+
546
+ main().catch((error) => {
547
+ const message = error && error.stack ? error.stack : String(error);
548
+ process.stderr.write(`mcp-aws-manager-mcp startup error: ${message}\n`);
549
+ process.exitCode = 1;
550
+ });