From 8c62f837e5825dfaec7476d5023e901182d8f0e8 Mon Sep 17 00:00:00 2001 From: Akshay Dodeja Date: Wed, 24 Jun 2026 10:57:46 -0700 Subject: [PATCH 1/3] fix(mcp): authoritative status, demurrage staleness guard, terminal-local times; drop _mapped bloat Extracts three pure, unit-tested modules under packages/mcp/src/lib/ and wires them into get_container, the container resource, and get_shipment_details: - ContainerStatusResolver (container-status.ts): surfaces the API `current_status` as the headline status in get_container AND resources/container.ts, ending the three-way status-vocabulary divergence. The heuristic lifecycle is kept only as clearly-labeled, non-authoritative steering metadata and never reports "delivered" unless `delivered_at` is set. - DemurrageUrgencyEvaluator (demurrage.ts): removes the fabricated "~$75-150/day demurrage accruing / URGENT" message. Surfaces the real fees_at_pod_terminal (amount + currency) and suppresses urgency when terminal_checked_at is stale, was never set, or tracking is stopped/closed. - TemporalFormatter (temporal.ts): renders terminal timestamps in pod_timezone and computes day-deltas in terminal-local time, fixing the get_shipment_details "ETA in N days" UTC off-by-one. Also: - get_container `include` now AUGMENTS the default [shipment, pod_terminal] instead of replacing it. - Surfaces pod_timezone + per-channel LFDs (terminal/rail/line from import_deadlines) in get_container. - equipment.length numeric-enum guard (no "" sentinel); drops the phantom `updated_at` the API never returns on containers. - Drops the duplicate `_mapped` payload from get_container and get_shipment_details (no more format:both with no mapper); returns the curated summary only. - Removes the dead getContainerTool / getShipmentDetailsTool objects; the Zod input schema in server.ts is the single source of truth. Closes DEV-10659 Closes DEV-10661 Closes DEV-10664 Closes DEV-10665 Green gate: SDK build/type-check/test (51 pass, 2 skip) + MCP build/type-check/ test (106 pass) all green; oxlint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp/src/lib/container-status.test.ts | 86 ++++ packages/mcp/src/lib/container-status.ts | 82 +++ packages/mcp/src/lib/demurrage.test.ts | 125 +++++ packages/mcp/src/lib/demurrage.ts | 158 ++++++ packages/mcp/src/lib/temporal.test.ts | 73 +++ packages/mcp/src/lib/temporal.ts | 113 +++++ packages/mcp/src/resources/container.ts | 148 +++--- packages/mcp/src/tools/contracts.test.ts | 245 +++++++-- packages/mcp/src/tools/get-container.ts | 475 +++++++++++------- .../mcp/src/tools/get-shipment-details.ts | 135 ++--- 10 files changed, 1301 insertions(+), 339 deletions(-) create mode 100644 packages/mcp/src/lib/container-status.test.ts create mode 100644 packages/mcp/src/lib/container-status.ts create mode 100644 packages/mcp/src/lib/demurrage.test.ts create mode 100644 packages/mcp/src/lib/demurrage.ts create mode 100644 packages/mcp/src/lib/temporal.test.ts create mode 100644 packages/mcp/src/lib/temporal.ts diff --git a/packages/mcp/src/lib/container-status.test.ts b/packages/mcp/src/lib/container-status.test.ts new file mode 100644 index 00000000..d1e733fe --- /dev/null +++ b/packages/mcp/src/lib/container-status.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { resolveContainerStatus } from './container-status.js'; + +describe('resolveContainerStatus', () => { + it('uses the API current_status verbatim as the headline status', () => { + const result = resolveContainerStatus({ + current_status: 'available', + pod_arrived_at: '2026-01-01T00:00:00Z', + pod_discharged_at: '2026-01-02T00:00:00Z', + available_for_pickup: true, + }); + + expect(result.status).toBe('available'); + expect(result.status_source).toBe('current_status'); + }); + + it('falls back to a derived lifecycle label only when current_status is absent', () => { + const result = resolveContainerStatus({ + pod_arrived_at: null, + pod_discharged_at: null, + }); + + // No API status to surface, so the headline is the derived label. + expect(result.status).toBe('in_transit'); + expect(result.status_source).toBe('derived'); + }); + + it('never reports a delivered lifecycle when delivered_at is null', () => { + // pod_full_out_at is set but the API has NOT confirmed delivery. + const result = resolveContainerStatus({ + current_status: 'picked_up', + pod_arrived_at: '2026-01-01T00:00:00Z', + pod_discharged_at: '2026-01-02T00:00:00Z', + pod_full_out_at: '2026-01-06T00:00:00Z', + delivered_at: null, + }); + + expect(result.status).toBe('picked_up'); + expect(result.derived_lifecycle).not.toBe('delivered'); + }); + + it('reports a delivered lifecycle only when delivered_at is present', () => { + const result = resolveContainerStatus({ + current_status: 'delivered', + pod_arrived_at: '2026-01-01T00:00:00Z', + pod_discharged_at: '2026-01-02T00:00:00Z', + delivered_at: '2026-01-07T00:00:00Z', + }); + + expect(result.derived_lifecycle).toBe('delivered'); + }); + + it('marks the derived lifecycle as non-authoritative steering metadata', () => { + const result = resolveContainerStatus({ + current_status: 'on_rail', + pod_arrived_at: '2026-01-01T00:00:00Z', + pod_discharged_at: '2026-01-02T00:00:00Z', + pod_rail_loaded_at: '2026-01-03T00:00:00Z', + }); + + expect(result.lifecycle_is_authoritative).toBe(false); + expect(result.derived_lifecycle).toBe('on_rail'); + }); + + it('derives discharged vs available_for_pickup from availability', () => { + const discharged = resolveContainerStatus({ + pod_arrived_at: '2026-01-01T00:00:00Z', + pod_discharged_at: '2026-01-02T00:00:00Z', + available_for_pickup: false, + }); + expect(discharged.derived_lifecycle).toBe('discharged'); + + const available = resolveContainerStatus({ + pod_arrived_at: '2026-01-01T00:00:00Z', + pod_discharged_at: '2026-01-02T00:00:00Z', + available_for_pickup: true, + }); + expect(available.derived_lifecycle).toBe('available_for_pickup'); + }); + + it('handles a completely empty attribute object without throwing', () => { + const result = resolveContainerStatus({}); + expect(result.status).toBe('in_transit'); + expect(result.status_source).toBe('derived'); + }); +}); diff --git a/packages/mcp/src/lib/container-status.ts b/packages/mcp/src/lib/container-status.ts new file mode 100644 index 00000000..c098b591 --- /dev/null +++ b/packages/mcp/src/lib/container-status.ts @@ -0,0 +1,82 @@ +/** + * Container status resolution. + * + * The Terminal49 API ships an authoritative `current_status` enum on every + * container. That value is the single source of truth for the headline status + * and MUST be surfaced verbatim — earlier code invented its own vocabulary, + * producing a three-way divergence (API status vs. derived lifecycle vs. search + * status). Here the API status is the headline; the derived lifecycle is kept + * only as clearly-labeled, non-authoritative steering metadata to help select + * follow-up tooling. + * + * The one hard rule on the derived lifecycle: never label a container + * "delivered" unless the API has actually set `delivered_at`. + */ + +export type DerivedLifecycle = + | 'in_transit' + | 'arrived' + | 'discharged' + | 'available_for_pickup' + | 'at_terminal' + | 'on_rail' + | 'delivered'; + +export interface ContainerStatusResult { + /** Headline status shown to the user. Prefers the API `current_status`. */ + status: string; + /** Where the headline came from: the API field or our derived fallback. */ + status_source: 'current_status' | 'derived'; + /** Heuristic lifecycle stage — steering metadata only, NOT authoritative. */ + derived_lifecycle: DerivedLifecycle; + /** Always false: the derived lifecycle must never be treated as truth. */ + lifecycle_is_authoritative: false; +} + +interface ContainerStatusAttrs { + current_status?: string | null; + delivered_at?: string | null; + pod_arrived_at?: string | null; + pod_discharged_at?: string | null; + pod_rail_loaded_at?: string | null; + pod_full_out_at?: string | null; + final_destination_full_out_at?: string | null; + available_for_pickup?: boolean | null; +} + +/** + * Compute a derived lifecycle stage from raw milestone timestamps. Used only as + * steering metadata. Delivery is gated strictly on `delivered_at` being set. + */ +function deriveLifecycle(attrs: ContainerStatusAttrs): DerivedLifecycle { + if (!attrs.pod_arrived_at) return 'in_transit'; + if (!attrs.pod_discharged_at) return 'arrived'; + + // Only the API's explicit delivery confirmation may yield "delivered". + if (attrs.delivered_at) return 'delivered'; + + if (attrs.pod_rail_loaded_at && !attrs.final_destination_full_out_at) { + return 'on_rail'; + } + + if (attrs.available_for_pickup === true) return 'available_for_pickup'; + if (attrs.available_for_pickup === false) return 'discharged'; + return 'at_terminal'; +} + +export function resolveContainerStatus( + attrs: ContainerStatusAttrs, +): ContainerStatusResult { + const derived = deriveLifecycle(attrs); + const apiStatus = + typeof attrs.current_status === 'string' && attrs.current_status.length > 0 + ? attrs.current_status + : null; + + return { + status: apiStatus ?? derived, + status_source: apiStatus ? 'current_status' : 'derived', + derived_lifecycle: derived, + lifecycle_is_authoritative: false, + }; +} diff --git a/packages/mcp/src/lib/demurrage.test.ts b/packages/mcp/src/lib/demurrage.test.ts new file mode 100644 index 00000000..4bdbb5a5 --- /dev/null +++ b/packages/mcp/src/lib/demurrage.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { evaluateDemurrageUrgency } from './demurrage.js'; + +const NOW = new Date('2026-02-10T00:00:00Z'); + +describe('evaluateDemurrageUrgency', () => { + it('surfaces real fees with currency and never fabricates a per-day estimate', () => { + const result = evaluateDemurrageUrgency( + { + fees_at_pod_terminal: [ + { type: 'demurrage', amount: 320, currency_code: 'USD' }, + { type: 'exam', amount: 75, currency_code: 'USD' }, + ], + terminal_checked_at: '2026-02-09T00:00:00Z', + }, + NOW, + ); + + expect(result.fees).toEqual([ + { type: 'demurrage', amount: 320, currency_code: 'USD' }, + { type: 'exam', amount: 75, currency_code: 'USD' }, + ]); + expect(result.total_amount).toBe(395); + expect(result.currency_code).toBe('USD'); + // No invented "~$75-150/day" guidance anywhere in the output. + expect(JSON.stringify(result)).not.toMatch(/\/day/); + expect(JSON.stringify(result)).not.toMatch(/75-150/); + }); + + it('flags overdue urgency when LFD is in the past and data is fresh', () => { + const result = evaluateDemurrageUrgency( + { + pickup_lfd: '2026-02-05T00:00:00Z', + terminal_checked_at: '2026-02-09T00:00:00Z', + }, + NOW, + ); + + expect(result.urgency).toBe('overdue'); + expect(result.days_until_lfd).toBe(-5); + expect(result.urgency_suppressed).toBe(false); + }); + + it('flags imminent urgency when LFD is within 3 days and data is fresh', () => { + const result = evaluateDemurrageUrgency( + { + pickup_lfd: '2026-02-12T00:00:00Z', + terminal_checked_at: '2026-02-09T00:00:00Z', + }, + NOW, + ); + + expect(result.urgency).toBe('imminent'); + expect(result.days_until_lfd).toBe(2); + }); + + it('suppresses urgency when terminal data is stale', () => { + const result = evaluateDemurrageUrgency( + { + pickup_lfd: '2026-02-05T00:00:00Z', + // Last checked 20 days before now -> stale (default threshold 7d). + terminal_checked_at: '2026-01-21T00:00:00Z', + }, + NOW, + ); + + expect(result.urgency).toBe('unknown'); + expect(result.urgency_suppressed).toBe(true); + expect(result.suppression_reason).toContain('stale'); + }); + + it('suppresses urgency when tracking is stopped/closed', () => { + const result = evaluateDemurrageUrgency( + { + pickup_lfd: '2026-02-05T00:00:00Z', + terminal_checked_at: '2026-02-09T00:00:00Z', + tracking_stopped: true, + }, + NOW, + ); + + expect(result.urgency).toBe('unknown'); + expect(result.urgency_suppressed).toBe(true); + expect(result.suppression_reason).toContain('tracking'); + }); + + it('suppresses urgency when terminal was never checked', () => { + const result = evaluateDemurrageUrgency( + { + pickup_lfd: '2026-02-05T00:00:00Z', + terminal_checked_at: null, + }, + NOW, + ); + + expect(result.urgency).toBe('unknown'); + expect(result.urgency_suppressed).toBe(true); + }); + + it('reports no urgency (none) when LFD is comfortably in the future', () => { + const result = evaluateDemurrageUrgency( + { + pickup_lfd: '2026-03-01T00:00:00Z', + terminal_checked_at: '2026-02-09T00:00:00Z', + }, + NOW, + ); + + expect(result.urgency).toBe('none'); + expect(result.urgency_suppressed).toBe(false); + }); + + it('preserves null fees as null rather than coercing to an empty array', () => { + const result = evaluateDemurrageUrgency( + { + fees_at_pod_terminal: null, + terminal_checked_at: '2026-02-09T00:00:00Z', + }, + NOW, + ); + + expect(result.fees).toBeNull(); + expect(result.total_amount).toBeNull(); + }); +}); diff --git a/packages/mcp/src/lib/demurrage.ts b/packages/mcp/src/lib/demurrage.ts new file mode 100644 index 00000000..2485734c --- /dev/null +++ b/packages/mcp/src/lib/demurrage.ts @@ -0,0 +1,158 @@ +/** + * Demurrage / last-free-day urgency evaluation. + * + * Earlier code emitted a fabricated "~$75-150/day demurrage accruing / URGENT" + * message regardless of the real data. This module instead: + * - surfaces the actual `fees_at_pod_terminal` (amount + currency) verbatim, + * inventing no per-day rate; + * - classifies LFD urgency from the real `pickup_lfd`; and + * - suppresses urgency when the underlying terminal data is stale, was never + * checked, or tracking has been stopped/closed — because in those cases we + * cannot trust availability/LFD enough to push the user to act. + */ + +export interface TerminalFee { + type?: string; + amount?: number; + currency_code?: string; +} + +export type DemurrageUrgency = 'overdue' | 'imminent' | 'none' | 'unknown'; + +export interface DemurrageEvaluation { + /** Real terminal fees, preserved as-is (null means "not reported", not "$0"). */ + fees: TerminalFee[] | null; + /** Sum of fee amounts when fees are present, otherwise null. */ + total_amount: number | null; + /** Currency of the fees when consistent/known, otherwise null. */ + currency_code: string | null; + /** Whole days until LFD, computed by the caller's clock; null when no LFD. */ + days_until_lfd: number | null; + urgency: DemurrageUrgency; + /** True when a real LFD signal was withheld due to stale/closed data. */ + urgency_suppressed: boolean; + suppression_reason: string | null; +} + +interface DemurrageAttrs { + fees_at_pod_terminal?: TerminalFee[] | null; + pickup_lfd?: string | null; + terminal_checked_at?: string | null; + tracking_stopped?: boolean | null; +} + +/** Terminal data older than this is treated as too stale to drive urgency. */ +const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function summarizeFees(fees: TerminalFee[] | null | undefined): { + fees: TerminalFee[] | null; + total: number | null; + currency: string | null; +} { + if (!Array.isArray(fees)) { + return { fees: fees == null ? null : [], total: null, currency: null }; + } + if (fees.length === 0) { + return { fees: [], total: 0, currency: null }; + } + + let total = 0; + let hasAmount = false; + const currencies = new Set(); + for (const fee of fees) { + if (typeof fee.amount === 'number' && Number.isFinite(fee.amount)) { + total += fee.amount; + hasAmount = true; + } + if (fee.currency_code) currencies.add(fee.currency_code); + } + + return { + fees, + total: hasAmount ? total : null, + currency: currencies.size === 1 ? [...currencies][0] : null, + }; +} + +function isStale( + terminalCheckedAt: string | null | undefined, + now: Date, +): { + stale: boolean; + reason: string | null; +} { + if (!terminalCheckedAt) { + return { + stale: true, + reason: 'terminal availability has never been checked', + }; + } + const checked = new Date(terminalCheckedAt); + if (Number.isNaN(checked.getTime())) { + return { stale: true, reason: 'terminal_checked_at is unparseable' }; + } + if (now.getTime() - checked.getTime() > STALE_THRESHOLD_MS) { + return { + stale: true, + reason: 'terminal data is stale (last checked over 7 days ago)', + }; + } + return { stale: false, reason: null }; +} + +export function evaluateDemurrageUrgency( + attrs: DemurrageAttrs, + now: Date = new Date(), +): DemurrageEvaluation { + const { fees, total, currency } = summarizeFees(attrs.fees_at_pod_terminal); + + const lfd = attrs.pickup_lfd ? new Date(attrs.pickup_lfd) : null; + const lfdValid = lfd !== null && !Number.isNaN(lfd.getTime()); + const daysUntilLfd = lfdValid + ? Math.round((lfd!.getTime() - now.getTime()) / MS_PER_DAY) + : null; + + // Reasons to distrust the LFD/availability signal entirely. + if (attrs.tracking_stopped) { + return { + fees, + total_amount: total, + currency_code: currency, + days_until_lfd: daysUntilLfd, + urgency: 'unknown', + urgency_suppressed: true, + suppression_reason: 'tracking is stopped/closed for this container', + }; + } + + const staleness = isStale(attrs.terminal_checked_at, now); + if (staleness.stale) { + return { + fees, + total_amount: total, + currency_code: currency, + days_until_lfd: daysUntilLfd, + urgency: 'unknown', + urgency_suppressed: true, + suppression_reason: staleness.reason, + }; + } + + let urgency: DemurrageUrgency = 'unknown'; + if (daysUntilLfd !== null) { + if (daysUntilLfd < 0) urgency = 'overdue'; + else if (daysUntilLfd <= 3) urgency = 'imminent'; + else urgency = 'none'; + } + + return { + fees, + total_amount: total, + currency_code: currency, + days_until_lfd: daysUntilLfd, + urgency, + urgency_suppressed: false, + suppression_reason: null, + }; +} diff --git a/packages/mcp/src/lib/temporal.test.ts b/packages/mcp/src/lib/temporal.test.ts new file mode 100644 index 00000000..d6fd5c08 --- /dev/null +++ b/packages/mcp/src/lib/temporal.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { dayDeltaInZone, formatInZone, localCalendarDate } from './temporal.js'; + +describe('formatInZone', () => { + it('renders a timestamp in the supplied IANA timezone', () => { + // 2026-02-11T02:00:00Z is 2026-02-10 21:00 in New York (EST, UTC-5). + const text = formatInZone('2026-02-11T02:00:00Z', 'America/New_York'); + expect(text).toContain('2026'); + expect(text).toContain('02/10'); + }); + + it('renders the same instant differently across zones', () => { + const ny = formatInZone('2026-02-11T02:00:00Z', 'America/New_York'); + const la = formatInZone('2026-02-11T02:00:00Z', 'America/Los_Angeles'); + expect(ny).not.toBe(la); + }); + + it('returns N/A for null and falls back to the raw value for unparseable input', () => { + expect(formatInZone(null, 'America/New_York')).toBe('N/A'); + expect(formatInZone('not-a-date', 'America/New_York')).toBe('not-a-date'); + }); + + it('falls back to UTC rendering when timezone is missing', () => { + const text = formatInZone('2026-02-11T02:00:00Z', null); + expect(text).toContain('2026'); + }); +}); + +describe('localCalendarDate', () => { + it('returns the terminal-local calendar date, not the UTC date', () => { + // 02:00Z on the 11th is still the 10th in New York. + expect(localCalendarDate('2026-02-11T02:00:00Z', 'America/New_York')).toBe( + '2026-02-10', + ); + }); +}); + +describe('dayDeltaInZone', () => { + it('computes day deltas in terminal-local time without a UTC off-by-one', () => { + // ETA: 2026-02-11T02:00:00Z -> 2026-02-10 (NY). + // Now: 2026-02-10T20:00:00Z -> 2026-02-10 15:00 (NY). + // Same NY calendar day -> 0 days, even though naive UTC ceil yields 1. + const delta = dayDeltaInZone( + '2026-02-11T02:00:00Z', + 'America/New_York', + new Date('2026-02-10T20:00:00Z'), + ); + expect(delta).toBe(0); + }); + + it('counts a full local day ahead as +1', () => { + const delta = dayDeltaInZone( + '2026-02-12T02:00:00Z', // 2026-02-11 NY + 'America/New_York', + new Date('2026-02-10T20:00:00Z'), // 2026-02-10 NY + ); + expect(delta).toBe(1); + }); + + it('counts a local day in the past as negative', () => { + const delta = dayDeltaInZone( + '2026-02-08T12:00:00Z', // 2026-02-08 NY + 'America/New_York', + new Date('2026-02-10T20:00:00Z'), // 2026-02-10 NY + ); + expect(delta).toBe(-2); + }); + + it('returns null for null or unparseable timestamps', () => { + expect(dayDeltaInZone(null, 'America/New_York', new Date())).toBeNull(); + expect(dayDeltaInZone('nope', 'America/New_York', new Date())).toBeNull(); + }); +}); diff --git a/packages/mcp/src/lib/temporal.ts b/packages/mcp/src/lib/temporal.ts new file mode 100644 index 00000000..a26da0bd --- /dev/null +++ b/packages/mcp/src/lib/temporal.ts @@ -0,0 +1,113 @@ +/** + * Temporal formatting helpers. + * + * Terminal timestamps (arrival, discharge, LFD, full-out) are meaningful in the + * terminal's local timezone, not UTC. Rendering and day-delta math must happen + * in that zone, otherwise a timestamp a few hours either side of midnight UTC + * gets attributed to the wrong calendar day ("ETA in N days" off-by-one). + */ + +const MS_PER_DAY = 1000 * 60 * 60 * 24; + +function parseTimestamp(ts: string | null | undefined): Date | null { + if (!ts) return null; + const date = new Date(ts); + if (Number.isNaN(date.getTime())) return null; + return date; +} + +/** + * Render a timestamp as a human-readable string in the given IANA timezone. + * Falls back to UTC when no timezone is supplied, to the raw string when the + * value cannot be parsed, and to "N/A" when the value is null/undefined. + */ +export function formatInZone( + ts: string | null | undefined, + timezone: string | null | undefined, +): string { + if (ts === null || ts === undefined || ts === '') return 'N/A'; + const date = parseTimestamp(ts); + if (!date) return ts; + + try { + return date.toLocaleString('en-US', { + timeZone: timezone || 'UTC', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + timeZoneName: 'short', + }); + } catch { + // Invalid IANA zone -> degrade gracefully to UTC. + return date.toLocaleString('en-US', { + timeZone: 'UTC', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + timeZoneName: 'short', + }); + } +} + +/** + * Return the calendar date (YYYY-MM-DD) of an instant as observed in the given + * timezone. This is the building block for off-by-one-safe day deltas. + */ +export function localCalendarDate( + ts: string | null | undefined, + timezone: string | null | undefined, +): string | null { + const date = parseTimestamp(ts); + if (!date) return null; + return calendarDateForDate(date, timezone); +} + +function calendarDateForDate( + date: Date, + timezone: string | null | undefined, +): string { + // en-CA yields ISO-like YYYY-MM-DD output, which we can compare/parse safely. + try { + return date.toLocaleDateString('en-CA', { + timeZone: timezone || 'UTC', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + } catch { + return date.toLocaleDateString('en-CA', { + timeZone: 'UTC', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + } +} + +/** + * Whole-day difference between a target timestamp and `now`, measured between + * the two calendar dates *in the terminal's timezone*. Positive = future, + * negative = past, 0 = same local day. Returns null for missing/invalid input. + */ +export function dayDeltaInZone( + ts: string | null | undefined, + timezone: string | null | undefined, + now: Date = new Date(), +): number | null { + const target = parseTimestamp(ts); + if (!target) return null; + + const targetDay = calendarDateForDate(target, timezone); + const nowDay = calendarDateForDate(now, timezone); + + // Compare the local calendar dates as UTC midnights so DST shifts inside the + // span never leak into the day count. + const targetMidnight = Date.parse(`${targetDay}T00:00:00Z`); + const nowMidnight = Date.parse(`${nowDay}T00:00:00Z`); + + return Math.round((targetMidnight - nowMidnight) / MS_PER_DAY); +} diff --git a/packages/mcp/src/resources/container.ts b/packages/mcp/src/resources/container.ts index 85f9cab2..75ce1963 100644 --- a/packages/mcp/src/resources/container.ts +++ b/packages/mcp/src/resources/container.ts @@ -4,6 +4,9 @@ */ import { Terminal49Client } from '@terminal49/sdk'; +import { resolveContainerStatus } from '../lib/container-status.js'; +import { evaluateDemurrageUrgency } from '../lib/demurrage.js'; +import { formatInZone } from '../lib/temporal.js'; const URI_PATTERN = /^(?:t49:|terminal49:\/\/)container\/([a-f0-9-]{36})$/i; @@ -22,7 +25,7 @@ export function matchesContainerUri(uri: string): boolean { export async function readContainerResource( uri: string, - client: Terminal49Client + client: Terminal49Client, ): Promise<{ uri: string; mimeType: string; text: string }> { const normalized = normalizeUri(uri); const match = normalized.match(URI_PATTERN); @@ -61,41 +64,95 @@ function normalizeUri(uri: string): string { } function generateSummary(id: string, container: any): string { - const status = determineStatus(container); - const railSection = container.pod_rail_carrier_scac ? generateRailSection(container) : ''; + // Headline status comes from the API current_status (shared resolver), ending + // the divergence between this resource and the get_container tool. + const { status } = resolveContainerStatus(container); + const podTimezone: string | null = container.pod_timezone ?? null; + const railSection = container.pod_rail_carrier_scac + ? generateRailSection(container, podTimezone) + : ''; const label = container.number || container.container_number || 'Unknown'; const equipment = formatEquipment(container); + const demurrage = evaluateDemurrageUrgency({ + fees_at_pod_terminal: container.fees_at_pod_terminal, + pickup_lfd: container.pickup_lfd ?? null, + terminal_checked_at: container.terminal_checked_at ?? null, + tracking_stopped: Boolean( + container.line_tracking_stopped_at || + container.line_tracking_stopped_reason, + ), + }); + + const importDeadlines = container.import_deadlines || {}; + return `# Container ${label} **ID:** \`${id}\` **Status:** ${status} **Equipment:** ${equipment} +${podTimezone ? `**Terminal Timezone:** ${podTimezone}` : ''} ## Location & Availability -- **Available for Pickup:** ${container.available_for_pickup ? 'Yes' : 'No'} +- **Available for Pickup:** ${formatAvailability(container)} - **Current Location:** ${container.location_at_pod_terminal || 'Unknown'} -- **POD Arrived:** ${formatTimestamp(container.pod_arrived_at)} -- **POD Discharged:** ${formatTimestamp(container.pod_discharged_at)} +- **POD Arrived:** ${formatInZone(container.pod_arrived_at, podTimezone)} +- **POD Discharged:** ${formatInZone(container.pod_discharged_at, podTimezone)} ## Demurrage & Fees -- **Last Free Day (LFD):** ${formatDate(container.pickup_lfd)} -- **Pickup Appointment:** ${formatTimestamp(container.pickup_appointment_at)} -- **Fees:** ${container.fees_at_pod_terminal?.length || 'None'} -- **Holds:** ${container.holds_at_pod_terminal?.length || 'None'} +- **Last Free Day (LFD):** ${formatInZone(container.pickup_lfd, podTimezone)} +- **LFD (Terminal):** ${formatInZone(importDeadlines.pickup_lfd_terminal, podTimezone)} +- **LFD (Rail):** ${formatInZone(importDeadlines.pickup_lfd_rail, container.final_destination_timezone ?? podTimezone)} +- **LFD (Line):** ${formatInZone(importDeadlines.pickup_lfd_line, podTimezone)} +- **Pickup Appointment:** ${formatInZone(container.pickup_appointment_at, podTimezone)} +- **Fees:** ${formatFees(demurrage)} +- **Holds:** ${formatHolds(container)} +${demurrage.urgency_suppressed ? `- **LFD Urgency:** Unavailable (${demurrage.suppression_reason})` : ''} -${railSection} +${railSection}`; +} ---- -*Last Updated: ${formatTimestamp(container.updated_at)}* -`; +function formatAvailability(container: any): string { + if (container.available_for_pickup === true) return 'Yes'; + if (container.available_for_pickup === false) return 'No'; + return 'Unknown'; +} + +function formatFees( + demurrage: ReturnType, +): string { + if (demurrage.fees == null) return 'Not reported'; + if (demurrage.fees.length === 0) return 'None'; + if (demurrage.total_amount != null) { + const currency = demurrage.currency_code + ? ` ${demurrage.currency_code}` + : ''; + return `${demurrage.fees.length} (total ${demurrage.total_amount}${currency})`; + } + return `${demurrage.fees.length}`; +} + +function formatHolds(container: any): string { + const holds = container.holds_at_pod_terminal; + if (holds == null) return 'Not reported'; + if (!Array.isArray(holds) || holds.length === 0) return 'None'; + return `${holds.length}`; } function formatEquipment(container: any): string { - const equipmentLength = container.equipment_length; - const equipmentType = container.equipment_type; + // equipment_length is the numeric enum 10|20|40|45; guard the 0/empty sentinel. + const rawLength = container.equipment_length; + const equipmentLength = + typeof rawLength === 'number' && rawLength > 0 + ? rawLength + : typeof rawLength === 'string' && + rawLength.trim() !== '' && + Number(rawLength) > 0 + ? Number(rawLength) + : null; + const equipmentType = container.equipment_type || null; if (equipmentLength && equipmentType) { return `${equipmentLength}' ${equipmentType}`; @@ -112,57 +169,16 @@ function formatEquipment(container: any): string { return 'Unknown'; } -function generateRailSection(container: any): string { - return ` -## Rail Information +function generateRailSection( + container: any, + podTimezone: string | null, +): string { + const railTimezone = container.final_destination_timezone ?? podTimezone; + return `## Rail Information - **Rail Carrier:** ${container.pod_rail_carrier_scac} -- **Rail Loaded:** ${formatTimestamp(container.pod_rail_loaded_at)} -- **Destination ETA:** ${formatTimestamp(container.ind_eta_at)} -- **Destination ATA:** ${formatTimestamp(container.ind_ata_at)} +- **Rail Loaded:** ${formatInZone(container.pod_rail_loaded_at, podTimezone)} +- **Destination ETA:** ${formatInZone(container.ind_eta_at, railTimezone)} +- **Destination ATA:** ${formatInZone(container.ind_ata_at, railTimezone)} `; } - -function determineStatus(container: any): string { - if (container.available_for_pickup) { - return 'Available for Pickup'; - } else if (container.pod_discharged_at) { - return 'Discharged at POD'; - } else if (container.pod_arrived_at) { - return 'Arrived at POD'; - } - return 'In Transit'; -} - -function formatTimestamp(ts: string | null): string { - if (!ts) return 'N/A'; - - const date = new Date(ts); - if (Number.isNaN(date.getTime())) { - return ts; - } - - return date.toLocaleString('en-US', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - timeZoneName: 'short', - }); -} - -function formatDate(date: string | null): string { - if (!date) return 'N/A'; - - const parsedDate = new Date(date); - if (Number.isNaN(parsedDate.getTime())) { - return date; - } - - return parsedDate.toLocaleDateString('en-US', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }); -} diff --git a/packages/mcp/src/tools/contracts.test.ts b/packages/mcp/src/tools/contracts.test.ts index 29681c1d..f47147c0 100644 --- a/packages/mcp/src/tools/contracts.test.ts +++ b/packages/mcp/src/tools/contracts.test.ts @@ -21,16 +21,25 @@ function buildContainerRaw(containerId = 'container-1') { type: 'container', attributes: { number: 'CAIU1234567', - equipment_type: '40HC', - equipment_length: '40', + equipment_type: 'dry', + equipment_length: 40, equipment_height: 'high_cube', weight_in_lbs: 12000, location_at_pod_terminal: 'APM', available_for_pickup: true, + current_status: 'available', pod_arrived_at: '2025-01-01T00:00:00Z', pod_discharged_at: '2025-01-02T00:00:00Z', pickup_lfd: '2099-01-10', - updated_at: '2025-01-05T00:00:00Z', + pod_timezone: 'America/Los_Angeles', + terminal_checked_at: '2099-01-05T00:00:00Z', + import_deadlines: { + pickup_lfd_terminal: '2099-01-10T00:00:00Z', + pickup_lfd_rail: null, + pickup_lfd_line: '2099-01-12T00:00:00Z', + }, + fees_at_pod_terminal: [], + holds_at_pod_terminal: [], created_at: '2025-01-01T00:00:00Z', }, relationships: { @@ -107,9 +116,8 @@ async function executeSupportedShippingLines( client: Terminal49Client, ) { vi.resetModules(); - const { executeGetSupportedShippingLines } = await import( - './get-supported-shipping-lines.js' - ); + const { executeGetSupportedShippingLines } = + await import('./get-supported-shipping-lines.js'); return executeGetSupportedShippingLines(args, client); } @@ -142,7 +150,10 @@ describe('MCP tool contracts', () => { }), }); - const result = await executeSearchContainer({ query: 'CAIU1234567' }, client); + const result = await executeSearchContainer( + { query: 'CAIU1234567' }, + client, + ); expect(result.total_results).toBe(2); expect(result.containers[0]).toMatchObject({ @@ -150,7 +161,10 @@ describe('MCP tool contracts', () => { container_number: 'CAIU1234567', shipping_line: 'MAEU', }); - expect(result.shipments[0]).toMatchObject({ id: 'sr-2', shipping_line: 'MSCU' }); + expect(result.shipments[0]).toMatchObject({ + id: 'sr-2', + shipping_line: 'MSCU', + }); }); it('search_container prioritizes full_out over discharged/arrived status', async () => { @@ -172,7 +186,10 @@ describe('MCP tool contracts', () => { }), }); - const result = await executeSearchContainer({ query: 'TIIU1234567' }, client); + const result = await executeSearchContainer( + { query: 'TIIU1234567' }, + client, + ); expect(result.containers[0]).toMatchObject({ id: 'container-legacy-1', @@ -208,10 +225,12 @@ describe('MCP tool contracts', () => { numberType: undefined, refNumbers: undefined, }); + // get_container now augments the default includes and returns the curated + // (raw-sourced) summary only — no _mapped bloat. expect(getContainer).toHaveBeenCalledWith( 'container-1', - ['shipment'], - { format: 'both' }, + ['shipment', 'pod_terminal'], + { format: 'raw' }, ); expect(result.tracking_request_created).toBe(true); expect(result.infer_result).toEqual({ inferred_type: 'container' }); @@ -243,10 +262,17 @@ describe('MCP tool contracts', () => { containers: { get: getContainer }, }); - const result = await executeTrackContainer({ number: 'SELU4039824' }, client); + const result = await executeTrackContainer( + { number: 'SELU4039824' }, + client, + ); expect(createFromInfer).not.toHaveBeenCalled(); - expect(getContainer).toHaveBeenCalledWith('container-42', ['shipment'], { format: 'both' }); + expect(getContainer).toHaveBeenCalledWith( + 'container-42', + ['shipment', 'pod_terminal'], + { format: 'raw' }, + ); expect(result.tracking_request_created).toBe(false); expect(result.container_number).toBe('CAIU1234567'); expect(result.infer_result).toMatchObject({ @@ -258,7 +284,9 @@ describe('MCP tool contracts', () => { it('track_container falls back to direct create when infer endpoint validation fails', async () => { const createFromInfer = vi .fn() - .mockRejectedValue(new Error('Unprocessable Entity (/data/attributes/number)')); + .mockRejectedValue( + new Error('Unprocessable Entity (/data/attributes/number)'), + ); const createTrackingRequest = vi.fn().mockResolvedValue({ included: [{ id: 'container-77', type: 'container' }], }); @@ -321,12 +349,39 @@ describe('MCP tool contracts', () => { ]); }); - it('get_container classifies discharged containers when not available for pickup', async () => { + it('get_container surfaces the API current_status as the authoritative headline', async () => { + const rawContainer = buildContainerRaw('container-status'); + rawContainer.data.attributes.current_status = 'grounded'; + rawContainer.data.attributes.available_for_pickup = false; + + const client = asClient({ + containers: { + get: vi.fn().mockResolvedValue({ + raw: rawContainer, + mapped: { id: 'container-status' }, + }), + }, + }); + + const result = await executeGetContainer( + { id: 'container-status' }, + client, + ); + + // Headline status is the API value verbatim, not a re-derived label. + expect(result.status).toBe('grounded'); + expect(result.status_source).toBe('current_status'); + expect(result._metadata.status_is_authoritative).toBe(true); + }); + + it('get_container derives a discharged lifecycle only as steering metadata when API status is absent', async () => { const rawContainer = buildContainerRaw('container-discharged'); + delete (rawContainer.data.attributes as any).current_status; rawContainer.data.attributes.available_for_pickup = false; rawContainer.data.attributes.pod_arrived_at = '2026-02-10T00:00:00Z'; rawContainer.data.attributes.pod_discharged_at = '2026-02-11T00:00:00Z'; (rawContainer.data.attributes as any).pod_full_out_at = null; + (rawContainer.data.attributes as any).delivered_at = null; (rawContainer.data.attributes as any).final_destination_full_out_at = null; (rawContainer.data.attributes as any).pod_rail_loaded_at = null; @@ -339,15 +394,118 @@ describe('MCP tool contracts', () => { }, }); - const result = await executeGetContainer({ id: 'container-discharged' }, client); + const result = await executeGetContainer( + { id: 'container-discharged' }, + client, + ); expect(result.status).toBe('discharged'); + expect(result.status_source).toBe('derived'); + expect(result._metadata.derived_lifecycle).toBe('discharged'); + }); + + it('get_container augments the default includes instead of replacing them', async () => { + const get = vi.fn().mockResolvedValue({ + raw: buildContainerRaw('container-augment'), + mapped: { id: 'container-augment' }, + }); + const client = asClient({ containers: { get } }); + + const result = await executeGetContainer( + { id: 'container-augment', include: ['transport_events'] }, + client, + ); + + // shipment + pod_terminal must survive even when only transport_events is requested. + expect(get).toHaveBeenCalledWith( + 'container-augment', + ['shipment', 'pod_terminal', 'transport_events'], + { format: 'raw' }, + ); + expect(result._metadata.includes_loaded).toEqual([ + 'shipment', + 'pod_terminal', + 'transport_events', + ]); + }); + + it('get_container returns real fees + currency and never fabricates a daily rate', async () => { + const rawContainer = buildContainerRaw('container-fees'); + (rawContainer.data.attributes as any).fees_at_pod_terminal = [ + { type: 'demurrage', amount: 240, currency_code: 'USD' }, + ]; + + const client = asClient({ + containers: { + get: vi.fn().mockResolvedValue({ + raw: rawContainer, + mapped: { id: 'container-fees' }, + }), + }, + }); + + const result = await executeGetContainer({ id: 'container-fees' }, client); + + expect(result.demurrage.fees_at_pod_terminal).toEqual([ + { type: 'demurrage', amount: 240, currency_code: 'USD' }, + ]); + expect(result.demurrage.fees_total_amount).toBe(240); + expect(result.demurrage.fees_currency_code).toBe('USD'); + expect(JSON.stringify(result.demurrage)).not.toMatch(/\/day/); + expect(JSON.stringify(result)).not.toMatch(/75-150/); + }); + + it('get_container suppresses demurrage urgency when terminal data is stale', async () => { + const rawContainer = buildContainerRaw('container-stale'); + (rawContainer.data.attributes as any).pickup_lfd = '2020-01-01'; + (rawContainer.data.attributes as any).terminal_checked_at = + '2019-01-01T00:00:00Z'; + + const client = asClient({ + containers: { + get: vi.fn().mockResolvedValue({ + raw: rawContainer, + mapped: { id: 'container-stale' }, + }), + }, + }); + + const result = await executeGetContainer({ id: 'container-stale' }, client); + + expect(result.demurrage.urgency).toBe('unknown'); + expect(result.demurrage.urgency_suppressed).toBe(true); + }); + + it('get_container surfaces pod_timezone and per-channel LFDs and drops phantom updated_at', async () => { + const client = asClient({ + containers: { + get: vi.fn().mockResolvedValue({ + raw: buildContainerRaw('container-tz'), + mapped: { id: 'container-tz' }, + }), + }, + }); + + const result = await executeGetContainer({ id: 'container-tz' }, client); + + expect(result.location.pod_timezone).toBe('America/Los_Angeles'); + expect(result.demurrage.last_free_days).toEqual({ + terminal: '2099-01-10T00:00:00Z', + rail: null, + line: '2099-01-12T00:00:00Z', + }); + expect(result.equipment.length).toBe(40); + expect((result as any).updated_at).toBeUndefined(); + expect((result as any)._mapped).toBeUndefined(); }); it('get_shipment_details returns shipment summary and container list', async () => { const shipmentsGet = vi .fn() - .mockResolvedValue({ raw: buildShipmentRaw(), mapped: { id: 'shipment-1' } }); + .mockResolvedValue({ + raw: buildShipmentRaw(), + mapped: { id: 'shipment-1' }, + }); const client = asClient({ shipments: { get: shipmentsGet } }); const result = await executeGetShipmentDetails( @@ -355,8 +513,9 @@ describe('MCP tool contracts', () => { client, ); + // Curated summary only: no _mapped bloat, so we request raw (not both). expect(shipmentsGet).toHaveBeenCalledWith('shipment-1', true, { - format: 'both', + format: 'raw', }); expect(result).toMatchObject({ id: 'shipment-1', @@ -364,6 +523,7 @@ describe('MCP tool contracts', () => { shipping_line: { scac: 'MAEU', name: 'Maersk' }, containers: { count: 1 }, }); + expect(result._mapped).toBeUndefined(); }); it('get_container_transport_events returns timeline and milestone summary', async () => { @@ -536,33 +696,46 @@ describe('MCP tool contracts', () => { shippingLines: { list: shippingList }, }); - const result = await executeSupportedShippingLines({ search: 'mae' }, client); + const result = await executeSupportedShippingLines( + { search: 'mae' }, + client, + ); expect(shippingList).toHaveBeenCalledWith(undefined, { format: 'mapped' }); expect(result.total_lines).toBe(1); - expect(result.shipping_lines[0]).toMatchObject({ scac: 'MAEU', name: 'Maersk' }); + expect(result.shipping_lines[0]).toMatchObject({ + scac: 'MAEU', + name: 'Maersk', + }); }); it('get_supported_shipping_lines fails when shipping_lines API call fails', async () => { - const shippingList = vi.fn().mockRejectedValue(new Error('downstream failure')); + const shippingList = vi + .fn() + .mockRejectedValue(new Error('downstream failure')); const client = asClient({ shippingLines: { list: shippingList }, }); - await expect(executeSupportedShippingLines({ search: 'mae' }, client)).rejects.toThrow('downstream failure'); + await expect( + executeSupportedShippingLines({ search: 'mae' }, client), + ).rejects.toThrow('downstream failure'); }); it('get_supported_shipping_lines does not reuse module cache across different clients', async () => { vi.resetModules(); - const { executeGetSupportedShippingLines } = await import('./get-supported-shipping-lines.js'); + const { executeGetSupportedShippingLines } = + await import('./get-supported-shipping-lines.js'); - const listForClientA = vi.fn().mockResolvedValue([ - { scac: 'MSCU', name: 'MSC', shortName: 'MSC' }, - ]); - const listForClientB = vi.fn().mockResolvedValue([ - { scac: 'MAEU', name: 'Maersk', shortName: 'Maersk' }, - ]); + const listForClientA = vi + .fn() + .mockResolvedValue([{ scac: 'MSCU', name: 'MSC', shortName: 'MSC' }]); + const listForClientB = vi + .fn() + .mockResolvedValue([ + { scac: 'MAEU', name: 'Maersk', shortName: 'Maersk' }, + ]); const clientA = asClient({ shippingLines: { list: listForClientA } }); const clientB = asClient({ shippingLines: { list: listForClientB } }); @@ -621,7 +794,10 @@ describe('MCP tool contracts', () => { }, }); - const result = await executeGetContainerRoute({ id: 'container-1' }, client); + const result = await executeGetContainerRoute( + { id: 'container-1' }, + client, + ); expect(result.route_id).toBe('route-1'); expect(result.total_legs).toBe(1); @@ -631,11 +807,16 @@ describe('MCP tool contracts', () => { it('get_container_route returns feature-not-enabled contract instead of throwing', async () => { const client = asClient({ containers: { - route: vi.fn().mockRejectedValue(new FeatureNotEnabledError('feature not enabled')), + route: vi + .fn() + .mockRejectedValue(new FeatureNotEnabledError('feature not enabled')), }, }); - const result = await executeGetContainerRoute({ id: 'container-1' }, client); + const result = await executeGetContainerRoute( + { id: 'container-1' }, + client, + ); expect(result).toMatchObject({ error: 'FeatureNotEnabled', diff --git a/packages/mcp/src/tools/get-container.ts b/packages/mcp/src/tools/get-container.ts index bebcf45d..9732542b 100644 --- a/packages/mcp/src/tools/get-container.ts +++ b/packages/mcp/src/tools/get-container.ts @@ -1,36 +1,72 @@ /** * get_container tool - * Retrieves detailed container information by Terminal49 ID + * Retrieves detailed container information by Terminal49 ID. + * + * The Zod input schema lives in server.ts (single source of truth); this file + * only owns the execution + curation logic. */ import { Terminal49Client } from '@terminal49/sdk'; +import { + type ContainerStatusResult, + resolveContainerStatus, +} from '../lib/container-status.js'; +import { + type DemurrageEvaluation, + evaluateDemurrageUrgency, +} from '../lib/demurrage.js'; +import { dayDeltaInZone, formatInZone } from '../lib/temporal.js'; + +export type ContainerInclude = 'shipment' | 'pod_terminal' | 'transport_events'; + +/** The default sideloads. `include` augments — never replaces — these. */ +const DEFAULT_INCLUDES: ContainerInclude[] = ['shipment', 'pod_terminal']; export interface GetContainerArgs { id: string; - include?: ('shipment' | 'pod_terminal' | 'transport_events')[]; + include?: ContainerInclude[]; } export interface ContainerStatus { id: string; container_number: string; - status: 'in_transit' | 'arrived' | 'discharged' | 'available_for_pickup' | 'at_terminal' | 'on_rail' | 'delivered'; + /** Authoritative headline status from the API `current_status`. */ + status: string; + status_source: ContainerStatusResult['status_source']; equipment: { - type: string; - length: string; - height: string; - weight_lbs: number; + type: string | null; + length: number | null; + height: string | null; + weight_lbs: number | null; }; location: { current_location: string | null; - available_for_pickup: boolean; + available_for_pickup: boolean | null; + availability_known: boolean | null; pod_arrived_at: string | null; + pod_arrived_at_local: string; pod_discharged_at: string | null; + pod_discharged_at_local: string; + pod_timezone: string | null; }; demurrage: { pickup_lfd: string | null; + pickup_lfd_local: string; + /** Per-channel LFDs from import_deadlines (terminal/rail/line). */ + last_free_days: { + terminal: string | null; + rail: string | null; + line: string | null; + }; pickup_appointment_at: string | null; - fees_at_pod_terminal: any[] | null; - holds_at_pod_terminal: any[] | null; + fees_at_pod_terminal: DemurrageEvaluation['fees']; + fees_total_amount: number | null; + fees_currency_code: string | null; + holds_at_pod_terminal: unknown[] | null; + urgency: DemurrageEvaluation['urgency']; + urgency_suppressed: boolean; + urgency_reason: string | null; + days_until_lfd: number | null; }; rail: { pod_rail_carrier: string | null; @@ -52,19 +88,22 @@ export interface ContainerStatus { name: string; firms_code: string; } | null; - events?: { - count: number; - latest_event?: { - event: string; - timestamp: string; - location?: string; - }; - rail_events_count?: number; - } | string; - updated_at: string; + events?: + | { + count: number; + latest_event?: { + event: string; + timestamp: string; + location?: string; + }; + rail_events_count?: number; + } + | string; created_at: string; _metadata: { container_state: string; + status_is_authoritative: boolean; + derived_lifecycle: string; includes_loaded: string[]; can_answer: string[]; needs_more_data_for: string[]; @@ -77,45 +116,20 @@ export interface ContainerStatus { }; } -export const getContainerTool = { - name: 'get_container', - description: - 'Get container information with flexible data loading. ' + - 'Returns core container data (status, location, equipment, dates) plus optional related data. ' + - 'Choose includes based on user question and container state. ' + - 'Response includes metadata hints to guide follow-up queries.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'The Terminal49 container ID (UUID format)', - }, - include: { - type: 'array', - items: { - type: 'string', - enum: ['shipment', 'pod_terminal', 'transport_events'], - }, - description: - "Optional related data to include. Default: ['shipment'] covers most use cases.\n\n" + - "• 'shipment': Routing, BOL, line, ref numbers (lightweight, always useful)\n" + - "• 'pod_terminal': Terminal name, location, availability (lightweight, needed for demurrage questions)\n" + - "• 'transport_events': Full event history, rail tracking (heavy 50-100 events, use for journey/timeline questions)\n\n" + - "When to include:\n" + - "- shipment: Always useful for context (minimal cost)\n" + - "- pod_terminal: For availability, demurrage, holds, fees, pickup questions\n" + - "- transport_events: For journey timeline, 'what happened', rail tracking, milestone analysis", - default: ['shipment'], - }, - }, - required: ['id'], - }, -}; +/** Merge requested includes onto the defaults, de-duplicated, order-stable. */ +export function resolveIncludes( + requested: ContainerInclude[] | undefined, +): ContainerInclude[] { + const merged: ContainerInclude[] = [...DEFAULT_INCLUDES]; + for (const inc of requested ?? []) { + if (!merged.includes(inc)) merged.push(inc); + } + return merged; +} export async function executeGetContainer( args: GetContainerArgs, - client: Terminal49Client + client: Terminal49Client, ): Promise { if (!args.id || args.id.trim() === '') { throw new Error('Container ID is required'); @@ -128,33 +142,31 @@ export async function executeGetContainer( tool: 'get_container', container_id: args.id, timestamp: new Date().toISOString(), - }) + }), ); try { - const includes = args.include || ['shipment']; - const result = await client.containers.get(args.id, includes, { format: 'both' }); + const includes = resolveIncludes(args.include); + const result = await client.containers.get(args.id, includes, { + format: 'raw', + }); const raw = (result as any)?.raw ?? result; - const mapped = (result as any)?.mapped; const duration = Date.now() - startTime; - console.error( JSON.stringify({ event: 'tool.execute.complete', tool: 'get_container', container_id: args.id, - includes: includes, + includes, duration_ms: duration, timestamp: new Date().toISOString(), - }) + }), ); - const summary = formatContainerResponse(raw, includes); - return { ...summary, _mapped: mapped } as any; + return formatContainerResponse(raw, includes); } catch (error) { const duration = Date.now() - startTime; - console.error( JSON.stringify({ event: 'tool.execute.error', @@ -164,72 +176,121 @@ export async function executeGetContainer( message: (error as Error).message, duration_ms: duration, timestamp: new Date().toISOString(), - }) + }), ); - throw error; } } -function formatContainerResponse(apiResponse: any, includes: string[]): ContainerStatus { +function formatContainerResponse( + apiResponse: any, + includes: string[], +): ContainerStatus { const container = apiResponse.data?.attributes || {}; const relationships = apiResponse.data?.relationships || {}; const included = apiResponse.included || []; - // Determine container lifecycle state - const containerState = determineContainerState(container); + const statusResult = resolveContainerStatus(container); // Extract shipment info const shipmentId = relationships.shipment?.data?.id; const shipment = included.find( - (item: any) => item.id === shipmentId && item.type === 'shipment' + (item: any) => item.id === shipmentId && item.type === 'shipment', ); // Extract terminal info const terminalId = relationships.pod_terminal?.data?.id; const podTerminal = included.find( - (item: any) => item.id === terminalId && item.type === 'terminal' + (item: any) => item.id === terminalId && item.type === 'terminal', ); // Extract transport events - const transportEvents = included.filter((item: any) => item.type === 'transport_event'); + const transportEvents = included.filter( + (item: any) => item.type === 'transport_event', + ); - // Format events data based on whether it was included const eventsData = includes.includes('transport_events') ? formatEventsData(transportEvents) : `Call get_container with include=['transport_events'] to fetch ${transportEvents.length || '~50-100'} event records`; - // Generate LLM steering metadata - const metadata = generateMetadata(container, containerState, includes); + const podTimezone: string | null = container.pod_timezone ?? null; + const rawDemurrage = evaluateDemurrageUrgency({ + fees_at_pod_terminal: container.fees_at_pod_terminal, + pickup_lfd: container.pickup_lfd ?? null, + terminal_checked_at: container.terminal_checked_at ?? null, + tracking_stopped: isTrackingStopped(container), + }); + // Surface the LFD countdown in terminal-local days so "N days until LFD" never + // lands on the wrong calendar day near a UTC midnight boundary. + const localDaysUntilLfd = dayDeltaInZone(container.pickup_lfd, podTimezone); + const demurrage: DemurrageEvaluation = { + ...rawDemurrage, + days_until_lfd: localDaysUntilLfd ?? rawDemurrage.days_until_lfd, + }; + + const importDeadlines = container.import_deadlines || {}; + + const metadata = generateMetadata( + container, + statusResult, + demurrage, + podTimezone, + includes, + ); return { id: apiResponse.data?.id, container_number: container.number, - status: containerState, + status: statusResult.status, + status_source: statusResult.status_source, equipment: { - type: container.equipment_type, - length: container.equipment_length, - height: container.equipment_height, - weight_lbs: container.weight_in_lbs, + type: container.equipment_type ?? null, + // equipment_length is a numeric enum (10|20|40|45). Guard the 0/empty + // sentinel so we never emit a meaningless "". + length: normalizeEquipmentLength(container.equipment_length), + height: container.equipment_height ?? null, + weight_lbs: + typeof container.weight_in_lbs === 'number' + ? container.weight_in_lbs + : null, }, location: { - current_location: container.location_at_pod_terminal, - available_for_pickup: container.available_for_pickup, - pod_arrived_at: container.pod_arrived_at, - pod_discharged_at: container.pod_discharged_at, + current_location: container.location_at_pod_terminal ?? null, + available_for_pickup: container.available_for_pickup ?? null, + availability_known: container.availability_known ?? null, + pod_arrived_at: container.pod_arrived_at ?? null, + pod_arrived_at_local: formatInZone(container.pod_arrived_at, podTimezone), + pod_discharged_at: container.pod_discharged_at ?? null, + pod_discharged_at_local: formatInZone( + container.pod_discharged_at, + podTimezone, + ), + pod_timezone: podTimezone, }, demurrage: { pickup_lfd: container.pickup_lfd ?? null, + pickup_lfd_local: formatInZone(container.pickup_lfd, podTimezone), + last_free_days: { + terminal: importDeadlines.pickup_lfd_terminal ?? null, + rail: importDeadlines.pickup_lfd_rail ?? null, + line: importDeadlines.pickup_lfd_line ?? null, + }, pickup_appointment_at: container.pickup_appointment_at ?? null, - // Preserve nulls so clients don’t mistake “unavailable” for “empty”. - fees_at_pod_terminal: container.fees_at_pod_terminal ?? null, + // Preserve nulls so clients don't mistake "unavailable" for "empty". + fees_at_pod_terminal: demurrage.fees, + fees_total_amount: demurrage.total_amount, + fees_currency_code: demurrage.currency_code, holds_at_pod_terminal: container.holds_at_pod_terminal ?? null, + urgency: demurrage.urgency, + urgency_suppressed: demurrage.urgency_suppressed, + urgency_reason: demurrage.suppression_reason, + days_until_lfd: demurrage.days_until_lfd, }, rail: { - pod_rail_carrier: container.pod_rail_carrier_scac, - pod_rail_loaded_at: container.pod_rail_loaded_at, - destination_eta: container.ind_eta_at, - destination_ata: container.ind_ata_at, + pod_rail_carrier: container.pod_rail_carrier_scac ?? null, + pod_rail_loaded_at: container.pod_rail_loaded_at ?? null, + destination_eta: container.ind_eta_at ?? null, + destination_ata: container.ind_ata_at ?? null, }, shipment: shipment ? { @@ -250,43 +311,47 @@ function formatContainerResponse(apiResponse: any, includes: string[]): Containe } : null, events: eventsData, - updated_at: container.updated_at, created_at: container.created_at, _metadata: metadata, }; } -/** - * Determine container lifecycle state for intelligent data loading - */ -function determineContainerState( - container: any -): 'in_transit' | 'arrived' | 'discharged' | 'available_for_pickup' | 'at_terminal' | 'on_rail' | 'delivered' { - if (!container.pod_arrived_at) return 'in_transit'; - if (!container.pod_discharged_at) return 'arrived'; - if (container.pod_rail_loaded_at && !container.final_destination_full_out_at) return 'on_rail'; - if (container.final_destination_full_out_at || container.pod_full_out_at) return 'delivered'; - if (container.available_for_pickup === true) return 'available_for_pickup'; - if (container.available_for_pickup === false) return 'discharged'; - return 'at_terminal'; +/** equipment_length is the numeric enum 10|20|40|45; everything else is null. */ +function normalizeEquipmentLength(value: unknown): number | null { + if (typeof value === 'number' && value > 0) return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return null; } /** - * Format transport events data when included + * Whether line tracking has stopped/closed for the container's shipment, which + * makes terminal availability/LFD signals untrustworthy for urgency. */ +function isTrackingStopped(container: any): boolean { + return Boolean( + container.line_tracking_stopped_at || + container.line_tracking_stopped_reason, + ); +} + function formatEventsData(events: any[]): any { if (!events || events.length === 0) { return { count: 0 }; } const railEvents = events.filter( - (e: any) => e.attributes?.event?.startsWith('rail.') || e.attributes?.event?.includes('rail') + (e: any) => + e.attributes?.event?.startsWith('rail.') || + e.attributes?.event?.includes('rail'), ); - // Get most recent event const sortedEvents = [...events].sort( (a: any, b: any) => - new Date(b.attributes?.timestamp || 0).getTime() - new Date(a.attributes?.timestamp || 0).getTime() + new Date(b.attributes?.timestamp || 0).getTime() - + new Date(a.attributes?.timestamp || 0).getTime(), ); const latestEvent = sortedEvents[0]?.attributes; @@ -305,40 +370,74 @@ function formatEventsData(events: any[]): any { } /** - * Generate metadata hints to steer LLM decision-making + * Generate metadata hints to steer LLM decision-making. The derived lifecycle + * is exposed here as non-authoritative steering metadata only — the headline + * `status` above is the source of truth. */ -function generateMetadata(container: any, state: string, includes: string[]): any { - const canAnswer: string[] = ['container status', 'equipment details', 'basic timeline']; +function generateMetadata( + container: any, + statusResult: ContainerStatusResult, + demurrage: DemurrageEvaluation, + podTimezone: string | null, + includes: string[], +): ContainerStatus['_metadata'] { + const lifecycle = statusResult.derived_lifecycle; + const canAnswer: string[] = [ + 'container status', + 'equipment details', + 'basic timeline', + ]; const needsMoreDataFor: string[] = []; - // What can we answer based on what's loaded? if (includes.includes('shipment')) { - canAnswer.push('routing information', 'shipping line details', 'reference numbers'); + canAnswer.push( + 'routing information', + 'shipping line details', + 'reference numbers', + ); } if (includes.includes('pod_terminal')) { - canAnswer.push('availability status', 'demurrage/LFD', 'holds and fees', 'terminal location'); + canAnswer.push( + 'availability status', + 'demurrage/LFD', + 'holds and fees', + 'terminal location', + ); } if (includes.includes('transport_events')) { - canAnswer.push('full journey timeline', 'milestone analysis', 'rail tracking details', 'event history'); + canAnswer.push( + 'full journey timeline', + 'milestone analysis', + 'rail tracking details', + 'event history', + ); } else { needsMoreDataFor.push( "journey timeline → include: ['transport_events']", "milestone analysis → include: ['transport_events']", - "rail movement details → include: ['transport_events']" + "rail movement details → include: ['transport_events']", ); } - // Generate contextual suggestions based on state - const suggestions = generateSuggestions(container, state, includes); - - // Generate lifecycle-specific guidance - const relevantFields = getRelevantFieldsForState(state, container); - const presentationGuidance = getPresentationGuidance(state, container); + const suggestions = generateSuggestions( + container, + lifecycle, + demurrage, + includes, + ); + const relevantFields = getRelevantFieldsForState(lifecycle, container); + const presentationGuidance = getPresentationGuidance( + lifecycle, + container, + demurrage, + ); return { - container_state: state, + container_state: lifecycle, + status_is_authoritative: statusResult.status_source === 'current_status', + derived_lifecycle: lifecycle, includes_loaded: includes, can_answer: canAnswer, needs_more_data_for: needsMoreDataFor, @@ -348,72 +447,77 @@ function generateMetadata(container: any, state: string, includes: string[]): an }; } -/** - * Generate contextual suggestions for LLM based on container state - */ -function generateSuggestions(container: any, state: string, includes: string[]): any { +function generateSuggestions( + container: any, + state: string, + demurrage: DemurrageEvaluation, + includes: string[], +): { message?: string; recommended_follow_up?: string | null } { let message: string | undefined; let recommendedFollowUp: string | null = null; - // State-specific suggestions switch (state) { case 'in_transit': - message = 'Container is still in transit. User may ask about vessel ETA or shipping route.'; + message = + 'Container is still in transit. User may ask about vessel ETA or shipping route.'; break; case 'arrived': - message = 'Container has arrived but not yet discharged. User may ask about discharge timing.'; + message = + 'Container has arrived but not yet discharged. User may ask about discharge timing.'; break; case 'at_terminal': case 'available_for_pickup': - if (Array.isArray(container.holds_at_pod_terminal) && container.holds_at_pod_terminal.length > 0) { - const holdTypes = container.holds_at_pod_terminal.map((h: any) => h.name).join(', '); + if ( + Array.isArray(container.holds_at_pod_terminal) && + container.holds_at_pod_terminal.length > 0 + ) { + const holdTypes = container.holds_at_pod_terminal + .map((h: any) => h.name) + .join(', '); message = `Container has holds: ${holdTypes}. User may ask about hold details or clearance timeline.`; - } else if (container.holds_at_pod_terminal == null && includes.includes('pod_terminal')) { + } else if ( + container.holds_at_pod_terminal == null && + includes.includes('pod_terminal') + ) { message = 'Hold/fee/LFD data is not available for this container/terminal via the API response. ' + 'User may need to check terminal portal or customs/broker docs.'; - } else if (container.pickup_lfd) { - const lfdDate = new Date(container.pickup_lfd); - const now = new Date(); - const daysUntilLFD = Math.ceil((lfdDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); - - if (daysUntilLFD < 0) { - message = `Container is ${Math.abs(daysUntilLFD)} days past LFD. User may ask about demurrage charges.`; - } else if (daysUntilLFD <= 3) { - message = `LFD is in ${daysUntilLFD} days. Urgent pickup needed to avoid demurrage.`; + } else if (demurrage.urgency_suppressed) { + message = `LFD urgency is unavailable: ${demurrage.suppression_reason}. Do not assert demurrage urgency from this data alone.`; + } else if (demurrage.days_until_lfd !== null) { + const days = demurrage.days_until_lfd; + if (demurrage.urgency === 'overdue') { + message = `Container is ${Math.abs(days)} days past LFD. User may ask about demurrage charges.`; + } else if (demurrage.urgency === 'imminent') { + message = `LFD is in ${days} days. Urgent pickup needed to avoid demurrage.`; } else { - message = `Container available for pickup. LFD is in ${daysUntilLFD} days.`; + message = `Container available for pickup. LFD is in ${days} days.`; } } break; case 'on_rail': - message = 'Container is on rail transport. User may ask about rail carrier, destination ETA, or inland movement.'; + message = + 'Container is on rail transport. User may ask about rail carrier, destination ETA, or inland movement.'; if (!includes.includes('transport_events')) { recommendedFollowUp = 'transport_events'; } break; case 'delivered': - message = 'Container has been delivered. User may ask about delivery details or empty return.'; + message = + 'Container has been delivered. User may ask about delivery details or empty return.'; if (!includes.includes('transport_events')) { recommendedFollowUp = 'transport_events'; } break; } - return { - message, - recommended_follow_up: recommendedFollowUp, - }; + return { message, recommended_follow_up: recommendedFollowUp }; } -/** - * Get relevant fields/attributes for current lifecycle state - * Helps LLM know what to focus on in the response - */ function getRelevantFieldsForState(state: string, container: any): string[] { switch (state) { case 'in_transit': @@ -435,12 +539,14 @@ function getRelevantFieldsForState(state: string, container: any): string[] { case 'available_for_pickup': { const fields = [ 'location.available_for_pickup - Ready to pick up?', - 'demurrage.pickup_lfd - Last Free Day (avoid demurrage)', + 'demurrage.last_free_days - Per-channel LFDs (terminal/rail/line)', 'demurrage.holds_at_pod_terminal - Blocks pickup if present', 'location.current_location - Where in terminal yard', ]; if (container.fees_at_pod_terminal?.length > 0) { - fields.push('demurrage.fees_at_pod_terminal - Storage/handling charges'); + fields.push( + 'demurrage.fees_at_pod_terminal - Storage/handling charges', + ); } if (container.pickup_appointment_at) { fields.push('demurrage.pickup_appointment_at - Scheduled pickup time'); @@ -469,11 +575,11 @@ function getRelevantFieldsForState(state: string, container: any): string[] { } } -/** - * Get presentation guidance for formatting output based on state - * Tells LLM how to prioritize and structure the response - */ -function getPresentationGuidance(state: string, container: any): string { +function getPresentationGuidance( + state: string, + container: any, + demurrage: DemurrageEvaluation, +): string { switch (state) { case 'in_transit': return 'Focus on ETA and vessel information. User wants to know WHEN it will arrive and WHERE it is now.'; @@ -482,30 +588,39 @@ function getPresentationGuidance(state: string, container: any): string { return 'Explain vessel arrived but container not yet discharged. User wants to know WHEN discharge will happen.'; case 'at_terminal': - case 'available_for_pickup': - // Check for urgent situations + case 'available_for_pickup': { if (container.holds_at_pod_terminal?.length > 0) { - const holdTypes = container.holds_at_pod_terminal.map((h: any) => h.name).join(', '); + const holdTypes = container.holds_at_pod_terminal + .map((h: any) => h.name) + .join(', '); return `URGENT: Lead with holds (${holdTypes}) - they BLOCK pickup. Explain what each hold means and how to clear. Then mention LFD and location.`; } - const lfdDate = container.pickup_lfd ? new Date(container.pickup_lfd) : null; - const now = new Date(); + if (demurrage.urgency_suppressed) { + return `Availability/LFD data is not reliable here (${demurrage.suppression_reason}). State availability cautiously and do NOT assert demurrage urgency. Suggest verifying with the terminal directly.`; + } + + if ( + demurrage.urgency === 'overdue' && + demurrage.days_until_lfd !== null + ) { + const fees = describeFees(demurrage); + return `Container is ${Math.abs(demurrage.days_until_lfd)} days past LFD.${fees} Emphasize that pickup is overdue; report only the fees the API returned (do not estimate a daily rate).`; + } - if (lfdDate && lfdDate < now) { - const daysOverdue = Math.ceil((now.getTime() - lfdDate.getTime()) / (1000 * 60 * 60 * 24)); - return `URGENT: Container is ${daysOverdue} days past LFD. Demurrage is accruing daily (~$75-150/day typical). Emphasize urgency of pickup.`; + if ( + demurrage.urgency === 'imminent' && + demurrage.days_until_lfd !== null + ) { + return `Only ${demurrage.days_until_lfd} days until LFD. Pickup needed soon to avoid demurrage charges.`; } - if (lfdDate) { - const daysRemaining = Math.ceil((lfdDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); - if (daysRemaining <= 2) { - return `URGENT: Only ${daysRemaining} days until LFD. Pickup needed ASAP to avoid demurrage charges.`; - } - return `Lead with availability status. Mention LFD date and days remaining (${daysRemaining}). Include location if user picking up.`; + if (demurrage.days_until_lfd !== null) { + return `Lead with availability status. Mention LFD date and days remaining (${demurrage.days_until_lfd}). Include location if user picking up.`; } - return 'State availability clearly. Mention location in terminal. Note any fees.'; + return 'State availability clearly. Mention location in terminal. Note any fees the API returned.'; + } case 'on_rail': return 'Explain rail journey: Departed [port] on [date] via [carrier], heading to [city]. ETA: [date]. Emphasize destination and timing.'; @@ -517,3 +632,9 @@ function getPresentationGuidance(state: string, container: any): string { return 'Present information clearly based on container lifecycle stage. Prioritize actionable details.'; } } + +function describeFees(demurrage: DemurrageEvaluation): string { + if (demurrage.total_amount == null) return ''; + const currency = demurrage.currency_code ? ` ${demurrage.currency_code}` : ''; + return ` Reported fees total ${demurrage.total_amount}${currency}.`; +} diff --git a/packages/mcp/src/tools/get-shipment-details.ts b/packages/mcp/src/tools/get-shipment-details.ts index ad7f1607..25903ef7 100644 --- a/packages/mcp/src/tools/get-shipment-details.ts +++ b/packages/mcp/src/tools/get-shipment-details.ts @@ -1,41 +1,22 @@ /** * get_shipment_details tool - * Retrieves detailed shipment information by Terminal49 shipment ID + * Retrieves detailed shipment information by Terminal49 shipment ID. + * + * The Zod input schema lives in server.ts (single source of truth); this file + * only owns the execution + curation logic. */ import { Terminal49Client } from '@terminal49/sdk'; +import { dayDeltaInZone } from '../lib/temporal.js'; export interface GetShipmentArgs { id: string; include_containers?: boolean; } -export const getShipmentDetailsTool = { - name: 'get_shipment_details', - description: - 'Get detailed shipment information including routing, BOL, containers, and port details. ' + - 'Use this when user asks about a shipment (vs a specific container). ' + - 'Returns: Bill of Lading, shipping line, port details, vessel info, ETAs, container list.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'The Terminal49 shipment ID (UUID format)', - }, - include_containers: { - type: 'boolean', - description: 'Include list of containers in this shipment. Default: true', - default: true, - }, - }, - required: ['id'], - }, -}; - export async function executeGetShipmentDetails( args: GetShipmentArgs, - client: Terminal49Client + client: Terminal49Client, ): Promise { if (!args.id || args.id.trim() === '') { throw new Error('Shipment ID is required'); @@ -48,14 +29,15 @@ export async function executeGetShipmentDetails( tool: 'get_shipment_details', shipment_id: args.id, timestamp: new Date().toISOString(), - }) + }), ); try { const includeContainers = args.include_containers !== false; - const result = await client.shipments.get(args.id, includeContainers, { format: 'both' }); + const result = await client.shipments.get(args.id, includeContainers, { + format: 'raw', + }); const raw = (result as any)?.raw ?? result; - const mapped = (result as any)?.mapped; const duration = Date.now() - startTime; console.error( @@ -65,11 +47,10 @@ export async function executeGetShipmentDetails( shipment_id: args.id, duration_ms: duration, timestamp: new Date().toISOString(), - }) + }), ); - const summary = formatShipmentResponse(raw, includeContainers); - return { ...summary, _mapped: mapped } as any; + return formatShipmentResponse(raw, includeContainers); } catch (error) { const duration = Date.now() - startTime; @@ -82,14 +63,17 @@ export async function executeGetShipmentDetails( message: (error as Error).message, duration_ms: duration, timestamp: new Date().toISOString(), - }) + }), ); throw error; } } -function formatShipmentResponse(apiResponse: any, includeContainers: boolean): any { +function formatShipmentResponse( + apiResponse: any, + includeContainers: boolean, +): any { const shipment = apiResponse.data?.attributes || {}; const relationships = apiResponse.data?.relationships || {}; const included = apiResponse.included || []; @@ -105,22 +89,26 @@ function formatShipmentResponse(apiResponse: any, includeContainers: boolean): a // Extract port/terminal info const portOfLading = included.find( (item: any) => - item.id === relationships.port_of_lading?.data?.id && item.type === 'port' + item.id === relationships.port_of_lading?.data?.id && + item.type === 'port', ); const portOfDischarge = included.find( (item: any) => - item.id === relationships.port_of_discharge?.data?.id && item.type === 'port' + item.id === relationships.port_of_discharge?.data?.id && + item.type === 'port', ); const podTerminal = included.find( (item: any) => - item.id === relationships.pod_terminal?.data?.id && item.type === 'terminal' + item.id === relationships.pod_terminal?.data?.id && + item.type === 'terminal', ); const destinationTerminal = included.find( (item: any) => - item.id === relationships.destination_terminal?.data?.id && item.type === 'terminal' + item.id === relationships.destination_terminal?.data?.id && + item.type === 'terminal', ); // Note: shipping_line is available directly from shipment attributes @@ -140,25 +128,33 @@ function formatShipmentResponse(apiResponse: any, includeContainers: boolean): a tags: shipment.tags || [], routing: { port_of_lading: { - locode: shipment.port_of_lading_locode || portOfLading?.attributes?.locode, + locode: + shipment.port_of_lading_locode || portOfLading?.attributes?.locode, name: shipment.port_of_lading_name || portOfLading?.attributes?.name, - port_details: portOfLading ? { - id: portOfLading.id, - code: portOfLading.attributes?.code, - country_code: portOfLading.attributes?.country_code, - } : null, + port_details: portOfLading + ? { + id: portOfLading.id, + code: portOfLading.attributes?.code, + country_code: portOfLading.attributes?.country_code, + } + : null, etd: shipment.pol_etd_at, atd: shipment.pol_atd_at, timezone: shipment.pol_timezone, }, port_of_discharge: { - locode: shipment.port_of_discharge_locode || portOfDischarge?.attributes?.locode, - name: shipment.port_of_discharge_name || portOfDischarge?.attributes?.name, - port_details: portOfDischarge ? { - id: portOfDischarge.id, - code: portOfDischarge.attributes?.code, - country_code: portOfDischarge.attributes?.country_code, - } : null, + locode: + shipment.port_of_discharge_locode || + portOfDischarge?.attributes?.locode, + name: + shipment.port_of_discharge_name || portOfDischarge?.attributes?.name, + port_details: portOfDischarge + ? { + id: portOfDischarge.id, + code: portOfDischarge.attributes?.code, + country_code: portOfDischarge.attributes?.country_code, + } + : null, terminal: podTerminal ? { id: podTerminal.id, @@ -176,12 +172,14 @@ function formatShipmentResponse(apiResponse: any, includeContainers: boolean): a ? { locode: shipment.destination_locode, name: shipment.destination_name, - terminal: destinationTerminal ? { - id: destinationTerminal.id, - name: destinationTerminal.attributes?.name, - nickname: destinationTerminal.attributes?.nickname, - firms_code: destinationTerminal.attributes?.firms_code, - } : null, + terminal: destinationTerminal + ? { + id: destinationTerminal.id, + name: destinationTerminal.attributes?.name, + nickname: destinationTerminal.attributes?.nickname, + firms_code: destinationTerminal.attributes?.firms_code, + } + : null, eta: shipment.destination_eta_at, ata: shipment.destination_ata_at, timezone: shipment.destination_timezone, @@ -204,7 +202,9 @@ function formatShipmentResponse(apiResponse: any, includeContainers: boolean): a created_at: shipment.created_at, _metadata: { shipment_status: status, - includes_loaded: includeContainers ? ['containers', 'ports', 'terminals'] : ['ports', 'terminals'], + includes_loaded: includeContainers + ? ['containers', 'ports', 'terminals'] + : ['ports', 'terminals'], presentation_guidance: getShipmentPresentationGuidance(status, shipment), }, }; @@ -220,7 +220,7 @@ function extractContainers(relationships: any, included: any[]): any { const containers = containerRefs .map((ref: any) => { const container = included.find( - (item: any) => item.id === ref.id && item.type === 'container' + (item: any) => item.id === ref.id && item.type === 'container', ); if (!container) return null; @@ -252,7 +252,10 @@ function determineShipmentStatus(shipment: any): string { return 'pending'; } -function getShipmentPresentationGuidance(status: string, shipment: any): string { +function getShipmentPresentationGuidance( + status: string, + shipment: any, +): string { switch (status) { case 'pending': return 'Shipment is being prepared. Focus on expected departure date and origin details.'; @@ -261,11 +264,15 @@ function getShipmentPresentationGuidance(status: string, shipment: any): string return 'Vessel has not yet departed. Emphasize ETD and vessel details.'; case 'in_transit': { - const eta = shipment.pod_eta_at ? new Date(shipment.pod_eta_at) : null; - const now = new Date(); - if (eta) { - const daysToArrival = Math.ceil((eta.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); - return `Shipment is in transit. ETA in ${daysToArrival} days. Focus on vessel name, route, and arrival timing.`; + // Compute the day delta in the destination terminal's local time so the + // "ETA in N days" count never lands on the wrong calendar day (the classic + // UTC off-by-one near midnight). + const daysToArrival = dayDeltaInZone( + shipment.pod_eta_at, + shipment.pod_timezone, + ); + if (daysToArrival !== null) { + return `Shipment is in transit. ETA in ${daysToArrival} days (destination-local). Focus on vessel name, route, and arrival timing.`; } return 'Shipment is in transit. Focus on vessel and expected arrival.'; } From c14098b1855e00aa279a7e8c8f5176fbb7a78770 Mon Sep 17 00:00:00 2001 From: Akshay Dodeja Date: Wed, 24 Jun 2026 11:18:12 -0700 Subject: [PATCH 2/3] fix(mcp): read line_tracking_stopped_* from the sideloaded shipment, not the container line_tracking_stopped_at / line_tracking_stopped_reason live on the SHIPMENT schema (per the generated OpenAPI types), not the container. The previous code read them off the container attributes, so on real API data the tracking-stopped branch never fired and the "suppress demurrage urgency when tracking is stopped/closed" acceptance criterion was unmet. get_container now reads these fields from the shipment already resolved out of the JSON:API included[] (relationships.shipment.data.id), and the container resource resolves the sideloaded shipment the same way. When the shipment is not included, both fall back to treating tracking as not-stopped rather than crash. Adds unit tests proving suppression fires when the sideloaded shipment has line_tracking_stopped_at set, does not fire when absent, and does not crash when the shipment is missing. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp/src/resources/container.test.ts | 79 +++++++++++++- packages/mcp/src/resources/container.ts | 30 +++++- packages/mcp/src/tools/contracts.test.ts | 105 +++++++++++++++++-- packages/mcp/src/tools/get-container.ts | 18 +++- 4 files changed, 215 insertions(+), 17 deletions(-) diff --git a/packages/mcp/src/resources/container.test.ts b/packages/mcp/src/resources/container.test.ts index a3da2dc8..d7d61d1a 100644 --- a/packages/mcp/src/resources/container.test.ts +++ b/packages/mcp/src/resources/container.test.ts @@ -28,12 +28,85 @@ describe('container resource', () => { expect(resource.text).toContain('Available for Pickup:** Yes'); }); + it('suppresses LFD urgency when the sideloaded shipment has tracking stopped', async () => { + const client = { + getContainer: vi.fn().mockResolvedValue({ + data: { + attributes: { + number: 'MSCU1234567', + available_for_pickup: true, + // Fresh terminal data + near-future LFD would normally NOT suppress; + // only the shipment-level tracking-stopped flag should. + terminal_checked_at: new Date().toISOString(), + pickup_lfd: new Date( + Date.now() + 2 * 24 * 60 * 60 * 1000, + ).toISOString(), + }, + relationships: { + shipment: { data: { id: 'shipment-9', type: 'shipment' } }, + }, + }, + included: [ + { + id: 'shipment-9', + type: 'shipment', + attributes: { + line_tracking_stopped_at: '2026-01-01T00:00:00Z', + line_tracking_stopped_reason: 'all_containers_terminated', + }, + }, + ], + }), + } as any; + + const uri = 'terminal49://container/123e4567-e89b-12d3-a456-426614174000'; + const resource = await readContainerResource(uri, client); + + expect(resource.text).toContain('LFD Urgency:** Unavailable'); + expect(resource.text).toContain('tracking is stopped'); + }); + + it('does not suppress LFD urgency when no sideloaded shipment is present', async () => { + const client = { + getContainer: vi.fn().mockResolvedValue({ + data: { + attributes: { + number: 'MSCU1234567', + available_for_pickup: true, + terminal_checked_at: new Date().toISOString(), + pickup_lfd: new Date( + Date.now() + 2 * 24 * 60 * 60 * 1000, + ).toISOString(), + }, + relationships: { + shipment: { data: { id: 'shipment-9', type: 'shipment' } }, + }, + }, + // Shipment relationship declared but not actually included. + included: [], + }), + } as any; + + const uri = 'terminal49://container/123e4567-e89b-12d3-a456-426614174000'; + const resource = await readContainerResource(uri, client); + + expect(resource.text).not.toContain('LFD Urgency:** Unavailable'); + }); + it('validates URI format', async () => { - expect(matchesContainerUri('terminal49://container/123e4567-e89b-12d3-a456-426614174000')).toBe(true); - expect(matchesContainerUri('terminal49://container/not-a-uuid')).toBe(false); + expect( + matchesContainerUri( + 'terminal49://container/123e4567-e89b-12d3-a456-426614174000', + ), + ).toBe(true); + expect(matchesContainerUri('terminal49://container/not-a-uuid')).toBe( + false, + ); await expect( - readContainerResource('terminal49://container/not-a-uuid', { getContainer: vi.fn() } as any), + readContainerResource('terminal49://container/not-a-uuid', { + getContainer: vi.fn(), + } as any), ).rejects.toThrow('Invalid container URI format'); }); }); diff --git a/packages/mcp/src/resources/container.ts b/packages/mcp/src/resources/container.ts index 75ce1963..af10dc24 100644 --- a/packages/mcp/src/resources/container.ts +++ b/packages/mcp/src/resources/container.ts @@ -42,7 +42,12 @@ export async function readContainerResource( const rawResult = (result as any)?.raw ?? result; const container = rawResult?.data?.attributes || {}; - const summary = generateSummary(containerId, container); + // line_tracking_stopped_* lives on the SHIPMENT (per the generated OpenAPI + // types), not the container — resolve the sideloaded shipment from the + // JSON:API included[] so we can read it. Absent shipment → not-stopped. + const shipment = resolveSideloadedShipment(rawResult); + + const summary = generateSummary(containerId, container, shipment); return { uri: normalized, @@ -63,7 +68,21 @@ function normalizeUri(uri: string): string { return uri; } -function generateSummary(id: string, container: any): string { +/** + * Resolve the sideloaded shipment for this container from the JSON:API + * `included[]` (relationships.shipment.data.id -> matching included resource). + * Returns undefined when the shipment wasn't included in the response. + */ +function resolveSideloadedShipment(rawResult: any): any { + const shipmentId = rawResult?.data?.relationships?.shipment?.data?.id; + if (!shipmentId) return undefined; + const included = rawResult?.included || []; + return included.find( + (item: any) => item.id === shipmentId && item.type === 'shipment', + ); +} + +function generateSummary(id: string, container: any, shipment?: any): string { // Headline status comes from the API current_status (shared resolver), ending // the divergence between this resource and the get_container tool. const { status } = resolveContainerStatus(container); @@ -74,13 +93,16 @@ function generateSummary(id: string, container: any): string { const label = container.number || container.container_number || 'Unknown'; const equipment = formatEquipment(container); + // line_tracking_stopped_* lives on the SHIPMENT, not the container, so read it + // from the sideloaded shipment's attributes (absent shipment → not-stopped). + const shipmentAttrs = shipment?.attributes; const demurrage = evaluateDemurrageUrgency({ fees_at_pod_terminal: container.fees_at_pod_terminal, pickup_lfd: container.pickup_lfd ?? null, terminal_checked_at: container.terminal_checked_at ?? null, tracking_stopped: Boolean( - container.line_tracking_stopped_at || - container.line_tracking_stopped_reason, + shipmentAttrs?.line_tracking_stopped_at || + shipmentAttrs?.line_tracking_stopped_reason, ), }); diff --git a/packages/mcp/src/tools/contracts.test.ts b/packages/mcp/src/tools/contracts.test.ts index f47147c0..3000338d 100644 --- a/packages/mcp/src/tools/contracts.test.ts +++ b/packages/mcp/src/tools/contracts.test.ts @@ -476,6 +476,101 @@ describe('MCP tool contracts', () => { expect(result.demurrage.urgency_suppressed).toBe(true); }); + it('get_container suppresses demurrage urgency when the sideloaded shipment has tracking stopped', async () => { + const rawContainer = buildContainerRaw('container-tracking-stopped'); + // Fresh terminal data + a near-future LFD would normally yield an active + // urgency; only the shipment-level tracking-stopped flag should suppress it. + (rawContainer.data.attributes as any).terminal_checked_at = + new Date().toISOString(); + (rawContainer.data.attributes as any).pickup_lfd = new Date( + Date.now() + 2 * 24 * 60 * 60 * 1000, + ).toISOString(); + const shipmentInclude = rawContainer.included.find( + (item: any) => item.type === 'shipment', + ) as any; + shipmentInclude.attributes.line_tracking_stopped_at = + '2026-01-01T00:00:00Z'; + shipmentInclude.attributes.line_tracking_stopped_reason = + 'all_containers_terminated'; + + const client = asClient({ + containers: { + get: vi.fn().mockResolvedValue({ + raw: rawContainer, + mapped: { id: 'container-tracking-stopped' }, + }), + }, + }); + + const result = await executeGetContainer( + { id: 'container-tracking-stopped' }, + client, + ); + + expect(result.demurrage.urgency).toBe('unknown'); + expect(result.demurrage.urgency_suppressed).toBe(true); + expect(result.demurrage.urgency_reason).toContain('tracking is stopped'); + }); + + it('get_container does NOT suppress urgency when the shipment has no tracking-stopped flag', async () => { + const rawContainer = buildContainerRaw('container-tracking-active'); + // Same fresh-data + near-future-LFD setup, but the shipment carries no + // line_tracking_stopped_* — urgency must remain active (not suppressed). + (rawContainer.data.attributes as any).terminal_checked_at = + new Date().toISOString(); + (rawContainer.data.attributes as any).pickup_lfd = new Date( + Date.now() + 2 * 24 * 60 * 60 * 1000, + ).toISOString(); + + const client = asClient({ + containers: { + get: vi.fn().mockResolvedValue({ + raw: rawContainer, + mapped: { id: 'container-tracking-active' }, + }), + }, + }); + + const result = await executeGetContainer( + { id: 'container-tracking-active' }, + client, + ); + + expect(result.demurrage.urgency_suppressed).toBe(false); + expect(result.demurrage.urgency).toBe('imminent'); + }); + + it('get_container does not crash when the shipment is not sideloaded (tracking treated as active)', async () => { + const rawContainer = buildContainerRaw('container-no-shipment'); + (rawContainer.data.attributes as any).terminal_checked_at = + new Date().toISOString(); + (rawContainer.data.attributes as any).pickup_lfd = new Date( + Date.now() + 2 * 24 * 60 * 60 * 1000, + ).toISOString(); + // Drop the sideloaded shipment entirely. + rawContainer.included = rawContainer.included.filter( + (item: any) => item.type !== 'shipment', + ); + + const client = asClient({ + containers: { + get: vi.fn().mockResolvedValue({ + raw: rawContainer, + mapped: { id: 'container-no-shipment' }, + }), + }, + }); + + const result = await executeGetContainer( + { id: 'container-no-shipment' }, + client, + ); + + expect(result.shipment).toBeNull(); + expect(result.demurrage.urgency_suppressed).toBe(false); + expect(result.demurrage.urgency).toBe('imminent'); + }); + it('get_container surfaces pod_timezone and per-channel LFDs and drops phantom updated_at', async () => { const client = asClient({ containers: { @@ -500,12 +595,10 @@ describe('MCP tool contracts', () => { }); it('get_shipment_details returns shipment summary and container list', async () => { - const shipmentsGet = vi - .fn() - .mockResolvedValue({ - raw: buildShipmentRaw(), - mapped: { id: 'shipment-1' }, - }); + const shipmentsGet = vi.fn().mockResolvedValue({ + raw: buildShipmentRaw(), + mapped: { id: 'shipment-1' }, + }); const client = asClient({ shipments: { get: shipmentsGet } }); const result = await executeGetShipmentDetails( diff --git a/packages/mcp/src/tools/get-container.ts b/packages/mcp/src/tools/get-container.ts index 9732542b..037ba0c9 100644 --- a/packages/mcp/src/tools/get-container.ts +++ b/packages/mcp/src/tools/get-container.ts @@ -218,7 +218,10 @@ function formatContainerResponse( fees_at_pod_terminal: container.fees_at_pod_terminal, pickup_lfd: container.pickup_lfd ?? null, terminal_checked_at: container.terminal_checked_at ?? null, - tracking_stopped: isTrackingStopped(container), + // line_tracking_stopped_* lives on the SHIPMENT, not the container, so we + // read it from the sideloaded shipment. When the shipment isn't included we + // fall back to "not stopped" rather than crash. + tracking_stopped: isTrackingStopped(shipment), }); // Surface the LFD countdown in terminal-local days so "N days until LFD" never // lands on the wrong calendar day near a UTC midnight boundary. @@ -329,11 +332,18 @@ function normalizeEquipmentLength(value: unknown): number | null { /** * Whether line tracking has stopped/closed for the container's shipment, which * makes terminal availability/LFD signals untrustworthy for urgency. + * + * `line_tracking_stopped_at` / `line_tracking_stopped_reason` live on the + * SHIPMENT schema (per the generated OpenAPI types), not on the container, so + * we read them from the sideloaded shipment resource (JSON:API `included[]`). + * When the shipment wasn't included in this call, `shipment` is undefined and + * we treat tracking as not-stopped rather than crashing. */ -function isTrackingStopped(container: any): boolean { +function isTrackingStopped(shipment: any): boolean { + const attrs = shipment?.attributes; + if (!attrs) return false; return Boolean( - container.line_tracking_stopped_at || - container.line_tracking_stopped_reason, + attrs.line_tracking_stopped_at || attrs.line_tracking_stopped_reason, ); } From 6b705cf45d46e8877a02c3aea542d0d2a18bd8ee Mon Sep 17 00:00:00 2001 From: Akshay Dodeja Date: Fri, 26 Jun 2026 05:31:03 -0500 Subject: [PATCH 3/3] fix(mcp): classify LFD urgency from terminal-local days; date-only LFD safe Address PR #277 review feedback (codex P2 / greptile P1 + P2s) and known residuals: - evaluateDemurrageUrgency now accepts a terminal-local `days_until_lfd` and classifies `urgency` from it, so the displayed count and the urgency band can no longer disagree at a threshold boundary (e.g. 3 vs 4 days). get- container.ts feeds dayDeltaInZone() in instead of patching days_until_lfd after the fact. - temporal: treat a date-only value (e.g. "2099-01-10") as a literal calendar day in the terminal timezone in formatInZone/localCalendarDate/dayDeltaInZone, fixing a one-day-early render/delta in west-of-UTC zones from the UTC-midnight parse. - container resource: drop the stray double blank line at the bottom of the markdown when urgency isn't suppressed and there's no rail section. - demurrage: correct the total_amount JSDoc wording. - tests: add temporal date-only cases, demurrage urgency-from-local-days cases, and container resource-level tests for status/fees/LFD-urgency rendering. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp/src/lib/demurrage.test.ts | 31 ++++++ packages/mcp/src/lib/demurrage.ts | 28 +++-- packages/mcp/src/lib/temporal.test.ts | 27 +++++ packages/mcp/src/lib/temporal.ts | 34 +++++- packages/mcp/src/resources/container.test.ts | 108 +++++++++++++++++++ packages/mcp/src/resources/container.ts | 5 +- packages/mcp/src/tools/get-container.ts | 16 +-- 7 files changed, 228 insertions(+), 21 deletions(-) diff --git a/packages/mcp/src/lib/demurrage.test.ts b/packages/mcp/src/lib/demurrage.test.ts index 4bdbb5a5..d0647193 100644 --- a/packages/mcp/src/lib/demurrage.test.ts +++ b/packages/mcp/src/lib/demurrage.test.ts @@ -110,6 +110,37 @@ describe('evaluateDemurrageUrgency', () => { expect(result.urgency_suppressed).toBe(false); }); + it('classifies urgency from the supplied terminal-local day count', () => { + // pickup_lfd would round to 4 UTC days (none), but the terminal-local + // count is 3 -> urgency must follow the local count and read "imminent". + const result = evaluateDemurrageUrgency( + { + pickup_lfd: '2026-02-13T12:00:00Z', + terminal_checked_at: '2026-02-09T00:00:00Z', + days_until_lfd: 3, + }, + NOW, + ); + + expect(result.days_until_lfd).toBe(3); + expect(result.urgency).toBe('imminent'); + expect(result.urgency_suppressed).toBe(false); + }); + + it('treats an explicit null day count as "no LFD" (urgency unknown)', () => { + const result = evaluateDemurrageUrgency( + { + pickup_lfd: '2026-02-12T00:00:00Z', + terminal_checked_at: '2026-02-09T00:00:00Z', + days_until_lfd: null, + }, + NOW, + ); + + expect(result.days_until_lfd).toBeNull(); + expect(result.urgency).toBe('unknown'); + }); + it('preserves null fees as null rather than coercing to an empty array', () => { const result = evaluateDemurrageUrgency( { diff --git a/packages/mcp/src/lib/demurrage.ts b/packages/mcp/src/lib/demurrage.ts index 2485734c..3350f986 100644 --- a/packages/mcp/src/lib/demurrage.ts +++ b/packages/mcp/src/lib/demurrage.ts @@ -22,7 +22,7 @@ export type DemurrageUrgency = 'overdue' | 'imminent' | 'none' | 'unknown'; export interface DemurrageEvaluation { /** Real terminal fees, preserved as-is (null means "not reported", not "$0"). */ fees: TerminalFee[] | null; - /** Sum of fee amounts when fees are present, otherwise null. */ + /** Sum of fee amounts when fees carry amounts; null when absent/unknown. */ total_amount: number | null; /** Currency of the fees when consistent/known, otherwise null. */ currency_code: string | null; @@ -39,6 +39,14 @@ interface DemurrageAttrs { pickup_lfd?: string | null; terminal_checked_at?: string | null; tracking_stopped?: boolean | null; + /** + * Whole days until LFD measured in the terminal's local calendar (see + * `dayDeltaInZone`). When provided, both `days_until_lfd` and the `urgency` + * classification are derived from this single value so the displayed count + * and the urgency band can never disagree at a threshold boundary. When + * omitted we fall back to a raw UTC millisecond delta from `pickup_lfd`. + */ + days_until_lfd?: number | null; } /** Terminal data older than this is treated as too stale to drive urgency. */ @@ -107,11 +115,19 @@ export function evaluateDemurrageUrgency( ): DemurrageEvaluation { const { fees, total, currency } = summarizeFees(attrs.fees_at_pod_terminal); - const lfd = attrs.pickup_lfd ? new Date(attrs.pickup_lfd) : null; - const lfdValid = lfd !== null && !Number.isNaN(lfd.getTime()); - const daysUntilLfd = lfdValid - ? Math.round((lfd!.getTime() - now.getTime()) / MS_PER_DAY) - : null; + // Prefer the caller-supplied terminal-local day count; only fall back to a + // raw UTC delta when it is absent, so the urgency band below is classified + // from the same number we ultimately display as `days_until_lfd`. + let daysUntilLfd: number | null; + if (attrs.days_until_lfd !== undefined) { + daysUntilLfd = attrs.days_until_lfd; + } else { + const lfd = attrs.pickup_lfd ? new Date(attrs.pickup_lfd) : null; + const lfdValid = lfd !== null && !Number.isNaN(lfd.getTime()); + daysUntilLfd = lfdValid + ? Math.round((lfd!.getTime() - now.getTime()) / MS_PER_DAY) + : null; + } // Reasons to distrust the LFD/availability signal entirely. if (attrs.tracking_stopped) { diff --git a/packages/mcp/src/lib/temporal.test.ts b/packages/mcp/src/lib/temporal.test.ts index d6fd5c08..1f490e31 100644 --- a/packages/mcp/src/lib/temporal.test.ts +++ b/packages/mcp/src/lib/temporal.test.ts @@ -24,6 +24,14 @@ describe('formatInZone', () => { const text = formatInZone('2026-02-11T02:00:00Z', null); expect(text).toContain('2026'); }); + + it('renders a date-only value verbatim without a west-of-UTC day shift', () => { + // "2099-01-10" parsed as UTC midnight would print as 01/09 in Los Angeles; + // a date-only LFD must stay on its own calendar day. + expect(formatInZone('2099-01-10', 'America/Los_Angeles')).toBe( + '2099-01-10', + ); + }); }); describe('localCalendarDate', () => { @@ -33,6 +41,12 @@ describe('localCalendarDate', () => { '2026-02-10', ); }); + + it('returns a date-only value unchanged regardless of timezone', () => { + expect(localCalendarDate('2099-01-10', 'America/Los_Angeles')).toBe( + '2099-01-10', + ); + }); }); describe('dayDeltaInZone', () => { @@ -70,4 +84,17 @@ describe('dayDeltaInZone', () => { expect(dayDeltaInZone(null, 'America/New_York', new Date())).toBeNull(); expect(dayDeltaInZone('nope', 'America/New_York', new Date())).toBeNull(); }); + + it('treats a date-only LFD as its literal calendar day, not UTC midnight', () => { + // A date-only LFD of 2099-01-10 is exactly 1 day after the 2099-01-09 + // local day in Los Angeles. Parsing it as UTC midnight would land it on + // 2099-01-09 LA and yield 0 — the off-by-one this guards against. + const delta = dayDeltaInZone( + '2099-01-10', + 'America/Los_Angeles', + // 2099-01-09 18:00Z -> 2099-01-09 10:00 LA. + new Date('2099-01-09T18:00:00Z'), + ); + expect(delta).toBe(1); + }); }); diff --git a/packages/mcp/src/lib/temporal.ts b/packages/mcp/src/lib/temporal.ts index a26da0bd..d4c45d20 100644 --- a/packages/mcp/src/lib/temporal.ts +++ b/packages/mcp/src/lib/temporal.ts @@ -9,6 +9,20 @@ const MS_PER_DAY = 1000 * 60 * 60 * 24; +/** Matches a bare calendar date with no time component, e.g. "2099-01-10". */ +const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * True for a value that is a calendar date with no time/zone, e.g. a date-only + * `pickup_lfd` like "2099-01-10". `new Date("2099-01-10")` parses such a value + * as UTC midnight, which then renders/deltas one day early in west-of-UTC + * terminal zones. We special-case these so a date-only value is treated as that + * literal calendar day in the terminal timezone, with no UTC midnight shift. + */ +function isDateOnly(ts: string): boolean { + return DATE_ONLY_RE.test(ts); +} + function parseTimestamp(ts: string | null | undefined): Date | null { if (!ts) return null; const date = new Date(ts); @@ -26,6 +40,10 @@ export function formatInZone( timezone: string | null | undefined, ): string { if (ts === null || ts === undefined || ts === '') return 'N/A'; + // A date-only value (e.g. "2099-01-10") is a calendar day, not an instant. + // Render it verbatim rather than shifting it into a UTC-midnight time that + // would print as the previous day in west-of-UTC terminal zones. + if (isDateOnly(ts)) return ts; const date = parseTimestamp(ts); if (!date) return ts; @@ -61,6 +79,9 @@ export function localCalendarDate( ts: string | null | undefined, timezone: string | null | undefined, ): string | null { + // A date-only value already *is* the local calendar date; do not push it + // through UTC midnight (which would slip it back a day west of UTC). + if (ts && isDateOnly(ts)) return ts; const date = parseTimestamp(ts); if (!date) return null; return calendarDateForDate(date, timezone); @@ -98,10 +119,17 @@ export function dayDeltaInZone( timezone: string | null | undefined, now: Date = new Date(), ): number | null { - const target = parseTimestamp(ts); - if (!target) return null; + // A date-only target is already the local calendar day. Using it verbatim + // (instead of UTC midnight) keeps the count correct in west-of-UTC zones. + let targetDay: string | null; + if (ts && isDateOnly(ts)) { + targetDay = ts; + } else { + const target = parseTimestamp(ts); + if (!target) return null; + targetDay = calendarDateForDate(target, timezone); + } - const targetDay = calendarDateForDate(target, timezone); const nowDay = calendarDateForDate(now, timezone); // Compare the local calendar dates as UTC midnights so DST shifts inside the diff --git a/packages/mcp/src/resources/container.test.ts b/packages/mcp/src/resources/container.test.ts index d7d61d1a..5266a69f 100644 --- a/packages/mcp/src/resources/container.test.ts +++ b/packages/mcp/src/resources/container.test.ts @@ -93,6 +93,114 @@ describe('container resource', () => { expect(resource.text).not.toContain('LFD Urgency:** Unavailable'); }); + it('renders the authoritative current_status in the headline', async () => { + const client = { + getContainer: vi.fn().mockResolvedValue({ + data: { + attributes: { + number: 'MSCU1234567', + current_status: 'available', + }, + }, + }), + } as any; + + const uri = 'terminal49://container/123e4567-e89b-12d3-a456-426614174000'; + const resource = await readContainerResource(uri, client); + + expect(resource.text).toContain('**Status:** available'); + }); + + it('renders reported demurrage fees with their total and currency', async () => { + const client = { + getContainer: vi.fn().mockResolvedValue({ + data: { + attributes: { + number: 'MSCU1234567', + terminal_checked_at: new Date().toISOString(), + fees_at_pod_terminal: [ + { type: 'demurrage', amount: 320, currency_code: 'USD' }, + { type: 'exam', amount: 75, currency_code: 'USD' }, + ], + }, + }, + }), + } as any; + + const uri = 'terminal49://container/123e4567-e89b-12d3-a456-426614174000'; + const resource = await readContainerResource(uri, client); + + expect(resource.text).toContain('**Fees:** 2 (total 395 USD)'); + // Never invent a per-day demurrage estimate from the raw fees. + expect(resource.text).not.toMatch(/\/day/); + }); + + it('distinguishes "None" fees from "Not reported" fees', async () => { + const reported = { + getContainer: vi.fn().mockResolvedValue({ + data: { + attributes: { number: 'A', fees_at_pod_terminal: [] }, + }, + }), + } as any; + const absent = { + getContainer: vi.fn().mockResolvedValue({ + data: { attributes: { number: 'A' } }, + }), + } as any; + const uri = 'terminal49://container/123e4567-e89b-12d3-a456-426614174000'; + + expect((await readContainerResource(uri, reported)).text).toContain( + '**Fees:** None', + ); + expect((await readContainerResource(uri, absent)).text).toContain( + '**Fees:** Not reported', + ); + }); + + it('omits the LFD Urgency line and trailing blank lines when not suppressed', async () => { + const client = { + getContainer: vi.fn().mockResolvedValue({ + data: { + attributes: { + number: 'MSCU1234567', + terminal_checked_at: new Date().toISOString(), + pickup_lfd: new Date( + Date.now() + 2 * 24 * 60 * 60 * 1000, + ).toISOString(), + }, + }, + }), + } as any; + + const uri = 'terminal49://container/123e4567-e89b-12d3-a456-426614174000'; + const resource = await readContainerResource(uri, client); + + expect(resource.text).not.toContain('LFD Urgency:** Unavailable'); + // No rail carrier and no suppression -> output must not end in blank lines. + expect(resource.text).not.toMatch(/\n\s*\n\s*$/); + expect(resource.text.trimEnd()).toBe(resource.text); + }); + + it('renders a rail section when a rail carrier is present', async () => { + const client = { + getContainer: vi.fn().mockResolvedValue({ + data: { + attributes: { + number: 'MSCU1234567', + pod_rail_carrier_scac: 'BNSF', + }, + }, + }), + } as any; + + const uri = 'terminal49://container/123e4567-e89b-12d3-a456-426614174000'; + const resource = await readContainerResource(uri, client); + + expect(resource.text).toContain('## Rail Information'); + expect(resource.text).toContain('**Rail Carrier:** BNSF'); + }); + it('validates URI format', async () => { expect( matchesContainerUri( diff --git a/packages/mcp/src/resources/container.ts b/packages/mcp/src/resources/container.ts index af10dc24..f35d67e1 100644 --- a/packages/mcp/src/resources/container.ts +++ b/packages/mcp/src/resources/container.ts @@ -130,10 +130,7 @@ ${podTimezone ? `**Terminal Timezone:** ${podTimezone}` : ''} - **LFD (Line):** ${formatInZone(importDeadlines.pickup_lfd_line, podTimezone)} - **Pickup Appointment:** ${formatInZone(container.pickup_appointment_at, podTimezone)} - **Fees:** ${formatFees(demurrage)} -- **Holds:** ${formatHolds(container)} -${demurrage.urgency_suppressed ? `- **LFD Urgency:** Unavailable (${demurrage.suppression_reason})` : ''} - -${railSection}`; +- **Holds:** ${formatHolds(container)}${demurrage.urgency_suppressed ? `\n- **LFD Urgency:** Unavailable (${demurrage.suppression_reason})` : ''}${railSection ? `\n\n${railSection}` : ''}`; } function formatAvailability(container: any): string { diff --git a/packages/mcp/src/tools/get-container.ts b/packages/mcp/src/tools/get-container.ts index 037ba0c9..220c7bd6 100644 --- a/packages/mcp/src/tools/get-container.ts +++ b/packages/mcp/src/tools/get-container.ts @@ -214,7 +214,13 @@ function formatContainerResponse( : `Call get_container with include=['transport_events'] to fetch ${transportEvents.length || '~50-100'} event records`; const podTimezone: string | null = container.pod_timezone ?? null; - const rawDemurrage = evaluateDemurrageUrgency({ + // Compute the LFD countdown in terminal-local days so "N days until LFD" never + // lands on the wrong calendar day near a UTC midnight boundary, then feed that + // same count into the urgency classifier — otherwise `urgency` (raw UTC delta) + // and the displayed `days_until_lfd` (terminal-local) could disagree at a + // threshold (e.g. 3 vs 4 days). + const localDaysUntilLfd = dayDeltaInZone(container.pickup_lfd, podTimezone); + const demurrage: DemurrageEvaluation = evaluateDemurrageUrgency({ fees_at_pod_terminal: container.fees_at_pod_terminal, pickup_lfd: container.pickup_lfd ?? null, terminal_checked_at: container.terminal_checked_at ?? null, @@ -222,14 +228,8 @@ function formatContainerResponse( // read it from the sideloaded shipment. When the shipment isn't included we // fall back to "not stopped" rather than crash. tracking_stopped: isTrackingStopped(shipment), + days_until_lfd: localDaysUntilLfd, }); - // Surface the LFD countdown in terminal-local days so "N days until LFD" never - // lands on the wrong calendar day near a UTC midnight boundary. - const localDaysUntilLfd = dayDeltaInZone(container.pickup_lfd, podTimezone); - const demurrage: DemurrageEvaluation = { - ...rawDemurrage, - days_until_lfd: localDaysUntilLfd ?? rawDemurrage.days_until_lfd, - }; const importDeadlines = container.import_deadlines || {};