diff --git a/README.md b/README.md index 2f5c68c..af202d5 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,8 @@ All tools are prefixed with `opa_` to avoid name collisions when multiple MCP se | `opa_get_futures_curve` | Full forward curve with contango/backwardation analysis | | `opa_get_futures_history` | Brent, WTI, NYMEX natural gas OHLC history per contract month, up to 730 days | | `opa_get_marine_fuels` | Bunker fuel prices by port and fuel type (VLSFO/MGO/IFO380) | +| `opa_get_marine_ports` | Bunkering port directory (UN/LOCODE, region, fuel services) or one port with recent prices | +| `opa_get_marine_fuel_history` | Daily history of one marine fuel at one port; paid subscription or active trial | | `opa_get_rig_counts` | Baker Hughes US total rig count with region and observation date | | `opa_get_drilling` | Drilling snapshot: rig counts, frac spreads, 30-day permits, DUCs | | `opa_get_diesel_by_state` | AAA retail diesel price for any US state (50 states + DC) | diff --git a/capability-ledger.json b/capability-ledger.json index cefa401..98e523d 100644 --- a/capability-ledger.json +++ b/capability-ledger.json @@ -1,7 +1,7 @@ { "$comment": "API-to-MCP capability ledger (#77). One row per operation in the published OpenAPI contract. Checked by `npm run check:capability-ledger` against the live contract, build/capabilities.json and src/index.ts. Bump ledgerVersion and append to changes for every decision change, and link the change from the release notes.", "schemaVersion": "1.0.0", - "ledgerVersion": 6, + "ledgerVersion": 7, "source": { "openapi": { "url": "https://api.oilpriceapi.com/openapi.json", @@ -200,7 +200,9 @@ "status": "partial", "owner": "mcp-server", "tools": [ - "opa_get_marine_fuels" + "opa_get_marine_fuels", + "opa_get_marine_ports", + "opa_get_marine_fuel_history" ], "reason": "Latest prices are exposed through the canonical GET /v1/prices/marine-fuels/latest; ports, all-port prices and history are selection 3." }, @@ -911,20 +913,22 @@ { "operation": "GET /v1/marine-ports", "family": "marine", - "status": "not_exposed", - "disposition": "selected", + "status": "exposed", + "tools": [ + "opa_get_marine_ports" + ], "selection": 3, - "owner": "mcp-server", - "reason": "Port directory, so an agent can resolve a port before pricing it. Selection 3." + "owner": "mcp-server" }, { "operation": "GET /v1/marine-ports/{port_code}", "family": "marine", - "status": "not_exposed", - "disposition": "selected", + "status": "exposed", + "tools": [ + "opa_get_marine_ports" + ], "selection": 3, - "owner": "mcp-server", - "reason": "Single-port detail with prices. Selection 3." + "owner": "mcp-server" }, { "operation": "GET /v1/market-brief", @@ -1012,16 +1016,17 @@ "disposition": "selected", "selection": 3, "owner": "mcp-server", - "reason": "All-port marine fuel prices; opa_get_marine_fuels returns latest only. Selection 3." + "reason": "Paginated feed of recent marine collections (live 2026-10-15: 62 rows across 8 ports, several per fuel), overlapping opa_get_marine_fuels (latest per series) and opa_get_marine_fuel_history (per-port series). Selection 3; not shipped in the first #125 slice." }, { "operation": "GET /v1/prices/marine-fuels/historical", "family": "marine", - "status": "not_exposed", - "disposition": "selected", + "status": "exposed", + "tools": [ + "opa_get_marine_fuel_history" + ], "selection": 3, - "owner": "mcp-server", - "reason": "Marine fuel history; opa_get_marine_fuels returns latest only. Selection 3." + "owner": "mcp-server" }, { "operation": "GET /v1/prices/marine-fuels/latest", @@ -1494,6 +1499,12 @@ "date": "2026-10-17", "issue": "https://github.com/OilpriceAPI/mcp-server/issues/124", "summary": "Selection 2 shipped: GET /v1/futures/brent/historical, GET /v1/futures/wti/historical and GET /v1/futures/natural-gas/historical are exposed by the new opa_get_futures_history tool (from/to up to 730 days, optional contract_month, daily/weekly/monthly, explicit row cap notice). The three canonical spreads rows stay deferred with an updated reason; preview OHLC, intraday and gasoil/carbon history stay deferred under futures-expanded (route policy: product_preview)." + }, + { + "ledgerVersion": 7, + "date": "2026-10-17", + "issue": "https://github.com/OilpriceAPI/mcp-server/issues/125", + "summary": "Selection 3, first slice: expose GET /v1/marine-ports and GET /v1/marine-ports/{port_code} through opa_get_marine_ports and GET /v1/prices/marine-fuels/historical through opa_get_marine_fuel_history; move opa_get_marine_fuels from the deprecated /v1/marine-fuels/latest alias to canonical GET /v1/prices/marine-fuels/latest. GET /v1/prices/marine-fuels stays selected and not exposed. History requires a paid subscription or an active trial (API 403 otherwise); the tool reports the API's refusal text." } ] } diff --git a/scripts/docker-smoke.mjs b/scripts/docker-smoke.mjs index 74eb5aa..952f6ca 100644 --- a/scripts/docker-smoke.mjs +++ b/scripts/docker-smoke.mjs @@ -82,7 +82,7 @@ try { ); if ( tools.scope !== "read" || - tools.tools.length !== 34 || + tools.tools.length !== 36 || tools.tools.some((tool) => tool.access === "write") ) { throw new Error("Container default tool inventory was not exact/read-only."); @@ -117,7 +117,7 @@ try { const capabilities = JSON.parse(readFileSync(join(build, "capabilities.json"), "utf8")); if (capabilities.package.sourceCommit !== process.env.EXPECTED_SOURCE_COMMIT) throw new Error("capability provenance drift"); if (capabilities.generatedAt !== process.env.EXPECTED_GENERATED_AT) throw new Error("capability timestamp drift"); - if (capabilities.tools.length !== 38) throw new Error("capability inventory drift"); + if (capabilities.tools.length !== 40) throw new Error("capability inventory drift"); if (existsSync(join(build, "__tests__"))) throw new Error("test artifacts leaked into runtime image"); for (const entry of readdirSync(build)) { if (/\.(?:test|spec)\./.test(entry)) throw new Error("test artifact leaked into runtime image"); diff --git a/scripts/live-contract-catalog.mjs b/scripts/live-contract-catalog.mjs index 44bb66c..e374296 100644 --- a/scripts/live-contract-catalog.mjs +++ b/scripts/live-contract-catalog.mjs @@ -91,11 +91,23 @@ export const LIVE_CONTRACT_CATALOG = { { args: {} }, ), opa_get_marine_fuels: read( - "/v1/prices/marine-fuels/latest", + "/v1/prices/marine-fuels/latest?limit=100", "success-envelope", "conditional", { args: {} }, ), + opa_get_marine_ports: read( + "/v1/marine-ports/SGSIN", + "success-envelope", + "conditional", + { args: { port: "SGSIN" } }, + ), + opa_get_marine_fuel_history: read( + "/v1/prices/marine-fuels/historical?port_code=SGSIN&fuel_type=VLSFO_SGSIN_USD&days=7", + "success-envelope", + "conditional", + { args: { port_code: "SGSIN", fuel_type: "VLSFO", days: 7 } }, + ), opa_get_rig_counts: read( "/v1/rig-counts/latest", "success-envelope", diff --git a/scripts/live-contract-matrix-smoke.mjs b/scripts/live-contract-matrix-smoke.mjs index c30b3a1..26a16fe 100644 --- a/scripts/live-contract-matrix-smoke.mjs +++ b/scripts/live-contract-matrix-smoke.mjs @@ -6,7 +6,7 @@ import { LIVE_CONTRACT_CATALOG } from "./live-contract-catalog.mjs"; const capabilities = JSON.parse(readFileSync(DEFAULT_CAPABILITIES_PATH, "utf8")); const matrix = buildLiveContractMatrix(capabilities, LIVE_CONTRACT_CATALOG); -if (matrix.totals.tools !== 38 || matrix.tools.length !== capabilities.tools.length) { +if (matrix.totals.tools !== 40 || matrix.tools.length !== capabilities.tools.length) { throw new Error("matrix did not cover the complete registered tool inventory"); } for (const mode of ["network-read", "network-write", "non-network"]) { diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index 8d3fbee..a5dc328 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -201,7 +201,7 @@ try { ); if ( readTools.scope !== "read" || - readTools.tools.length !== 34 || + readTools.tools.length !== 36 || readTools.tools.some((tool) => tool.access === "write") ) { throw new Error( @@ -314,14 +314,14 @@ try { entryPoint, baseUrl, "read", - 34, + 36, capabilities.tools.length, ); await assertProtocolScope( entryPoint, baseUrl, "write", - 38, + 40, capabilities.tools.length, ); diff --git a/src/__tests__/capabilityLedger.test.ts b/src/__tests__/capabilityLedger.test.ts index 8097b7d..7096c52 100644 --- a/src/__tests__/capabilityLedger.test.ts +++ b/src/__tests__/capabilityLedger.test.ts @@ -144,11 +144,11 @@ describe("capability ledger", () => { it("fails when an exposed row is not reached by any MCP source call", () => { const broken = clone(ledger); - // #123 exposed /v1/prices/historical for real, so the fixture uses a row + // Marine history is exposed, so the fixture uses a row // no tool reaches yet. const row = broken.operations.find( (entry: { operation: string }) => - entry.operation === "GET /v1/prices/marine-fuels/historical", + entry.operation === "GET /v1/electricity", ); Object.assign(row, { status: "exposed", tools: ["opa_get_marine_fuels"] }); delete row.disposition; @@ -156,7 +156,7 @@ describe("capability ledger", () => { delete row.selection; const result = evaluate({ ledger: broken }); expect(result.errors.join("\n")).toContain( - "GET /v1/prices/marine-fuels/historical is marked exposed but no MCP source call reaches it", + "GET /v1/electricity is marked exposed but no MCP source call reaches it", ); }); diff --git a/src/__tests__/entitlementMisattribution.test.ts b/src/__tests__/entitlementMisattribution.test.ts index 0cc444e..84cb09b 100644 --- a/src/__tests__/entitlementMisattribution.test.ts +++ b/src/__tests__/entitlementMisattribution.test.ts @@ -121,6 +121,12 @@ const SITES: Array<{ args: {}, }, { label: "opa_get_marine_fuels", tool: "opa_get_marine_fuels", args: {} }, + { label: "opa_get_marine_ports (list)", tool: "opa_get_marine_ports", args: {} }, + { + label: "opa_get_marine_fuel_history", + tool: "opa_get_marine_fuel_history", + args: { port_code: "SGSIN", fuel_type: "VLSFO", days: 30 }, + }, { label: "opa_get_rig_counts", tool: "opa_get_rig_counts", args: {} }, { label: "opa_get_drilling", tool: "opa_get_drilling", args: {} }, { diff --git a/src/__tests__/fixtures/marineWorkflowsLive.ts b/src/__tests__/fixtures/marineWorkflowsLive.ts new file mode 100644 index 0000000..f922133 --- /dev/null +++ b/src/__tests__/fixtures/marineWorkflowsLive.ts @@ -0,0 +1,366 @@ +// Live payloads for the marine workflow tools (#125). Do not hand-edit: re-capture. +// Captured 2026-10-15 from api.oilpriceapi.com with the production smoke key. + +/** Verbatim: GET /v1/marine-ports */ +export const LIVE_MARINE_PORTS = { + "status": "success", + "data": { + "ports": [ + { + "code": "SGSIN", + "name": "Singapore", + "country": "Singapore", + "region": "Asia", + "major_port": true, + "coordinates": { + "latitude": 1.2966, + "longitude": 103.7764 + }, + "fuel_services": [ + "MGO_05S", + "VLSFO", + "HFO_380", + "HFO_180" + ], + "trading_hours": "24/7" + }, + { + "code": "NLRTM", + "name": "Rotterdam", + "country": "Netherlands", + "region": "Europe", + "major_port": true, + "coordinates": { + "latitude": 51.9225, + "longitude": 4.47917 + }, + "fuel_services": [ + "MGO_05S", + "VLSFO", + "HFO_380", + "HFO_180" + ], + "trading_hours": "24/7" + }, + { + "code": "USHOU", + "name": "Houston", + "country": "United States", + "region": "Americas", + "major_port": true, + "coordinates": { + "latitude": 29.7604, + "longitude": -95.3698 + }, + "fuel_services": [ + "MGO_05S", + "VLSFO", + "HFO_380", + "HFO_180" + ], + "trading_hours": "24/7" + }, + { + "code": "AEFUJ", + "name": "Fujairah", + "country": "United Arab Emirates", + "region": "Middle East", + "major_port": true, + "coordinates": { + "latitude": 25.1312, + "longitude": 56.3269 + }, + "fuel_services": [ + "MGO_05S", + "VLSFO", + "HFO_380", + "HFO_180" + ], + "trading_hours": "24/7" + }, + { + "code": "HKHKG", + "name": "Hong Kong", + "country": "Hong Kong", + "region": "Asia", + "major_port": true, + "coordinates": { + "latitude": 22.3193, + "longitude": 114.1694 + }, + "fuel_services": [ + "MGO_05S", + "VLSFO", + "HFO_380" + ], + "trading_hours": "24/7" + }, + { + "code": "USLAX", + "name": "Los Angeles", + "country": "United States", + "region": "Americas", + "major_port": true, + "coordinates": { + "latitude": 33.7405, + "longitude": -118.2637 + }, + "fuel_services": [ + "MGO_05S", + "VLSFO", + "HFO_380" + ], + "trading_hours": "06:00-22:00" + }, + { + "code": "BRSSZ", + "name": "Santos", + "country": "Brazil", + "region": "Americas", + "major_port": true, + "coordinates": { + "latitude": -23.9537, + "longitude": -46.3227 + }, + "fuel_services": [ + "MGO_05S", + "VLSFO", + "HFO_380" + ], + "trading_hours": "06:00-22:00" + }, + { + "code": "USNYC", + "name": "New York", + "country": "United States", + "region": "Americas", + "major_port": true, + "coordinates": { + "latitude": 40.7128, + "longitude": -74.006 + }, + "fuel_services": [ + "MGO_05S", + "VLSFO", + "HFO_380" + ], + "trading_hours": "06:00-22:00" + } + ], + "count": 8, + "filters": { + "region": null, + "country": null, + "major_ports_only": false + } + } +}; + +/** Verbatim: GET /v1/marine-ports/SGSIN */ +export const LIVE_MARINE_PORT_SGSIN = { + "status": "success", + "data": { + "port": { + "code": "SGSIN", + "name": "Singapore", + "country": "Singapore", + "region": "Asia", + "major_port": true, + "coordinates": { + "latitude": 1.2966, + "longitude": 103.7764 + }, + "metadata": { + "timezone": "Asia/Singapore", + "fuel_services": [ + "MGO_05S", + "VLSFO", + "HFO_380", + "HFO_180" + ], + "trading_hours": "24/7", + "annual_volume_mt": 50000000 + }, + "fuel_services": [ + "MGO_05S", + "VLSFO", + "HFO_380", + "HFO_180" + ], + "trading_hours": "24/7", + "timezone": "Asia/Singapore", + "annual_volume_mt": 50000000, + "recent_prices": [ + { + "fuel_type": "HFO_380", + "fuel_name": "Heavy Fuel Oil 380 CST (HFO 380) - Singapore (SGSIN)", + "price": 876, + "formatted": "$876.00", + "currency": "USD", + "unit": "metric_ton", + "source": "market_reporting", + "timestamp": "2026-10-15T03:11:13.045Z" + }, + { + "fuel_type": "MGO_05S", + "fuel_name": "Marine Gas Oil 0.5%S (MGO) - Singapore (SGSIN)", + "price": 1295, + "formatted": "$1295.00", + "currency": "USD", + "unit": "metric_ton", + "source": "market_reporting", + "timestamp": "2026-10-15T03:11:12.984Z" + }, + { + "fuel_type": "VLSFO", + "fuel_name": "Very Low Sulfur Fuel Oil (VLSFO) - Singapore (SGSIN)", + "price": 917, + "formatted": "$917.00", + "currency": "USD", + "unit": "metric_ton", + "source": "market_reporting", + "timestamp": "2026-10-15T03:11:12.927Z" + } + ], + "statistics": { + "total_fuel_types": 4, + "recent_price_updates": 3, + "last_update": "2026-10-15T03:11:13Z" + } + } + } +}; + +/** Verbatim: GET /v1/prices/marine-fuels/historical?port_code=SGSIN&fuel_type=VLSFO_SGSIN_USD&days=7 */ +export const LIVE_MARINE_HISTORY_SGSIN_VLSFO_7D = { + "status": "success", + "data": { + "prices": [ + { + "port_code": "SGSIN", + "port_name": "Singapore", + "country": "Singapore", + "region": "Asia", + "coordinates": { + "latitude": 1.2966, + "longitude": 103.7764 + }, + "fuels": [ + { + "code": "VLSFO_SGSIN_USD", + "fuel_type": "VLSFO", + "fuel_name": "Very Low Sulfur Fuel Oil (VLSFO) - Singapore (SGSIN)", + "price": 917, + "formatted": "$917.00", + "currency": "USD", + "unit": "metric_ton", + "source": "market_reporting", + "timestamp": "2026-10-15T03:11:12.927Z", + "quality_grade": "ISO 8217 RMG380", + "as_of": "2026-10-14T17:00:00.000Z", + "collected_at": "2026-10-15T03:11:12.927Z", + "synthetic": false, + "stale": false, + "age_days": 1 + }, + { + "code": "VLSFO_SGSIN_USD", + "fuel_type": "VLSFO", + "fuel_name": "Very Low Sulfur Fuel Oil (VLSFO) - Singapore (SGSIN)", + "price": 907, + "formatted": "$907.00", + "currency": "USD", + "unit": "metric_ton", + "source": "market_reporting", + "timestamp": "2026-10-14T03:11:02.913Z", + "quality_grade": "ISO 8217 RMG380", + "as_of": "2026-10-13T17:00:00.000Z", + "collected_at": "2026-10-14T03:11:02.913Z", + "synthetic": false, + "stale": true, + "age_days": 2 + }, + { + "code": "VLSFO_SGSIN_USD", + "fuel_type": "VLSFO", + "fuel_name": "Very Low Sulfur Fuel Oil (VLSFO) - Singapore (SGSIN)", + "price": 892.5, + "formatted": "$892.50", + "currency": "USD", + "unit": "metric_ton", + "source": "market_reporting", + "timestamp": "2026-10-13T03:11:13.189Z", + "quality_grade": "ISO 8217 RMG380", + "as_of": "2026-10-12T17:00:00.000Z", + "collected_at": "2026-10-13T03:11:13.189Z", + "synthetic": false, + "stale": true, + "age_days": 3 + }, + { + "code": "VLSFO_SGSIN_USD", + "fuel_type": "VLSFO", + "fuel_name": "Very Low Sulfur Fuel Oil (VLSFO) - Singapore (SGSIN)", + "price": 889.5, + "formatted": "$889.50", + "currency": "USD", + "unit": "metric_ton", + "source": "market_reporting", + "timestamp": "2026-10-10T03:11:14.303Z", + "quality_grade": "ISO 8217 RMG380", + "as_of": "2026-10-09T17:00:00.000Z", + "collected_at": "2026-10-10T03:11:14.303Z", + "synthetic": false, + "stale": true, + "age_days": 6 + }, + { + "code": "VLSFO_SGSIN_USD", + "fuel_type": "VLSFO", + "fuel_name": "Very Low Sulfur Fuel Oil (VLSFO) - Singapore (SGSIN)", + "price": 908, + "formatted": "$908.00", + "currency": "USD", + "unit": "metric_ton", + "source": "market_reporting", + "timestamp": "2026-10-09T03:11:05.040Z", + "quality_grade": "ISO 8217 RMG380", + "as_of": "2026-10-08T17:00:00.000Z", + "collected_at": "2026-10-09T03:11:05.040Z", + "synthetic": false, + "stale": true, + "age_days": 7 + } + ] + } + ], + "metadata": { + "port": "SGSIN", + "fuel_type": "VLSFO_SGSIN_USD", + "days": 7, + "count": 1, + "period": { + "start": "2026-10-08T17:18:57Z", + "end": "2026-10-15T17:18:57Z" + } + } + } +}; + +/** Verbatim: GET /v1/prices/marine-fuels/historical?port_code=SGSIN&fuel_type=HFO_180_SGSIN_USD&days=30 (SGSIN lists HFO_180 in fuel_services but carries no rows) */ +export const LIVE_MARINE_HISTORY_EMPTY = { + "status": "success", + "data": { + "prices": [], + "metadata": { + "port": "SGSIN", + "fuel_type": "HFO_180_SGSIN_USD", + "days": 30, + "count": 0, + "period": { + "start": "2026-09-15T17:24:17Z", + "end": "2026-10-15T17:24:17Z" + } + } + } +}; diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 6e907ea..93c8beb 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -1526,8 +1526,8 @@ describe("tool registration metadata", () => { const DELETE_TOOLS = ["opa_delete_price_alert", "opa_delete_subscription"]; const WRITE_TOOLS = new Set([...CREATE_TOOLS, ...DELETE_TOOLS]); - it("registers exactly 38 tools", () => { - expect(Object.keys(tools)).toHaveLength(38); + it("registers exactly 40 tools", () => { + expect(Object.keys(tools)).toHaveLength(40); expect(tools.opa_get_product_facts).toBeDefined(); expect(tools.opa_get_natural_gas_hubs).toBeDefined(); expect(tools.opa_get_account_status).toBeDefined(); @@ -1544,6 +1544,7 @@ describe("tool registration metadata", () => { opa_get_futures_curve: /eligible account entitlement.*opa_get_plans/, opa_get_futures_history: /eligible account entitlement.*opa_get_plans/, opa_get_marine_fuels: /eligible account entitlement.*opa_get_plans/, + opa_get_marine_fuel_history: /eligible account entitlement.*opa_get_plans/, opa_get_spread: /eligible account entitlement.*opa_get_plans/, opa_get_storage: /eligible account entitlement.*opa_get_plans/, opa_get_rig_counts: /eligible account entitlement.*opa_get_plans/, diff --git a/src/__tests__/liveContractFields.test.ts b/src/__tests__/liveContractFields.test.ts index b814c33..e315ed7 100644 --- a/src/__tests__/liveContractFields.test.ts +++ b/src/__tests__/liveContractFields.test.ts @@ -165,7 +165,7 @@ describe("#118 live contract asserts the fields the formatter reads", () => { const outcome = await run( "opa_get_marine_fuels", {}, - "/v1/prices/marine-fuels/latest", + "/v1/prices/marine-fuels/latest?limit=100", { status: "success", data: { prices: [{ port_code: "AEFUJ" }] } }, ); diff --git a/src/__tests__/marineFuelsFieldMapping.test.ts b/src/__tests__/marineFuelsFieldMapping.test.ts index 1f5ddd6..c1bc9c5 100644 --- a/src/__tests__/marineFuelsFieldMapping.test.ts +++ b/src/__tests__/marineFuelsFieldMapping.test.ts @@ -197,7 +197,7 @@ describe("#120 filters match what the response carries, not what the API filter ); // The API's own port filter matches codes only and its fuel_type filter // matches nothing, so the tool never forwards either. - expect(requestedPath(fetchSpy)).toBe("/v1/prices/marine-fuels/latest"); + expect(requestedPath(fetchSpy)).toBe("/v1/prices/marine-fuels/latest?limit=100"); }, ); @@ -218,7 +218,7 @@ describe("#120 filters match what the response carries, not what the API filter expect(result.isError).not.toBe(true); expect(rows).toHaveLength(8); expect(rows.every((row) => row.includes(` | ${reported} | `))).toBe(true); - expect(requestedPath(fetchSpy)).toBe("/v1/prices/marine-fuels/latest"); + expect(requestedPath(fetchSpy)).toBe("/v1/prices/marine-fuels/latest?limit=100"); }); it("port and fuel_type together narrow to one price", async () => { @@ -276,7 +276,7 @@ describe("#120 field-level live contract (#118) passes on the live payload", () tool: "opa_get_marine_fuels", handler: marine.handler, args, - contractPath: "/v1/prices/marine-fuels/latest", + contractPath: "/v1/prices/marine-fuels/latest?limit=100", contractResponse: { status: 200, text: JSON.stringify(LIVE_MARINE_FUELS_LATEST), diff --git a/src/__tests__/marineWorkflows.test.ts b/src/__tests__/marineWorkflows.test.ts new file mode 100644 index 0000000..ddb5455 --- /dev/null +++ b/src/__tests__/marineWorkflows.test.ts @@ -0,0 +1,389 @@ +/** + * #125 — marine workflows: the port directory, single-port detail and + * marine-fuel history, plus moving opa_get_marine_fuels to the canonical path. + * + * Backend contract, read from oilpriceapi-api origin/main and probed live on + * 2026-10-15 with the production smoke key: + * + * GET /v1/marine-ports -> {ports[], count, filters} + * GET /v1/marine-ports/SGSIN -> {port: {..., recent_prices[]}} + * GET /v1/marine-ports/XXXXX -> 404 {"error":"Marine fuel port not found"} + * GET /v1/prices/marine-fuels/historical?port_code=SGSIN&fuel_type=VLSFO + * -> 200, prices: [] (bare type matches nothing, + * oilpriceapi-api#7774) + * ...&fuel_type=VLSFO_SGSIN_USD -> 200, the series + * free account without a live trial -> 403 {"error":"Historical marine fuel data requires a paid subscription"} + * + * Assertions are on the rendered text through the real registered handler, + * with only `fetch` stubbed. + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { ApiGateError, createSandboxServer } from "../index.js"; +import { + LIVE_MARINE_HISTORY_EMPTY, + LIVE_MARINE_HISTORY_SGSIN_VLSFO_7D, + LIVE_MARINE_PORT_SGSIN, + LIVE_MARINE_PORTS, +} from "./fixtures/marineWorkflowsLive.js"; +import { LIVE_MARINE_FUELS_LATEST } from "./fixtures/marineFuelsLive.js"; + +type ToolResult = { + content: Array<{ type: string; text: string }>; + isError?: boolean; +}; +type Handler = ( + args: Record, + extra: Record, +) => Promise; +type Registered = Record< + string, + { + handler: Handler; + description?: string; + inputSchema?: { safeParse: (v: unknown) => { success: boolean } }; + } +>; + +const tools = ( + createSandboxServer() as unknown as { _registeredTools: Registered } +)._registeredTools; + +function copy(value: T): any { + return JSON.parse(JSON.stringify(value)); +} + +function stubResponse(body: unknown, status = 200) { + const fetchSpy = vi.fn().mockImplementation( + async () => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchSpy); + return fetchSpy; +} + +function requestedPath(fetchSpy: ReturnType): string { + const url = new URL(String(fetchSpy.mock.calls[0][0])); + return `${url.pathname}${url.search}`; +} + +function textOf(result: ToolResult): string { + return result.content.map((c) => c.text).join("\n"); +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe("#125 opa_get_marine_fuels uses the canonical latest path", () => { + it("requests /v1/prices/marine-fuels/latest, not the deprecated alias", async () => { + vi.stubEnv("OILPRICEAPI_KEY", "test-key-123"); + const fetchSpy = stubResponse(LIVE_MARINE_FUELS_LATEST); + const result = await tools.opa_get_marine_fuels.handler({}, {}); + expect(result.isError).not.toBe(true); + expect(requestedPath(fetchSpy)).toBe( + "/v1/prices/marine-fuels/latest?limit=100", + ); + }); +}); + +describe("#125 opa_get_marine_ports", () => { + const ports = () => tools.opa_get_marine_ports; + + it("is registered", () => { + expect(ports()).toBeDefined(); + }); + + it("lists every port the directory returns with its fuel services", async () => { + vi.stubEnv("OILPRICEAPI_KEY", "test-key-123"); + const fetchSpy = stubResponse(LIVE_MARINE_PORTS); + const result = await ports().handler({}, {}); + const text = textOf(result); + + expect(result.isError).not.toBe(true); + expect(requestedPath(fetchSpy)).toBe("/v1/marine-ports"); + for (const code of [ + "SGSIN", + "NLRTM", + "USHOU", + "AEFUJ", + "HKHKG", + "USLAX", + "BRSSZ", + "USNYC", + ]) { + expect(text).toContain(`| ${code} |`); + } + expect(text).toContain( + "| SGSIN | Singapore | Singapore | Asia | yes | MGO_05S, VLSFO, HFO_380, HFO_180 | 24/7 |", + ); + expect(text).toContain("8 ports"); + }); + + it("forwards region, country and major_only as query filters", async () => { + vi.stubEnv("OILPRICEAPI_KEY", "test-key-123"); + const fetchSpy = stubResponse(LIVE_MARINE_PORTS); + await ports().handler( + { + region: "Middle East", + country: "United Arab Emirates", + major_only: true, + }, + {}, + ); + expect(requestedPath(fetchSpy)).toBe( + "/v1/marine-ports?region=Middle+East&country=United+Arab+Emirates&major_ports=true", + ); + }); + + it("reads one port by UN/LOCODE, uppercased, with its recent prices", async () => { + vi.stubEnv("OILPRICEAPI_KEY", "test-key-123"); + const fetchSpy = stubResponse(LIVE_MARINE_PORT_SGSIN); + const result = await ports().handler({ port: "sgsin" }, {}); + const text = textOf(result); + + expect(result.isError).not.toBe(true); + expect(requestedPath(fetchSpy)).toBe("/v1/marine-ports/SGSIN"); + expect(text).toContain("Singapore (SGSIN)"); + expect(text).toContain("Asia/Singapore"); + expect(text).toContain( + "| VLSFO | 917 | USD | metric_ton | 2026-10-15T03:11:12.927Z |", + ); + expect(text).toContain("| MGO_05S | 1295 | USD | metric_ton |"); + }); + + it("says a port's recent prices are absent instead of rendering an empty table", async () => { + vi.stubEnv("OILPRICEAPI_KEY", "test-key-123"); + const body = copy(LIVE_MARINE_PORT_SGSIN); + body.data.port.recent_prices = []; + stubResponse(body); + const text = textOf(await ports().handler({ port: "SGSIN" }, {})); + expect(text).toMatch(/no prices observed in the last 7 days/i); + expect(text).not.toContain("| Fuel Type |"); + }); + + it("labels weekend observations with their actual age instead of claiming a 24-hour price", async () => { + vi.stubEnv("OILPRICEAPI_KEY", "test-key-123"); + const body = copy(LIVE_MARINE_PORT_SGSIN); + body.data.port.recent_prices = [{ + fuel_type: "VLSFO", price: 917, currency: "USD", unit: "metric_ton", + timestamp: "2026-10-12T03:00:00Z", collected_at: "2026-10-12T03:00:00Z", + as_of: "2026-10-09T17:00:00Z", age_days: 3, stale: true, + }]; + stubResponse(body); + const text = textOf(await ports().handler({ port: "SGSIN" }, {})); + expect(text).toContain("2026-10-09T17:00:00Z | 3 | **yes**"); + expect(text).toContain("up to 7 days old"); + expect(text).not.toContain("last 24 hours"); + }); + + it("never prints an absent field as data", async () => { + vi.stubEnv("OILPRICEAPI_KEY", "test-key-123"); + const body = copy(LIVE_MARINE_PORT_SGSIN); + delete body.data.port.recent_prices[0].currency; + delete body.data.port.timezone; + stubResponse(body); + const text = textOf(await ports().handler({ port: "SGSIN" }, {})); + expect(text).not.toMatch(/undefined|NaN|null/); + expect(text).toContain("| HFO_380 | 876 | not reported | metric_ton |"); + expect(text).toMatch(/Timezone: not reported/); + }); + + it("a 404 names the unknown port and points at the directory, not the plan", async () => { + vi.stubEnv("OILPRICEAPI_KEY", "test-key-123"); + stubResponse({ error: "Marine fuel port not found" }, 404); + const result = await ports().handler({ port: "XXXXX" }, {}); + const text = textOf(result); + expect(result.isError).toBe(true); + expect(text).toContain("XXXXX"); + expect(text).toContain("opa_get_marine_ports"); + expect(text).not.toMatch(/upgrade|higher plan/i); + }); + + it("an empty directory answer is an error, not an empty table", async () => { + vi.stubEnv("OILPRICEAPI_KEY", "test-key-123"); + stubResponse({ + status: "success", + data: { ports: [], count: 0, filters: {} }, + }); + const result = await ports().handler({ region: "Antarctica" }, {}); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain("Antarctica"); + }); + + it("rejects path-unsafe port input before any network call", () => { + const schema = ports().inputSchema!; + expect(schema.safeParse({ port: "../admin" }).success).toBe(false); + expect(schema.safeParse({ port: "SGSIN/x" }).success).toBe(false); + expect(schema.safeParse({ port: "SGSIN" }).success).toBe(true); + expect(schema.safeParse({ port: "Singapore" }).success).toBe(true); + }); +}); + +describe("#125 opa_get_marine_fuel_history", () => { + const history = () => tools.opa_get_marine_fuel_history; + + it("is registered and states its plan boundary", () => { + expect(history()).toBeDefined(); + expect(history().description).toMatch( + /paid subscription or an active trial/, + ); + expect(history().description).toMatch(/opa_get_plans/); + }); + + it("requests the full series code, because a bare fuel type returns an empty 200", async () => { + vi.stubEnv("OILPRICEAPI_KEY", "test-key-123"); + const fetchSpy = stubResponse(LIVE_MARINE_HISTORY_SGSIN_VLSFO_7D); + await history().handler( + { port_code: "sgsin", fuel_type: "VLSFO", days: 7 }, + {}, + ); + expect(requestedPath(fetchSpy)).toBe( + "/v1/prices/marine-fuels/historical?port_code=SGSIN&fuel_type=VLSFO_SGSIN_USD&days=7", + ); + }); + + it("renders one row per assessment date, newest first, with currency and unit", async () => { + vi.stubEnv("OILPRICEAPI_KEY", "test-key-123"); + stubResponse(LIVE_MARINE_HISTORY_SGSIN_VLSFO_7D); + const result = await history().handler( + { port_code: "SGSIN", fuel_type: "VLSFO", days: 7 }, + {}, + ); + const text = textOf(result); + + expect(result.isError).not.toBe(true); + expect(text).toContain("Singapore (SGSIN)"); + expect(text).toContain( + "| 2026-10-14 | 917 | USD | metric_ton | 2026-10-15T03:11:12.927Z |", + ); + expect(text).toContain("| 2026-10-08 | 908 | USD | metric_ton |"); + expect(text).toMatch(/5 assessment dates/); + expect(text).toMatch(/High 917.*Low 889\.5/); + }); + + it("keeps the most recently collected value when one date was collected twice", async () => { + vi.stubEnv("OILPRICEAPI_KEY", "test-key-123"); + const body = copy(LIVE_MARINE_HISTORY_SGSIN_VLSFO_7D); + const fuels = body.data.prices[0].fuels; + // An earlier collection of the 10-14 assessment at a different price. + fuels.splice(1, 0, { + ...fuels[0], + price: 900, + collected_at: "2026-10-14T23:00:00.000Z", + timestamp: "2026-10-14T23:00:00.000Z", + }); + stubResponse(body); + const text = textOf( + await history().handler( + { port_code: "SGSIN", fuel_type: "VLSFO", days: 7 }, + {}, + ), + ); + expect(text).toContain("| 2026-10-14 | 917 |"); + expect(text).not.toContain("| 2026-10-14 | 900 |"); + expect(text).toMatch(/6 rows/); + }); + + it("says when the returned rows stop short of the requested window", async () => { + vi.stubEnv("OILPRICEAPI_KEY", "test-key-123"); + stubResponse(LIVE_MARINE_HISTORY_SGSIN_VLSFO_7D); + const text = textOf( + await history().handler( + { port_code: "SGSIN", fuel_type: "VLSFO", days: 365 }, + {}, + ), + ); + expect(text).toMatch(/earliest assessment returned is 2026-10-08/i); + }); + + it("an empty series names the port and fuel and points at fuel_services", async () => { + vi.stubEnv("OILPRICEAPI_KEY", "test-key-123"); + stubResponse(LIVE_MARINE_HISTORY_EMPTY); + const result = await history().handler( + { port_code: "SGSIN", fuel_type: "HFO_180", days: 30 }, + {}, + ); + const text = textOf(result); + expect(result.isError).toBe(true); + expect(text).toContain("HFO_180"); + expect(text).toContain("SGSIN"); + expect(text).toContain("opa_get_marine_ports"); + expect(text).not.toMatch(/upgrade|higher plan/i); + }); + + it("never prints an absent field as data", async () => { + vi.stubEnv("OILPRICEAPI_KEY", "test-key-123"); + const body = copy(LIVE_MARINE_HISTORY_SGSIN_VLSFO_7D); + delete body.data.prices[0].fuels[0].unit; + delete body.data.prices[0].fuels[1].as_of; + stubResponse(body); + const text = textOf( + await history().handler( + { port_code: "SGSIN", fuel_type: "VLSFO", days: 7 }, + {}, + ), + ); + expect(text).not.toMatch(/undefined|NaN|null|Invalid Date/); + expect(text).toContain("| 2026-10-14 | 917 | USD | not reported |"); + expect(text).toContain("| not reported | 907 |"); + }); + + it("a 403 surfaces the API's own entitlement text", async () => { + vi.stubEnv("OILPRICEAPI_KEY", "test-key-123"); + stubResponse( + { error: "Historical marine fuel data requires a paid subscription" }, + 403, + ); + const error = await history() + .handler({ port_code: "SGSIN", fuel_type: "VLSFO" }, {}) + .then( + () => null, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(ApiGateError); + expect((error as ApiGateError).status).toBe(403); + expect((error as Error).message).toContain( + "Historical marine fuel data requires a paid subscription", + ); + }); + + it("validates input before any network call", () => { + const schema = history().inputSchema!; + expect( + schema.safeParse({ port_code: "SGSIN", fuel_type: "VLSFO" }).success, + ).toBe(true); + expect( + schema.safeParse({ port_code: "Singapore", fuel_type: "VLSFO" }).success, + ).toBe(false); + expect( + schema.safeParse({ port_code: "SG/IN", fuel_type: "VLSFO" }).success, + ).toBe(false); + expect( + schema.safeParse({ port_code: "SGSIN", fuel_type: "DIESEL" }).success, + ).toBe(false); + expect( + schema.safeParse({ port_code: "SGSIN", fuel_type: "VLSFO", days: 0 }) + .success, + ).toBe(false); + expect( + schema.safeParse({ port_code: "SGSIN", fuel_type: "VLSFO", days: 366 }) + .success, + ).toBe(false); + }); + + it("without a key returns the keyless teaser and makes no request", async () => { + vi.stubEnv("OILPRICEAPI_KEY", ""); + const fetchSpy = stubResponse({}); + const result = await history().handler( + { port_code: "SGSIN", fuel_type: "VLSFO" }, + {}, + ); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain("requires an API key"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/toolRegistry.test.ts b/src/__tests__/toolRegistry.test.ts index c5f8741..b00c5fd 100644 --- a/src/__tests__/toolRegistry.test.ts +++ b/src/__tests__/toolRegistry.test.ts @@ -26,8 +26,8 @@ describe("MCP tool scope and profiles", () => { const registered = getRegisteredToolEntries(server); expect(configuration).toMatchObject({ scope: "read", profile: "all" }); - expect(registered).toHaveLength(38); - expect(enabled).toHaveLength(34); + expect(registered).toHaveLength(40); + expect(enabled).toHaveLength(36); expect(enabled).toContain("opa_list_price_alerts"); expect(enabled).toContain("opa_get_subscription_events"); for (const name of WRITE_TOOL_NAMES) { @@ -46,7 +46,7 @@ describe("MCP tool scope and profiles", () => { }); const enabled = applyToolConfiguration(server, configuration); - expect(enabled).toHaveLength(38); + expect(enabled).toHaveLength(40); for (const name of WRITE_TOOL_NAMES) expect(enabled).toContain(name); }); @@ -119,7 +119,7 @@ describe("generated MCP capability manifest", () => { scope: "read", profile: "all", }); - expect(first.tools).toHaveLength(38); + expect(first.tools).toHaveLength(40); expect(first.tools.map((tool) => tool.name).sort()).toEqual( getRegisteredToolEntries(server) .map(([name]) => name) @@ -128,8 +128,8 @@ describe("generated MCP capability manifest", () => { expect(first.resources).toContainEqual( expect.objectContaining({ uri: "oilpriceapi://product-facts" }), ); - expect(first.inventories.read.all).toHaveLength(34); - expect(first.inventories.write.all).toHaveLength(38); + expect(first.inventories.read.all).toHaveLength(36); + expect(first.inventories.write.all).toHaveLength(40); }); it("contains no credential, account, prompt, or customer response data", () => { diff --git a/src/index.ts b/src/index.ts index ef5e520..4f1776d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -675,6 +675,46 @@ function marineStaleCell(stale: unknown): string { return NOT_REPORTED; } +// Canonical latest path (#125). /v1/marine-fuels/latest is a deprecated alias +// in the API route policy. The API caps `limit` at 100 and defaults it to 50 +// series rows; asking for the cap keeps a growing port list from truncating. +const MARINE_FUELS_LATEST_PATH = "/v1/prices/marine-fuels/latest?limit=100"; + +// Shapes of GET /v1/marine-ports and /v1/marine-ports/{code} (verified live +// 2026-10-15). Optional throughout: the renderer states an absence. +interface MarinePortSummary { + code?: string; + name?: string; + country?: string; + region?: string; + major_port?: boolean; + fuel_services?: string[]; + trading_hours?: string; + timezone?: string; + recent_prices?: MarineFuelQuote[]; +} + +/** Fuel series the API carries per port (Price::MARINE_FUEL_PREFIXES). */ +const MARINE_FUEL_TYPES = ["VLSFO", "MGO_05S", "HFO_380", "HFO_180"] as const; + +function yesNo(value: unknown): string { + if (value === true) return "yes"; + if (value === false) return "no"; + return NOT_REPORTED; +} + +function listCell(values: unknown): string { + return Array.isArray(values) && values.length + ? values.map((v) => reportedValue(v)).join(", ") + : NOT_REPORTED; +} + +/** The date part of an ISO timestamp, or NOT_REPORTED. */ +function isoDate(value: unknown): string { + const text = reportedValue(value); + return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : NOT_REPORTED; +} + // Shape of GET /v1/rig-counts/latest as served by the current API (verified // live 2026-09-13). The pre-#115 formatter read oil/gas/total/date — four // fields this envelope has never carried — so every line of the answer was @@ -2720,6 +2760,14 @@ const KEYLESS_TOOL_TEASERS: Record = does: "Returns marine bunker fuel prices (VLSFO, MGO, IFO380) across major shipping ports like Singapore and Rotterdam.", example: "SINGAPORE | VLSFO | $XXX.XX | USD | metric ton", }, + opa_get_marine_ports: { + does: "Lists the bunkering ports the API carries with UN/LOCODE, region and fuel services, or one port with its recent prices.", + example: "| SGSIN | Singapore | Singapore | Asia | yes | MGO_05S, VLSFO, HFO_380 | 24/7 |", + }, + opa_get_marine_fuel_history: { + does: "Returns the daily price history of one marine fuel at one port, one row per assessment date.", + example: "| YYYY-MM-DD | XXX.XX | USD | metric_ton | collected YYYY-MM-DDTHH:MM:SSZ |", + }, opa_get_rig_counts: { does: "Returns the latest Baker Hughes US total rig count with its region and observation date. No oil/gas split and no prior-week delta — that is opa_get_drilling.", example: @@ -4334,7 +4382,7 @@ server.registerTool( // (OilpriceAPI/oilpriceapi-api#7774). Forwarding either turned every // filtered call into a confident "no data". const outcome = await requestApi>( - "/v1/prices/marine-fuels/latest", + MARINE_FUELS_LATEST_PATH, ); const response = outcome.data; const ports = @@ -4422,6 +4470,194 @@ server.registerTool( }, ); +server.registerTool( + "opa_get_marine_ports", + { + title: "Get Marine Fuel Ports", + description: + "List the bunkering ports the API carries (UN/LOCODE, name, country, region, major-port flag, fuel services, trading hours), or read one port with its prices collected in the last 24 hours. Use it to resolve a port name to the UN/LOCODE that opa_get_marine_fuel_history needs, or to check which fuel services a port lists. Works with any valid API key; the API applies its own marine per-minute rate limit. " + + ACCOUNT_ENTITLEMENT_GUIDANCE, + inputSchema: { + port: z + .string() + .regex(/^[A-Za-z][A-Za-z ]{1,39}$/, "Port must be a UN/LOCODE or a port name (letters and spaces)") + .optional() + .describe("One port by UN/LOCODE or name, e.g. 'SGSIN' or 'Singapore'. Omit to list ports."), + region: z + .string() + .regex(/^[A-Za-z][A-Za-z ]{1,39}$/) + .optional() + .describe("List filter: region as the API reports it, e.g. 'Asia', 'Europe', 'Americas', 'Middle East'"), + country: z + .string() + .regex(/^[A-Za-z][A-Za-z .'-]{1,59}$/) + .optional() + .describe("List filter: country as the API reports it, e.g. 'Singapore', 'United States'"), + major_only: z.boolean().optional().describe("List filter: only ports the API flags as major"), + }, + annotations: READ_TOOL_ANNOTATIONS, + }, + async ({ port, region, country, major_only }) => { + if (!getApiKey()) return keylessTeaserResult("opa_get_marine_ports"); + + if (port) { + const code = port.trim().toUpperCase(); + const outcome = await requestApi>( + `/v1/marine-ports/${encodeURIComponent(code)}`, + ); + if (outcome.status === 404) { + return errorResult( + `The API has no marine port "${port}" (HTTP 404). Use opa_get_marine_ports without a port to list the UN/LOCODEs it carries.`, + ); + } + const p = outcome.data?.status === "success" ? outcome.data.data?.port : undefined; + if (!p) { + return errorResult( + describeUnavailable(`Marine port "${port}"`, outcome, `The API answered but returned no record for marine port "${port}".`), + ); + } + const label = marinePortLabel({ port_code: p.code, port_name: p.name }); + let text = `# Marine Port: ${label}\n\n`; + text += `Country: ${reportedValue(p.country)} | Region: ${reportedValue(p.region)} | Major port: ${yesNo(p.major_port)}\n`; + text += `Fuel services: ${listCell(p.fuel_services)}\n`; + text += `Trading hours: ${reportedValue(p.trading_hours)} | Timezone: ${reportedValue(p.timezone)}\n\n`; + const prices = Array.isArray(p.recent_prices) ? p.recent_prices : []; + if (prices.length === 0) { + text += "_The API returned no prices observed in the last 7 days for this port. Use opa_get_marine_fuels for the latest assessment with its age._\n"; + } else { + text += "Latest available prices (up to 7 days old):\n\n"; + text += "| Fuel Type | Price | Currency | Unit | Collected | Observed | Age (days) | Stale |\n|-----------|-------|----------|------|-----------|----------|------------|-------|\n"; + for (const f of prices) { + text += `| ${reportedValue(f.fuel_type)} | ${reportedValue(f.price)} | ${reportedValue(f.currency)} | ${reportedValue(f.unit)} | ${reportedValue(f.collected_at ?? f.timestamp)} | ${reportedValue(f.as_of)} | ${reportedValue(f.age_days)} | ${marineStaleCell(f.stale)} |\n`; + } + } + text += "\n_Data from [OilPriceAPI](https://oilpriceapi.com)_"; + return textResult(text); + } + + const query = new URLSearchParams(); + if (region) query.set("region", region.trim()); + if (country) query.set("country", country.trim()); + if (major_only) query.set("major_ports", "true"); + const qs = query.toString(); + const outcome = await requestApi>( + `/v1/marine-ports${qs ? `?${qs}` : ""}`, + ); + const ports = + outcome.data?.status === "success" && Array.isArray(outcome.data.data?.ports) + ? outcome.data.data.ports + : []; + if (ports.length === 0) { + const filters = [region && `region "${region}"`, country && `country "${country}"`, major_only && "major ports only"] + .filter(Boolean) + .join(", "); + return errorResult( + describeUnavailable( + "The marine port directory", + outcome, + `The API returned no marine ports${filters ? ` for ${filters}` : ""}.`, + filters ? "Retry without filters to see the regions and countries it carries." : undefined, + ), + ); + } + let text = "# Marine Fuel Ports\n\n"; + text += "| Code | Name | Country | Region | Major | Fuel services | Trading hours |\n"; + text += "|------|------|---------|--------|-------|---------------|---------------|\n"; + for (const p of ports) { + text += `| ${reportedValue(p.code)} | ${reportedValue(p.name)} | ${reportedValue(p.country)} | ${reportedValue(p.region)} | ${yesNo(p.major_port)} | ${listCell(p.fuel_services)} | ${reportedValue(p.trading_hours)} |\n`; + } + text += `\n_${ports.length} port${ports.length === 1 ? "" : "s"}. A listed fuel service does not guarantee a priced series; opa_get_marine_fuels shows what is priced. | Data from [OilPriceAPI](https://oilpriceapi.com)_`; + return textResult(text); + }, +); + +server.registerTool( + "opa_get_marine_fuel_history", + { + title: "Get Marine Fuel Price History", + description: + "Get the daily price history of one marine fuel at one port: one row per assessment date with price, currency, unit and collection time, plus high/low over the rows returned. Use when the user asks how bunker prices at a port moved over days or months. Needs the port's UN/LOCODE (resolve names with opa_get_marine_ports) and a fuel type: VLSFO, MGO_05S (MGO), HFO_380 (IFO380) or HFO_180. Requires a paid subscription or an active trial; other accounts get HTTP 403. The API rate-limits history more tightly than latest prices and caps the rows per request, so a long window can come back shorter than asked; the answer says when it does. " + + ACCOUNT_ENTITLEMENT_GUIDANCE, + inputSchema: { + port_code: z + .string() + .regex(/^[A-Za-z]{5}$/, "port_code must be a 5-letter UN/LOCODE, e.g. SGSIN") + .describe("Port UN/LOCODE, e.g. 'SGSIN' (Singapore), 'NLRTM' (Rotterdam), 'USHOU' (Houston)"), + fuel_type: z.enum(MARINE_FUEL_TYPES).describe("Fuel series: VLSFO, MGO_05S (MGO), HFO_380 (IFO380) or HFO_180"), + days: z.number().int().min(1).max(365).default(30).describe("Days back from now, 1-365 (default 30)"), + }, + annotations: READ_TOOL_ANNOTATIONS, + }, + async ({ port_code, fuel_type, days = 30 }) => { + if (!getApiKey()) return keylessTeaserResult("opa_get_marine_fuel_history"); + + const port = port_code.toUpperCase(); + // Send the full series code. A bare `fuel_type=VLSFO` answers an empty 200 + // (live 2026-10-15; OilpriceAPI/oilpriceapi-api#7774), while the series + // code matches exactly and keeps matching once the API resolves bare types. + const series = `${fuel_type}_${port}_USD`; + const outcome = await requestApi< + ApiResponse<{ prices?: MarineFuelPort[]; metadata?: { period?: { end?: string } } }> + >(`/v1/prices/marine-fuels/historical?port_code=${port}&fuel_type=${series}&days=${days}`); + const groups = + outcome.data?.status === "success" && Array.isArray(outcome.data.data?.prices) + ? outcome.data.data.prices + : []; + const rows = groups.flatMap((g) => (Array.isArray(g.fuels) ? g.fuels : [])); + if (rows.length === 0) { + return errorResult( + describeUnavailable( + `${fuel_type} history at ${port}`, + outcome, + `The API returned no ${fuel_type} history at ${port} over the last ${days} days.`, + "Check the port's fuel services and recent prices with opa_get_marine_ports; a listed service does not guarantee a priced series.", + ), + ); + } + + // The API returns every collection, newest first, and can collect one + // assessment date more than once. Keep the most recently collected value + // per as_of date; rows without an as_of stay as they are. + const collected = (f: MarineFuelQuote) => f.collected_at ?? f.timestamp ?? ""; + const byDate = new Map(); + const undated: MarineFuelQuote[] = []; + for (const f of rows) { + const date = isoDate(f.as_of); + if (date === NOT_REPORTED) { + undated.push(f); + continue; + } + const held = byDate.get(date); + if (!held || collected(f) > collected(held)) byDate.set(date, f); + } + const dates = [...byDate.keys()].sort().reverse(); + const series_rows = [...dates.map((d) => byDate.get(d)!), ...undated]; + + const first = groups[0]; + let text = `# ${fuel_type} History: ${marinePortLabel(first)}\n\n`; + const units = new Set(series_rows.map((f) => `${reportedValue(f.currency)} ${reportedValue(f.unit)}`)); + const prices = series_rows.map((f) => f.price).filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + if (units.size === 1 && prices.length && !units.values().next().value!.includes(NOT_REPORTED)) { + text += `High ${Math.max(...prices)} · Low ${Math.min(...prices)} (${units.values().next().value}) over the rows shown.\n\n`; + } + const end = Date.parse(outcome.data?.data?.metadata?.period?.end ?? "") || Date.now(); + const windowStart = new Date(end - days * 86_400_000).toISOString().slice(0, 10); + const earliest = dates.at(-1); + if (earliest && Date.parse(earliest) - Date.parse(windowStart) > 3 * 86_400_000) { + text += `_The earliest assessment returned is ${earliest}, later than the requested window start ${windowStart}. The API returned ${rows.length} rows; narrow \`days\` or treat earlier dates as not returned, not as absent from the market._\n\n`; + } + const synthetic = series_rows.filter((f) => f.synthetic === true).length; + if (synthetic) text += `_${synthetic} of ${series_rows.length} rows carry \`synthetic: true\` from the API._\n\n`; + + text += "| Assessment date | Price | Currency | Unit | Collected |\n|-----------------|-------|----------|------|-----------|\n"; + for (const f of series_rows) { + text += `| ${isoDate(f.as_of)} | ${reportedValue(f.price)} | ${reportedValue(f.currency)} | ${reportedValue(f.unit)} | ${reportedValue(collected(f))} |\n`; + } + text += `\n_${dates.length} assessment dates from ${rows.length} rows; where a date was collected more than once, the most recently collected value is shown. | Data from [OilPriceAPI](https://oilpriceapi.com)_`; + return textResult(text); + }, +); + server.registerTool( "opa_get_rig_counts", { diff --git a/src/toolRegistry.ts b/src/toolRegistry.ts index 46a202d..0fad572 100644 --- a/src/toolRegistry.ts +++ b/src/toolRegistry.ts @@ -38,6 +38,8 @@ const TOOL_CATEGORY_BY_NAME: Record = { opa_get_futures_curve: "market", opa_get_futures_history: "market", opa_get_marine_fuels: "market", + opa_get_marine_ports: "market", + opa_get_marine_fuel_history: "market", opa_get_natural_gas_hubs: "market", opa_get_rig_counts: "market", opa_get_drilling: "market",