oilpriceapi-mcp 1.2.0 → 2.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.
package/build/index.js CHANGED
@@ -1,9 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * OilPriceAPI MCP Server
3
+ * OilPriceAPI MCP Server v2.2.0
4
4
  *
5
- * Provides real-time oil, gas, and commodity prices through the Model Context Protocol.
6
- * For use with Claude Desktop, Claude Code, and other MCP-compatible clients.
5
+ * The energy commodity MCP server. Real-time oil, gas, and commodity prices
6
+ * for Claude, Cursor, VS Code, and any MCP-compatible client.
7
+ *
8
+ * 17 read-only tools (opa_ prefixed): prices, history, futures, marine fuels,
9
+ * rig counts, drilling, diesel-by-state, storage, OPEC production, forecasts,
10
+ * EIA oil inventories, well permits, refining spreads.
11
+ *
12
+ * 4 authenticated price-alert tools (opa_*_price_alert / opa_get_alert_triggers):
13
+ * create/list/delete persistent price alerts tied to the user's account and read
14
+ * recent trigger activity. These wrap the existing /v1/alerts engine and REQUIRE
15
+ * an API key (OILPRICEAPI_KEY).
7
16
  *
8
17
  * @see https://oilpriceapi.com
9
18
  * @see https://modelcontextprotocol.io
@@ -12,11 +21,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
21
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
22
  import { z } from "zod";
14
23
  // API Configuration
15
- const API_BASE = "https://api.oilpriceapi.com";
16
- export const USER_AGENT = "oilpriceapi-mcp/1.2.0";
24
+ const API_BASE = process.env.OILPRICEAPI_BASE_URL || "https://api.oilpriceapi.com";
25
+ export const USER_AGENT = "oilpriceapi-mcp/2.2.0";
17
26
  // Get API key from environment
18
27
  const API_KEY = process.env.OILPRICEAPI_KEY || process.env.OIL_PRICE_API_KEY;
28
+ // ---------------------------------------------------------------------------
19
29
  // Natural language to commodity code mapping
30
+ // ---------------------------------------------------------------------------
20
31
  export const COMMODITY_ALIASES = {
21
32
  // Crude Oil
22
33
  brent: "BRENT_CRUDE_USD",
@@ -68,7 +79,7 @@ export const COMMODITY_ALIASES = {
68
79
  "aviation fuel": "JET_FUEL_USD",
69
80
  kerosene: "JET_FUEL_USD",
70
81
  "heating oil": "HEATING_OIL_USD",
71
- // Other
82
+ // Precious Metals
72
83
  gold: "GOLD_USD",
73
84
  "gold am fix": "GOLD_AM_USD",
74
85
  "lbma gold am": "GOLD_AM_USD",
@@ -91,7 +102,9 @@ export const COMMODITY_ALIASES = {
91
102
  "gbp usd": "GBP_USD",
92
103
  sterling: "GBP_USD",
93
104
  };
105
+ // ---------------------------------------------------------------------------
94
106
  // Commodity metadata for formatting
107
+ // ---------------------------------------------------------------------------
95
108
  export const COMMODITY_INFO = {
96
109
  BRENT_CRUDE_USD: { name: "Brent Crude Oil", unit: "barrel" },
97
110
  WTI_USD: { name: "WTI Crude Oil", unit: "barrel" },
@@ -121,7 +134,7 @@ export const COMMODITY_INFO = {
121
134
  EUR_USD: { name: "Euro to USD", unit: "rate" },
122
135
  GBP_USD: { name: "British Pound to USD", unit: "rate" },
123
136
  };
124
- // Available commodity codes
137
+ // Available commodity codes (used for input validation)
125
138
  export const COMMODITY_CODES = [
126
139
  "BRENT_CRUDE_USD",
127
140
  "WTI_USD",
@@ -151,13 +164,90 @@ export const COMMODITY_CODES = [
151
164
  "EUR_USD",
152
165
  "GBP_USD",
153
166
  ];
167
+ // US state abbreviation lookup for diesel tool
168
+ const US_STATES = {
169
+ alabama: "AL",
170
+ alaska: "AK",
171
+ arizona: "AZ",
172
+ arkansas: "AR",
173
+ california: "CA",
174
+ colorado: "CO",
175
+ connecticut: "CT",
176
+ delaware: "DE",
177
+ florida: "FL",
178
+ georgia: "GA",
179
+ hawaii: "HI",
180
+ idaho: "ID",
181
+ illinois: "IL",
182
+ indiana: "IN",
183
+ iowa: "IA",
184
+ kansas: "KS",
185
+ kentucky: "KY",
186
+ louisiana: "LA",
187
+ maine: "ME",
188
+ maryland: "MD",
189
+ massachusetts: "MA",
190
+ michigan: "MI",
191
+ minnesota: "MN",
192
+ mississippi: "MS",
193
+ missouri: "MO",
194
+ montana: "MT",
195
+ nebraska: "NE",
196
+ nevada: "NV",
197
+ "new hampshire": "NH",
198
+ "new jersey": "NJ",
199
+ "new mexico": "NM",
200
+ "new york": "NY",
201
+ "north carolina": "NC",
202
+ "north dakota": "ND",
203
+ ohio: "OH",
204
+ oklahoma: "OK",
205
+ oregon: "OR",
206
+ pennsylvania: "PA",
207
+ "rhode island": "RI",
208
+ "south carolina": "SC",
209
+ "south dakota": "SD",
210
+ tennessee: "TN",
211
+ texas: "TX",
212
+ utah: "UT",
213
+ vermont: "VT",
214
+ virginia: "VA",
215
+ washington: "WA",
216
+ "west virginia": "WV",
217
+ wisconsin: "WI",
218
+ wyoming: "WY",
219
+ "district of columbia": "DC",
220
+ };
221
+ // Supported futures contracts (used by opa_get_futures + opa_get_futures_curve)
222
+ export const FUTURES_CONTRACTS = [
223
+ "BZ",
224
+ "CL",
225
+ "ice-gasoil",
226
+ "ttf-gas",
227
+ "lng-jkm",
228
+ "eua-carbon",
229
+ ];
230
+ export const FUTURES_CONTRACT_NAMES = {
231
+ BZ: "Brent Crude",
232
+ CL: "WTI Crude",
233
+ "ice-gasoil": "ICE Gasoil",
234
+ "ttf-gas": "European TTF Natural Gas",
235
+ "lng-jkm": "LNG JKM (Asia)",
236
+ "eua-carbon": "EU Carbon Allowance (EUA)",
237
+ };
238
+ // ---------------------------------------------------------------------------
154
239
  // Create server instance
240
+ // ---------------------------------------------------------------------------
155
241
  const server = new McpServer({
156
242
  name: "oilpriceapi",
157
- version: "1.2.0",
243
+ version: "2.2.0",
158
244
  });
245
+ // ---------------------------------------------------------------------------
246
+ // Helpers
247
+ // ---------------------------------------------------------------------------
159
248
  /**
160
- * Resolve a natural language commodity name to its API code
249
+ * Resolve a natural language commodity name to its API code.
250
+ * Returns null if no match found — callers should return an actionable error.
161
251
  */
162
252
  export function resolveCommodityCode(input) {
163
253
  const normalized = input.toLowerCase().trim();
@@ -165,19 +255,83 @@ export function resolveCommodityCode(input) {
165
255
  if (COMMODITY_CODES.includes(normalized.toUpperCase())) {
166
256
  return normalized.toUpperCase();
167
257
  }
168
- // Try alias mapping
258
+ // Try exact alias mapping
169
259
  const mapped = COMMODITY_ALIASES[normalized];
170
260
  if (mapped) {
171
261
  return mapped;
172
262
  }
173
- // Fuzzy match - check if input contains key words
263
+ // Fuzzy match check if input contains key words
174
264
  for (const [alias, code] of Object.entries(COMMODITY_ALIASES)) {
175
265
  if (normalized.includes(alias) || alias.includes(normalized)) {
176
266
  return code;
177
267
  }
178
268
  }
179
- // Default to Brent if no match
180
- return "BRENT_CRUDE_USD";
269
+ // No match found
270
+ return null;
271
+ }
272
+ /**
273
+ * Find the closest matching alias for an unrecognized input.
274
+ */
275
+ function suggestCommodities(input) {
276
+ const normalized = input.toLowerCase().trim();
277
+ const suggestions = [];
278
+ for (const [alias, code] of Object.entries(COMMODITY_ALIASES)) {
279
+ let score = 0;
280
+ const words = normalized.split(/\s+/);
281
+ for (const word of words) {
282
+ if (alias.includes(word) || word.includes(alias)) {
283
+ score += word.length;
284
+ }
285
+ }
286
+ if (score > 0) {
287
+ suggestions.push({ alias, code, score });
288
+ }
289
+ }
290
+ // Deduplicate by code and return top 3
291
+ const seen = new Set();
292
+ return suggestions
293
+ .sort((a, b) => b.score - a.score)
294
+ .filter((s) => {
295
+ if (seen.has(s.code))
296
+ return false;
297
+ seen.add(s.code);
298
+ return true;
299
+ })
300
+ .slice(0, 3)
301
+ .map((s) => `'${s.alias}' (${s.code})`);
302
+ }
303
+ /**
304
+ * Build an error tool result with isError flag so LLMs can distinguish
305
+ * errors from data.
306
+ */
307
+ function errorResult(message) {
308
+ return {
309
+ content: [{ type: "text", text: message }],
310
+ isError: true,
311
+ };
312
+ }
313
+ /**
314
+ * Build a success tool result.
315
+ */
316
+ function textResult(text) {
317
+ return {
318
+ content: [{ type: "text", text }],
319
+ };
320
+ }
321
+ /**
322
+ * Handle commodity resolution with helpful error on no match.
323
+ */
324
+ function resolveOrError(input) {
325
+ const code = resolveCommodityCode(input);
326
+ if (code)
327
+ return { code };
328
+ const suggestions = suggestCommodities(input);
329
+ let msg = `Commodity '${input}' not recognized.`;
330
+ if (suggestions.length > 0) {
331
+ msg += ` Did you mean: ${suggestions.join(", ")}?`;
332
+ }
333
+ msg += " Use opa_list_commodities to see all available codes.";
334
+ return { error: errorResult(msg) };
181
335
  }
182
336
  /**
183
337
  * Format a price for display
@@ -226,7 +380,7 @@ export async function makeApiRequest(endpoint, fetchFn = fetch) {
226
380
  return (await response.json());
227
381
  }
228
382
  if (response.status === 401) {
229
- console.error("Authentication failed. Check OILPRICEAPI_KEY environment variable.");
383
+ console.error("Authentication failed. Set OILPRICEAPI_KEY environment variable. Get a free key at https://oilpriceapi.com/signup");
230
384
  return null;
231
385
  }
232
386
  // Retry on 429 and 5xx
@@ -239,7 +393,6 @@ export async function makeApiRequest(endpoint, fetchFn = fetch) {
239
393
  await new Promise((resolve) => setTimeout(resolve, delay));
240
394
  continue;
241
395
  }
242
- // Non-retryable HTTP error (403, 404, etc.) — return null immediately
243
396
  console.error(`HTTP ${response.status}: ${response.statusText} for ${endpoint}`);
244
397
  return null;
245
398
  }
@@ -248,66 +401,142 @@ export async function makeApiRequest(endpoint, fetchFn = fetch) {
248
401
  console.error(`API request failed after ${maxRetries + 1} attempts: ${endpoint}`, error);
249
402
  return null;
250
403
  }
251
- // Network error - retry with backoff
252
404
  const delay = Math.pow(2, attempt) * 1000;
253
405
  await new Promise((resolve) => setTimeout(resolve, delay));
254
406
  }
255
407
  }
256
408
  return null;
257
409
  }
258
- // Register Tools
259
410
  /**
260
- * Get current price of a specific commodity
411
+ * Make an authenticated request to a stateful endpoint. Requires API_KEY.
412
+ * Returns { ok, status, body }; callers decide how to render success/error.
413
+ * Returns status 0 on a network/transport failure.
414
+ */
415
+ export async function makeAuthRequest(endpoint, options = {}, fetchFn = fetch) {
416
+ const { method = "GET", body } = options;
417
+ const headers = {
418
+ "User-Agent": USER_AGENT,
419
+ Accept: "application/json",
420
+ // The API accepts the customer API key as a bearer token.
421
+ Authorization: `Bearer ${API_KEY}`,
422
+ };
423
+ if (body !== undefined) {
424
+ headers["Content-Type"] = "application/json";
425
+ }
426
+ try {
427
+ const response = await fetchFn(`${API_BASE}${endpoint}`, {
428
+ method,
429
+ headers,
430
+ body: body !== undefined ? JSON.stringify(body) : undefined,
431
+ });
432
+ let parsed = null;
433
+ const text = await response.text();
434
+ if (text) {
435
+ try {
436
+ parsed = JSON.parse(text);
437
+ }
438
+ catch {
439
+ parsed = text;
440
+ }
441
+ }
442
+ return { ok: response.ok, status: response.status, body: parsed };
443
+ }
444
+ catch (error) {
445
+ console.error(`Authenticated request failed: ${method} ${endpoint}`, error);
446
+ return { ok: false, status: 0, body: null };
447
+ }
448
+ }
449
+ /**
450
+ * Standard guard for alert tools: returns an actionable error result when no
451
+ * API key is configured, otherwise null.
452
+ */
453
+ function requireApiKey() {
454
+ if (!API_KEY) {
455
+ return errorResult("Price alerts require an OilPriceAPI key. These tools create and manage " +
456
+ "persistent alerts tied to your account, so the server must be configured " +
457
+ "with one. Set the OILPRICEAPI_KEY environment variable for this MCP " +
458
+ "server (get a free key at https://oilpriceapi.com/signup), then retry.");
459
+ }
460
+ return null;
461
+ }
462
+ /**
463
+ * Map an authenticated-request failure to a clear, agent-readable error.
464
+ */
465
+ function alertHttpError(result, action) {
466
+ if (result.status === 0) {
467
+ return `Could not ${action} — the OilPriceAPI service was unreachable. Try again in a moment.`;
468
+ }
469
+ if (result.status === 401) {
470
+ return `Could not ${action}: authentication failed (401). The configured OILPRICEAPI_KEY is missing or invalid. Set a valid key (https://oilpriceapi.com/signup) and retry.`;
471
+ }
472
+ // Surface the API's own error/validation message when present.
473
+ const body = result.body;
474
+ let detail = "";
475
+ if (body && typeof body === "object") {
476
+ const obj = body;
477
+ if (typeof obj.message === "string")
478
+ detail = obj.message;
479
+ else if (typeof obj.error === "string")
480
+ detail = obj.error;
481
+ else if (obj.errors)
482
+ detail = JSON.stringify(obj.errors);
483
+ }
484
+ return `Could not ${action} (HTTP ${result.status})${detail ? `: ${detail}` : "."}`;
485
+ }
486
+ // Operators accepted by the /v1/alerts engine (PriceAlert::VALID_OPERATORS).
487
+ export const ALERT_OPERATORS = [
488
+ "greater_than",
489
+ "less_than",
490
+ "equals",
491
+ "greater_than_or_equal",
492
+ "less_than_or_equal",
493
+ ];
494
+ /**
495
+ * Resolve a US state name or abbreviation to a 2-letter code.
261
496
  */
262
- server.tool("get_commodity_price", "Get the current real-time price of an oil, gas, or energy commodity. Use natural language like 'brent oil', 'natural gas', 'wti', or 'diesel'.", {
497
+ export function resolveStateCode(input) {
498
+ const normalized = input.toLowerCase().trim();
499
+ // Already a 2-letter code
500
+ if (/^[a-z]{2}$/i.test(normalized)) {
501
+ const upper = normalized.toUpperCase();
502
+ // Verify it's a real state abbreviation
503
+ if (Object.values(US_STATES).includes(upper) || upper === "DC") {
504
+ return upper;
505
+ }
506
+ return null;
507
+ }
508
+ // Try full name lookup
509
+ return US_STATES[normalized] ?? null;
510
+ }
511
+ // =========================================================================
512
+ // READ TOOLS (17 — opa_ prefixed to avoid collisions). Plus 4 authenticated
513
+ // price-alert tools further below, for 21 tools total.
514
+ // =========================================================================
515
+ server.tool("opa_get_price", "Get the current real-time spot price of an energy commodity. Use when the user asks about a single commodity's current price. Accepts natural language ('brent oil', 'diesel') or API codes ('WTI_USD'). Returns price, currency, 24h change, and timestamp. For multiple commodities at once, use opa_market_overview. For price trends, use opa_get_history.", {
263
516
  commodity: z
264
517
  .string()
265
518
  .describe("Commodity name or code (e.g., 'brent oil', 'natural gas', 'WTI_USD', 'diesel')"),
266
519
  }, async ({ commodity }) => {
267
- const code = resolveCommodityCode(commodity);
268
- const response = await makeApiRequest(`/v1/prices/latest?by_code=${code}`);
520
+ const resolved = resolveOrError(commodity);
521
+ if ("error" in resolved)
522
+ return resolved.error;
523
+ const response = await makeApiRequest(`/v1/prices/latest?by_code=${resolved.code}`);
269
524
  if (!response || response.status !== "success") {
270
- return {
271
- content: [
272
- {
273
- type: "text",
274
- text: `Failed to retrieve price for ${commodity}. Please try again or check if the commodity is supported.`,
275
- },
276
- ],
277
- };
278
- }
279
- const formatted = formatPrice(response.data);
280
- return {
281
- content: [
282
- {
283
- type: "text",
284
- text: `${formatted}\n\n_Data from [OilPriceAPI](https://oilpriceapi.com)_`,
285
- },
286
- ],
287
- };
525
+ return errorResult(`Could not retrieve price for '${commodity}' (code: ${resolved.code}). The API may be temporarily unavailable — try again in a moment.`);
526
+ }
527
+ return textResult(`${formatPrice(response.data)}\n\n_Data from [OilPriceAPI](https://oilpriceapi.com)_`);
288
528
  });
289
- /**
290
- * Get all commodity prices (market overview)
291
- */
292
- server.tool("get_market_overview", "Get current prices for all tracked commodities. Returns a market overview with oil, gas, coal, and refined product prices.", {
529
+ server.tool("opa_market_overview", "Get current prices for all tracked energy commodities in one call. Use when the user wants a broad market snapshot or asks about overall energy prices. Returns prices grouped by category (oil, gas, coal, refined products, metals, forex) with 24h changes. Supports filtering by category. For a single commodity, use opa_get_price instead.", {
293
530
  category: z
294
- .enum(["all", "oil", "gas", "coal", "refined"])
531
+ .enum(["all", "oil", "gas", "coal", "refined", "metals", "forex"])
295
532
  .optional()
296
- .describe("Filter by commodity category (default: all)"),
533
+ .describe("Filter by commodity category (default: all). Options: oil, gas, coal, refined, metals, forex."),
297
534
  }, async ({ category = "all" }) => {
298
535
  const response = await makeApiRequest("/v1/prices/all");
299
536
  if (!response || response.status !== "success") {
300
- return {
301
- content: [
302
- {
303
- type: "text",
304
- text: "Failed to retrieve market data. Please try again.",
305
- },
306
- ],
307
- };
537
+ return errorResult("Could not retrieve market data. The API may be temporarily unavailable — try again in a moment.");
308
538
  }
309
539
  const prices = response.data.data.prices;
310
- // Category filters
311
540
  const categoryFilters = {
312
541
  oil: ["BRENT_CRUDE_USD", "WTI_USD", "URALS_CRUDE_USD", "DUBAI_CRUDE_USD"],
313
542
  gas: ["NATURAL_GAS_USD", "NATURAL_GAS_GBP", "DUTCH_TTF_EUR"],
@@ -319,6 +548,8 @@ server.tool("get_market_overview", "Get current prices for all tracked commoditi
319
548
  "JET_FUEL_USD",
320
549
  "HEATING_OIL_USD",
321
550
  ],
551
+ metals: ["GOLD_USD", "GOLD_AM_USD", "GOLD_PM_USD", "SILVER_FIX_USD"],
552
+ forex: ["EUR_USD", "GBP_USD"],
322
553
  };
323
554
  let filteredCodes;
324
555
  if (category === "all") {
@@ -328,12 +559,13 @@ server.tool("get_market_overview", "Get current prices for all tracked commoditi
328
559
  filteredCodes = categoryFilters[category] || [];
329
560
  }
330
561
  const sections = ["# Energy Market Overview\n"];
331
- // Group by category
332
562
  const groupedPrices = {
333
563
  "Crude Oil": [],
334
564
  "Natural Gas": [],
335
565
  Coal: [],
336
566
  "Refined Products": [],
567
+ "Precious Metals": [],
568
+ Forex: [],
337
569
  Other: [],
338
570
  };
339
571
  for (const code of filteredCodes) {
@@ -359,6 +591,12 @@ server.tool("get_market_overview", "Get current prices for all tracked commoditi
359
591
  ].includes(code)) {
360
592
  groupedPrices["Refined Products"].push(data);
361
593
  }
594
+ else if (code.includes("GOLD") || code.includes("SILVER")) {
595
+ groupedPrices["Precious Metals"].push(data);
596
+ }
597
+ else if (code === "EUR_USD" || code === "GBP_USD") {
598
+ groupedPrices["Forex"].push(data);
599
+ }
362
600
  else {
363
601
  groupedPrices["Other"].push(data);
364
602
  }
@@ -386,156 +624,143 @@ server.tool("get_market_overview", "Get current prices for all tracked commoditi
386
624
  }
387
625
  sections.push("");
388
626
  }
389
- sections.push(`_Updated: ${new Date(response.data.data.timestamp).toLocaleString("en-US", {
390
- dateStyle: "medium",
391
- timeStyle: "short",
392
- timeZone: "UTC",
393
- })} UTC | Data from [OilPriceAPI](https://oilpriceapi.com)_`);
394
- return {
395
- content: [
396
- {
397
- type: "text",
398
- text: sections.join("\n"),
399
- },
400
- ],
401
- };
627
+ sections.push(`_Updated: ${new Date(response.data.data.timestamp).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" })} UTC | Data from [OilPriceAPI](https://oilpriceapi.com)_`);
628
+ return textResult(sections.join("\n"));
402
629
  });
403
- /**
404
- * Compare prices between commodities
405
- */
406
- server.tool("compare_prices", "Compare current prices between multiple commodities (e.g., Brent vs WTI, US gas vs European gas).", {
630
+ server.tool("opa_compare_prices", "Compare current prices between 2-5 commodities side by side. Use when the user asks to compare commodities (e.g., 'Brent vs WTI', 'US gas vs EU gas'). Returns each commodity's price with 24h changes, plus the spread if comparing two same-currency commodities. Accepts natural language or codes.", {
407
631
  commodities: z
408
632
  .array(z.string())
409
633
  .min(2)
410
634
  .max(5)
411
- .describe("List of commodities to compare (2-5 items)"),
635
+ .describe("List of 2-5 commodity names or codes to compare (e.g., ['brent', 'wti'] or ['NATURAL_GAS_USD', 'DUTCH_TTF_EUR'])"),
412
636
  }, async ({ commodities }) => {
413
- const codes = commodities.map(resolveCommodityCode);
414
637
  const results = [];
415
- for (const code of codes) {
416
- const response = await makeApiRequest(`/v1/prices/latest?by_code=${code}`);
638
+ const errors = [];
639
+ for (const commodity of commodities) {
640
+ const resolved = resolveOrError(commodity);
641
+ if ("error" in resolved) {
642
+ errors.push(commodity);
643
+ continue;
644
+ }
645
+ const response = await makeApiRequest(`/v1/prices/latest?by_code=${resolved.code}`);
417
646
  if (response?.status === "success") {
418
647
  results.push(response.data);
419
648
  }
420
649
  }
421
650
  if (results.length < 2) {
422
- return {
423
- content: [
424
- {
425
- type: "text",
426
- text: "Could not retrieve enough price data for comparison. Please check commodity names.",
427
- },
428
- ],
429
- };
651
+ let msg = "Could not retrieve enough price data for comparison (need at least 2).";
652
+ if (errors.length > 0) {
653
+ msg += ` Unrecognized commodities: ${errors.join(", ")}. Use opa_list_commodities to see valid codes.`;
654
+ }
655
+ return errorResult(msg);
430
656
  }
431
657
  const sections = ["# Price Comparison\n"];
432
658
  for (const data of results) {
433
659
  sections.push(formatPrice(data));
434
660
  sections.push("");
435
661
  }
436
- // Calculate spread if comparing similar commodities
437
662
  if (results.length === 2 && results[0].currency === results[1].currency) {
438
663
  const spread = Math.abs(results[0].price - results[1].price);
439
664
  const info0 = COMMODITY_INFO[results[0].code]?.name || results[0].code;
440
665
  const info1 = COMMODITY_INFO[results[1].code]?.name || results[1].code;
441
- sections.push(`**Spread**: $${spread.toFixed(2)} (${info0} vs ${info1})`);
666
+ const currencySymbol = results[0].currency === "EUR"
667
+ ? "€"
668
+ : results[0].currency === "GBP"
669
+ ? "£"
670
+ : "$";
671
+ sections.push(`**Spread**: ${currencySymbol}${spread.toFixed(2)} (${info0} vs ${info1})`);
442
672
  }
443
673
  sections.push(`\n_Data from [OilPriceAPI](https://oilpriceapi.com)_`);
444
- return {
445
- content: [
446
- {
447
- type: "text",
448
- text: sections.join("\n"),
449
- },
450
- ],
451
- };
674
+ return textResult(sections.join("\n"));
452
675
  });
453
- /**
454
- * List available commodities
455
- */
456
- server.tool("list_commodities", "List all available commodities that can be queried for prices.", {}, async () => {
676
+ server.tool("opa_list_commodities", "List all available commodities that can be queried for prices. Use when the user asks what commodities are available, what codes to use, or when another tool returns a 'commodity not recognized' error. Returns the full catalog fetched live from the API, grouped by category. No parameters needed.", {}, async () => {
677
+ // Try to fetch the live commodity catalog from the API
678
+ const response = await makeApiRequest("/v1/commodities");
679
+ if (response?.status === "success" && response.data.commodities?.length) {
680
+ const grouped = {};
681
+ for (const c of response.data.commodities) {
682
+ const cat = c.category || "Other";
683
+ if (!grouped[cat])
684
+ grouped[cat] = [];
685
+ grouped[cat].push({ code: c.code, name: c.name });
686
+ }
687
+ const sections = [
688
+ `# Available Commodities (${response.data.commodities.length} total)\n`,
689
+ ];
690
+ for (const [category, items] of Object.entries(grouped)) {
691
+ sections.push(`## ${category}`);
692
+ for (const item of items) {
693
+ sections.push(`- \`${item.code}\` — ${item.name}`);
694
+ }
695
+ sections.push("");
696
+ }
697
+ sections.push("_You can use natural language like 'brent oil' or 'natural gas' — the server translates it to the right code._");
698
+ return textResult(sections.join("\n"));
699
+ }
700
+ // Fallback to static list if API call fails
457
701
  const sections = ["# Available Commodities\n"];
458
702
  sections.push("## Crude Oil");
459
- sections.push("- `BRENT_CRUDE_USD` - Brent Crude (global benchmark)");
460
- sections.push("- `WTI_USD` - West Texas Intermediate (US benchmark)");
461
- sections.push("- `URALS_CRUDE_USD` - Urals Crude (Russian)");
462
- sections.push("- `DUBAI_CRUDE_USD` - Dubai Crude (Middle East)");
703
+ sections.push("- `BRENT_CRUDE_USD` Brent Crude (global benchmark)");
704
+ sections.push("- `WTI_USD` West Texas Intermediate (US benchmark)");
705
+ sections.push("- `URALS_CRUDE_USD` Urals Crude (Russian)");
706
+ sections.push("- `DUBAI_CRUDE_USD` Dubai Crude (Middle East)");
463
707
  sections.push("");
464
708
  sections.push("## Natural Gas");
465
- sections.push("- `NATURAL_GAS_USD` - US Henry Hub ($/MMBtu)");
466
- sections.push("- `NATURAL_GAS_GBP` - UK NBP (pence/therm)");
467
- sections.push("- `DUTCH_TTF_EUR` - European TTF (€/MWh)");
709
+ sections.push("- `NATURAL_GAS_USD` US Henry Hub ($/MMBtu)");
710
+ sections.push("- `NATURAL_GAS_GBP` UK NBP (pence/therm)");
711
+ sections.push("- `DUTCH_TTF_EUR` European TTF (€/MWh)");
468
712
  sections.push("");
469
713
  sections.push("## Coal");
470
- sections.push("- `COAL_USD` - Thermal Coal");
471
- sections.push("- `NEWCASTLE_COAL_USD` - Newcastle (Asia-Pacific)");
714
+ sections.push("- `COAL_USD` Thermal Coal");
715
+ sections.push("- `NEWCASTLE_COAL_USD` Newcastle (Asia-Pacific)");
472
716
  sections.push("");
473
717
  sections.push("## Refined Products");
474
- sections.push("- `DIESEL_USD` - Diesel");
475
- sections.push("- `GASOLINE_USD` - Gasoline");
476
- sections.push("- `GASOLINE_RBOB_USD` - RBOB Gasoline");
477
- sections.push("- `JET_FUEL_USD` - Jet Fuel");
478
- sections.push("- `HEATING_OIL_USD` - Heating Oil");
718
+ sections.push("- `DIESEL_USD` Diesel");
719
+ sections.push("- `GASOLINE_USD` Gasoline");
720
+ sections.push("- `GASOLINE_RBOB_USD` RBOB Gasoline");
721
+ sections.push("- `JET_FUEL_USD` Jet Fuel");
722
+ sections.push("- `HEATING_OIL_USD` Heating Oil");
479
723
  sections.push("");
480
724
  sections.push("## Precious Metals");
481
- sections.push("- `GOLD_USD` - Gold");
482
- sections.push("- `GOLD_AM_USD` - LBMA Gold AM Fix (USD)");
483
- sections.push("- `GOLD_AM_GBP` - LBMA Gold AM Fix (GBP)");
484
- sections.push("- `GOLD_AM_EUR` - LBMA Gold AM Fix (EUR)");
485
- sections.push("- `GOLD_PM_USD` - LBMA Gold PM Fix (USD)");
486
- sections.push("- `GOLD_PM_GBP` - LBMA Gold PM Fix (GBP)");
487
- sections.push("- `GOLD_PM_EUR` - LBMA Gold PM Fix (EUR)");
488
- sections.push("- `SILVER_FIX_USD` - LBMA Silver Fix (USD)");
489
- sections.push("- `SILVER_FIX_GBP` - LBMA Silver Fix (GBP)");
490
- sections.push("- `SILVER_FIX_EUR` - LBMA Silver Fix (EUR)");
725
+ sections.push("- `GOLD_USD` Gold");
726
+ sections.push("- `SILVER_FIX_USD` LBMA Silver Fix");
491
727
  sections.push("");
492
728
  sections.push("## Other");
493
- sections.push("- `EU_CARBON_EUR` - EU Carbon Allowances");
494
- sections.push("- `EUR_USD` - Euro to USD");
495
- sections.push("- `GBP_USD` - British Pound to USD");
729
+ sections.push("- `EU_CARBON_EUR` EU Carbon Allowances");
730
+ sections.push("- `EUR_USD` Euro to USD");
731
+ sections.push("- `GBP_USD` British Pound to USD");
496
732
  sections.push("");
497
- sections.push("_You can use natural language like 'brent oil' or 'natural gas' - I'll translate it to the right code._");
498
- return {
499
- content: [
500
- {
501
- type: "text",
502
- text: sections.join("\n"),
503
- },
504
- ],
505
- };
733
+ sections.push("_Note: This is a partial list (API was unreachable). The full catalog has 70+ commodities. Try again later for the complete list._");
734
+ return textResult(sections.join("\n"));
506
735
  });
507
- /**
508
- * Get historical price data for a commodity
509
- */
510
- server.tool("get_historical_prices", "Get historical price data for a commodity over a time period (past day, week, month, or year).", {
511
- commodity: z.string().describe("Commodity name or code"),
736
+ server.tool("opa_get_history", "Get historical price data for a commodity over a time period. Use when the user asks about price trends, historical prices, or how a commodity has performed over time. Returns high, low, average, change, and data point count. Periods: day (24h), week (7d), month (30d), year (365d).", {
737
+ commodity: z
738
+ .string()
739
+ .describe("Commodity name or code (e.g., 'brent', 'WTI_USD')"),
512
740
  period: z
513
741
  .enum(["day", "week", "month", "year"])
514
742
  .default("month")
515
- .describe("Time period"),
743
+ .describe("Time period: day, week, month, or year (default: month)"),
516
744
  }, async ({ commodity, period }) => {
517
- const code = resolveCommodityCode(commodity);
518
- const response = await makeApiRequest(`/v1/prices/past_${period}?by_code=${code}`);
745
+ const resolved = resolveOrError(commodity);
746
+ if ("error" in resolved)
747
+ return resolved.error;
748
+ const response = await makeApiRequest(`/v1/prices/past_${period}?by_code=${resolved.code}`);
519
749
  if (!response ||
520
750
  response.status !== "success" ||
521
751
  !response.data.prices?.length) {
522
- return {
523
- content: [
524
- {
525
- type: "text",
526
- text: `No historical data found for ${commodity} over the past ${period}.`,
527
- },
528
- ],
529
- };
530
- }
531
- const info = COMMODITY_INFO[code] || { name: code, unit: "unit" };
532
- // Derive currency symbol from commodity code (same logic as formatPrice)
533
- const currencyFromCode = code.endsWith("_EUR")
752
+ return errorResult(`No historical data found for '${commodity}' (code: ${resolved.code}) over the past ${period}. This commodity may not have enough data for this period, or may require a paid plan.`);
753
+ }
754
+ const info = COMMODITY_INFO[resolved.code] || {
755
+ name: resolved.code,
756
+ unit: "unit",
757
+ };
758
+ const currencyFromCode = resolved.code.endsWith("_EUR")
534
759
  ? "EUR"
535
- : code.endsWith("_GBP") || code.endsWith("_GBp")
760
+ : resolved.code.endsWith("_GBP") || resolved.code.endsWith("_GBp")
536
761
  ? "GBP"
537
762
  : "USD";
538
- const historicalCurrencySymbol = currencyFromCode === "EUR" ? "€" : currencyFromCode === "GBP" ? "£" : "$";
763
+ const sym = currencyFromCode === "EUR" ? "€" : currencyFromCode === "GBP" ? "£" : "$";
539
764
  const prices = response.data.prices;
540
765
  const latest = prices[0];
541
766
  const oldest = prices[prices.length - 1];
@@ -545,40 +770,30 @@ server.tool("get_historical_prices", "Get historical price data for a commodity
545
770
  const change = latest.price - oldest.price;
546
771
  const changePct = (change / oldest.price) * 100;
547
772
  const sections = [
548
- `# ${info.name} - Past ${period.charAt(0).toUpperCase() + period.slice(1)} Historical Data\n`,
549
- `- **Latest**: ${historicalCurrencySymbol}${latest.price.toFixed(2)}/${info.unit}`,
550
- `- **High**: ${historicalCurrencySymbol}${high.toFixed(2)}`,
551
- `- **Low**: ${historicalCurrencySymbol}${low.toFixed(2)}`,
552
- `- **Average**: ${historicalCurrencySymbol}${avg.toFixed(2)}`,
553
- `- **Change**: ${change >= 0 ? "+" : ""}${historicalCurrencySymbol}${change.toFixed(2)} (${change >= 0 ? "+" : ""}${changePct.toFixed(1)}%)`,
773
+ `# ${info.name} Past ${period.charAt(0).toUpperCase() + period.slice(1)}\n`,
774
+ `- **Latest**: ${sym}${latest.price.toFixed(2)}/${info.unit}`,
775
+ `- **High**: ${sym}${high.toFixed(2)}`,
776
+ `- **Low**: ${sym}${low.toFixed(2)}`,
777
+ `- **Average**: ${sym}${avg.toFixed(2)}`,
778
+ `- **Change**: ${change >= 0 ? "+" : ""}${sym}${change.toFixed(2)} (${change >= 0 ? "+" : ""}${changePct.toFixed(1)}%)`,
554
779
  `- **Data Points**: ${prices.length}`,
555
780
  `\n_Data from [OilPriceAPI](https://oilpriceapi.com)_`,
556
781
  ];
557
- return { content: [{ type: "text", text: sections.join("\n") }] };
782
+ return textResult(sections.join("\n"));
558
783
  });
559
- /**
560
- * Get the latest futures contract price
561
- */
562
- server.tool("get_futures_price", "Get the latest futures contract price for a commodity (Brent BZ or WTI CL).", {
784
+ server.tool("opa_get_futures", "Get the latest front-month futures contract price for energy commodities. Use when the user asks about futures, forward prices, or contract prices. Supports crude oil (BZ = Brent, CL = WTI), ICE Gasoil (ice-gasoil), European TTF gas (ttf-gas), LNG JKM (lng-jkm), and EUA carbon (eua-carbon). For the full forward curve across all contract months, use opa_get_futures_curve instead.", {
563
785
  contract: z
564
- .enum(["BZ", "CL"])
786
+ .enum(FUTURES_CONTRACTS)
565
787
  .default("BZ")
566
- .describe("Futures contract (BZ=Brent, CL=WTI)"),
788
+ .describe("Futures contract: BZ = Brent crude, CL = WTI crude, ice-gasoil = ICE Gasoil, ttf-gas = European TTF natural gas, lng-jkm = LNG JKM (Asia), eua-carbon = EU carbon allowance (default: BZ)"),
567
789
  }, async ({ contract }) => {
568
- const response = await makeApiRequest(`/v1/futures/latest?contract=${contract}`);
790
+ const response = await makeApiRequest(`/v1/futures/latest?contract=${encodeURIComponent(contract)}`);
569
791
  if (!response ||
570
792
  response.status !== "success" ||
571
793
  !response.data.contracts?.length) {
572
- return {
573
- content: [
574
- {
575
- type: "text",
576
- text: `No futures data available for contract ${contract}.`,
577
- },
578
- ],
579
- };
580
- }
581
- const contractName = contract === "BZ" ? "Brent Crude" : "WTI Crude";
794
+ return errorResult(`No futures data available for ${FUTURES_CONTRACT_NAMES[contract]} (${contract}). Futures data requires a paid plan.`);
795
+ }
796
+ const contractName = FUTURES_CONTRACT_NAMES[contract];
582
797
  const front = response.data.contracts[0];
583
798
  let text = `# ${contractName} Futures (${contract})\n\n`;
584
799
  text += `**Front Month (${front.month})**: $${front.price.toFixed(2)}`;
@@ -586,31 +801,21 @@ server.tool("get_futures_price", "Get the latest futures contract price for a co
586
801
  text += ` (${front.change >= 0 ? "+" : ""}$${front.change.toFixed(2)})`;
587
802
  }
588
803
  text += `\n\n_Data from [OilPriceAPI](https://oilpriceapi.com)_`;
589
- return { content: [{ type: "text", text }] };
804
+ return textResult(text);
590
805
  });
591
- /**
592
- * Get the full futures forward curve
593
- */
594
- server.tool("get_futures_curve", "Get the full futures forward curve showing prices across contract months.", {
806
+ server.tool("opa_get_futures_curve", "Get the full futures forward curve showing prices across all contract months. Use when the user asks about the forward curve, contango/backwardation, or term structure. Supports crude oil (BZ = Brent, CL = WTI), ICE Gasoil (ice-gasoil), European TTF gas (ttf-gas), LNG JKM (lng-jkm), and EUA carbon (eua-carbon). Returns a table of contract months with prices and changes, plus market structure analysis.", {
595
807
  contract: z
596
- .enum(["BZ", "CL"])
808
+ .enum(FUTURES_CONTRACTS)
597
809
  .default("BZ")
598
- .describe("Futures contract (BZ=Brent, CL=WTI)"),
810
+ .describe("Futures contract: BZ = Brent crude, CL = WTI crude, ice-gasoil = ICE Gasoil, ttf-gas = European TTF natural gas, lng-jkm = LNG JKM (Asia), eua-carbon = EU carbon allowance (default: BZ)"),
599
811
  }, async ({ contract }) => {
600
- const response = await makeApiRequest(`/v1/futures/curve?contract=${contract}`);
812
+ const response = await makeApiRequest(`/v1/futures/curve?contract=${encodeURIComponent(contract)}`);
601
813
  if (!response ||
602
814
  response.status !== "success" ||
603
815
  !response.data.contracts?.length) {
604
- return {
605
- content: [
606
- {
607
- type: "text",
608
- text: `No futures curve data available for contract ${contract}.`,
609
- },
610
- ],
611
- };
612
- }
613
- const contractName = contract === "BZ" ? "Brent Crude" : "WTI Crude";
816
+ return errorResult(`No futures curve data available for ${FUTURES_CONTRACT_NAMES[contract]} (${contract}). Futures data requires a paid plan.`);
817
+ }
818
+ const contractName = FUTURES_CONTRACT_NAMES[contract];
614
819
  const contracts = response.data.contracts;
615
820
  let text = `# ${contractName} Futures Curve (${contract})\n\n`;
616
821
  text += `| Month | Price | Change |\n|-------|-------|--------|\n`;
@@ -625,20 +830,17 @@ server.tool("get_futures_curve", "Get the full futures forward curve showing pri
625
830
  const structure = front > back ? "backwardation" : "contango";
626
831
  text += `\n**Market Structure**: ${structure} (front $${front.toFixed(2)} vs back $${back.toFixed(2)})`;
627
832
  text += `\n\n_Data from [OilPriceAPI](https://oilpriceapi.com)_`;
628
- return { content: [{ type: "text", text }] };
833
+ return textResult(text);
629
834
  });
630
- /**
631
- * Get marine fuel (bunker) prices
632
- */
633
- server.tool("get_marine_fuel_prices", "Get latest marine fuel (bunker) prices across major ports. Includes VLSFO, MGO, and IFO380.", {
835
+ server.tool("opa_get_marine_fuels", "Get latest marine fuel (bunker) prices across major shipping ports. Use when the user asks about bunker fuel, marine fuel, VLSFO, MGO, IFO380, or shipping fuel costs. Can filter by port (e.g., SINGAPORE, ROTTERDAM, HOUSTON) and/or fuel type (VLSFO, MGO, IFO380). Returns a table of port prices.", {
634
836
  port: z
635
837
  .string()
636
838
  .optional()
637
- .describe("Filter by port name (e.g., 'SINGAPORE', 'ROTTERDAM')"),
839
+ .describe("Filter by port name (e.g., 'SINGAPORE', 'ROTTERDAM', 'HOUSTON')"),
638
840
  fuel_type: z
639
841
  .string()
640
842
  .optional()
641
- .describe("Filter by fuel type (VLSFO, MGO, IFO380)"),
843
+ .describe("Filter by fuel type: VLSFO, MGO, or IFO380"),
642
844
  }, async ({ port, fuel_type }) => {
643
845
  let endpoint = "/v1/marine-fuels/latest";
644
846
  const params = [];
@@ -652,11 +854,7 @@ server.tool("get_marine_fuel_prices", "Get latest marine fuel (bunker) prices ac
652
854
  if (!response ||
653
855
  response.status !== "success" ||
654
856
  !response.data.prices?.length) {
655
- return {
656
- content: [
657
- { type: "text", text: "No marine fuel price data available." },
658
- ],
659
- };
857
+ return errorResult("No marine fuel price data available. Marine fuel data requires a paid plan with bunker fuel coverage.");
660
858
  }
661
859
  const prices = response.data.prices;
662
860
  let text = "# Marine Fuel Prices\n\n";
@@ -666,20 +864,15 @@ server.tool("get_marine_fuel_prices", "Get latest marine fuel (bunker) prices ac
666
864
  text += `| ${p.port} | ${p.fuel_type} | ${p.price.toFixed(2)} | ${p.currency} | ${p.unit} |\n`;
667
865
  }
668
866
  text += `\n_${prices.length} prices | Data from [OilPriceAPI](https://oilpriceapi.com)_`;
669
- return { content: [{ type: "text", text }] };
867
+ return textResult(text);
670
868
  });
671
- /**
672
- * Get the latest oil and gas rig count data
673
- */
674
- server.tool("get_rig_counts", "Get the latest oil and gas rig count data (Baker Hughes).", {}, async () => {
869
+ server.tool("opa_get_rig_counts", "Get the latest US oil and gas rig count data (Baker Hughes). Use when the user asks about drilling activity, rig counts, or oil field operations. Returns oil rigs, gas rigs, total count, and week-over-week change. No parameters needed.", {}, async () => {
675
870
  const response = await makeApiRequest("/v1/rig-counts/latest");
676
871
  if (!response || response.status !== "success") {
677
- return {
678
- content: [{ type: "text", text: "Rig count data not available." }],
679
- };
872
+ return errorResult("Rig count data not available. This may require a paid plan with energy intelligence access.");
680
873
  }
681
874
  const data = response.data;
682
- let text = `# Rig Count Data\n\n`;
875
+ let text = `# US Rig Count (Baker Hughes)\n\n`;
683
876
  text += `- **Oil Rigs**: ${data.oil}\n`;
684
877
  text += `- **Gas Rigs**: ${data.gas}\n`;
685
878
  text += `- **Total**: ${data.total}\n`;
@@ -689,22 +882,12 @@ server.tool("get_rig_counts", "Get the latest oil and gas rig count data (Baker
689
882
  }
690
883
  text += `- **Date**: ${data.date}\n`;
691
884
  text += `\n_Data from [OilPriceAPI](https://oilpriceapi.com)_`;
692
- return { content: [{ type: "text", text }] };
885
+ return textResult(text);
693
886
  });
694
- /**
695
- * Get drilling intelligence data
696
- */
697
- server.tool("get_drilling_intelligence", "Get drilling intelligence data including active wells, permits, and completions.", {}, async () => {
887
+ server.tool("opa_get_drilling", "Get drilling intelligence data including active wells, permits issued, and completions by region. Use when the user asks about drilling activity, well permits, or upstream operations. Returns totals and regional breakdown.", {}, async () => {
698
888
  const response = await makeApiRequest("/v1/drilling/latest");
699
889
  if (!response || response.status !== "success") {
700
- return {
701
- content: [
702
- {
703
- type: "text",
704
- text: "Drilling intelligence data not available.",
705
- },
706
- ],
707
- };
890
+ return errorResult("Drilling intelligence data not available. This requires a paid plan with energy intelligence access.");
708
891
  }
709
892
  const data = response.data;
710
893
  let text = `# Drilling Intelligence\n\n`;
@@ -722,9 +905,345 @@ server.tool("get_drilling_intelligence", "Get drilling intelligence data includi
722
905
  }
723
906
  }
724
907
  text += `\n_Data from [OilPriceAPI](https://oilpriceapi.com)_`;
725
- return { content: [{ type: "text", text }] };
908
+ return textResult(text);
909
+ });
910
+ // ---------------------------------------------------------------------------
911
+ // NEW TOOLS — Sprint 3
912
+ // ---------------------------------------------------------------------------
913
+ server.tool("opa_get_diesel_by_state", "Get the current average retail diesel price for a US state. Use when the user asks about diesel prices in a specific state, diesel fuel costs by state, or state-level fuel prices. Accepts state names ('California') or 2-letter codes ('CA'). Returns the AAA-sourced state average diesel price. Covers all 50 states plus DC.", {
914
+ state: z
915
+ .string()
916
+ .describe("US state name or 2-letter code (e.g., 'California', 'CA', 'Texas', 'TX')"),
917
+ }, async ({ state }) => {
918
+ const stateCode = resolveStateCode(state);
919
+ if (!stateCode) {
920
+ return errorResult(`'${state}' is not a recognized US state. Use a full state name (e.g., 'California') or 2-letter code (e.g., 'CA').`);
921
+ }
922
+ const code = `DIESEL_RETAIL_STATE_${stateCode}_USD`;
923
+ const response = await makeApiRequest(`/v1/prices/latest?by_code=${code}`);
924
+ if (!response || response.status !== "success") {
925
+ return errorResult(`No diesel price data available for ${state} (${stateCode}). State diesel data requires a plan with AAA diesel coverage.`);
926
+ }
927
+ const data = response.data;
928
+ let text = `# Diesel Price — ${stateCode}\n\n`;
929
+ text += `- **Price**: $${data.price.toFixed(3)}/gallon\n`;
930
+ if (data.change_24h !== undefined) {
931
+ const sign = data.change_24h >= 0 ? "+" : "";
932
+ text += `- **24h Change**: ${sign}$${data.change_24h.toFixed(3)}\n`;
933
+ }
934
+ const timestamp = data.updated_at || data.created_at;
935
+ if (timestamp) {
936
+ text += `- **Updated**: ${new Date(timestamp).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" })} UTC\n`;
937
+ }
938
+ text += `- **Source**: AAA\n`;
939
+ text += `\n_Data from [OilPriceAPI](https://oilpriceapi.com)_`;
940
+ return textResult(text);
941
+ });
942
+ server.tool("opa_get_storage", "Get oil storage and inventory levels for Cushing, Oklahoma (WTI delivery hub) and/or the US Strategic Petroleum Reserve (SPR). Use when the user asks about oil inventories, storage levels, Cushing stocks, or the SPR. Returns current inventory levels with changes.", {
943
+ facility: z
944
+ .enum(["cushing", "spr", "all"])
945
+ .default("all")
946
+ .describe("Storage facility: cushing (WTI delivery hub), spr (Strategic Petroleum Reserve), or all (default: all)"),
947
+ }, async ({ facility }) => {
948
+ const sections = ["# Oil Storage Levels\n"];
949
+ let hasData = false;
950
+ if (facility === "cushing" || facility === "all") {
951
+ const response = await makeApiRequest("/v1/storage/cushing");
952
+ if (response?.status === "success") {
953
+ hasData = true;
954
+ sections.push("## Cushing, Oklahoma (WTI Hub)\n");
955
+ sections.push("```json\n" + JSON.stringify(response.data, null, 2) + "\n```\n");
956
+ }
957
+ }
958
+ if (facility === "spr" || facility === "all") {
959
+ const response = await makeApiRequest("/v1/storage/spr");
960
+ if (response?.status === "success") {
961
+ hasData = true;
962
+ sections.push("## Strategic Petroleum Reserve (SPR)\n");
963
+ sections.push("```json\n" + JSON.stringify(response.data, null, 2) + "\n```\n");
964
+ }
965
+ }
966
+ if (!hasData) {
967
+ return errorResult("Storage data not available. This requires a paid plan with energy intelligence access.");
968
+ }
969
+ sections.push("_Data from [OilPriceAPI](https://oilpriceapi.com)_");
970
+ return textResult(sections.join("\n"));
726
971
  });
727
- // Register Resources subscribable price snapshots
972
+ server.tool("opa_get_opec_production", "Get the latest OPEC oil production data. Use when the user asks about OPEC output, production quotas, supply cuts, or OPEC+ compliance. Returns country-level production figures. Requires a paid plan with energy intelligence access.", {}, async () => {
973
+ const response = await makeApiRequest("/v1/ei/opec_productions/latest");
974
+ if (!response || response.status !== "success") {
975
+ return errorResult("OPEC production data not available. This requires a paid plan with energy intelligence access.");
976
+ }
977
+ let text = "# OPEC Production Data\n\n";
978
+ text += "```json\n" + JSON.stringify(response.data, null, 2) + "\n```\n";
979
+ text += "\n_Data from [OilPriceAPI](https://oilpriceapi.com)_";
980
+ return textResult(text);
981
+ });
982
+ server.tool("opa_get_forecasts", "Get energy price forecasts from EIA Short-Term Energy Outlook (STEO) and other sources. Use when the user asks about price predictions, outlooks, or where oil/gas prices are heading. Returns forecast data for key commodities. Requires a paid plan with energy intelligence access.", {}, async () => {
983
+ const response = await makeApiRequest("/v1/ei/forecasts/latest");
984
+ if (!response || response.status !== "success") {
985
+ return errorResult("Forecast data not available. This requires a paid plan with energy intelligence access.");
986
+ }
987
+ let text = "# Energy Price Forecasts\n\n";
988
+ text += "```json\n" + JSON.stringify(response.data, null, 2) + "\n```\n";
989
+ text +=
990
+ "\n_Source: EIA STEO | Data from [OilPriceAPI](https://oilpriceapi.com)_";
991
+ return textResult(text);
992
+ });
993
+ // ---------------------------------------------------------------------------
994
+ // NEW TOOLS — Sprint 4 (EIA inventories, well permits, refining spreads)
995
+ // ---------------------------------------------------------------------------
996
+ server.tool("opa_get_oil_inventories", "Get the latest EIA weekly petroleum inventory (stocks) data. Use when the user asks about oil inventories, crude stocks, weekly EIA stocks, inventory builds/draws, or product-level inventory levels. Returns the latest weekly figures; optionally a summary view or a breakdown by petroleum product. Requires a paid plan with energy intelligence access.", {
997
+ view: z
998
+ .enum(["latest", "summary", "by_product"])
999
+ .default("latest")
1000
+ .describe("Which view to return: latest (most recent weekly snapshot), summary (headline totals + week-over-week change), or by_product (breakdown per petroleum product). Default: latest."),
1001
+ }, async ({ view }) => {
1002
+ const endpointByView = {
1003
+ latest: "/v1/ei/oil_inventories/latest",
1004
+ summary: "/v1/ei/oil_inventories/summary",
1005
+ by_product: "/v1/ei/oil_inventories/by_product",
1006
+ };
1007
+ const response = await makeApiRequest(endpointByView[view]);
1008
+ if (!response || response.status !== "success") {
1009
+ return errorResult("EIA oil inventory data not available. This requires a paid plan with energy intelligence access.");
1010
+ }
1011
+ let text = `# EIA Weekly Oil Inventories (${view})\n\n`;
1012
+ text += "```json\n" + JSON.stringify(response.data, null, 2) + "\n```\n";
1013
+ text +=
1014
+ "\n_Source: EIA Weekly Petroleum Status Report | Data from [OilPriceAPI](https://oilpriceapi.com)_";
1015
+ return textResult(text);
1016
+ });
1017
+ server.tool("opa_get_well_permits", "Get the latest US oil & gas well drilling permit data. Use when the user asks about well permits, new drilling permits, permitting activity, or upstream permit trends. Returns the latest permits; optionally filtered/aggregated by state or by operator. Requires a paid plan with energy intelligence access.", {
1018
+ view: z
1019
+ .enum(["latest", "by_state", "by_operator"])
1020
+ .default("latest")
1021
+ .describe("Which view to return: latest (most recent permits), by_state (counts aggregated per state), or by_operator (counts aggregated per operator). Default: latest."),
1022
+ state: z
1023
+ .string()
1024
+ .optional()
1025
+ .describe("Optional US state name or 2-letter code to filter permits (e.g., 'Texas', 'TX'). Applies to the latest and by_state views."),
1026
+ }, async ({ view, state }) => {
1027
+ const pathByView = {
1028
+ latest: "/v1/ei/well-permits/latest",
1029
+ by_state: "/v1/ei/well-permits/by-state",
1030
+ by_operator: "/v1/ei/well-permits/by-operator",
1031
+ };
1032
+ let endpoint = pathByView[view];
1033
+ if (state) {
1034
+ const stateCode = resolveStateCode(state);
1035
+ if (!stateCode) {
1036
+ return errorResult(`'${state}' is not a recognized US state. Use a full state name (e.g., 'Texas') or 2-letter code (e.g., 'TX').`);
1037
+ }
1038
+ endpoint += `?state=${stateCode}`;
1039
+ }
1040
+ const response = await makeApiRequest(endpoint);
1041
+ if (!response || response.status !== "success") {
1042
+ return errorResult("Well permit data not available. This requires a paid plan with energy intelligence access.");
1043
+ }
1044
+ let text = `# US Well Permits (${view})\n\n`;
1045
+ text += "```json\n" + JSON.stringify(response.data, null, 2) + "\n```\n";
1046
+ text += "\n_Data from [OilPriceAPI](https://oilpriceapi.com)_";
1047
+ return textResult(text);
1048
+ });
1049
+ server.tool("opa_get_spread", "Get refining and trading spreads: crack spreads (refining margin proxy), basis spreads (regional price differentials), and blending/transport margins. Use when the user asks about crack spreads, 3-2-1 crack, refining margins, basis differentials, or blend/transport margins. Requires a paid plan with energy intelligence access.", {
1050
+ type: z
1051
+ .enum(["crack", "basis", "margin"])
1052
+ .describe("Spread type: crack (refining crack spread, e.g. 3-2-1), basis (regional/grade price differential), or margin (blending/transport margin)."),
1053
+ }, async ({ type }) => {
1054
+ const response = await makeApiRequest(`/v1/spreads/${type}`);
1055
+ if (!response || response.status !== "success") {
1056
+ return errorResult(`${type.charAt(0).toUpperCase() + type.slice(1)} spread data not available. This requires a paid plan with energy intelligence access.`);
1057
+ }
1058
+ let text = `# ${type.charAt(0).toUpperCase() + type.slice(1)} Spread\n\n`;
1059
+ text += "```json\n" + JSON.stringify(response.data, null, 2) + "\n```\n";
1060
+ text += "\n_Data from [OilPriceAPI](https://oilpriceapi.com)_";
1061
+ return textResult(text);
1062
+ });
1063
+ function formatAlertLine(a) {
1064
+ const label = a.summary ||
1065
+ a.condition ||
1066
+ `${a.commodity_code} ${a.condition_operator} ${a.condition_value}`;
1067
+ const status = a.enabled === false ? "disabled" : "enabled";
1068
+ const triggers = typeof a.trigger_count === "number" ? `, ${a.trigger_count} triggers` : "";
1069
+ const last = a.last_triggered_at
1070
+ ? `, last triggered ${a.last_triggered_at}`
1071
+ : "";
1072
+ return `- **${a.name || label}** (id: \`${a.id}\`) — ${label} [${status}${triggers}${last}]`;
1073
+ }
1074
+ server.tool("opa_create_price_alert", "Create a PERSISTENT price alert tied to the user's OilPriceAPI account. " +
1075
+ "The alert engine continuously watches live prices and notifies the user (by " +
1076
+ "email, plus webhook if a URL is given) when the commodity price crosses the " +
1077
+ "threshold. Use when the user asks to be alerted/notified when a price goes " +
1078
+ "above or below a level (e.g. 'tell me when Brent drops below $70'). " +
1079
+ "REQUIRES an API key (OILPRICEAPI_KEY) — this writes to the user's account. " +
1080
+ "Alerts persist until deleted; manage them with opa_list_price_alerts and " +
1081
+ "opa_delete_price_alert.", {
1082
+ commodity: z
1083
+ .string()
1084
+ .describe("Commodity name or code to watch (e.g., 'brent', 'natural gas', 'WTI_USD')."),
1085
+ operator: z
1086
+ .enum(ALERT_OPERATORS)
1087
+ .describe("Threshold comparison: greater_than, less_than, equals, greater_than_or_equal, or less_than_or_equal. The alert fires when (current price) <operator> (threshold)."),
1088
+ threshold: z
1089
+ .number()
1090
+ .positive()
1091
+ .describe("The price threshold to compare against, in the commodity's native currency (e.g., 70 for $70/barrel)."),
1092
+ name: z
1093
+ .string()
1094
+ .optional()
1095
+ .describe("Optional human-readable label for the alert. If omitted, a descriptive name is generated."),
1096
+ notify: z
1097
+ .string()
1098
+ .url()
1099
+ .optional()
1100
+ .describe("Optional HTTPS webhook URL to POST to when the alert triggers (in addition to email). Must start with https://."),
1101
+ }, async ({ commodity, operator, threshold, name, notify }) => {
1102
+ const keyErr = requireApiKey();
1103
+ if (keyErr)
1104
+ return keyErr;
1105
+ const resolved = resolveOrError(commodity);
1106
+ if ("error" in resolved)
1107
+ return resolved.error;
1108
+ const operatorText = {
1109
+ greater_than: ">",
1110
+ less_than: "<",
1111
+ equals: "=",
1112
+ greater_than_or_equal: ">=",
1113
+ less_than_or_equal: "<=",
1114
+ };
1115
+ const defaultName = `${resolved.code} ${operatorText[operator]} ${threshold}`;
1116
+ const alert = {
1117
+ name: name || defaultName,
1118
+ commodity_code: resolved.code,
1119
+ condition_operator: operator,
1120
+ condition_value: threshold,
1121
+ // Attribution: stamp the alert as MCP-created via the alert's metadata
1122
+ // (the API permits a free-form metadata object on price alerts).
1123
+ metadata: { source: "mcp" },
1124
+ };
1125
+ if (notify)
1126
+ alert.webhook_url = notify;
1127
+ const result = await makeAuthRequest("/v1/alerts", {
1128
+ method: "POST",
1129
+ body: { price_alert: alert },
1130
+ });
1131
+ if (!result.ok) {
1132
+ return errorResult(alertHttpError(result, "create the price alert"));
1133
+ }
1134
+ const created = result.body;
1135
+ let text = "# Price Alert Created\n\n";
1136
+ text += formatAlertLine(created);
1137
+ text +=
1138
+ "\n\nThis alert is now active on the user's account and will notify them when the condition is met. " +
1139
+ "Use `opa_list_price_alerts` to see all alerts or `opa_delete_price_alert` to remove it.";
1140
+ text += `\n\n_Data from [OilPriceAPI](https://oilpriceapi.com)_`;
1141
+ return textResult(text);
1142
+ });
1143
+ server.tool("opa_list_price_alerts", "List all PERSISTENT price alerts on the user's OilPriceAPI account. Use when " +
1144
+ "the user asks what alerts they have set up, or to find an alert's id before " +
1145
+ "deleting it. REQUIRES an API key (OILPRICEAPI_KEY) — alerts are account-scoped. " +
1146
+ "No parameters needed.", {}, async () => {
1147
+ const keyErr = requireApiKey();
1148
+ if (keyErr)
1149
+ return keyErr;
1150
+ const result = await makeAuthRequest("/v1/alerts");
1151
+ if (!result.ok) {
1152
+ return errorResult(alertHttpError(result, "list price alerts"));
1153
+ }
1154
+ const alerts = Array.isArray(result.body)
1155
+ ? result.body
1156
+ : [];
1157
+ if (alerts.length === 0) {
1158
+ return textResult("No price alerts are set up on this account yet. Use `opa_create_price_alert` to create one.");
1159
+ }
1160
+ const sections = [`# Price Alerts (${alerts.length})\n`];
1161
+ for (const a of alerts)
1162
+ sections.push(formatAlertLine(a));
1163
+ sections.push(`\n_Data from [OilPriceAPI](https://oilpriceapi.com)_`);
1164
+ return textResult(sections.join("\n"));
1165
+ });
1166
+ server.tool("opa_delete_price_alert", "Permanently delete a price alert from the user's OilPriceAPI account by id. " +
1167
+ "Use when the user wants to remove/cancel/stop an existing alert. Get the id " +
1168
+ "from opa_list_price_alerts first. This permanently removes the alert from the " +
1169
+ "user's account. REQUIRES an API key (OILPRICEAPI_KEY).", {
1170
+ id: z
1171
+ .string()
1172
+ .describe("The id of the alert to delete (a UUID, as returned by opa_list_price_alerts or opa_create_price_alert)."),
1173
+ }, async ({ id }) => {
1174
+ const keyErr = requireApiKey();
1175
+ if (keyErr)
1176
+ return keyErr;
1177
+ const result = await makeAuthRequest(`/v1/alerts/${encodeURIComponent(id)}`, { method: "DELETE" });
1178
+ if (result.status === 404) {
1179
+ return errorResult(`No alert found with id \`${id}\` on this account. Use opa_list_price_alerts to see valid ids.`);
1180
+ }
1181
+ if (!result.ok) {
1182
+ return errorResult(alertHttpError(result, "delete the price alert"));
1183
+ }
1184
+ return textResult(`Price alert \`${id}\` was permanently deleted from the user's account.`);
1185
+ });
1186
+ server.tool("opa_get_alert_triggers", "Get recent trigger activity for the user's price alerts — which alerts have " +
1187
+ "fired, how many times, and when they last triggered. Use when the user asks " +
1188
+ "whether any alerts have gone off or about recent alert activity. REQUIRES an " +
1189
+ "API key (OILPRICEAPI_KEY). Note: the API tracks trigger history as per-alert " +
1190
+ "counters (trigger_count / last_triggered_at) rather than a separate event " +
1191
+ "feed, so this returns alerts that have triggered.", {
1192
+ since: z
1193
+ .string()
1194
+ .optional()
1195
+ .describe("Optional ISO 8601 date/time (e.g., '2026-06-01' or '2026-06-01T00:00:00Z'). Only alerts last triggered on or after this time are shown."),
1196
+ }, async ({ since }) => {
1197
+ const keyErr = requireApiKey();
1198
+ if (keyErr)
1199
+ return keyErr;
1200
+ let sinceTime = null;
1201
+ if (since) {
1202
+ const parsed = Date.parse(since);
1203
+ if (Number.isNaN(parsed)) {
1204
+ return errorResult(`'${since}' is not a valid date. Use an ISO 8601 date like '2026-06-01' or '2026-06-01T00:00:00Z'.`);
1205
+ }
1206
+ sinceTime = parsed;
1207
+ }
1208
+ const result = await makeAuthRequest("/v1/alerts");
1209
+ if (!result.ok) {
1210
+ return errorResult(alertHttpError(result, "fetch alert triggers"));
1211
+ }
1212
+ const alerts = Array.isArray(result.body)
1213
+ ? result.body
1214
+ : [];
1215
+ let triggered = alerts.filter((a) => typeof a.trigger_count === "number" && a.trigger_count > 0);
1216
+ if (sinceTime !== null) {
1217
+ triggered = triggered.filter((a) => {
1218
+ if (!a.last_triggered_at)
1219
+ return false;
1220
+ const t = Date.parse(a.last_triggered_at);
1221
+ return !Number.isNaN(t) && t >= sinceTime;
1222
+ });
1223
+ }
1224
+ if (triggered.length === 0) {
1225
+ return textResult(since
1226
+ ? `No price alerts have triggered since ${since}.`
1227
+ : "No price alerts have triggered yet.");
1228
+ }
1229
+ triggered.sort((a, b) => {
1230
+ const ta = a.last_triggered_at ? Date.parse(a.last_triggered_at) : 0;
1231
+ const tb = b.last_triggered_at ? Date.parse(b.last_triggered_at) : 0;
1232
+ return tb - ta;
1233
+ });
1234
+ const sections = [`# Recent Alert Triggers (${triggered.length})\n`];
1235
+ for (const a of triggered) {
1236
+ const label = a.summary ||
1237
+ a.condition ||
1238
+ `${a.commodity_code} ${a.condition_operator} ${a.condition_value}`;
1239
+ sections.push(`- **${a.name || label}** (id: \`${a.id}\`) — ${a.trigger_count} trigger(s), last at ${a.last_triggered_at}`);
1240
+ }
1241
+ sections.push(`\n_Data from [OilPriceAPI](https://oilpriceapi.com)_`);
1242
+ return textResult(sections.join("\n"));
1243
+ });
1244
+ // =========================================================================
1245
+ // RESOURCES — subscribable price snapshots + dynamic template
1246
+ // =========================================================================
728
1247
  server.resource("price-brent", "price://brent", {
729
1248
  description: "Current Brent Crude oil price (global benchmark)",
730
1249
  mimeType: "application/json",
@@ -785,14 +1304,31 @@ server.resource("market-overview", "price://all", {
785
1304
  ],
786
1305
  };
787
1306
  });
788
- // Register Prompts — pre-built analyst templates
1307
+ server.resource("price-diesel", "price://diesel", {
1308
+ description: "Current US national average diesel price",
1309
+ mimeType: "application/json",
1310
+ }, async () => {
1311
+ const response = await makeApiRequest("/v1/prices/latest?by_code=DIESEL_USD");
1312
+ return {
1313
+ contents: [
1314
+ {
1315
+ uri: "price://diesel",
1316
+ mimeType: "application/json",
1317
+ text: JSON.stringify(response?.data ?? { error: "unavailable" }, null, 2),
1318
+ },
1319
+ ],
1320
+ };
1321
+ });
1322
+ // =========================================================================
1323
+ // PROMPTS — pre-built analyst templates
1324
+ // =========================================================================
789
1325
  server.prompt("daily-briefing", "Energy market daily briefing with key prices, changes, and notable movements", {}, () => ({
790
1326
  messages: [
791
1327
  {
792
1328
  role: "user",
793
1329
  content: {
794
1330
  type: "text",
795
- text: "Give me today's energy market briefing. Get all commodity prices and provide:\n1. Key price levels for Brent, WTI, and Natural Gas\n2. Biggest movers (largest 24h % changes)\n3. Notable spreads (Brent-WTI, US gas vs EU gas)\n4. Brief market context\nFormat as a concise analyst briefing.",
1331
+ text: "Give me today's energy market briefing. Use opa_market_overview to get all commodity prices, then provide:\n1. Key price levels for Brent, WTI, and Natural Gas\n2. Biggest movers (largest 24h % changes)\n3. Notable spreads (Brent-WTI, US gas vs EU gas)\n4. Brief market context\nFormat as a concise analyst briefing.",
796
1332
  },
797
1333
  },
798
1334
  ],
@@ -803,7 +1339,7 @@ server.prompt("brent-wti-spread", "Analyze the Brent-WTI crude oil spread", {},
803
1339
  role: "user",
804
1340
  content: {
805
1341
  type: "text",
806
- text: "Compare Brent and WTI crude oil prices. Calculate the spread and explain what it means for the market. Is the spread widening or narrowing based on the 24h changes?",
1342
+ text: "Use opa_compare_prices with ['brent', 'wti'] to compare Brent and WTI crude oil prices. Calculate the spread and explain what it means for the market. Is the spread widening or narrowing based on the 24h changes?",
807
1343
  },
808
1344
  },
809
1345
  ],
@@ -814,7 +1350,7 @@ server.prompt("gas-market-analysis", "Compare US vs European natural gas markets
814
1350
  role: "user",
815
1351
  content: {
816
1352
  type: "text",
817
- text: "Get prices for US Natural Gas (Henry Hub), UK Natural Gas (NBP), and European Gas (TTF). Compare the three markets:\n1. Current price levels in their native currencies\n2. 24h changes\n3. Which market is moving most?\n4. What does the transatlantic gas price gap suggest about supply/demand dynamics?",
1353
+ text: "Use opa_compare_prices with ['natural gas', 'uk gas', 'european gas'] to compare the three gas markets:\n1. Current price levels in their native currencies\n2. 24h changes\n3. Which market is moving most?\n4. What does the transatlantic gas price gap suggest about supply/demand dynamics?",
818
1354
  },
819
1355
  },
820
1356
  ],
@@ -829,7 +1365,29 @@ server.prompt("commodity-report", "Detailed report on a specific commodity", {
829
1365
  role: "user",
830
1366
  content: {
831
1367
  type: "text",
832
- text: `Get the current price for ${commodity} and provide a detailed report:\n1. Current price and currency\n2. 24h price change (absolute and percentage)\n3. Compare with related commodities in the same category\n4. Key factors that typically affect this commodity's price\n5. Who are the main consumers and producers?`,
1368
+ text: `Use opa_get_price for ${commodity} and opa_get_history for its past month, then provide a detailed report:\n1. Current price and currency\n2. 24h price change (absolute and percentage)\n3. Monthly trend (high, low, average)\n4. Key factors that typically affect this commodity's price\n5. Who are the main consumers and producers?`,
1369
+ },
1370
+ },
1371
+ ],
1372
+ }));
1373
+ server.prompt("diesel-cost-analysis", "Compare diesel prices across US states for fleet cost planning", {}, () => ({
1374
+ messages: [
1375
+ {
1376
+ role: "user",
1377
+ content: {
1378
+ type: "text",
1379
+ text: "Use opa_get_diesel_by_state to get diesel prices for the top 5 trucking corridor states (TX, CA, FL, PA, IL). Compare prices and identify the cheapest and most expensive states. Calculate the cost difference for a 200-gallon fill-up between the cheapest and most expensive state.",
1380
+ },
1381
+ },
1382
+ ],
1383
+ }));
1384
+ server.prompt("supply-analysis", "Analyze oil supply fundamentals using production, rig counts, and storage data", {}, () => ({
1385
+ messages: [
1386
+ {
1387
+ role: "user",
1388
+ content: {
1389
+ type: "text",
1390
+ text: "Use opa_get_opec_production, opa_get_rig_counts, and opa_get_storage to analyze current oil supply fundamentals:\n1. OPEC production levels and recent changes\n2. US rig count trends\n3. Cushing and SPR inventory levels\n4. Overall supply outlook — bullish or bearish for prices?",
833
1391
  },
834
1392
  },
835
1393
  ],
@@ -840,13 +1398,12 @@ export function createSandboxServer() {
840
1398
  }
841
1399
  // Main entry point
842
1400
  async function main() {
843
- // Check for API key
844
1401
  if (!API_KEY) {
845
- console.error("Warning: OILPRICEAPI_KEY not set. Some features may be limited.");
1402
+ console.error("Warning: OILPRICEAPI_KEY not set. Get a free key at https://oilpriceapi.com/signup");
846
1403
  }
847
1404
  const transport = new StdioServerTransport();
848
1405
  await server.connect(transport);
849
- console.error("OilPriceAPI MCP Server running on stdio");
1406
+ console.error("OilPriceAPI MCP Server v2.2.0 running on stdio");
850
1407
  }
851
1408
  main().catch((error) => {
852
1409
  console.error("Fatal error:", error);