sample-aws-pricing-calculator-mcp 1.0.1

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.
package/mcp-server.js ADDED
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env node
2
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3
+ // SPDX-License-Identifier: MIT-0
4
+ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
5
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
6
+ const { z } = require('zod');
7
+ const { PARTITIONS, loadManifest, findService, fetchServiceDefinition, extractInputFields, enrichFieldsWithMetadata, searchServices, fetchEstimate, estimateToMarkdown } = require('./lib/aws-client');
8
+ const EstimateBuilder = require('./lib/estimate-builder');
9
+
10
+ const estimates = new Map();
11
+
12
+ const META_KEYS = new Set(['region', 'description']);
13
+
14
+ function levenshtein(a, b) {
15
+ const m = a.length, n = b.length;
16
+ const d = Array.from({ length: m + 1 }, (_, i) => i);
17
+ for (let j = 1; j <= n; j++) {
18
+ let prev = d[0];
19
+ d[0] = j;
20
+ for (let i = 1; i <= m; i++) {
21
+ const tmp = d[i];
22
+ d[i] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, d[i], d[i - 1]);
23
+ prev = tmp;
24
+ }
25
+ }
26
+ return d[m];
27
+ }
28
+
29
+ function suggestMatch(invalid, validIds, max = 3) {
30
+ const lower = invalid.toLowerCase();
31
+ return validIds
32
+ .map(id => ({ id, dist: levenshtein(lower, id.toLowerCase()) }))
33
+ .filter(m => m.dist <= Math.max(Math.floor(invalid.length * 0.6), 3))
34
+ .sort((a, b) => a.dist - b.dist)
35
+ .slice(0, max)
36
+ .map(m => m.id);
37
+ }
38
+
39
+ async function validateConfigKeys(serviceKey, config, partition) {
40
+ if (serviceKey.toLowerCase() === 'ec2enhancement') return null;
41
+
42
+ const configKeys = Object.keys(config).filter(k => !META_KEYS.has(k));
43
+ if (configKeys.length === 0) return null;
44
+
45
+ try {
46
+ const manifest = await loadManifest(partition || 'aws');
47
+ const svc = findService(manifest, serviceKey);
48
+ if (!svc) return null; // service not found — will fail later at export
49
+
50
+ const def = await fetchServiceDefinition(manifest, svc.key, partition || 'aws');
51
+ if (!def) return null;
52
+
53
+ const fields = extractInputFields(def);
54
+ const validIds = fields.map(f => f.id);
55
+ const validSet = new Set(validIds);
56
+ const invalid = configKeys.filter(k => !validSet.has(k));
57
+ if (invalid.length > 0) {
58
+ const lines = invalid.map(k => {
59
+ const suggestions = suggestMatch(k, validIds);
60
+ return suggestions.length
61
+ ? ` "${k}" — did you mean: ${suggestions.map(s => `"${s}"`).join(', ')}?`
62
+ : ` "${k}" — no close match found`;
63
+ });
64
+ return `Invalid field IDs for ${svc.key}:\n${lines.join('\n')}\nUse get_service_fields to discover valid field IDs.`;
65
+ }
66
+
67
+ // Validate selector values within columnFormIPM fields
68
+ const enriched = await enrichFieldsWithMetadata(def, fields);
69
+ const selectorErrors = [];
70
+ for (const field of enriched) {
71
+ if (field.type !== 'columnFormIPM' || !field.selectorValues) continue;
72
+ const configVal = config[field.id];
73
+ if (!configVal || !configVal.value || !Array.isArray(configVal.value)) continue;
74
+
75
+ for (const row of configVal.value) {
76
+ for (const [selectorId, allowedValues] of Object.entries(field.selectorValues)) {
77
+ if (!allowedValues || allowedValues.length === 0) continue;
78
+ const cell = row[selectorId];
79
+ if (!cell) continue;
80
+ const cellValue = typeof cell === 'object' && cell.value !== undefined ? cell.value : cell;
81
+ if (typeof cellValue !== 'string') continue;
82
+ if (!allowedValues.includes(cellValue)) {
83
+ const suggestions = suggestMatch(cellValue, allowedValues);
84
+ const hint = suggestions.length
85
+ ? ` Did you mean: ${suggestions.map(s => `"${s}"`).join(', ')}?`
86
+ : '';
87
+ selectorErrors.push(` Field "${field.id}", selector "${selectorId}": "${cellValue}" is not valid.${hint}\n Allowed: ${allowedValues.slice(0, 10).join(', ')}${allowedValues.length > 10 ? ` ... (${allowedValues.length} total)` : ''}`);
88
+ }
89
+ }
90
+ }
91
+ }
92
+ if (selectorErrors.length > 0) {
93
+ return `Invalid selector values for ${svc.key}:\n${selectorErrors.join('\n')}`;
94
+ }
95
+
96
+ return null;
97
+ } catch {
98
+ return null; // validation is best-effort; don't block on fetch failures
99
+ }
100
+ }
101
+
102
+ const pkg = require('./package.json');
103
+
104
+ const server = new McpServer({
105
+ name: pkg.name,
106
+ version: pkg.version,
107
+ });
108
+
109
+ server.tool(
110
+ 'get_server_info',
111
+ 'Get version and capability information about this MCP server.',
112
+ {},
113
+ async () => {
114
+ return {
115
+ content: [{
116
+ type: 'text',
117
+ text: JSON.stringify({
118
+ name: pkg.name,
119
+ version: pkg.version,
120
+ description: pkg.description,
121
+ tools: ['search_services', 'get_service_fields', 'create_estimate', 'add_service', 'export_estimate', 'import_estimate', 'get_server_info'],
122
+ partitions: Object.keys(PARTITIONS),
123
+ }, null, 2),
124
+ }],
125
+ };
126
+ }
127
+ );
128
+
129
+ server.tool(
130
+ 'search_services',
131
+ 'Search AWS services available in the calculator. Returns service keys and names. Use this to find the correct service key before adding it to an estimate. Supports multiple comma-separated search terms in a single call (e.g. "Lambda, S3, API Gateway, CloudWatch").',
132
+ {
133
+ query: z.string().describe('One or more search terms, comma-separated (e.g. "Lambda, S3, Amazon Personalize, API Gateway, CloudWatch")'),
134
+ partition: z.string().optional().describe('AWS partition to search in (default: "aws"). Valid values: "aws", "aws-iso", "aws-iso-b"'),
135
+ },
136
+ async ({ query, partition }) => {
137
+ const p = partition || 'aws';
138
+ if (!PARTITIONS[p]) {
139
+ return { content: [{ type: 'text', text: `Unknown partition '${p}'. Valid partitions: ${Object.keys(PARTITIONS).join(', ')}` }], isError: true };
140
+ }
141
+ const manifest = await loadManifest(p);
142
+ const results = searchServices(manifest, query);
143
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
144
+ }
145
+ );
146
+
147
+ server.tool(
148
+ 'get_service_fields',
149
+ 'Get the input fields for one or more AWS services. Returns field IDs, types, labels, and valid options. Use this to discover what configuration a service accepts before adding it to an estimate. The field IDs returned here are the exact keys to use in add_service config. Accepts multiple comma-separated service keys. IMPORTANT: When duplicate fields exist with version suffixes (e.g. fieldName and fieldName_v2), ALWAYS use the highest version — it maps to the latest configuration path. Ignore lower versions.',
150
+ { service: z.string().describe('One or more service keys, comma-separated (e.g. "aWSLambda, amazonS3, stepFunctionStandard, amazonApiGateway")'),
151
+ partition: z.string().optional().describe('AWS partition to fetch from (default: "aws"). Valid values: "aws", "aws-iso", "aws-iso-b"'),
152
+ },
153
+ async ({ service, partition }) => {
154
+ const p = partition || 'aws';
155
+ if (!PARTITIONS[p]) {
156
+ return { content: [{ type: 'text', text: `Unknown partition '${p}'. Valid partitions: ${Object.keys(PARTITIONS).join(', ')}` }], isError: true };
157
+ }
158
+ const manifest = await loadManifest(p);
159
+ const keys = service.split(',').map(s => s.trim()).filter(Boolean);
160
+ const results = [];
161
+ const errors = [];
162
+
163
+ for (const key of keys) {
164
+ const svc = findService(manifest, key);
165
+ if (!svc) { errors.push(`Service "${key}" not found.`); continue; }
166
+
167
+ const definition = await fetchServiceDefinition(manifest, svc.key, p);
168
+ if (!definition) { errors.push(`Failed to fetch definition for "${svc.key}".`); continue; }
169
+
170
+ const fields = extractInputFields(definition);
171
+ const enriched = await enrichFieldsWithMetadata(definition, fields);
172
+ results.push({ serviceCode: svc.key, serviceName: svc.name, fields: enriched });
173
+ }
174
+
175
+ const output = errors.length
176
+ ? { services: results, errors }
177
+ : keys.length === 1 ? results[0] : results;
178
+ return { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }] };
179
+ }
180
+ );
181
+
182
+ server.tool(
183
+ 'create_estimate',
184
+ 'Create a new empty estimate. Returns an estimate ID to use with add_service and export_estimate.',
185
+ {
186
+ name: z.string().optional().describe('Name for the estimate (default: "My Estimate")'),
187
+ partition: z.string().optional().describe('AWS partition for this estimate (default: "aws"). Valid values: "aws", "aws-iso", "aws-iso-b"'),
188
+ },
189
+ async ({ name, partition }) => {
190
+ const p = partition || undefined;
191
+ if (p && !PARTITIONS[p]) {
192
+ return { content: [{ type: 'text', text: `Unknown partition '${p}'. Valid partitions: ${Object.keys(PARTITIONS).join(', ')}` }], isError: true };
193
+ }
194
+ const estimate = new EstimateBuilder(name, p);
195
+ estimates.set(estimate.id, estimate);
196
+ return { content: [{ type: 'text', text: JSON.stringify({ estimate_id: estimate.id, name: estimate.name }) }] };
197
+ }
198
+ );
199
+
200
+ server.tool(
201
+ 'add_service',
202
+ `Add one or more AWS services to an estimate. Accepts a single service or a JSON array of services in the "services" parameter.
203
+
204
+ Field values follow these patterns based on field type:
205
+ - numericInput: plain string value, e.g. "1000"
206
+ - frequency: object with value and unit, e.g. {"value": "19", "unit": "millionPerMonth"}
207
+ - fileSize: object with value and unit. The unit format is "{size}|{frequency}" where size comes from the field's validSizes (gb, tb, mb, etc.) and frequency is usually "NA". Check the field's defaultUnit from get_service_fields. Examples: {"value": "512", "unit": "mb|NA"}, {"value": "1", "unit": "tb|NA"}, {"value": "10", "unit": "gb|NA"}, {"value": "8", "unit": "gb|month"}
208
+ - dropdown: string matching one of the option IDs from get_service_fields
209
+ - durationInput: object with value and unit, e.g. {"value": "960", "unit": "min"}
210
+ - pricingStrategy (Amazon EC2 only): object with model, term, and upfrontPayment keys, e.g. {"model": "computeSavings", "term": "1yr", "upfrontPayment": "None"}. Valid models: "instanceSavings" (EC2 Instance Savings Plans), "computeSavings" (Compute Savings Plans), "ondemand", "spot". For dedicated tenancy only: "reserved" (Standard RI), "convertible" (Convertible RI). Valid terms: "1yr", "3yr". Valid upfrontPayment: "None", "Partial", "All". Shorthand strings also work, e.g. "computeSavings1yrNoUpfront".
211
+
212
+ Amazon EC2 (ec2Enhancement) has special config fields handled automatically:
213
+ - "quantity": number of instances (e.g. "2" for 2 instances). Default: 1.
214
+ - "instanceType": instance type (e.g. "g6.12xlarge")
215
+ - "selectedOS": operating system. Default: "linux". Options: "linux", "windows", "rhel", "suse", etc.
216
+ - "tenancy": "shared" (default), "dedicated", or "host"
217
+ - "pricingStrategy": see above
218
+ - "storageType": EBS volume type. Default: "Storage General Purpose gp3 GB Mo". Options: "Storage General Purpose gp3 GB Mo" (gp3), "Storage General Purpose GB Mo" (gp2), "Storage Provisioned IOPS GB Mo" (io1), "Storage Provisioned IOPS io2 GB month" (io2), "Storage Throughput Optimized HDD GB Mo" (st1), "Storage Cold HDD GB Mo" (sc1), "Storage Magnetic GB Mo" (magnetic)
219
+ - "storageAmount": EBS storage, e.g. {"value": "30", "unit": "gb|NA"}
220
+ - "snapshotFrequency": snapshot frequency, e.g. "0" for none
221
+ - "gp3Iops": gp3 provisioned IOPS (e.g. "5000"). Auto-sets storageType to gp3 if not specified.
222
+ - "gp3Throughput": gp3 provisioned throughput in MBps (e.g. "250"). Auto-sets storageType to gp3 if not specified.
223
+ - "iops": io1 provisioned IOPS (e.g. "10000"). Auto-sets storageType to io1 if not specified.
224
+ - "iops2": io2 provisioned IOPS (e.g. "20000"). Auto-sets storageType to io2 if not specified.
225
+ - "storageAmountIo2": io2 storage amount, e.g. {"value": "100", "unit": "gb|NA"}
226
+ Do NOT use get_service_fields for Amazon EC2 — these fields are handled by a custom transform.
227
+
228
+ IMPORTANT: Before calling this tool, you MUST confirm the desired AWS region with the user if they haven't already specified one. Do NOT assume a default region. Always include "region" in each service config. Use "description" to label what each service entry represents. IMPORTANT: descriptions and group names must NOT contain <, >, or & characters (AWS rejects them).
229
+
230
+ Config keys are validated against the service definition. Invalid field IDs will be rejected with suggested corrections. Use get_service_fields first to discover valid field IDs for a service.
231
+
232
+ For batch mode, pass a JSON array in "services":
233
+ [{"service":"aWSLambda","instance":"Compute","group":"Prod","config":{...}},{"service":"amazonS3Standard","group":"Prod","config":{...}}]`,
234
+ {
235
+ estimate_id: z.string().describe('Estimate ID from create_estimate'),
236
+ services: z.string().describe('JSON array of service entries. Each entry: {"service":"serviceKey","instance":"optional","group":"optional","config":{...with region, description, and field values}}. Example: [{"service":"aWSLambda","group":"Prod","config":{"region":"eu-west-1","description":"Compute","numberOfRequests":{"value":"19","unit":"millionPerMonth"}}}]'),
237
+ },
238
+ async ({ estimate_id, services: servicesStr }) => {
239
+ const estimate = estimates.get(estimate_id);
240
+ if (!estimate) return { content: [{ type: 'text', text: `Estimate "${estimate_id}" not found.` }], isError: true };
241
+
242
+ let entries;
243
+ try {
244
+ entries = JSON.parse(servicesStr);
245
+ if (!Array.isArray(entries)) entries = [entries];
246
+ } catch {
247
+ return { content: [{ type: 'text', text: 'Invalid JSON in services parameter.' }], isError: true };
248
+ }
249
+
250
+ const results = [];
251
+ for (const entry of entries) {
252
+ const { service, instance, group } = entry;
253
+ let config = entry.config;
254
+ if (!service || !config) {
255
+ results.push({ error: 'Missing "service" or "config" in entry', entry });
256
+ continue;
257
+ }
258
+ // Handle config passed as JSON string within the array
259
+ if (typeof config === 'string') {
260
+ try { config = JSON.parse(config); } catch {
261
+ results.push({ error: 'Invalid JSON in config', service });
262
+ continue;
263
+ }
264
+ }
265
+ const key = instance ? `${service}:${instance}` : service;
266
+ const validationError = await validateConfigKeys(service, config, estimate.partition);
267
+ if (validationError) {
268
+ results.push({ error: validationError, service: key });
269
+ continue;
270
+ }
271
+ estimate.addService(key, config, { group });
272
+ results.push({ success: true, service: key, group: group || '(ungrouped)' });
273
+ }
274
+ return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
275
+ }
276
+ );
277
+
278
+ server.tool(
279
+ 'export_estimate',
280
+ 'Export an estimate to calculator.aws and get a shareable URL. The link will show the full estimate with AWS-calculated pricing.',
281
+ { estimate_id: z.string().describe('Estimate ID from create_estimate') },
282
+ async ({ estimate_id }) => {
283
+ const estimate = estimates.get(estimate_id);
284
+ if (!estimate) return { content: [{ type: 'text', text: `Estimate "${estimate_id}" not found.` }], isError: true };
285
+
286
+ try {
287
+ const result = await estimate.export();
288
+ return { content: [{ type: 'text', text: JSON.stringify({ sharable_url: result.shareableUrl, aws_estimate_id: result.estimateId }) }] };
289
+ } catch (err) {
290
+ return { content: [{ type: 'text', text: `Export failed: ${err.message}` }], isError: true };
291
+ }
292
+ }
293
+ );
294
+
295
+ server.tool(
296
+ 'import_estimate',
297
+ 'Download an existing AWS Pricing Calculator estimate by URL or ID. Returns the estimate in JSON (raw, for modifications like region swaps) or Markdown (for LLM consumption, summaries, funding recommendations).',
298
+ {
299
+ estimate_id: z.string().describe('Estimate ID or full calculator.aws URL (e.g. "bedb9a10..." or "https://calculator.aws/#/estimate?id=bedb9a10...")'),
300
+ format: z.enum(['json', 'markdown']).optional().describe('Output format: "json" for raw data (default), "markdown" for LLM-friendly summary'),
301
+ },
302
+ async ({ estimate_id, format }) => {
303
+ // Extract ID from URL if needed
304
+ let id = estimate_id;
305
+ const urlMatch = estimate_id.match(/[?&]id=([a-f0-9]+)/);
306
+ if (urlMatch) id = urlMatch[1];
307
+
308
+ try {
309
+ const data = await fetchEstimate(id);
310
+ const output = (format === 'markdown')
311
+ ? estimateToMarkdown(data)
312
+ : JSON.stringify(data, null, 2);
313
+ return { content: [{ type: 'text', text: output }] };
314
+ } catch (err) {
315
+ return { content: [{ type: 'text', text: `Import failed: ${err.message}` }], isError: true };
316
+ }
317
+ }
318
+ );
319
+
320
+ async function main() {
321
+ const transport = new StdioServerTransport();
322
+ await server.connect(transport);
323
+ }
324
+
325
+ main().catch(err => {
326
+ console.error(err);
327
+ process.exit(1);
328
+ });
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "sample-aws-pricing-calculator-mcp",
3
+ "version": "1.0.1",
4
+ "bin": "dist/mcp-server.js",
5
+ "files": ["dist/mcp-server.js"],
6
+ "description": "MCP server for creating, reading, updating AWS Pricing Calculator estimates through natural language.",
7
+ "license": "MIT-0",
8
+ "main": "mcp-server.js",
9
+ "scripts": {
10
+ "test": "node --test test/*.test.js",
11
+ "mcp": "node mcp-server.js",
12
+ "build": "esbuild mcp-server.js --bundle --platform=node --outfile=dist/mcp-server.js --format=cjs --minify && rm -f dist/aws-calculator.zip && zip dist/aws-calculator.zip mcp-server.js lib/aws-client.js lib/estimate-builder.js lib/ec2.js package.json"
13
+ },
14
+ "dependencies": {
15
+ "@modelcontextprotocol/sdk": "^1.29.0"
16
+ },
17
+ "devDependencies": {
18
+ "esbuild": "^0.27.4"
19
+ }
20
+ }