@cyanheads/epa-mcp-server 0.3.3 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/AGENTS.md +5 -5
  2. package/CLAUDE.md +5 -5
  3. package/Dockerfile +3 -3
  4. package/README.md +58 -23
  5. package/changelog/0.4.x/0.4.0.md +35 -0
  6. package/changelog/0.4.x/0.4.1.md +34 -0
  7. package/dist/index.js +1 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp-server/resources/definitions/facility.resource.d.ts +8 -1
  10. package/dist/mcp-server/resources/definitions/facility.resource.d.ts.map +1 -1
  11. package/dist/mcp-server/resources/definitions/facility.resource.js +13 -7
  12. package/dist/mcp-server/resources/definitions/facility.resource.js.map +1 -1
  13. package/dist/mcp-server/resources/definitions/index.d.ts +7 -1
  14. package/dist/mcp-server/resources/definitions/index.d.ts.map +1 -1
  15. package/dist/mcp-server/tools/definitions/get-air-quality.tool.d.ts +23 -9
  16. package/dist/mcp-server/tools/definitions/get-air-quality.tool.d.ts.map +1 -1
  17. package/dist/mcp-server/tools/definitions/get-air-quality.tool.js +165 -93
  18. package/dist/mcp-server/tools/definitions/get-air-quality.tool.js.map +1 -1
  19. package/dist/mcp-server/tools/definitions/get-facility.tool.d.ts +30 -2
  20. package/dist/mcp-server/tools/definitions/get-facility.tool.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/definitions/get-facility.tool.js +211 -53
  22. package/dist/mcp-server/tools/definitions/get-facility.tool.js.map +1 -1
  23. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.d.ts +5 -2
  24. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.d.ts.map +1 -1
  25. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.js +18 -15
  26. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.js.map +1 -1
  27. package/dist/mcp-server/tools/definitions/index.d.ts +165 -32
  28. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  29. package/dist/mcp-server/tools/definitions/search-facilities.tool.d.ts.map +1 -1
  30. package/dist/mcp-server/tools/definitions/search-facilities.tool.js +39 -26
  31. package/dist/mcp-server/tools/definitions/search-facilities.tool.js.map +1 -1
  32. package/dist/mcp-server/tools/definitions/search-superfund.tool.d.ts +3 -1
  33. package/dist/mcp-server/tools/definitions/search-superfund.tool.d.ts.map +1 -1
  34. package/dist/mcp-server/tools/definitions/search-superfund.tool.js +13 -9
  35. package/dist/mcp-server/tools/definitions/search-superfund.tool.js.map +1 -1
  36. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.d.ts +17 -3
  37. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.d.ts.map +1 -1
  38. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.js +64 -12
  39. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.js.map +1 -1
  40. package/dist/mcp-server/tools/definitions/search-violations.tool.d.ts +20 -3
  41. package/dist/mcp-server/tools/definitions/search-violations.tool.d.ts.map +1 -1
  42. package/dist/mcp-server/tools/definitions/search-violations.tool.js +84 -15
  43. package/dist/mcp-server/tools/definitions/search-violations.tool.js.map +1 -1
  44. package/dist/mcp-server/tools/definitions/search-water-systems.tool.d.ts +3 -1
  45. package/dist/mcp-server/tools/definitions/search-water-systems.tool.d.ts.map +1 -1
  46. package/dist/mcp-server/tools/definitions/search-water-systems.tool.js +22 -7
  47. package/dist/mcp-server/tools/definitions/search-water-systems.tool.js.map +1 -1
  48. package/dist/mcp-server/tools/format-lbs.d.ts +14 -0
  49. package/dist/mcp-server/tools/format-lbs.d.ts.map +1 -0
  50. package/dist/mcp-server/tools/format-lbs.js +16 -0
  51. package/dist/mcp-server/tools/format-lbs.js.map +1 -0
  52. package/dist/mcp-server/tools/format-location.d.ts +18 -0
  53. package/dist/mcp-server/tools/format-location.d.ts.map +1 -0
  54. package/dist/mcp-server/tools/format-location.js +26 -0
  55. package/dist/mcp-server/tools/format-location.js.map +1 -0
  56. package/dist/mcp-server/tools/format-tri-release.d.ts +16 -0
  57. package/dist/mcp-server/tools/format-tri-release.d.ts.map +1 -0
  58. package/dist/mcp-server/tools/format-tri-release.js +23 -0
  59. package/dist/mcp-server/tools/format-tri-release.js.map +1 -0
  60. package/dist/services/airnow/airnow-service.d.ts +10 -29
  61. package/dist/services/airnow/airnow-service.d.ts.map +1 -1
  62. package/dist/services/airnow/airnow-service.js +156 -97
  63. package/dist/services/airnow/airnow-service.js.map +1 -1
  64. package/dist/services/airnow/types.d.ts +62 -23
  65. package/dist/services/airnow/types.d.ts.map +1 -1
  66. package/dist/services/airnow/types.js +2 -1
  67. package/dist/services/airnow/types.js.map +1 -1
  68. package/dist/services/call-budget.d.ts +22 -0
  69. package/dist/services/call-budget.d.ts.map +1 -0
  70. package/dist/services/call-budget.js +28 -0
  71. package/dist/services/call-budget.js.map +1 -0
  72. package/dist/services/dmap/dmap-service.d.ts +76 -7
  73. package/dist/services/dmap/dmap-service.d.ts.map +1 -1
  74. package/dist/services/dmap/dmap-service.js +461 -144
  75. package/dist/services/dmap/dmap-service.js.map +1 -1
  76. package/dist/services/dmap/types.d.ts +17 -4
  77. package/dist/services/dmap/types.d.ts.map +1 -1
  78. package/dist/services/echo/echo-service.d.ts +38 -11
  79. package/dist/services/echo/echo-service.d.ts.map +1 -1
  80. package/dist/services/echo/echo-service.js +417 -223
  81. package/dist/services/echo/echo-service.js.map +1 -1
  82. package/dist/services/echo/types.d.ts +167 -80
  83. package/dist/services/echo/types.d.ts.map +1 -1
  84. package/dist/services/ejscreen/ejscreen-service.d.ts.map +1 -1
  85. package/dist/services/ejscreen/ejscreen-service.js +9 -3
  86. package/dist/services/ejscreen/ejscreen-service.js.map +1 -1
  87. package/package.json +7 -7
  88. package/server.json +9 -3
@@ -7,15 +7,18 @@ import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
7
7
  import { getDmapService } from '../../../services/dmap/dmap-service.js';
8
8
  export const searchWaterSystemsTool = tool('epa_search_water_systems', {
9
9
  title: 'Search Drinking Water Systems',
10
- description: 'Search drinking water systems (SDWIS) by state or ZIP code. Returns system name, PWSID, population served, primary water source, and active violation status. Use to identify community water systems with current or recent compliance violations. At least one of state or zip_code is required.',
10
+ description: 'Search drinking water systems (SDWIS) by state or ZIP code. Returns system name, PWSID, population served, primary water source, and activity status. With has_violation, returns only systems with an open violation — one SDWIS has not marked returned to compliance or closed out when the system was deactivated. At least one of state or zip_code is required.',
11
11
  annotations: { readOnlyHint: true, openWorldHint: true, idempotentHint: true },
12
12
  input: z.object({
13
13
  state: z.string().optional().describe('2-letter US state abbreviation (e.g. "WA", "CA")'),
14
- zip_code: z.string().optional().describe('5-digit ZIP code to search within'),
14
+ zip_code: z
15
+ .string()
16
+ .optional()
17
+ .describe("5-digit ZIP code of the water system's address of record — often its owner's or operator's office, which can be far from the area the system serves"),
15
18
  has_violation: z
16
19
  .boolean()
17
20
  .optional()
18
- .describe('When true, return only systems with active violations'),
21
+ .describe('When true, return only systems with an open SDWIS violation (not yet returned to compliance). Works with state, zip_code, or both.'),
19
22
  pws_type: z
20
23
  .enum(['community', 'non-transient', 'transient'])
21
24
  .optional()
@@ -38,8 +41,14 @@ export const searchWaterSystemsTool = tool('epa_search_water_systems', {
38
41
  .string()
39
42
  .optional()
40
43
  .describe('2-letter state abbreviation (primacy agency code)'),
41
- city: z.string().optional().describe('City served by this water system'),
42
- zip: z.string().optional().describe('ZIP code'),
44
+ city: z
45
+ .string()
46
+ .optional()
47
+ .describe("City of the water system's address of record — often its owner's or operator's office, not necessarily the area it serves"),
48
+ zip: z
49
+ .string()
50
+ .optional()
51
+ .describe("ZIP code of the water system's address of record (the value zip_code matches)"),
43
52
  type: z
44
53
  .string()
45
54
  .optional()
@@ -55,7 +64,7 @@ export const searchWaterSystemsTool = tool('epa_search_water_systems', {
55
64
  hasViolation: z
56
65
  .boolean()
57
66
  .optional()
58
- .describe('Whether this system has an active compliance violation on record'),
67
+ .describe('True when SDWIS lists an open violation for this system. Present only on has_violation searches, which return only such systems.'),
59
68
  isActive: z
60
69
  .boolean()
61
70
  .optional()
@@ -69,6 +78,12 @@ export const searchWaterSystemsTool = tool('epa_search_water_systems', {
69
78
  .optional()
70
79
  .describe('Recovery hint when no systems are found. Absent when systems are returned.'),
71
80
  }),
81
+ enrichment: {
82
+ notice: z
83
+ .string()
84
+ .optional()
85
+ .describe('Present when the has_violation check read only part of what it needed — the first 1,000 open violation records for the state, the first 1,000 systems in the ZIP, or the first 1,000 open violation records for a group of the ZIP’s systems — so violating systems outside that part may be missing from the results.'),
86
+ },
72
87
  errors: [
73
88
  {
74
89
  reason: 'no_geographic_filter',
@@ -135,7 +150,7 @@ export const searchWaterSystemsTool = tool('epa_search_water_systems', {
135
150
  if (s.primarySourceCode)
136
151
  lines.push(`**Primary Source:** ${s.primarySourceCode}`);
137
152
  if (s.hasViolation !== undefined)
138
- lines.push(`**Active Violation:** ${s.hasViolation ? 'Yes' : 'No'}`);
153
+ lines.push(`**Open Violation:** ${s.hasViolation ? 'Yes' : 'No'}`);
139
154
  if (s.isActive !== undefined)
140
155
  lines.push(`**Active:** ${s.isActive ? 'Yes' : 'No'}`);
141
156
  }
@@ -1 +1 @@
1
- {"version":3,"file":"search-water-systems.tool.js","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/search-water-systems.tool.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"search-water-systems.tool.js","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/search-water-systems.tool.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @fileoverview Renders a pound quantity for tool text output without rounding away small values.
3
+ * @module mcp-server/tools/format-lbs
4
+ */
5
+ /**
6
+ * Format a quantity in pounds as `"<n> lbs"`, grouped and at full reported precision.
7
+ *
8
+ * Default `toLocaleString()` stops at 3 fraction digits, so a TRI release of 0.00062 lbs prints
9
+ * as 0.001. TRI quantities carry at most 7 significant digits; 12 keeps every one of them, and
10
+ * matches the 12 significant digits the DMAP service rounds its per-medium sums and gram-to-pound
11
+ * conversions to, so text and structuredContent show one value.
12
+ */
13
+ export declare function formatLbs(lbs: number): string;
14
+ //# sourceMappingURL=format-lbs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format-lbs.d.ts","sourceRoot":"","sources":["../../../src/mcp-server/tools/format-lbs.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE7C"}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @fileoverview Renders a pound quantity for tool text output without rounding away small values.
3
+ * @module mcp-server/tools/format-lbs
4
+ */
5
+ /**
6
+ * Format a quantity in pounds as `"<n> lbs"`, grouped and at full reported precision.
7
+ *
8
+ * Default `toLocaleString()` stops at 3 fraction digits, so a TRI release of 0.00062 lbs prints
9
+ * as 0.001. TRI quantities carry at most 7 significant digits; 12 keeps every one of them, and
10
+ * matches the 12 significant digits the DMAP service rounds its per-medium sums and gram-to-pound
11
+ * conversions to, so text and structuredContent show one value.
12
+ */
13
+ export function formatLbs(lbs) {
14
+ return `${lbs.toLocaleString(undefined, { maximumSignificantDigits: 12 })} lbs`;
15
+ }
16
+ //# sourceMappingURL=format-lbs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format-lbs.js","sourceRoot":"","sources":["../../../src/mcp-server/tools/format-lbs.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH;;;;;;;GAOG;AACH,MAAM,UAAU,SAAS,CAAC,GAAW;IACnC,OAAO,GAAG,GAAG,CAAC,cAAc,CAAC,SAAS,EAAE,EAAE,wBAAwB,EAAE,EAAE,EAAE,CAAC,MAAM,CAAC;AAClF,CAAC"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @fileoverview Renders the address, county, and coordinate lines shared by facility and site tool output.
3
+ * @module mcp-server/tools/format-location
4
+ */
5
+ interface LocatedRecord {
6
+ city?: string | undefined;
7
+ county?: string | undefined;
8
+ fipsCode?: string | undefined;
9
+ latitude?: number | undefined;
10
+ longitude?: number | undefined;
11
+ state?: string | undefined;
12
+ street?: string | undefined;
13
+ zip?: string | undefined;
14
+ }
15
+ /** Markdown lines for a record's address, county (with FIPS), and coordinates; absent parts are skipped. */
16
+ export declare function formatLocationLines(r: LocatedRecord): string[];
17
+ export {};
18
+ //# sourceMappingURL=format-location.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format-location.d.ts","sourceRoot":"","sources":["../../../src/mcp-server/tools/format-location.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,UAAU,aAAa;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC1B;AAED,4GAA4G;AAC5G,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,aAAa,GAAG,MAAM,EAAE,CAc9D"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @fileoverview Renders the address, county, and coordinate lines shared by facility and site tool output.
3
+ * @module mcp-server/tools/format-location
4
+ */
5
+ /** Markdown lines for a record's address, county (with FIPS), and coordinates; absent parts are skipped. */
6
+ export function formatLocationLines(r) {
7
+ const lines = [];
8
+ const address = [r.street, r.city, r.state, r.zip].filter(Boolean).join(', ');
9
+ if (address)
10
+ lines.push(`**Location:** ${address}`);
11
+ if (r.county)
12
+ lines.push(`**County:** ${r.county}${r.fipsCode ? ` (FIPS: ${r.fipsCode})` : ''}`);
13
+ else if (r.fipsCode)
14
+ lines.push(`**County FIPS:** ${r.fipsCode}`);
15
+ if (r.latitude !== undefined && r.longitude !== undefined) {
16
+ lines.push(`**Coordinates:** ${r.latitude}, ${r.longitude}`);
17
+ }
18
+ else if (r.latitude !== undefined) {
19
+ lines.push(`**Latitude:** ${r.latitude}`);
20
+ }
21
+ else if (r.longitude !== undefined) {
22
+ lines.push(`**Longitude:** ${r.longitude}`);
23
+ }
24
+ return lines;
25
+ }
26
+ //# sourceMappingURL=format-location.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format-location.js","sourceRoot":"","sources":["../../../src/mcp-server/tools/format-location.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAaH,4GAA4G;AAC5G,MAAM,UAAU,mBAAmB,CAAC,CAAgB;IAClD,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,MAAM,OAAO,GAAG,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC9E,IAAI,OAAO;QAAE,KAAK,CAAC,IAAI,CAAC,iBAAiB,OAAO,EAAE,CAAC,CAAC;IACpD,IAAI,CAAC,CAAC,MAAM;QAAE,KAAK,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;SAC5F,IAAI,CAAC,CAAC,QAAQ;QAAE,KAAK,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC;IAClE,IAAI,CAAC,CAAC,QAAQ,KAAK,SAAS,IAAI,CAAC,CAAC,SAAS,KAAK,SAAS,EAAE,CAAC;QAC1D,KAAK,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAC,QAAQ,KAAK,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC;IAC/D,CAAC;SAAM,IAAI,CAAC,CAAC,QAAQ,KAAK,SAAS,EAAE,CAAC;QACpC,KAAK,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC;IAC5C,CAAC;SAAM,IAAI,CAAC,CAAC,SAAS,KAAK,SAAS,EAAE,CAAC;QACrC,KAAK,CAAC,IAAI,CAAC,kBAAkB,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC;IAC9C,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC"}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @fileoverview Renders the unit note and release-quantity lines shared by the TRI tools' text output.
3
+ * @module mcp-server/tools/format-tri-release
4
+ */
5
+ interface TriReleaseQuantities {
6
+ releasesToAirInLbs?: number | undefined;
7
+ releasesToLandInLbs?: number | undefined;
8
+ releasesToUndergroundInjectionInLbs?: number | undefined;
9
+ releasesToWaterInLbs?: number | undefined;
10
+ reportedUnit?: 'grams' | undefined;
11
+ totalReleasesInLbs?: number | undefined;
12
+ }
13
+ /** Markdown lines for a TRI record's reported unit and per-medium and one-time quantities; absent fields are skipped. */
14
+ export declare function formatTriReleaseLines(r: TriReleaseQuantities): string[];
15
+ export {};
16
+ //# sourceMappingURL=format-tri-release.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format-tri-release.d.ts","sourceRoot":"","sources":["../../../src/mcp-server/tools/format-tri-release.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH,UAAU,oBAAoB;IAC5B,kBAAkB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,mBAAmB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,mCAAmC,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzD,oBAAoB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1C,YAAY,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACnC,kBAAkB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACzC;AAED,yHAAyH;AACzH,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,oBAAoB,GAAG,MAAM,EAAE,CAiBvE"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @fileoverview Renders the unit note and release-quantity lines shared by the TRI tools' text output.
3
+ * @module mcp-server/tools/format-tri-release
4
+ */
5
+ import { formatLbs } from './format-lbs.js';
6
+ /** Markdown lines for a TRI record's reported unit and per-medium and one-time quantities; absent fields are skipped. */
7
+ export function formatTriReleaseLines(r) {
8
+ const lines = [];
9
+ if (r.reportedUnit)
10
+ lines.push(`**Reported Unit:** ${r.reportedUnit} — any quantities shown are converted to pounds (1 lb = 453.59237 g)`);
11
+ if (r.releasesToAirInLbs !== undefined)
12
+ lines.push(`**Air Releases:** ${formatLbs(r.releasesToAirInLbs)}`);
13
+ if (r.releasesToWaterInLbs !== undefined)
14
+ lines.push(`**Water Releases:** ${formatLbs(r.releasesToWaterInLbs)}`);
15
+ if (r.releasesToLandInLbs !== undefined)
16
+ lines.push(`**Land Releases:** ${formatLbs(r.releasesToLandInLbs)}`);
17
+ if (r.releasesToUndergroundInjectionInLbs !== undefined)
18
+ lines.push(`**Underground Injection:** ${formatLbs(r.releasesToUndergroundInjectionInLbs)}`);
19
+ if (r.totalReleasesInLbs !== undefined)
20
+ lines.push(`**One-Time / Non-Routine Release:** ${formatLbs(r.totalReleasesInLbs)}`);
21
+ return lines;
22
+ }
23
+ //# sourceMappingURL=format-tri-release.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format-tri-release.js","sourceRoot":"","sources":["../../../src/mcp-server/tools/format-tri-release.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAW7D,yHAAyH;AACzH,MAAM,UAAU,qBAAqB,CAAC,CAAuB;IAC3D,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,IAAI,CAAC,CAAC,YAAY;QAChB,KAAK,CAAC,IAAI,CACR,sBAAsB,CAAC,CAAC,YAAY,sEAAsE,CAC3G,CAAC;IACJ,IAAI,CAAC,CAAC,kBAAkB,KAAK,SAAS;QACpC,KAAK,CAAC,IAAI,CAAC,qBAAqB,SAAS,CAAC,CAAC,CAAC,kBAAkB,CAAC,EAAE,CAAC,CAAC;IACrE,IAAI,CAAC,CAAC,oBAAoB,KAAK,SAAS;QACtC,KAAK,CAAC,IAAI,CAAC,uBAAuB,SAAS,CAAC,CAAC,CAAC,oBAAoB,CAAC,EAAE,CAAC,CAAC;IACzE,IAAI,CAAC,CAAC,mBAAmB,KAAK,SAAS;QACrC,KAAK,CAAC,IAAI,CAAC,sBAAsB,SAAS,CAAC,CAAC,CAAC,mBAAmB,CAAC,EAAE,CAAC,CAAC;IACvE,IAAI,CAAC,CAAC,mCAAmC,KAAK,SAAS;QACrD,KAAK,CAAC,IAAI,CAAC,8BAA8B,SAAS,CAAC,CAAC,CAAC,mCAAmC,CAAC,EAAE,CAAC,CAAC;IAC/F,IAAI,CAAC,CAAC,kBAAkB,KAAK,SAAS;QACpC,KAAK,CAAC,IAAI,CAAC,uCAAuC,SAAS,CAAC,CAAC,CAAC,kBAAkB,CAAC,EAAE,CAAC,CAAC;IACvF,OAAO,KAAK,CAAC;AACf,CAAC"}
@@ -1,44 +1,25 @@
1
1
  /**
2
- * @fileoverview AirNow API service for real-time and forecast air quality data.
3
- * Wraps www.airnowapi.org/aq endpoints. Responses cached at ~1 hour TTL per the
4
- * AirNow API's recommendation to avoid rate-limiting.
2
+ * @fileoverview AirNow API service for current observations and issued forecasts.
3
+ * Wraps the 2026 www.airnowapi.org/aq web services `observation/current/ziplatlong/`
4
+ * and `forecast/current/`. Responses cached at ~1 hour TTL per the AirNow API's
5
+ * recommendation to avoid rate-limiting.
5
6
  * @module services/airnow/airnow-service
6
7
  */
7
8
  import type { Context } from '@cyanheads/mcp-ts-core';
8
9
  import type { AppConfig } from '@cyanheads/mcp-ts-core/config';
9
10
  import type { StorageService } from '@cyanheads/mcp-ts-core/storage';
10
- import type { AirQualityResult } from './types.js';
11
+ import type { AirNowLocation, AirQualityLookup } from './types.js';
11
12
  export declare class AirNowService {
12
13
  private readonly baseUrl;
13
14
  private readonly apiKey;
14
15
  constructor(_config: AppConfig, _storage: StorageService);
16
+ /** Current hourly observations: per pollutant, the closest monitor within AirNow's 50-mile boundary. */
17
+ getCurrent(location: AirNowLocation, ctx: Context): Promise<AirQualityLookup>;
18
+ /** Every forecast day the location's reporting area has issued. */
19
+ getForecast(location: AirNowLocation, ctx: Context): Promise<AirQualityLookup>;
15
20
  private buildUrl;
16
21
  private cacheKey;
17
- private fetchWithCache;
18
- /** Get current air quality by ZIP code. */
19
- getCurrentByZip(params: {
20
- zipCode: string;
21
- distanceMiles?: number;
22
- }, ctx: Context): Promise<AirQualityResult[]>;
23
- /** Get current air quality by lat/lng. */
24
- getCurrentByLatLng(params: {
25
- latitude: number;
26
- longitude: number;
27
- distanceMiles?: number;
28
- }, ctx: Context): Promise<AirQualityResult[]>;
29
- /** Get air quality forecast by ZIP code. */
30
- getForecastByZip(params: {
31
- zipCode: string;
32
- date: string;
33
- distanceMiles?: number;
34
- }, ctx: Context): Promise<AirQualityResult[]>;
35
- /** Get air quality forecast by lat/lng. */
36
- getForecastByLatLng(params: {
37
- latitude: number;
38
- longitude: number;
39
- date: string;
40
- distanceMiles?: number;
41
- }, ctx: Context): Promise<AirQualityResult[]>;
22
+ private fetchBody;
42
23
  }
43
24
  export declare function initAirNowService(config: AppConfig, storage: StorageService): void;
44
25
  export declare function getAirNowService(): AirNowService;
@@ -1 +1 @@
1
- {"version":3,"file":"airnow-service.d.ts","sourceRoot":"","sources":["../../../src/services/airnow/airnow-service.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAC;AACtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+BAA+B,CAAC;AAE/D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAGrE,OAAO,KAAK,EAAE,gBAAgB,EAAmB,MAAM,YAAY,CAAC;AAsCpE,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAEhC,YAAY,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,cAAc,EAWvD;IAED,OAAO,CAAC,QAAQ;IAYhB,OAAO,CAAC,QAAQ;YAWF,cAAc;IAuC5B,2CAA2C;IACrC,eAAe,CACnB,MAAM,EAAE;QACN,OAAO,EAAE,MAAM,CAAC;QAChB,aAAa,CAAC,EAAE,MAAM,CAAC;KACxB,EACD,GAAG,EAAE,OAAO,GACX,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAO7B;IAED,0CAA0C;IACpC,kBAAkB,CACtB,MAAM,EAAE;QACN,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,EAAE,MAAM,CAAC;QAClB,aAAa,CAAC,EAAE,MAAM,CAAC;KACxB,EACD,GAAG,EAAE,OAAO,GACX,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAW7B;IAED,4CAA4C;IACtC,gBAAgB,CACpB,MAAM,EAAE;QACN,OAAO,EAAE,MAAM,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;QACb,aAAa,CAAC,EAAE,MAAM,CAAC;KACxB,EACD,GAAG,EAAE,OAAO,GACX,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAW7B;IAED,2CAA2C;IACrC,mBAAmB,CACvB,MAAM,EAAE;QACN,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,EAAE,MAAM,CAAC;QAClB,IAAI,EAAE,MAAM,CAAC;QACb,aAAa,CAAC,EAAE,MAAM,CAAC;KACxB,EACD,GAAG,EAAE,OAAO,GACX,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAgB7B;CACF;AAMD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,cAAc,GAAG,IAAI,CAElF;AAED,wBAAgB,gBAAgB,IAAI,aAAa,CAKhD"}
1
+ {"version":3,"file":"airnow-service.d.ts","sourceRoot":"","sources":["../../../src/services/airnow/airnow-service.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAC;AACtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+BAA+B,CAAC;AAM/D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAGrE,OAAO,KAAK,EACV,cAAc,EAEd,gBAAgB,EAGjB,MAAM,YAAY,CAAC;AA+HpB,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAEhC,YAAY,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,cAAc,EAWvD;IAED,wGAAwG;IAClG,UAAU,CAAC,QAAQ,EAAE,cAAc,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAKlF;IAED,mEAAmE;IAC7D,WAAW,CAAC,QAAQ,EAAE,cAAc,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAKnF;IAED,OAAO,CAAC,QAAQ;IAUhB,OAAO,CAAC,QAAQ;YAUF,SAAS;CA6DxB;AAMD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,cAAc,GAAG,IAAI,CAElF;AAED,wBAAgB,gBAAgB,IAAI,aAAa,CAKhD"}
@@ -1,43 +1,127 @@
1
1
  /**
2
- * @fileoverview AirNow API service for real-time and forecast air quality data.
3
- * Wraps www.airnowapi.org/aq endpoints. Responses cached at ~1 hour TTL per the
4
- * AirNow API's recommendation to avoid rate-limiting.
2
+ * @fileoverview AirNow API service for current observations and issued forecasts.
3
+ * Wraps the 2026 www.airnowapi.org/aq web services `observation/current/ziplatlong/`
4
+ * and `forecast/current/`. Responses cached at ~1 hour TTL per the AirNow API's
5
+ * recommendation to avoid rate-limiting.
5
6
  * @module services/airnow/airnow-service
6
7
  */
7
- import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
8
+ import { serializationError, serviceUnavailable, validationError, } from '@cyanheads/mcp-ts-core/errors';
8
9
  import { httpErrorFromResponse, withRetry } from '@cyanheads/mcp-ts-core/utils';
9
10
  import { getServerConfig } from '../../config/server-config.js';
10
11
  const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
11
- /** Normalize raw AirNow records (grouped by reporting area) into AirQualityResult[]. */
12
- function normalizeRecords(records) {
13
- // Group by reporting area + date/hour
14
- const grouped = new Map();
15
- for (const r of records) {
16
- const key = `${r.ReportingArea ?? ''}|${r.DateObserved ?? ''}|${r.HourObserved ?? ''}`;
17
- let entry = grouped.get(key);
18
- if (!entry) {
19
- entry = {
20
- ...(r.ReportingArea && { reportingArea: r.ReportingArea.trim() }),
21
- ...(r.StateCode && { stateCode: r.StateCode }),
22
- ...(r.Latitude !== undefined && { latitude: r.Latitude }),
23
- ...(r.Longitude !== undefined && { longitude: r.Longitude }),
24
- ...(r.DateObserved && { dateObserved: r.DateObserved.trim() }),
25
- ...(r.HourObserved !== undefined && { hourObserved: r.HourObserved }),
26
- ...(r.LocalTimeZone && { localTimeZone: r.LocalTimeZone }),
12
+ /**
13
+ * Budget for one AirNow lookup — every attempt and backoff — kept under the 60 s default request
14
+ * timeout of MCP SDK clients with room for transport.
15
+ */
16
+ const REQUEST_DEADLINE_MS = 55_000;
17
+ /** The six AirNow AQI descriptors (lowercased) and their category numbers. */
18
+ const AQI_CATEGORY_NUMBERS = new Map([
19
+ ['good', 1],
20
+ ['moderate', 2],
21
+ ['unhealthy for sensitive groups', 3],
22
+ ['unhealthy', 4],
23
+ ['very unhealthy', 5],
24
+ ['hazardous', 6],
25
+ ]);
26
+ /** The message(s) of an `{"WebServiceError":[{"Message":…}]}` body; undefined for any other shape. */
27
+ function webServiceErrorMessage(body) {
28
+ if (typeof body !== 'object' || body === null || !('WebServiceError' in body))
29
+ return;
30
+ const entries = body.WebServiceError;
31
+ if (!Array.isArray(entries))
32
+ return;
33
+ const messages = entries.flatMap((entry) => typeof entry?.Message === 'string' && entry.Message.trim() !== '' ? [entry.Message] : []);
34
+ return messages.length > 0 ? messages.join(' ') : undefined;
35
+ }
36
+ /** Classify a 200 body. Anything but a record array or a WebServiceError message fails loudly. */
37
+ function parseBody(text) {
38
+ let body;
39
+ try {
40
+ body = JSON.parse(text);
41
+ }
42
+ catch (cause) {
43
+ throw serializationError('AirNow returned a response that is not JSON.', undefined, { cause });
44
+ }
45
+ if (Array.isArray(body))
46
+ return { rows: body };
47
+ const noDataMessage = webServiceErrorMessage(body);
48
+ if (noDataMessage !== undefined)
49
+ return { noDataMessage };
50
+ throw serializationError('AirNow returned an unrecognized response: expected an array of records or a WebServiceError message.');
51
+ }
52
+ /** An AQI value, or undefined when upstream sent none (forecasts use -1 for category-only). */
53
+ function validAqi(value) {
54
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
55
+ }
56
+ /** Group observation records by reporting area and observed hour; one reading per pollutant. */
57
+ function normalizeObservations(rows) {
58
+ const areas = new Map();
59
+ for (const r of rows) {
60
+ const aqi = validAqi(r.nowcastAQI);
61
+ if (!r.parameterName || (aqi === undefined && !r.aqiCategoryName))
62
+ continue;
63
+ const key = [r.reportingAreaName, r.dateObserved, r.hourObserved, r.localTimeZone].join('|');
64
+ let area = areas.get(key);
65
+ if (!area) {
66
+ area = {
67
+ ...(r.reportingAreaName && { reportingArea: r.reportingAreaName }),
68
+ ...(r.dateObserved && { dateObserved: r.dateObserved }),
69
+ ...(r.hourObserved && { hourObserved: r.hourObserved }),
70
+ ...(r.localTimeZone && { localTimeZone: r.localTimeZone }),
27
71
  readings: [],
28
72
  };
29
- grouped.set(key, entry);
73
+ areas.set(key, area);
30
74
  }
31
- if (r.ParameterName && r.AQI !== undefined) {
32
- entry.readings.push({
33
- parameterName: r.ParameterName,
34
- aqi: r.AQI,
35
- ...(r.Category?.Number !== undefined && { categoryNumber: r.Category.Number }),
36
- ...(r.Category?.Name && { categoryName: r.Category.Name }),
37
- });
75
+ const categoryNumber = r.aqiCategoryName
76
+ ? AQI_CATEGORY_NUMBERS.get(r.aqiCategoryName.trim().toLowerCase())
77
+ : undefined;
78
+ area.readings.push({
79
+ parameterName: r.parameterName,
80
+ ...(aqi !== undefined && { aqi }),
81
+ ...(r.aqiCategoryName && { categoryName: r.aqiCategoryName }),
82
+ ...(categoryNumber !== undefined && { categoryNumber }),
83
+ ...(r.siteName && { siteName: r.siteName }),
84
+ ...(r.siteID && { siteID: r.siteID }),
85
+ ...(r.reportingAgency && { reportingAgency: r.reportingAgency }),
86
+ });
87
+ }
88
+ return [...areas.values()];
89
+ }
90
+ /** Group forecast records by reporting area; one reading per pollutant per valid date. */
91
+ function normalizeForecasts(rows) {
92
+ const areas = new Map();
93
+ for (const r of rows) {
94
+ const aqi = validAqi(r.aqi);
95
+ if (!r.parameterName ||
96
+ (aqi === undefined && !r.categoryName && r.categoryNumber === undefined)) {
97
+ continue;
38
98
  }
99
+ const key = r.reportingAreaCode ?? r.reportingArea ?? '';
100
+ let area = areas.get(key);
101
+ if (!area) {
102
+ area = {
103
+ ...(r.reportingArea && { reportingArea: r.reportingArea }),
104
+ ...(r.reportingAreaCode && { reportingAreaCode: r.reportingAreaCode }),
105
+ ...(r.stateCode && { stateCode: r.stateCode }),
106
+ readings: [],
107
+ };
108
+ areas.set(key, area);
109
+ }
110
+ // AirNow repeats the area's discussion on every row; keep it once, verbatim.
111
+ if (!area.discussion && r.discussion)
112
+ area.discussion = r.discussion;
113
+ area.readings.push({
114
+ parameterName: r.parameterName,
115
+ ...(aqi !== undefined && { aqi }),
116
+ ...(r.categoryNumber !== undefined && { categoryNumber: r.categoryNumber }),
117
+ ...(r.categoryName && { categoryName: r.categoryName }),
118
+ ...(r.dateIssue && { dateIssue: r.dateIssue }),
119
+ ...(r.dateValid && { dateValid: r.dateValid }),
120
+ ...(r.forecastAgency && { forecastAgency: r.forecastAgency }),
121
+ ...(typeof r.actionDay === 'boolean' && { actionDay: r.actionDay }),
122
+ });
39
123
  }
40
- return Array.from(grouped.values());
124
+ return [...areas.values()];
41
125
  }
42
126
  export class AirNowService {
43
127
  baseUrl;
@@ -52,20 +136,31 @@ export class AirNowService {
52
136
  this.baseUrl = serverConfig.airNowBaseUrl;
53
137
  this.apiKey = serverConfig.airNowApiKey;
54
138
  }
139
+ /** Current hourly observations: per pollutant, the closest monitor within AirNow's 50-mile boundary. */
140
+ async getCurrent(location, ctx) {
141
+ const body = await this.fetchBody('observation', location, ctx);
142
+ return 'noDataMessage' in body
143
+ ? { areas: [], noDataMessage: body.noDataMessage }
144
+ : { areas: normalizeObservations(body.rows) };
145
+ }
146
+ /** Every forecast day the location's reporting area has issued. */
147
+ async getForecast(location, ctx) {
148
+ const body = await this.fetchBody('forecast', location, ctx);
149
+ return 'noDataMessage' in body
150
+ ? { areas: [], noDataMessage: body.noDataMessage }
151
+ : { areas: normalizeForecasts(body.rows) };
152
+ }
55
153
  buildUrl(path, params) {
56
154
  const url = new URL(`${this.baseUrl}/${path}`);
57
155
  url.searchParams.set('format', 'application/json');
58
156
  url.searchParams.set('API_KEY', this.apiKey);
59
157
  for (const [key, val] of Object.entries(params)) {
60
- if (val !== undefined && val !== '') {
61
- url.searchParams.set(key, String(val));
62
- }
158
+ url.searchParams.set(key, String(val));
63
159
  }
64
160
  return url.toString();
65
161
  }
66
162
  cacheKey(kind, params) {
67
163
  const stable = Object.entries(params)
68
- .filter(([, v]) => v !== undefined)
69
164
  .sort(([a], [b]) => a.localeCompare(b))
70
165
  .map(([k, v]) => `${k}-${String(v).replace(/[^a-zA-Z0-9_.\-/]/g, '_')}`)
71
166
  .join('_');
@@ -73,15 +168,33 @@ export class AirNowService {
73
168
  const safeKind = kind.replace(/:/g, '/');
74
169
  return `airnow/${safeKind}/${stable}`;
75
170
  }
76
- async fetchWithCache(url, cacheKey, ctx) {
77
- // Try cache first
171
+ async fetchBody(service, location, ctx) {
172
+ const params = location.kind === 'zip'
173
+ ? { zipCode: location.zipCode }
174
+ : { latitude: location.latitude, longitude: location.longitude };
175
+ const cacheKey = this.cacheKey(`${service}:${location.kind}`, params);
78
176
  const cached = await ctx.state.get(cacheKey);
79
177
  if (cached && cached.expiresAt > Date.now()) {
80
178
  ctx.log.debug('AirNow cache hit', { cacheKey });
81
- return cached.data;
179
+ return cached.body;
82
180
  }
83
- const data = await withRetry(async () => {
84
- const response = await fetch(url, { signal: ctx.signal });
181
+ const path = service === 'observation' ? 'observation/current/ziplatlong/' : 'forecast/current/';
182
+ const url = this.buildUrl(path, params);
183
+ ctx.log.debug('AirNow request', { service, ...params });
184
+ const body = await withRetry(async ({ signal }) => {
185
+ const response = await fetch(url, { signal });
186
+ // A ZIP is the only caller-supplied input on a ZIP request, so a 400 rejects it.
187
+ if (response.status === 400 && location.kind === 'zip') {
188
+ const upstream = await response.text();
189
+ let detail;
190
+ try {
191
+ detail = webServiceErrorMessage(JSON.parse(upstream));
192
+ }
193
+ catch {
194
+ // Non-JSON 400 body: report the status alone.
195
+ }
196
+ throw validationError(`AirNow rejected zip_code "${location.zipCode}": ${detail ?? 'HTTP 400'}.`, { reason: 'invalid_zip_code', zipCode: location.zipCode });
197
+ }
85
198
  if (!response.ok) {
86
199
  throw await httpErrorFromResponse(response, { service: 'AirNow' });
87
200
  }
@@ -89,69 +202,15 @@ export class AirNowService {
89
202
  if (/^\s*<(!DOCTYPE\s+html|html[\s>])/i.test(text)) {
90
203
  throw serviceUnavailable('AirNow API returned HTML instead of JSON — likely rate-limited.');
91
204
  }
92
- const parsed = JSON.parse(text);
93
- return Array.isArray(parsed) ? parsed : [];
205
+ return parseBody(text);
94
206
  }, {
95
- operation: 'AirNowService.fetch',
207
+ operation: 'AirNow request',
96
208
  baseDelayMs: 2000,
97
209
  signal: ctx.signal,
210
+ deadlineMs: REQUEST_DEADLINE_MS,
98
211
  });
99
- // Store in cache
100
- await ctx.state.set(cacheKey, { data, expiresAt: Date.now() + CACHE_TTL_MS });
101
- return data;
102
- }
103
- /** Get current air quality by ZIP code. */
104
- async getCurrentByZip(params, ctx) {
105
- const qparams = { zipCode: params.zipCode, distance: params.distanceMiles ?? 25 };
106
- const url = this.buildUrl('observation/zipCode/current/', qparams);
107
- const ck = this.cacheKey('current:zip', qparams);
108
- ctx.log.debug('AirNow current by ZIP', { zipCode: params.zipCode });
109
- const records = await this.fetchWithCache(url, ck, ctx);
110
- return normalizeRecords(records);
111
- }
112
- /** Get current air quality by lat/lng. */
113
- async getCurrentByLatLng(params, ctx) {
114
- const qparams = {
115
- latitude: params.latitude,
116
- longitude: params.longitude,
117
- distance: params.distanceMiles ?? 25,
118
- };
119
- const url = this.buildUrl('observation/latLong/current/', qparams);
120
- const ck = this.cacheKey('current:latlng', qparams);
121
- ctx.log.debug('AirNow current by lat/lng', { lat: params.latitude, lng: params.longitude });
122
- const records = await this.fetchWithCache(url, ck, ctx);
123
- return normalizeRecords(records);
124
- }
125
- /** Get air quality forecast by ZIP code. */
126
- async getForecastByZip(params, ctx) {
127
- const qparams = {
128
- zipCode: params.zipCode,
129
- date: params.date,
130
- distance: params.distanceMiles ?? 25,
131
- };
132
- const url = this.buildUrl('forecast/zipCode/', qparams);
133
- const ck = this.cacheKey('forecast:zip', qparams);
134
- ctx.log.debug('AirNow forecast by ZIP', { zipCode: params.zipCode, date: params.date });
135
- const records = await this.fetchWithCache(url, ck, ctx);
136
- return normalizeRecords(records);
137
- }
138
- /** Get air quality forecast by lat/lng. */
139
- async getForecastByLatLng(params, ctx) {
140
- const qparams = {
141
- latitude: params.latitude,
142
- longitude: params.longitude,
143
- date: params.date,
144
- distance: params.distanceMiles ?? 25,
145
- };
146
- const url = this.buildUrl('forecast/latLong/', qparams);
147
- const ck = this.cacheKey('forecast:latlng', qparams);
148
- ctx.log.debug('AirNow forecast by lat/lng', {
149
- lat: params.latitude,
150
- lng: params.longitude,
151
- date: params.date,
152
- });
153
- const records = await this.fetchWithCache(url, ck, ctx);
154
- return normalizeRecords(records);
212
+ await ctx.state.set(cacheKey, { body, expiresAt: Date.now() + CACHE_TTL_MS });
213
+ return body;
155
214
  }
156
215
  }
157
216
  // --- Init/accessor pattern ---