@pipeworx/mcp-nhtsa 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -295,12 +295,28 @@ async function httpErrorMessage(res: Response, name: string): Promise<string> {
295
295
  // internal-host-class.ts; no-op for every third-party upstream, which is why
296
296
  // this touches 481 packs' error text and changes none of it.
297
297
  return markInternalOrigin(
298
- `${name}: ${res.status}${detailSuffix(await readDetail(res))}`,
298
+ `${name}: ${res.status}${detailSuffix(await readDetail(res))}${responseHints(res)}`,
299
299
  res.url,
300
300
  res.status,
301
301
  );
302
302
  }
303
303
 
304
+ /**
305
+ * Facts about a FAILED response that live in its headers, not its body, appended
306
+ * so the gateway's classifier (which only ever sees this string) can tell a limit
307
+ * from a wall from a refusal (fleet #3477). Both are standard, both are absent on
308
+ * almost every response, and neither is invented: `Retry-After` is the server
309
+ * saying "come back later" (the one thing that makes a 403 a throttle), and
310
+ * Cloudflare marks a bot challenge with `cf-mitigated: challenge`.
311
+ */
312
+ function responseHints(res: Response): string {
313
+ const hints: string[] = [];
314
+ const retryAfter = res.headers.get('retry-after');
315
+ if (retryAfter) hints.push(`retry-after: ${retryAfter.slice(0, 40)}`);
316
+ if ((res.headers.get('cf-mitigated') ?? '').toLowerCase() === 'challenge') hints.push('Cloudflare bot challenge (cf-mitigated: challenge)');
317
+ return hints.length ? ` (${hints.join('; ')})` : '';
318
+ }
319
+
304
320
  /**
305
321
  * Just the upstream's own explanation — no name, no status.
306
322
  *
@@ -670,15 +686,284 @@ function pickMessage(node: unknown, depth: number): string | null {
670
686
  function collapse(s: string): string {
671
687
  return s.replace(/\s+/g, ' ').trim();
672
688
  }
689
+
690
+ // ── Bounded readers — peak memory, not just wall time (fleet #3322) ──────
691
+ //
692
+ // `parseJson` above does `await res.text()` then `JSON.parse(raw)`, so a
693
+ // multi-megabyte upstream body is held TWICE at peak: once as the raw string
694
+ // and once as the object graph (which for an array of small objects runs
695
+ // several times the byte size). Nothing in `workers/gateway` can see that
696
+ // peak — it happens inside the pack, before any gateway code holds a payload —
697
+ // which is why the gateway-side payload caps of #3293 and #3320 did not move
698
+ // the 503/1102 rate at all.
699
+ //
700
+ // Measured on the live gateway, 2026-10-19, sha fa5de5c34, direct tools/call:
701
+ // statcan_list_cubes 8,049,566 B <- one upstream array, no limit argument
702
+ // gnomad_gene BRCA1 2,732,706 B
703
+ // Five of those in flight at the quality runner's width is ~40 MB of parse
704
+ // peak against a 128 MB isolate; Cloudflare kills the isolate and every
705
+ // request in flight returns 503/1102, which is why the victims look random.
706
+ //
707
+ // `readJsonArrayPrefix` is the fix for the dominant shape: an upstream that
708
+ // returns one enormous top-level JSON array with no server-side paging. It
709
+ // parses elements off the stream and DISCARDS the consumed bytes, so peak
710
+ // retained memory is one element plus one chunk rather than the whole body,
711
+ // and it cancels the body as soon as it has what it was asked for.
712
+
713
+ /** Default ceiling for a single upstream JSON body read into an isolate.
714
+ * Chosen so the known tail (statcan 8.0 MB, gnomad 2.7 MB) is bounded well
715
+ * below the point where a handful of concurrent reads exhausts 128 MB. */
716
+ const DEFAULT_MAX_JSON_BYTES = 1_500_000;
717
+
718
+ interface JsonArrayPrefix<T> {
719
+ /** The decoded elements, at most `maxItems` of them. */
720
+ items: T[];
721
+ /** True when the upstream array had more elements than we read. */
722
+ truncated: boolean;
723
+ /** Bytes actually pulled off the socket — the number that bounds memory. */
724
+ bytesRead: number;
725
+ /** `Content-Length` if the upstream declared one, else null. */
726
+ declaredBytes: number | null;
727
+ }
728
+
729
+ /**
730
+ * Read the FIRST `maxItems` elements of a top-level JSON array off a response
731
+ * stream without ever materialising the whole body.
732
+ *
733
+ * Use this instead of `parseJson` when the upstream returns one big array and
734
+ * offers no `limit`/`offset` of its own. Peak retained memory is one element
735
+ * plus the current chunk; the body is cancelled as soon as `maxItems` is
736
+ * reached, so the remaining megabytes are never transferred at all.
737
+ *
738
+ * const { items, truncated } = await readJsonArrayPrefix<Cube>(
739
+ * res, 'StatCan WDS', { maxItems: limit + offset });
740
+ *
741
+ * `maxBytes` is a second, independent backstop for a pathological element (a
742
+ * single 50 MB object): hitting it stops the read and sets `truncated`, rather
743
+ * than throwing, because a partial answer plus an explicit truncated flag is
744
+ * more useful to a caller than an error.
745
+ */
746
+ async function readJsonArrayPrefix<T>(
747
+ res: Response,
748
+ name: string,
749
+ opts: { maxItems: number; maxBytes?: number },
750
+ ): Promise<JsonArrayPrefix<T>> {
751
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_JSON_BYTES;
752
+ const declaredRaw = Number(res.headers.get('content-length'));
753
+ const declaredBytes = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
754
+
755
+ // No streaming body available (a mock, or an already-buffered response):
756
+ // fall back to the whole-text path so behaviour stays correct, just unbounded.
757
+ if (!res.body) {
758
+ const all = await parseJson<T[]>(res, name);
759
+ if (!Array.isArray(all)) throw new Error(`upstream_down: ${name} returned ${typeof all} where a JSON array was expected.`);
760
+ return {
761
+ items: all.slice(0, opts.maxItems),
762
+ truncated: all.length > opts.maxItems,
763
+ bytesRead: declaredBytes ?? 0,
764
+ declaredBytes,
765
+ };
766
+ }
767
+
768
+ const reader = res.body.getReader();
769
+ const decoder = new TextDecoder();
770
+ const items: T[] = [];
771
+
772
+ let buf = '';
773
+ let scan = 0;
774
+ let bytesRead = 0;
775
+ let started = false; // consumed the opening '['
776
+ let arrayEnded = false;
777
+ let depth = 0;
778
+ let inStr = false;
779
+ let esc = false;
780
+ let elemStart = -1;
781
+ let truncated = false;
782
+
783
+ const push = (text: string) => {
784
+ const t = text.trim();
785
+ if (!t) return;
786
+ items.push(JSON.parse(t) as T);
787
+ };
788
+
789
+ try {
790
+ outer: while (items.length < opts.maxItems && !arrayEnded) {
791
+ const { value, done } = await reader.read();
792
+ if (done) break;
793
+ bytesRead += value.byteLength;
794
+ buf += decoder.decode(value, { stream: true });
795
+ if (bytesRead > maxBytes) { truncated = true; break; }
796
+
797
+ for (; scan < buf.length; scan++) {
798
+ const ch = buf[scan];
799
+
800
+ if (inStr) {
801
+ if (esc) { esc = false; continue; }
802
+ if (ch === '\\') { esc = true; continue; }
803
+ if (ch === '"') inStr = false;
804
+ continue;
805
+ }
806
+
807
+ if (!started) {
808
+ if (ch === '[') { started = true; continue; }
809
+ if (ch.trim() === '') continue;
810
+ throw new Error(
811
+ `upstream_down: ${name} returned a body that does not begin with a JSON array (saw ${JSON.stringify(ch)}). ` +
812
+ 'This reader is only for upstreams that answer with one top-level array.',
813
+ );
814
+ }
815
+
816
+ if (ch === '"') { if (depth === 0 && elemStart < 0) elemStart = scan; inStr = true; continue; }
817
+
818
+ if (ch === '{' || ch === '[') {
819
+ if (depth === 0 && elemStart < 0) elemStart = scan;
820
+ depth++;
821
+ continue;
822
+ }
823
+
824
+ if (ch === '}' || ch === ']') {
825
+ if (depth > 0) {
826
+ depth--;
827
+ if (depth === 0 && elemStart >= 0) {
828
+ push(buf.slice(elemStart, scan + 1));
829
+ elemStart = -1;
830
+ if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
831
+ }
832
+ continue;
833
+ }
834
+ // depth 0 and a closing bracket: the end of the top-level array.
835
+ if (elemStart >= 0) { push(buf.slice(elemStart, scan)); elemStart = -1; }
836
+ arrayEnded = true;
837
+ break outer;
838
+ }
839
+
840
+ if (ch === ',' && depth === 0) {
841
+ if (elemStart >= 0) {
842
+ push(buf.slice(elemStart, scan));
843
+ elemStart = -1;
844
+ if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
845
+ }
846
+ continue;
847
+ }
848
+
849
+ // A bare scalar element (number / true / false / null).
850
+ if (depth === 0 && elemStart < 0 && ch.trim() !== '') elemStart = scan;
851
+ }
852
+
853
+ // Drop everything already consumed. THIS is what bounds memory: without
854
+ // it `buf` grows to the full body and the reader is streaming in name only.
855
+ if (elemStart >= 0) {
856
+ buf = buf.slice(elemStart);
857
+ scan -= elemStart;
858
+ elemStart = 0;
859
+ } else {
860
+ buf = buf.slice(scan);
861
+ scan = 0;
862
+ }
863
+ }
864
+ } finally {
865
+ // Cancel rather than drain: the point is that the remaining megabytes are
866
+ // never transferred. A cancel on an already-finished body is a no-op.
867
+ await reader.cancel().catch(() => {});
868
+ }
869
+
870
+ return { items, truncated, bytesRead, declaredBytes };
871
+ }
872
+
873
+ /**
874
+ * `parseJson`, with a hard ceiling on how many bytes are read.
875
+ *
876
+ * For an upstream whose body is not a single array (so `readJsonArrayPrefix`
877
+ * does not apply) but which can still answer with megabytes. Unlike the array
878
+ * reader this one THROWS when the cap is hit, because a truncated prefix of an
879
+ * arbitrary JSON document is not parseable and there is no partial answer to
880
+ * hand back. A declared `Content-Length` over the cap is refused before a
881
+ * single chunk is read.
882
+ */
883
+ async function parseJsonBounded<T>(
884
+ res: Response,
885
+ name: string,
886
+ maxBytes: number = DEFAULT_MAX_JSON_BYTES,
887
+ ): Promise<T> {
888
+ const declaredRaw = Number(res.headers.get('content-length'));
889
+ const declared = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
890
+
891
+ const tooBig = (n: number) =>
892
+ new Error(
893
+ `upstream_down: ${name} answered with ${n.toLocaleString()} bytes, over this pack's ${maxBytes.toLocaleString()}-byte ceiling. ` +
894
+ 'The body is not read past the ceiling on purpose — a few concurrent reads of a body that size exhaust the ' +
895
+ 'Worker isolate and take every other in-flight request down with them. Narrow the request (a smaller range, ' +
896
+ 'a specific id, or a lower limit) and retry.',
897
+ );
898
+
899
+ if (declared !== null && declared > maxBytes) {
900
+ await res.body?.cancel().catch(() => {});
901
+ throw tooBig(declared);
902
+ }
903
+ if (!res.body) return parseJson<T>(res, name);
904
+
905
+ const reader = res.body.getReader();
906
+ const decoder = new TextDecoder();
907
+ let raw = '';
908
+ let bytesRead = 0;
909
+ try {
910
+ for (;;) {
911
+ const { value, done } = await reader.read();
912
+ if (done) break;
913
+ bytesRead += value.byteLength;
914
+ if (bytesRead > maxBytes) throw tooBig(bytesRead);
915
+ raw += decoder.decode(value, { stream: true });
916
+ }
917
+ raw += decoder.decode();
918
+ } finally {
919
+ await reader.cancel().catch(() => {});
920
+ }
921
+
922
+ if (!raw.trim()) {
923
+ throw new Error(
924
+ `upstream_down: ${name} answered HTTP ${res.status} with an EMPTY body where JSON was expected. ` +
925
+ 'Nothing about the request can cause this — it is an upstream fault, and the same call may well work on retry.',
926
+ );
927
+ }
928
+ try {
929
+ return JSON.parse(raw) as T;
930
+ } catch {
931
+ throw new Error(
932
+ `upstream_down: ${name} answered HTTP ${res.status} with a body that is not valid JSON. ` +
933
+ `It begins: ${stripMarkup(raw).slice(0, 120) || '(unreadable)'}`,
934
+ );
935
+ }
936
+ }
673
937
  /**
674
- * NHTSA MCP — wraps the NHTSA vPIC (Vehicle Product Information Catalog) API (free, no auth)
938
+ * NHTSA MCP — wraps the NHTSA vPIC (Vehicle Product Information Catalog) API (free, no auth),
939
+ * NHTSA's Fatality Analysis Reporting System (FARS) fatal-crash data, and
940
+ * fueleconomy.gov vehicle MPG (fleet #2972).
675
941
  *
676
942
  * Tools:
677
943
  * - decode_vin: decode a Vehicle Identification Number into make, model, year, and attributes
678
944
  * - get_makes: retrieve all vehicle makes registered with NHTSA
679
945
  * - get_models: retrieve all models for a given make and model year
946
+ * - get_recalls / get_complaints / get_safety_ratings: live NHTSA safety data
947
+ * - fars_state_fatalities / fars_vehicle_makes / fars_coverage: FARS fatal-crash
948
+ * aggregates, loaded ahead of time into D1 (CrashAPI 403s on every probe; see
949
+ * fars-store.ts and README.md "FARS data")
950
+ * - fuel_economy_mpg / fuel_economy_models: live fueleconomy.gov MPG lookups
680
951
  */
681
952
 
953
+ import {
954
+ allStatesForYear,
955
+ FARS_SOURCE,
956
+ getFarsMeta,
957
+ listMakes,
958
+ lookupState,
959
+ requireFarsDb,
960
+ resolveState,
961
+ stateMonths,
962
+ vehicleMakesForYear,
963
+ vehicleMakeYears,
964
+ type D1Like,
965
+ } from './fars-store.js';
966
+ import { FE_SOURCE, menuModels, menuOptions, vehicleById } from './fuel-economy.js';
682
967
 
683
968
  // Bound every fetch() in this pack to a fixed timeout — an upstream that
684
969
  // degrades without erroring would otherwise hold the Worker in `await fetch()`
@@ -769,6 +1054,69 @@ const tools: McpToolExport['tools'] = [
769
1054
  required: ['make', 'model', 'model_year'],
770
1055
  },
771
1056
  },
1057
+ {
1058
+ name: 'fars_state_fatalities',
1059
+ description:
1060
+ 'Fatal motor-vehicle crash counts and fatalities for a US state, from NHTSA\'s Fatality Analysis Reporting System (FARS). Pass a state (full name like "Texas", 2-letter postal code like "TX", or FIPS code) and a year. Returns monthly rows: crashes, fatalities, persons involved, vehicles involved, pedestrians involved. Omit year to get every loaded year/month for that state. Covers a recent multi-year window (see data_as_of and call fars_coverage for the exact range), not full FARS history. Unknown states are refused with a clear error, never an empty success.',
1061
+ summary: 'Monthly fatal-crash counts and fatalities for one US state.',
1062
+ inputSchema: {
1063
+ type: 'object' as const,
1064
+ properties: {
1065
+ state: { type: 'string', description: 'State name ("Texas"), 2-letter postal code ("TX"), or FIPS code ("48").' },
1066
+ year: { type: 'number', description: 'Optional: calendar year, e.g. 2023. Omit to return every loaded year for this state.' },
1067
+ },
1068
+ required: ['state'],
1069
+ },
1070
+ },
1071
+ {
1072
+ name: 'fars_vehicle_makes',
1073
+ description:
1074
+ 'Vehicle makes involved in fatal crashes nationwide for a given year, from NHTSA FARS. Pass year alone to get the top makes ranked by vehicle-involvement count (e.g. "which vehicle makes show up most in fatal crashes in 2023"); pass make as well to get that one make\'s involvement count across every loaded year. This is an INVOLVEMENT count, not a claim that the make caused the crash. Unknown makes are refused with a clear error naming a few valid makes, never an empty success.',
1075
+ summary: 'Vehicle makes involved in fatal crashes, ranked or looked up by name.',
1076
+ inputSchema: {
1077
+ type: 'object' as const,
1078
+ properties: {
1079
+ year: { type: 'number', description: 'Calendar year, e.g. 2023. Required unless make is given (then optional, scoping to one year).' },
1080
+ make: { type: 'string', description: 'Optional: vehicle make, e.g. "Toyota", "Ford", "Harley-Davidson".' },
1081
+ limit: { type: 'number', description: 'Max makes to return when ranking by year. Default 20, max 50.' },
1082
+ },
1083
+ required: [],
1084
+ },
1085
+ },
1086
+ {
1087
+ name: 'fars_coverage',
1088
+ description: 'What FARS fatal-crash data this pack actually has loaded right now: the earliest and latest year covered and when it was last loaded. Call this before fars_state_fatalities/fars_vehicle_makes if unsure a given year is covered.',
1089
+ summary: 'Earliest/latest FARS year loaded, and when.',
1090
+ inputSchema: { type: 'object' as const, properties: {} },
1091
+ },
1092
+ {
1093
+ name: 'fuel_economy_mpg',
1094
+ description:
1095
+ 'EPA fuel-economy ratings (city/highway/combined MPG, CO2 g/mile, fuel type) for a specific model year + make + model, from fueleconomy.gov (DOE/EPA), proxied live. Pass the model string exactly as fueleconomy.gov lists it (e.g. "Camry", "F-150 2WD", "Model 3") -- if unsure, call fuel_economy_models first to see valid model strings for that year+make. A model year/make/model can have several engine/transmission/drivetrain VARIANTS; this returns up to 5, each with its own MPG. Unknown make/model/year combos are refused with a clear error (never an empty success) and, when the make or model itself is not recognized for that year, list a few valid options.',
1096
+ summary: 'EPA city/highway/combined MPG and CO2 for a model year + make + model.',
1097
+ inputSchema: {
1098
+ type: 'object' as const,
1099
+ properties: {
1100
+ year: { type: 'number', description: 'Model year, e.g. 2024.' },
1101
+ make: { type: 'string', description: 'Vehicle make, e.g. "Toyota".' },
1102
+ model: { type: 'string', description: 'Vehicle model exactly as fueleconomy.gov lists it, e.g. "Camry". Call fuel_economy_models to see valid strings.' },
1103
+ },
1104
+ required: ['year', 'make', 'model'],
1105
+ },
1106
+ },
1107
+ {
1108
+ name: 'fuel_economy_models',
1109
+ description: 'List the exact model strings fueleconomy.gov recognizes for a given model year + make (e.g. year 2024, make "Toyota" -> "Camry", "Camry Hybrid LE", "RAV4 AWD", ...). Call this before fuel_economy_mpg when you are not sure of the exact model string, or when fuel_economy_mpg refuses a model as unrecognized.',
1110
+ summary: 'Valid fueleconomy.gov model strings for a model year + make.',
1111
+ inputSchema: {
1112
+ type: 'object' as const,
1113
+ properties: {
1114
+ year: { type: 'number', description: 'Model year, e.g. 2024.' },
1115
+ make: { type: 'string', description: 'Vehicle make, e.g. "Toyota".' },
1116
+ },
1117
+ required: ['year', 'make'],
1118
+ },
1119
+ },
772
1120
  ];
773
1121
 
774
1122
  async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
@@ -785,11 +1133,232 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
785
1133
  return getComplaints(args.make as string, args.model as string, args.model_year as number);
786
1134
  case 'get_safety_ratings':
787
1135
  return getSafetyRatings(args.make as string, args.model as string, args.model_year as number);
1136
+ case 'fars_state_fatalities':
1137
+ return farsStateFatalities(requireFarsDb(args._d1), args);
1138
+ case 'fars_vehicle_makes':
1139
+ return farsVehicleMakes(requireFarsDb(args._d1), args);
1140
+ case 'fars_coverage':
1141
+ return farsCoverage(requireFarsDb(args._d1));
1142
+ case 'fuel_economy_mpg':
1143
+ return fuelEconomyMpg(args);
1144
+ case 'fuel_economy_models':
1145
+ return fuelEconomyModels(args);
788
1146
  default:
789
1147
  throw new Error(`Unknown tool: ${name}`);
790
1148
  }
791
1149
  }
792
1150
 
1151
+ // ── FARS fatal-crash tools (D1, loaded ahead of time) ──────────────────────
1152
+
1153
+ async function farsStateFatalities(db: D1Like, args: Record<string, unknown>) {
1154
+ const meta = await getFarsMeta(db);
1155
+ const resolved = resolveState(args.state);
1156
+ if (!resolved) {
1157
+ return { found: false, reason: 'invalid_state', message: `${JSON.stringify(args.state)} is not a recognizable state (give a full name, 2-letter postal code, or FIPS code).`, data_as_of: meta.latest_year };
1158
+ }
1159
+ const match = await lookupState(db, resolved);
1160
+ if (!match) {
1161
+ return {
1162
+ found: false,
1163
+ reason: 'unknown_state',
1164
+ message: `${JSON.stringify(args.state)} is not a recognized US state in this pack's loaded FARS data. Use a full state name ("Texas"), 2-letter postal code ("TX"), or FIPS code.`,
1165
+ data_as_of: meta.latest_year,
1166
+ };
1167
+ }
1168
+ const year = args.year != null && args.year !== '' ? Math.floor(Number(args.year)) : null;
1169
+ if (args.year != null && args.year !== '' && !Number.isFinite(year)) {
1170
+ return { found: false, reason: 'invalid_year', message: `year must be a valid number (got ${JSON.stringify(args.year)}).`, data_as_of: meta.latest_year };
1171
+ }
1172
+ const rows = await stateMonths(db, match.state_fips, year);
1173
+ if (!rows.length) {
1174
+ return {
1175
+ found: false,
1176
+ reason: 'no_data',
1177
+ state: match.state_name,
1178
+ year,
1179
+ message: `No FARS data for ${match.state_name}${year ? ` in ${year}` : ''} in the loaded window (${meta.earliest_year}-${meta.latest_year}).`,
1180
+ coverage: { earliest_year: meta.earliest_year, latest_year: meta.latest_year },
1181
+ ...FARS_SOURCE,
1182
+ data_as_of: meta.latest_year,
1183
+ };
1184
+ }
1185
+ const totalFatalities = rows.reduce((s, r) => s + r.fatalities, 0);
1186
+ const totalCrashes = rows.reduce((s, r) => s + r.crashes, 0);
1187
+ return {
1188
+ found: true,
1189
+ state: match.state_name,
1190
+ state_fips: match.state_fips,
1191
+ year,
1192
+ total_crashes: totalCrashes,
1193
+ total_fatalities: totalFatalities,
1194
+ returned_months: rows.length,
1195
+ coverage: { earliest_year: meta.earliest_year, latest_year: meta.latest_year },
1196
+ months: rows,
1197
+ ...FARS_SOURCE,
1198
+ data_as_of: year ?? meta.latest_year,
1199
+ };
1200
+ }
1201
+
1202
+ async function farsVehicleMakes(db: D1Like, args: Record<string, unknown>) {
1203
+ const meta = await getFarsMeta(db);
1204
+ const makeArg = args.make != null && args.make !== '' ? String(args.make).trim() : null;
1205
+
1206
+ if (makeArg) {
1207
+ const rows = await vehicleMakeYears(db, makeArg);
1208
+ const year = args.year != null && args.year !== '' ? Math.floor(Number(args.year)) : null;
1209
+ const filtered = year != null ? rows.filter((r) => r.year === year) : rows;
1210
+ if (!rows.length) {
1211
+ const sample = await listMakes(db, 15);
1212
+ return {
1213
+ found: false,
1214
+ reason: 'unknown_make',
1215
+ message: `${JSON.stringify(args.make)} is not a recognized vehicle make in this pack's loaded FARS data. A few makes that ARE loaded: ${sample.join(', ')}.`,
1216
+ data_as_of: meta.latest_year,
1217
+ };
1218
+ }
1219
+ if (!filtered.length) {
1220
+ return {
1221
+ found: false,
1222
+ reason: 'no_data',
1223
+ make: makeArg,
1224
+ year,
1225
+ message: `${makeArg} is a recognized make, but has no data for year ${year} in the loaded window (${meta.earliest_year}-${meta.latest_year}).`,
1226
+ coverage: { earliest_year: meta.earliest_year, latest_year: meta.latest_year },
1227
+ ...FARS_SOURCE,
1228
+ data_as_of: meta.latest_year,
1229
+ };
1230
+ }
1231
+ return {
1232
+ found: true,
1233
+ make: filtered[0].make_name,
1234
+ years: filtered,
1235
+ coverage: { earliest_year: meta.earliest_year, latest_year: meta.latest_year },
1236
+ ...FARS_SOURCE,
1237
+ data_as_of: meta.latest_year,
1238
+ };
1239
+ }
1240
+
1241
+ const year = args.year != null && args.year !== '' ? Math.floor(Number(args.year)) : null;
1242
+ if (year == null || !Number.isFinite(year)) {
1243
+ return { found: false, reason: 'missing_year', message: 'Pass year to rank makes for a year, or make to look up one make across years.', data_as_of: meta.latest_year };
1244
+ }
1245
+ const limit = Math.min(Math.max(1, Math.floor(Number(args.limit) || 20)), 50);
1246
+ const rows = await vehicleMakesForYear(db, year, limit);
1247
+ if (!rows.length) {
1248
+ return {
1249
+ found: false,
1250
+ reason: 'no_data',
1251
+ year,
1252
+ message: `No vehicle-make data for year ${year} in the loaded window (${meta.earliest_year}-${meta.latest_year}).`,
1253
+ coverage: { earliest_year: meta.earliest_year, latest_year: meta.latest_year },
1254
+ ...FARS_SOURCE,
1255
+ data_as_of: meta.latest_year,
1256
+ };
1257
+ }
1258
+ return {
1259
+ found: true,
1260
+ year,
1261
+ returned: rows.length,
1262
+ note: 'vehicles_involved counts vehicles of this make present in a fatal crash that year -- not a claim the make caused the crash or its fatalities.',
1263
+ makes: rows,
1264
+ coverage: { earliest_year: meta.earliest_year, latest_year: meta.latest_year },
1265
+ ...FARS_SOURCE,
1266
+ data_as_of: meta.latest_year,
1267
+ };
1268
+ }
1269
+
1270
+ async function farsCoverage(db: D1Like) {
1271
+ const meta = await getFarsMeta(db);
1272
+ return {
1273
+ earliest_year: meta.earliest_year,
1274
+ latest_year: meta.latest_year,
1275
+ years_loaded: meta.years_loaded,
1276
+ loaded_at: meta.loaded_at,
1277
+ note: 'Scoped to a recent multi-year window, not full FARS history (1975-present). Re-run the loader with a wider --years to extend further back.',
1278
+ ...FARS_SOURCE,
1279
+ data_as_of: meta.latest_year,
1280
+ };
1281
+ }
1282
+
1283
+ // ── fueleconomy.gov tools (live proxy) ─────────────────────────────────────
1284
+
1285
+ function reqFuelEconomyArgs(args: Record<string, unknown>) {
1286
+ const year = Math.floor(Number(args.year));
1287
+ const make = args.make != null ? String(args.make).trim() : '';
1288
+ if (!Number.isFinite(year) || !make) {
1289
+ throw new Error('year and make are both required (e.g. year:2024, make:"Toyota").');
1290
+ }
1291
+ return { year, make };
1292
+ }
1293
+
1294
+ async function fuelEconomyModels(args: Record<string, unknown>) {
1295
+ const { year, make } = reqFuelEconomyArgs(args);
1296
+ const models = await menuModels(year, make);
1297
+ if (!models.length) {
1298
+ throw new Error(`fueleconomy.gov has no models listed for make ${JSON.stringify(make)} in model year ${year} -- the make may be misspelled, or not sold that year. Try get_makes (NHTSA vPIC) to check spelling, or a nearby year.`);
1299
+ }
1300
+ return {
1301
+ found: true,
1302
+ year,
1303
+ make,
1304
+ returned: models.length,
1305
+ models: models.map((m) => m.text),
1306
+ ...FE_SOURCE,
1307
+ data_as_of: String(year),
1308
+ };
1309
+ }
1310
+
1311
+ async function fuelEconomyMpg(args: Record<string, unknown>) {
1312
+ const { year, make } = reqFuelEconomyArgs(args);
1313
+ const model = args.model != null ? String(args.model).trim() : '';
1314
+ if (!model) throw new Error('model is required (e.g. model:"Camry"). Call fuel_economy_models to see valid strings for this year+make.');
1315
+
1316
+ const options = await menuOptions(year, make, model);
1317
+ if (!options.length) {
1318
+ // Distinguish "bad make" from "bad model" so the refusal is actionable.
1319
+ const models = await menuModels(year, make);
1320
+ if (!models.length) {
1321
+ throw new Error(`fueleconomy.gov has no data for make ${JSON.stringify(make)} in model year ${year} -- the make may be misspelled or not sold that year.`);
1322
+ }
1323
+ const sample = models.slice(0, 15).map((m) => m.text);
1324
+ throw new Error(
1325
+ `fueleconomy.gov does not recognize model ${JSON.stringify(model)} for ${year} ${make}. Valid model strings for this year/make include: ${sample.join(', ')}${models.length > 15 ? ', ...' : ''}. Call fuel_economy_models for the full list.`,
1326
+ );
1327
+ }
1328
+
1329
+ const variants = await Promise.all(options.slice(0, 5).map((opt) => vehicleById(opt.value)));
1330
+ if (!variants.length) {
1331
+ // menuOptions returned items but every vehicleById call failed to parse --
1332
+ // loud error, never a silent empty success (wave-1 lesson).
1333
+ throw new Error(`fueleconomy.gov listed ${options.length} variant(s) for ${year} ${make} ${model} but none could be resolved to MPG data. Upstream may be degraded; retry.`);
1334
+ }
1335
+ return {
1336
+ found: true,
1337
+ year,
1338
+ make,
1339
+ model,
1340
+ returned_variants: variants.length,
1341
+ variants: variants.map((v) => ({
1342
+ trim_id: v.id,
1343
+ transmission: v.trany,
1344
+ drive: v.drive,
1345
+ cylinders: v.cylinders,
1346
+ displacement_l: v.displ,
1347
+ vehicle_class: v.vclass,
1348
+ fuel_type: v.fuel_type,
1349
+ fuel_type_secondary: v.fuel_type2,
1350
+ city_mpg: v.city_mpg,
1351
+ highway_mpg: v.highway_mpg,
1352
+ combined_mpg: v.combined_mpg,
1353
+ combined_mpge_phev: v.phev_combined_mpge,
1354
+ co2_g_per_mile: v.co2_g_per_mile,
1355
+ start_stop: v.start_stop,
1356
+ })),
1357
+ ...FE_SOURCE,
1358
+ data_as_of: String(year),
1359
+ };
1360
+ }
1361
+
793
1362
  function reqVehicle(make: string, model: string, year: number) {
794
1363
  if (!make || !model || !year) {
795
1364
  throw new Error('make, model, and model_year are all required (e.g., make:"Honda", model:"Civic", model_year:2021).');
package/src/server.ts CHANGED
@@ -8,8 +8,9 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
8
8
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
9
9
  import pack from './index.js';
10
10
 
11
+
11
12
  const server = new Server(
12
- { name: '@pipeworx/mcp-nhtsa', version: '0.2.2' },
13
+ { name: '@pipeworx/mcp-nhtsa', version: '0.2.3' },
13
14
  { capabilities: { tools: {} } },
14
15
  );
15
16
 
@@ -24,7 +25,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
24
25
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
25
26
  const { name, arguments: args } = request.params;
26
27
  try {
27
- const result = await pack.callTool(name, (args ?? {}) as Record<string, unknown>);
28
+ const callArgs = { ...((args ?? {}) as Record<string, unknown>) };
29
+
30
+ const result = await pack.callTool(name, callArgs);
28
31
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
29
32
  } catch (err) {
30
33
  return {