@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.
- package/AGENTS.md +5 -5
- package/CLAUDE.md +5 -5
- package/Dockerfile +3 -3
- package/README.md +58 -23
- package/changelog/0.4.x/0.4.0.md +35 -0
- package/changelog/0.4.x/0.4.1.md +34 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/resources/definitions/facility.resource.d.ts +8 -1
- package/dist/mcp-server/resources/definitions/facility.resource.d.ts.map +1 -1
- package/dist/mcp-server/resources/definitions/facility.resource.js +13 -7
- package/dist/mcp-server/resources/definitions/facility.resource.js.map +1 -1
- package/dist/mcp-server/resources/definitions/index.d.ts +7 -1
- package/dist/mcp-server/resources/definitions/index.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-air-quality.tool.d.ts +23 -9
- package/dist/mcp-server/tools/definitions/get-air-quality.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-air-quality.tool.js +165 -93
- package/dist/mcp-server/tools/definitions/get-air-quality.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-facility.tool.d.ts +30 -2
- package/dist/mcp-server/tools/definitions/get-facility.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-facility.tool.js +211 -53
- package/dist/mcp-server/tools/definitions/get-facility.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-tri-releases.tool.d.ts +5 -2
- package/dist/mcp-server/tools/definitions/get-tri-releases.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-tri-releases.tool.js +18 -15
- package/dist/mcp-server/tools/definitions/get-tri-releases.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/index.d.ts +165 -32
- package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-facilities.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-facilities.tool.js +39 -26
- package/dist/mcp-server/tools/definitions/search-facilities.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-superfund.tool.d.ts +3 -1
- package/dist/mcp-server/tools/definitions/search-superfund.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-superfund.tool.js +13 -9
- package/dist/mcp-server/tools/definitions/search-superfund.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-tri-releases.tool.d.ts +17 -3
- package/dist/mcp-server/tools/definitions/search-tri-releases.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-tri-releases.tool.js +64 -12
- package/dist/mcp-server/tools/definitions/search-tri-releases.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-violations.tool.d.ts +20 -3
- package/dist/mcp-server/tools/definitions/search-violations.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-violations.tool.js +84 -15
- package/dist/mcp-server/tools/definitions/search-violations.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-water-systems.tool.d.ts +3 -1
- package/dist/mcp-server/tools/definitions/search-water-systems.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-water-systems.tool.js +22 -7
- package/dist/mcp-server/tools/definitions/search-water-systems.tool.js.map +1 -1
- package/dist/mcp-server/tools/format-lbs.d.ts +14 -0
- package/dist/mcp-server/tools/format-lbs.d.ts.map +1 -0
- package/dist/mcp-server/tools/format-lbs.js +16 -0
- package/dist/mcp-server/tools/format-lbs.js.map +1 -0
- package/dist/mcp-server/tools/format-location.d.ts +18 -0
- package/dist/mcp-server/tools/format-location.d.ts.map +1 -0
- package/dist/mcp-server/tools/format-location.js +26 -0
- package/dist/mcp-server/tools/format-location.js.map +1 -0
- package/dist/mcp-server/tools/format-tri-release.d.ts +16 -0
- package/dist/mcp-server/tools/format-tri-release.d.ts.map +1 -0
- package/dist/mcp-server/tools/format-tri-release.js +23 -0
- package/dist/mcp-server/tools/format-tri-release.js.map +1 -0
- package/dist/services/airnow/airnow-service.d.ts +10 -29
- package/dist/services/airnow/airnow-service.d.ts.map +1 -1
- package/dist/services/airnow/airnow-service.js +156 -97
- package/dist/services/airnow/airnow-service.js.map +1 -1
- package/dist/services/airnow/types.d.ts +62 -23
- package/dist/services/airnow/types.d.ts.map +1 -1
- package/dist/services/airnow/types.js +2 -1
- package/dist/services/airnow/types.js.map +1 -1
- package/dist/services/call-budget.d.ts +22 -0
- package/dist/services/call-budget.d.ts.map +1 -0
- package/dist/services/call-budget.js +28 -0
- package/dist/services/call-budget.js.map +1 -0
- package/dist/services/dmap/dmap-service.d.ts +76 -7
- package/dist/services/dmap/dmap-service.d.ts.map +1 -1
- package/dist/services/dmap/dmap-service.js +461 -144
- package/dist/services/dmap/dmap-service.js.map +1 -1
- package/dist/services/dmap/types.d.ts +17 -4
- package/dist/services/dmap/types.d.ts.map +1 -1
- package/dist/services/echo/echo-service.d.ts +38 -11
- package/dist/services/echo/echo-service.d.ts.map +1 -1
- package/dist/services/echo/echo-service.js +417 -223
- package/dist/services/echo/echo-service.js.map +1 -1
- package/dist/services/echo/types.d.ts +167 -80
- package/dist/services/echo/types.d.ts.map +1 -1
- package/dist/services/ejscreen/ejscreen-service.d.ts.map +1 -1
- package/dist/services/ejscreen/ejscreen-service.js +9 -3
- package/dist/services/ejscreen/ejscreen-service.js.map +1 -1
- package/package.json +7 -7
- 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
|
|
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
|
|
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
|
|
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
|
|
42
|
-
|
|
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('
|
|
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(`**
|
|
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":"AAAA;;;GAGG;AAEH,OAAO,EAAE,IAAI,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AACjE,OAAO,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAEjE,MAAM,CAAC,MAAM,sBAAsB,GAAG,IAAI,CAAC,0BAA0B,EAAE;IACrE,KAAK,EAAE,+BAA+B;IACtC,WAAW,EACT,
|
|
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
|
|
3
|
-
* Wraps www.airnowapi.org/aq
|
|
4
|
-
* AirNow API's
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
3
|
-
* Wraps www.airnowapi.org/aq
|
|
4
|
-
* AirNow API's
|
|
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
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
73
|
+
areas.set(key, area);
|
|
30
74
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
77
|
-
|
|
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.
|
|
179
|
+
return cached.body;
|
|
82
180
|
}
|
|
83
|
-
const
|
|
84
|
-
|
|
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
|
-
|
|
93
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
205
|
+
return parseBody(text);
|
|
94
206
|
}, {
|
|
95
|
-
operation: '
|
|
207
|
+
operation: 'AirNow request',
|
|
96
208
|
baseDelayMs: 2000,
|
|
97
209
|
signal: ctx.signal,
|
|
210
|
+
deadlineMs: REQUEST_DEADLINE_MS,
|
|
98
211
|
});
|
|
99
|
-
|
|
100
|
-
|
|
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 ---
|