@pipeworx/mcp-medicare-pricing 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -4,7 +4,7 @@ What Medicare **pays** — physician fee schedule, lab rates, and per-day unit
4
4
  limits. The companion to `medicare-coverage`, which answers whether something is
5
5
  covered but not what it is worth.
6
6
 
7
- Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1831+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
7
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1882+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
8
8
 
9
9
  ## Tools
10
10
 
@@ -14,6 +14,7 @@ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents
14
14
  | `medicare_lab_rate` | "What does a comprehensive metabolic panel reimburse?" — national CLFS rate. |
15
15
  | `medicare_lab_test_lookup` | "What does Medicare pay for Signatera, and which MolDX LCD applies?" — brand name → PLA code, current CLFS rate, and the MolDX LCDs/articles for the test's category. |
16
16
  | `medicare_dmepos_rate` | "How much does Medicare pay for a CPAP machine (E0601) in Texas?" — state-priced, rural vs non-rural. |
17
+ | `medicare_opps_rate` | "What does Medicare pay a hospital outpatient department for CPT 33289 (CardioMEMS implant)?" — status indicator, APC, relative weight, national payment rate, copay, device/drug pass-through status. |
17
18
  | `medicare_code_units_limit` | "How many units of this code will they pay for in one day?" — NCCI MUE. |
18
19
  | `check_code_pair` | "Can I bill 00142 and 64474 together?" — NCCI PTP edit + modifier indicator for a code pair. |
19
20
  | `explain_ncci_edit` | "What is bundled into (or bundles into) this code?" — every recorded PTP edit for one code. |
@@ -31,6 +32,7 @@ All from CMS, all downloadable files with no API:
31
32
  - **Physician Fee Schedule relative value files** (RVUs + GPCIs) — annual
32
33
  - **Clinical Laboratory Fee Schedule** — quarterly
33
34
  - **DMEPOS fee schedule** (durable medical equipment, prosthetics, orthotics, supplies) — quarterly (as necessary): A=Jan, B=Apr, C=Jul, D=Oct
35
+ - **OPPS Addendum B** (Hospital Outpatient Prospective Payment System payment rates by HCPCS code) — quarterly: Q1=Jan, Q2=Apr, Q3=Jul, Q4=Oct
34
36
  - **NCCI Medically Unlikely Edits** — quarterly
35
37
  - **NCCI PTP quarterly additions/deletions/revisions files** — quarterly (see below — a narrower source than the other three)
36
38
  - **FQHC Prospective Payment System base payment rate** — annual (see below — a hand-maintained table, not a downloaded file)
@@ -49,9 +51,20 @@ prior_year_rate_usd, transmittal, change_request, publication}` in
49
51
  directly off its own primary CMS transmittal.
50
52
 
51
53
  This is the national BASE rate only — before the per-FQHC Geographic
52
- Adjustment Factor (GAF) CMS applies on top. The GAF table itself is not
53
- loaded; an agent that needs the GAF-adjusted dollar amount for a specific FQHC
54
- still needs that separately.
54
+ Adjustment Factor (GAF) CMS applies on top. The GAF-adjusted amount for a
55
+ locality is `medicare_fqhc_gaf` (below).
56
+
57
+ ## Geographic adjustment: `medicare_gpci_lookup` and `medicare_fqhc_gaf` (fleet #3398)
58
+
59
+ `medicare_gpci_lookup` returns the work / practice-expense / malpractice GPCIs
60
+ for a locality or a whole state with no procedure code (years 2024-2026 loaded;
61
+ 2024 and 2025 were backfilled with `scripts/ingest-medicare-pricing.mjs
62
+ --gpci-only`). `medicare_fqhc_gaf` returns CMS's own published FQHC GAF per
63
+ locality for CY2024-2026 and the GAF-adjusted per-visit rate. The GAF table is
64
+ CMS's published number, not derived here, and is a small generated source file
65
+ (`src/fqhc-gaf.ts`, 109 localities per year). **Refresh** each January: download
66
+ the new year's FQHC GAF file from CMS and run
67
+ `node scripts/build-fqhc-gaf-table.mjs 2024=<csv> 2025=<csv> 2026=<csv> 2027=<csv>`.
55
68
 
56
69
  **Refresh**: CMS issues the following year's transmittal in Nov/Dec (the
57
70
  CY2026 update was dated 2025-12-05 for a Jan 1, 2026 effective date). Add a
@@ -237,6 +250,48 @@ which is AMA copyright. The DMEPOS source file's own DMEREAD/DMEBACK
237
250
  documentation carries no AMA/copyright notice anywhere (checked), so
238
251
  `medicare_dmepos_rate` DOES return the code description.
239
252
 
253
+ ## OPPS — what a hospital outpatient department is paid
254
+
255
+ `medicare_opps_rate` answers the same "what does Medicare pay" question for a
256
+ **hospital outpatient department** claim, as opposed to a physician office
257
+ (`medicare_physician_payment`) or a lab (`medicare_lab_rate`). Source: CMS,
258
+ OPPS Addendum B, the quarterly national payment-rate file by HCPCS code.
259
+ Measured size: 19,278 rows for the CY2026 October release — small, loaded
260
+ whole, same shape as `clfs_rates`/`dmepos_rates` already in this pack.
261
+
262
+ **Device and drug/biological pass-through is the status indicator, not a
263
+ separate column.** CMS's own "Drug and Device Pass-Through Expiration during
264
+ Calendar Year" column in Addendum B is, in practice, not a usable date field —
265
+ checked against the CY2026 October release, every non-blank value in that
266
+ column was a stray change-marker rather than a date. The reliable signal is
267
+ the status indicator itself: **SI='H'** is a pass-through device category
268
+ (paid separately, outside the standard APC amount, for as long as the device
269
+ holds that status — typically 2-3 years from market entry, after which CMS
270
+ reprices it, often to packaged SI='N'), and **SI='G'** is a pass-through
271
+ drug/biological. `device_pass_through`/`drug_or_biological_pass_through` on
272
+ every response are derived from SI, not from the expiration column.
273
+
274
+ A **packaged** code (SI='N', among several other status indicators) returns a
275
+ valid `status_indicator` and `status_indicator_meaning` but `apc: null` and no
276
+ payment rate — that is a correct, normal answer: OPPS pays for that code only
277
+ as part of whatever procedure it is billed alongside, never on its own.
278
+ CardioMEMS (C2624, the implantable pulmonary-artery pressure sensor) is a
279
+ worked example of exactly this: it has been on the market long enough that it
280
+ is no longer pass-through (SI='N', packaged into the implant procedure
281
+ 33289's payment), which is the expected, current state — not a missing-data
282
+ gap.
283
+
284
+ ## No OPPS short descriptors — a mixed-code-type file
285
+
286
+ Addendum B's own header states *"CPT codes and descriptions only are
287
+ copyright ... American Medical Association"*, and the file mixes AMA-
288
+ copyright CPT (HCPCS Level I, numeric codes like 33289) with CMS-owned HCPCS
289
+ Level II codes (like C2624) under **one shared** descriptor column, with no
290
+ per-row schema signal to tell which is which. Unlike `medicare_dmepos_rate`
291
+ (Level II only, so the description is safely CMS-owned), `medicare_opps_rate`
292
+ follows the stricter `pfs_rvu` convention instead: no descriptor is stored at
293
+ all, for any code in this table. A caller passes a code they already hold.
294
+
240
295
  ## DMEPOS pays by state, not locality
241
296
 
242
297
  Medicare pays DMEPOS by **state** (not by the physician-fee-schedule
@@ -271,15 +326,30 @@ node scripts/ingest-ncci-ptp-baseline.mjs --dir /path/to/8-zips # full PTP base
271
326
 
272
327
  node scripts/ingest-dmepos.mjs # newest DMEPOS release (default: 2026 release C)
273
328
  node scripts/ingest-dmepos.mjs --year 2026 --release D # add a new release when CMS posts one
329
+
330
+ node scripts/ingest-opps.mjs # newest OPPS Addendum B release (default: 2026 October)
331
+ node scripts/ingest-opps.mjs --year 2026 --month january # add a new quarterly release when CMS posts one
274
332
  ```
275
333
 
276
334
  Loaded as of writing: 19,226 RVU rows, 109 localities, 2,081 lab rates, 3,531
277
- DMEPOS code/modifier rows (2026 release C, 53 states/territories each), 33,354
335
+ DMEPOS code/modifier rows (2026 release C, 53 states/territories each), 19,278
336
+ OPPS Addendum B rows (CY2026 October / Q4), 33,354
278
337
  MUEs, **4,109,749 distinct NCCI PTP pairs** — the FULL CMS baseline (2026q3,
279
338
  v322r0) with the 2025q1..2026q3 quarterly change files layered on top (fleet
280
339
  #1293, 2026-09-07; superseded the prior 182,286-pair change-window-only
281
340
  coverage). All loaders refuse rather than reporting an empty parse as success.
282
341
 
342
+ `ingest-opps.mjs` resolves the current download from the OPPS Addendum B
343
+ release landing page, same pattern as `ingest-dmepos.mjs`. The landing page's
344
+ own link routes through CMS's `/apps/ama/license.asp?file=...` AMA licence
345
+ click-through page — but, unlike the NCCI PTP baseline elsewhere in this pack,
346
+ the underlying file is NOT actually gated: fetching the plain
347
+ `https://www.cms.gov/files/zip/<name>.zip` URL directly (bypassing
348
+ license.asp entirely) returns the zip with no click-through, no cookie, and no
349
+ Referer needed — verified live, 2026-10-21. The script extracts that direct
350
+ URL from the landing page and fetches it, rather than going through
351
+ license.asp at all.
352
+
283
353
  `ingest-dmepos.mjs` resolves the current download from the DMEPOS release
284
354
  subpage rather than guessing the zip filename — CMS's DMEPOS naming is not
285
355
  stable (a release page 404s before it is posted, and old release URLs have
@@ -337,7 +407,7 @@ directly, instead of just this one's:
337
407
  }
338
408
  ```
339
409
 
340
- Both URLs reach the same gateway and the same 1831+ data sources. The
410
+ Both URLs reach the same gateway and the same 1882+ data sources. The
341
411
  only difference is which pack's tools are listed **directly**; `ask_pipeworx`
342
412
  reaches all of them from either one.
343
413
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipeworx/mcp-medicare-pricing",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "What does Medicare PAY — physician fee schedule, lab rates, and per-day unit limits.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -26,7 +26,7 @@
26
26
  "@cloudflare/workers-types": "^4.20260405.1"
27
27
  },
28
28
  "pipeworx": {
29
- "sourceHash": "v1-65f2f81a2d58d5024e9845e0238d0a24b75f7fa653f412494f5054bb6cfc84dd",
30
- "sourceCommit": "514e3efb42b8d81082c07e01d3ae7654bbf91f52"
29
+ "sourceHash": "v1-510fd66ca4a1743183dd5075ecc73df84758f6eeef177a53b00aaae2036235fe",
30
+ "sourceCommit": "ba9dd6df5992aeb16ef2425ee86d1752c34ca636"
31
31
  }
32
32
  }
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.pipeworx-io/medicare-pricing",
4
4
  "title": "Medicare Pricing",
5
5
  "description": "What does Medicare PAY — physician fee schedule, lab rates, and per-day unit limits.",
6
- "version": "0.1.4",
6
+ "version": "0.1.5",
7
7
  "websiteUrl": "https://pipeworx.io/packs/medicare-pricing",
8
8
  "repository": {
9
9
  "url": "https://github.com/pipeworx-io/mcp-medicare-pricing",
@@ -0,0 +1,332 @@
1
+ // GENERATED by scripts/build-fqhc-gaf-table.mjs -- do not hand-edit.
2
+ // Publisher: CMS, FQHC Prospective Payment System Geographic Adjustment Factors
3
+ // (one table per calendar year, "with 1.0 floor"). [year, state, locality_number, locality_name, gaf]
4
+ export const FQHC_GAF_ROWS: ReadonlyArray<readonly [number, string, string, string, number]> = [
5
+ [2024,"AL","00","ALABAMA",0.899],
6
+ [2024,"AK","01","ALASKA",1.246],
7
+ [2024,"AZ","00","ARIZONA",0.947],
8
+ [2024,"AR","13","ARKANSAS",0.894],
9
+ [2024,"CA","54","BAKERSFIELD",1.009],
10
+ [2024,"CA","55","CHICO",1.008],
11
+ [2024,"CA","71","EL CENTRO",1.008],
12
+ [2024,"CA","56","FRESNO",1.008],
13
+ [2024,"CA","57","HANFORD-CORCORAN",1.008],
14
+ [2024,"CA","18","LOS ANGELES-LONG BEACH-ANAHEIM (LOS ANGELES/ORANGE CNTY)",1.068],
15
+ [2024,"CA","58","MADERA",1.008],
16
+ [2024,"CA","59","MERCED",1.008],
17
+ [2024,"CA","60","MODESTO",1.008],
18
+ [2024,"CA","51","NAPA",1.129],
19
+ [2024,"CA","17","OXNARD-THOUSAND OAKS-VENTURA",1.055],
20
+ [2024,"CA","61","REDDING",1.008],
21
+ [2024,"CA","62","RIVERSIDE-SAN BERNARDINO-ONTARIO",1.008],
22
+ [2024,"CA","63","SACRAMENTO-ROSEVILLE-FOLSOM",1.047],
23
+ [2024,"CA","64","SALINAS",1.051],
24
+ [2024,"CA","72","SAN DIEGO-CHULA VISTA-CARLSBAD",1.059],
25
+ [2024,"CA","05","SAN FRANCISCO-OAKLAND-BERKELEY (SAN FRANCISCO/SAN MATEO/ALAMEDA/CONTRA COSTA CNTY)",1.194],
26
+ [2024,"CA","52","SAN FRANCISCO-OAKLAND-BERKELEY (MARIN CNTY)",1.194],
27
+ [2024,"CA","65","SAN JOSE-SUNNYVALE-SANTA CLARA (SAN BENITO CNTY)",1.207],
28
+ [2024,"CA","73","SAN LUIS OBISPO-PASO ROBLES",1.026],
29
+ [2024,"CA","09","SAN JOSE-SUNNYVALE-SANTA CLARA (SANTA CLARA CNTY)",1.207],
30
+ [2024,"CA","66","SANTA CRUZ-WATSONVILLE",1.062],
31
+ [2024,"CA","74","SANTA MARIA-SANTA BARBARA",1.049],
32
+ [2024,"CA","67","SANTA ROSA-PETALUMA",1.078],
33
+ [2024,"CA","68","STOCKTON",1.008],
34
+ [2024,"CA","53","VALLEJO",1.129],
35
+ [2024,"CA","69","VISALIA",1.008],
36
+ [2024,"CA","70","YUBA CITY",1.008],
37
+ [2024,"CA","75","REST OF CALIFORNIA",1.008],
38
+ [2024,"CO","01","COLORADO",0.986],
39
+ [2024,"CT","00","CONNECTICUT",1.011],
40
+ [2024,"DC","01","DC + MD/VA SUBURBS",1.074],
41
+ [2024,"DE","01","DELAWARE",0.959],
42
+ [2024,"FL","03","FORT LAUDERDALE",0.957],
43
+ [2024,"FL","04","MIAMI",0.971],
44
+ [2024,"FL","99","REST OF FLORIDA",0.931],
45
+ [2024,"GA","01","ATLANTA",0.957],
46
+ [2024,"GA","99","REST OF GEORGIA",0.905],
47
+ [2024,"HI","01","HAWAII, GUAM",1.026],
48
+ [2024,"ID","00","IDAHO",0.916],
49
+ [2024,"IL","16","CHICAGO",0.972],
50
+ [2024,"IL","12","EAST ST. LOUIS",0.921],
51
+ [2024,"IL","15","SUBURBAN CHICAGO",0.984],
52
+ [2024,"IL","99","REST OF ILLINOIS",0.918],
53
+ [2024,"IN","00","INDIANA",0.923],
54
+ [2024,"IA","00","IOWA",0.919],
55
+ [2024,"KS","00","KANSAS",0.915],
56
+ [2024,"KY","00","KENTUCKY",0.902],
57
+ [2024,"LA","01","NEW ORLEANS",0.929],
58
+ [2024,"LA","99","REST OF LOUISIANA",0.904],
59
+ [2024,"ME","03","SOUTHERN MAINE",0.964],
60
+ [2024,"ME","99","REST OF MAINE",0.919],
61
+ [2024,"MD","01","BALTIMORE/SURR. CNTYS",1.004],
62
+ [2024,"MD","99","REST OF MARYLAND",0.972],
63
+ [2024,"MA","01","METROPOLITAN BOSTON",1.069],
64
+ [2024,"MA","99","REST OF MASSACHUSETTS",0.995],
65
+ [2024,"MI","01","DETROIT",0.953],
66
+ [2024,"MI","99","REST OF MICHIGAN",0.918],
67
+ [2024,"MN","00","MINNESOTA",0.97],
68
+ [2024,"MS","00","MISSISSIPPI",0.891],
69
+ [2024,"MO","02","METROPOLITAN KANSAS CITY",0.935],
70
+ [2024,"MO","01","METROPOLITAN ST. LOUIS",0.936],
71
+ [2024,"MO","99","REST OF MISSOURI",0.894],
72
+ [2024,"MT","01","MONTANA",0.958],
73
+ [2024,"NE","00","NEBRASKA",0.92],
74
+ [2024,"NV","00","NEVADA",0.958],
75
+ [2024,"NH","40","NEW HAMPSHIRE",0.974],
76
+ [2024,"NJ","01","NORTHERN NJ",1.069],
77
+ [2024,"NJ","99","REST OF NEW JERSEY",1.028],
78
+ [2024,"NM","05","NEW MEXICO",0.916],
79
+ [2024,"NY","01","MANHATTAN",1.067],
80
+ [2024,"NY","02","NYC SUBURBS/LONG ISLAND",1.082],
81
+ [2024,"NY","03","POUGHKPSIE/N NYC SUBURBS",1.03],
82
+ [2024,"NY","04","QUEENS",1.08],
83
+ [2024,"NY","99","REST OF NEW YORK",0.935],
84
+ [2024,"NC","00","NORTH CAROLINA",0.925],
85
+ [2024,"ND","01","NORTH DAKOTA",0.958],
86
+ [2024,"OH","00","OHIO",0.918],
87
+ [2024,"OK","00","OKLAHOMA",0.909],
88
+ [2024,"OR","01","PORTLAND",1.012],
89
+ [2024,"OR","99","REST OF OREGON",0.952],
90
+ [2024,"PA","01","METROPOLITAN PHILADELPHIA",0.995],
91
+ [2024,"PA","99","REST OF PENNSYLVANIA",0.925],
92
+ [2024,"PR","20","PUERTO RICO",0.962],
93
+ [2024,"RI","01","RHODE ISLAND",0.989],
94
+ [2024,"SC","01","SOUTH CAROLINA",0.919],
95
+ [2024,"SD","02","SOUTH DAKOTA",0.958],
96
+ [2024,"TN","35","TENNESSEE",0.911],
97
+ [2024,"TX","31","AUSTIN",0.979],
98
+ [2024,"TX","20","BEAUMONT",0.914],
99
+ [2024,"TX","09","BRAZORIA",0.968],
100
+ [2024,"TX","11","DALLAS",0.967],
101
+ [2024,"TX","28","FORT WORTH",0.963],
102
+ [2024,"TX","15","GALVESTON",0.965],
103
+ [2024,"TX","18","HOUSTON",0.967],
104
+ [2024,"TX","99","REST OF TEXAS",0.933],
105
+ [2024,"UT","09","UTAH",0.928],
106
+ [2024,"VT","50","VERMONT",0.955],
107
+ [2024,"VA","00","VIRGINIA",0.952],
108
+ [2024,"VI","50","VIRGIN ISLANDS",0.962],
109
+ [2024,"WA","02","SEATTLE (KING CNTY)",1.08],
110
+ [2024,"WA","99","REST OF WASHINGTON",0.98],
111
+ [2024,"WV","16","WEST VIRGINIA",0.895],
112
+ [2024,"WI","00","WISCONSIN",0.939],
113
+ [2024,"WY","21","WYOMING",0.958],
114
+ [2025,"AL","00","ALABAMA",0.939],
115
+ [2025,"AK","01","ALASKA",1.304],
116
+ [2025,"AZ","00","ARIZONA",0.988],
117
+ [2025,"AR","13","ARKANSAS",0.934],
118
+ [2025,"CA","54","BAKERSFIELD",1.053],
119
+ [2025,"CA","55","CHICO",1.051],
120
+ [2025,"CA","71","EL CENTRO",1.051],
121
+ [2025,"CA","56","FRESNO",1.051],
122
+ [2025,"CA","57","HANFORD-CORCORAN",1.051],
123
+ [2025,"CA","18","LOS ANGELES-LONG BEACH-ANAHEIM (LOS ANGELES/ORANGE CNTY)",1.113],
124
+ [2025,"CA","58","MADERA",1.051],
125
+ [2025,"CA","59","MERCED",1.051],
126
+ [2025,"CA","60","MODESTO",1.051],
127
+ [2025,"CA","51","NAPA",1.176],
128
+ [2025,"CA","17","OXNARD-THOUSAND OAKS-VENTURA",1.1],
129
+ [2025,"CA","61","REDDING",1.051],
130
+ [2025,"CA","62","RIVERSIDE-SAN BERNARDINO-ONTARIO",1.051],
131
+ [2025,"CA","63","SACRAMENTO-ROSEVILLE-FOLSOM",1.091],
132
+ [2025,"CA","64","SALINAS",1.096],
133
+ [2025,"CA","72","SAN DIEGO-CHULA VISTA-CARLSBAD",1.104],
134
+ [2025,"CA","05","SAN FRANCISCO-OAKLAND-BERKELEY (SAN FRANCISCO/SAN MATEO/ALAMEDA/CONTRA COSTA CNTY)",1.243],
135
+ [2025,"CA","52","SAN FRANCISCO-OAKLAND-BERKELEY (MARIN CNTY)",1.243],
136
+ [2025,"CA","65","SAN JOSE-SUNNYVALE-SANTA CLARA (SAN BENITO CNTY)",1.257],
137
+ [2025,"CA","73","SAN LUIS OBISPO-PASO ROBLES",1.069],
138
+ [2025,"CA","09","SAN JOSE-SUNNYVALE-SANTA CLARA (SANTA CLARA CNTY)",1.257],
139
+ [2025,"CA","66","SANTA CRUZ-WATSONVILLE",1.107],
140
+ [2025,"CA","74","SANTA MARIA-SANTA BARBARA",1.094],
141
+ [2025,"CA","67","SANTA ROSA-PETALUMA",1.123],
142
+ [2025,"CA","68","STOCKTON",1.051],
143
+ [2025,"CA","53","VALLEJO",1.176],
144
+ [2025,"CA","69","VISALIA",1.051],
145
+ [2025,"CA","70","YUBA CITY",1.051],
146
+ [2025,"CA","75","REST OF CALIFORNIA",1.051],
147
+ [2025,"CO","01","COLORADO",1.029],
148
+ [2025,"CT","00","CONNECTICUT",1.054],
149
+ [2025,"DC","01","DC + MD/VA SUBURBS",1.12],
150
+ [2025,"DE","01","DELAWARE",1.001],
151
+ [2025,"FL","03","FORT LAUDERDALE",0.999],
152
+ [2025,"FL","04","MIAMI",1.013],
153
+ [2025,"FL","99","REST OF FLORIDA",0.972],
154
+ [2025,"GA","01","ATLANTA",0.999],
155
+ [2025,"GA","99","REST OF GEORGIA",0.945],
156
+ [2025,"HI","01","HAWAII, GUAM",1.07],
157
+ [2025,"ID","00","IDAHO",0.957],
158
+ [2025,"IL","16","CHICAGO",1.014],
159
+ [2025,"IL","12","EAST ST. LOUIS",0.962],
160
+ [2025,"IL","15","SUBURBAN CHICAGO",1.026],
161
+ [2025,"IL","99","REST OF ILLINOIS",0.959],
162
+ [2025,"IN","00","INDIANA",0.963],
163
+ [2025,"IA","00","IOWA",0.959],
164
+ [2025,"KS","00","KANSAS",0.956],
165
+ [2025,"KY","00","KENTUCKY",0.942],
166
+ [2025,"LA","01","NEW ORLEANS",0.97],
167
+ [2025,"LA","99","REST OF LOUISIANA",0.944],
168
+ [2025,"ME","03","SOUTHERN MAINE",1.006],
169
+ [2025,"ME","99","REST OF MAINE",0.959],
170
+ [2025,"MD","01","BALTIMORE/SURR. CNTYS",1.047],
171
+ [2025,"MD","99","REST OF MARYLAND",1.014],
172
+ [2025,"MA","01","METROPOLITAN BOSTON",1.115],
173
+ [2025,"MA","99","REST OF MASSACHUSETTS",1.038],
174
+ [2025,"MI","01","DETROIT",0.995],
175
+ [2025,"MI","99","REST OF MICHIGAN",0.958],
176
+ [2025,"MN","00","MINNESOTA",1.012],
177
+ [2025,"MS","00","MISSISSIPPI",0.931],
178
+ [2025,"MO","02","METROPOLITAN KANSAS CITY",0.976],
179
+ [2025,"MO","01","METROPOLITAN ST. LOUIS",0.978],
180
+ [2025,"MO","99","REST OF MISSOURI",0.934],
181
+ [2025,"MT","01","MONTANA",1],
182
+ [2025,"NE","00","NEBRASKA",0.961],
183
+ [2025,"NV","00","NEVADA",1],
184
+ [2025,"NH","40","NEW HAMPSHIRE",1.016],
185
+ [2025,"NJ","01","NORTHERN NJ",1.115],
186
+ [2025,"NJ","99","REST OF NEW JERSEY",1.072],
187
+ [2025,"NM","05","NEW MEXICO",0.957],
188
+ [2025,"NY","01","MANHATTAN",1.112],
189
+ [2025,"NY","02","NYC SUBURBS/LONG ISLAND",1.128],
190
+ [2025,"NY","03","POUGHKPSIE/N NYC SUBURBS",1.074],
191
+ [2025,"NY","04","QUEENS",1.126],
192
+ [2025,"NY","99","REST OF NEW YORK",0.976],
193
+ [2025,"NC","00","NORTH CAROLINA",0.965],
194
+ [2025,"ND","01","NORTH DAKOTA",1],
195
+ [2025,"OH","00","OHIO",0.958],
196
+ [2025,"OK","00","OKLAHOMA",0.949],
197
+ [2025,"OR","01","PORTLAND",1.055],
198
+ [2025,"OR","99","REST OF OREGON",0.993],
199
+ [2025,"PA","01","METROPOLITAN PHILADELPHIA",1.038],
200
+ [2025,"PA","99","REST OF PENNSYLVANIA",0.966],
201
+ [2025,"PR","20","PUERTO RICO",1.003],
202
+ [2025,"RI","01","RHODE ISLAND",1.032],
203
+ [2025,"SC","01","SOUTH CAROLINA",0.959],
204
+ [2025,"SD","02","SOUTH DAKOTA",1],
205
+ [2025,"TN","35","TENNESSEE",0.951],
206
+ [2025,"TX","31","AUSTIN",1.022],
207
+ [2025,"TX","20","BEAUMONT",0.955],
208
+ [2025,"TX","09","BRAZORIA",1.01],
209
+ [2025,"TX","11","DALLAS",1.009],
210
+ [2025,"TX","28","FORT WORTH",1.005],
211
+ [2025,"TX","15","GALVESTON",1.007],
212
+ [2025,"TX","18","HOUSTON",1.009],
213
+ [2025,"TX","99","REST OF TEXAS",0.974],
214
+ [2025,"UT","09","UTAH",0.969],
215
+ [2025,"VT","50","VERMONT",0.997],
216
+ [2025,"VA","00","VIRGINIA",0.994],
217
+ [2025,"VI","50","VIRGIN ISLANDS",1.003],
218
+ [2025,"WA","02","SEATTLE (KING CNTY)",1.126],
219
+ [2025,"WA","99","REST OF WASHINGTON",1.022],
220
+ [2025,"WV","16","WEST VIRGINIA",0.935],
221
+ [2025,"WI","00","WISCONSIN",0.98],
222
+ [2025,"WY","21","WYOMING",1],
223
+ [2026,"AL","00","ALABAMA",0.94],
224
+ [2026,"AK","01","ALASKA",1.29],
225
+ [2026,"AZ","00","ARIZONA",0.985],
226
+ [2026,"AR","13","ARKANSAS",0.932],
227
+ [2026,"CA","54","BAKERSFIELD",1.056],
228
+ [2026,"CA","55","CHICO",1.055],
229
+ [2026,"CA","71","EL CENTRO",1.055],
230
+ [2026,"CA","56","FRESNO",1.055],
231
+ [2026,"CA","57","HANFORD-CORCORAN",1.055],
232
+ [2026,"CA","18","LOS ANGELES-LONG BEACH-ANAHEIM (LOS ANGELES/ORANGE CNTY)",1.109],
233
+ [2026,"CA","58","MADERA",1.055],
234
+ [2026,"CA","59","MERCED",1.055],
235
+ [2026,"CA","60","MODESTO",1.055],
236
+ [2026,"CA","51","NAPA",1.186],
237
+ [2026,"CA","17","OXNARD-THOUSAND OAKS-VENTURA",1.102],
238
+ [2026,"CA","61","REDDING",1.055],
239
+ [2026,"CA","62","RIVERSIDE-SAN BERNARDINO-ONTARIO",1.056],
240
+ [2026,"CA","63","SACRAMENTO-ROSEVILLE-FOLSOM",1.097],
241
+ [2026,"CA","64","SALINAS",1.093],
242
+ [2026,"CA","72","SAN DIEGO-CHULA VISTA-CARLSBAD",1.11],
243
+ [2026,"CA","05","SAN FRANCISCO-OAKLAND-BERKELEY (SAN FRANCISCO/SAN MATEO/ALAMEDA/CONTRA COSTA CNTY)",1.247],
244
+ [2026,"CA","52","SAN FRANCISCO-OAKLAND-BERKELEY (MARIN CNTY)",1.247],
245
+ [2026,"CA","65","SAN JOSE-SUNNYVALE-SANTA CLARA (SAN BENITO CNTY)",1.27],
246
+ [2026,"CA","73","SAN LUIS OBISPO-PASO ROBLES",1.076],
247
+ [2026,"CA","09","SAN JOSE-SUNNYVALE-SANTA CLARA (SANTA CLARA CNTY)",1.27],
248
+ [2026,"CA","66","SANTA CRUZ-WATSONVILLE",1.114],
249
+ [2026,"CA","74","SANTA MARIA-SANTA BARBARA",1.094],
250
+ [2026,"CA","67","SANTA ROSA-PETALUMA",1.125],
251
+ [2026,"CA","68","STOCKTON",1.055],
252
+ [2026,"CA","53","VALLEJO",1.186],
253
+ [2026,"CA","69","VISALIA",1.055],
254
+ [2026,"CA","70","YUBA CITY",1.055],
255
+ [2026,"CA","75","REST OF CALIFORNIA",1.055],
256
+ [2026,"CO","01","COLORADO",1.037],
257
+ [2026,"CT","00","CONNECTICUT",1.047],
258
+ [2026,"DC","01","DC + MD/VA SUBURBS",1.114],
259
+ [2026,"DE","01","DELAWARE",0.997],
260
+ [2026,"FL","03","FORT LAUDERDALE",1.006],
261
+ [2026,"FL","04","MIAMI",1.02],
262
+ [2026,"FL","99","REST OF FLORIDA",0.979],
263
+ [2026,"GA","01","ATLANTA",1.009],
264
+ [2026,"GA","99","REST OF GEORGIA",0.948],
265
+ [2026,"HI","01","HAWAII, GUAM",1.066],
266
+ [2026,"ID","00","IDAHO",0.961],
267
+ [2026,"IL","16","CHICAGO",1.006],
268
+ [2026,"IL","12","EAST ST. LOUIS",0.961],
269
+ [2026,"IL","15","SUBURBAN CHICAGO",1.017],
270
+ [2026,"IL","99","REST OF ILLINOIS",0.958],
271
+ [2026,"IN","00","INDIANA",0.965],
272
+ [2026,"IA","00","IOWA",0.959],
273
+ [2026,"KS","00","KANSAS",0.954],
274
+ [2026,"KY","00","KENTUCKY",0.947],
275
+ [2026,"LA","01","NEW ORLEANS",0.972],
276
+ [2026,"LA","99","REST OF LOUISIANA",0.945],
277
+ [2026,"ME","03","SOUTHERN MAINE",0.996],
278
+ [2026,"ME","99","REST OF MAINE",0.961],
279
+ [2026,"MD","01","BALTIMORE/SURR. CNTYS",1.043],
280
+ [2026,"MD","99","REST OF MARYLAND",1.011],
281
+ [2026,"MA","01","METROPOLITAN BOSTON",1.115],
282
+ [2026,"MA","99","REST OF MASSACHUSETTS",1.034],
283
+ [2026,"MI","01","DETROIT",0.983],
284
+ [2026,"MI","99","REST OF MICHIGAN",0.958],
285
+ [2026,"MN","00","MINNESOTA",1.014],
286
+ [2026,"MS","00","MISSISSIPPI",0.933],
287
+ [2026,"MO","02","METROPOLITAN KANSAS CITY",0.971],
288
+ [2026,"MO","01","METROPOLITAN ST. LOUIS",0.977],
289
+ [2026,"MO","99","REST OF MISSOURI",0.934],
290
+ [2026,"MT","01","MONTANA",1],
291
+ [2026,"NE","00","NEBRASKA",0.963],
292
+ [2026,"NV","00","NEVADA",1],
293
+ [2026,"NH","40","NEW HAMPSHIRE",1.02],
294
+ [2026,"NJ","01","NORTHERN NJ",1.11],
295
+ [2026,"NJ","99","REST OF NEW JERSEY",1.065],
296
+ [2026,"NM","05","NEW MEXICO",0.96],
297
+ [2026,"NY","01","MANHATTAN",1.111],
298
+ [2026,"NY","02","NYC SUBURBS/LONG ISLAND",1.124],
299
+ [2026,"NY","03","POUGHKPSIE/N NYC SUBURBS",1.069],
300
+ [2026,"NY","04","QUEENS",1.121],
301
+ [2026,"NY","99","REST OF NEW YORK",0.976],
302
+ [2026,"NC","00","NORTH CAROLINA",0.968],
303
+ [2026,"ND","01","NORTH DAKOTA",1],
304
+ [2026,"OH","00","OHIO",0.958],
305
+ [2026,"OK","00","OKLAHOMA",0.948],
306
+ [2026,"OR","01","PORTLAND",1.062],
307
+ [2026,"OR","99","REST OF OREGON",0.998],
308
+ [2026,"PA","01","METROPOLITAN PHILADELPHIA",1.029],
309
+ [2026,"PA","99","REST OF PENNSYLVANIA",0.96],
310
+ [2026,"PR","20","PUERTO RICO",1.005],
311
+ [2026,"RI","01","RHODE ISLAND",1.026],
312
+ [2026,"SC","01","SOUTH CAROLINA",0.963],
313
+ [2026,"SD","02","SOUTH DAKOTA",1],
314
+ [2026,"TN","35","TENNESSEE",0.956],
315
+ [2026,"TX","31","AUSTIN",1.029],
316
+ [2026,"TX","20","BEAUMONT",0.957],
317
+ [2026,"TX","09","BRAZORIA",1],
318
+ [2026,"TX","11","DALLAS",1.003],
319
+ [2026,"TX","28","FORT WORTH",0.998],
320
+ [2026,"TX","15","GALVESTON",1.001],
321
+ [2026,"TX","18","HOUSTON",1.001],
322
+ [2026,"TX","99","REST OF TEXAS",0.975],
323
+ [2026,"UT","09","UTAH",0.971],
324
+ [2026,"VT","50","VERMONT",0.995],
325
+ [2026,"VA","00","VIRGINIA",0.992],
326
+ [2026,"VI","50","VIRGIN ISLANDS",1.005],
327
+ [2026,"WA","02","SEATTLE (KING CNTY)",1.135],
328
+ [2026,"WA","99","REST OF WASHINGTON",1.032],
329
+ [2026,"WV","16","WEST VIRGINIA",0.937],
330
+ [2026,"WI","00","WISCONSIN",0.98],
331
+ [2026,"WY","21","WYOMING",1],
332
+ ];
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,6 +686,254 @@ 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
938
  * What does Medicare PAY — physician fee schedule, lab rates, and per-day unit
675
939
  * limits.
@@ -737,9 +1001,26 @@ function collapse(s: string): string {
737
1001
  * year AND release (quarterly-ish: A=Jan primary annual update, B=Apr, C=Jul,
738
1002
  * D=Oct as needed) are stated on every response, and an unloaded year/release
739
1003
  * returns year_not_loaded rather than silently substituting another vintage.
1004
+ *
1005
+ * OPPS (Hospital Outpatient Prospective Payment System) is the sixth data
1006
+ * source, added for fleet task #3430 — what Medicare pays a HOSPITAL
1007
+ * OUTPATIENT department (as opposed to a physician office, a lab, or a
1008
+ * supplier) per HCPCS code: status indicator, Ambulatory Payment
1009
+ * Classification (APC), relative weight, national payment rate, and
1010
+ * beneficiary copayment, from CMS's quarterly OPPS Addendum B. Like pfs_rvu,
1011
+ * NO code descriptor is stored — Addendum B mixes AMA-copyright CPT codes
1012
+ * with CMS-owned HCPCS Level II codes under one shared descriptor column
1013
+ * with no per-row way to separate them, so none is kept. Device and drug/
1014
+ * biological PASS-THROUGH status (a separate, outside-the-APC payment while
1015
+ * a new device or drug holds that status) is derived from the status
1016
+ * indicator itself (SI='H' device, SI='G' drug/biological) rather than from
1017
+ * CMS's own "pass-through expiration" column, which is unreliable in
1018
+ * practice. Vintage is calendar-quarter (Q1=Jan, Q2=Apr, Q3=Jul, Q4=Oct),
1019
+ * stated on every response.
740
1020
  */
741
1021
 
742
1022
  import { ADLT_DATA_AS_OF, ADLT_SOURCE_URL, SNAPSHOT_LAST_MODIFIED, matchAdlt, type AdltTest } from './adlt.js';
1023
+ import { FQHC_GAF_ROWS } from './fqhc-gaf.js';
743
1024
  import { BRAND_DATA_AS_OF, BRAND_SOURCE, BRAND_TESTS, gtrLookup, matchTests, norm as normName } from './brand-codes.js';
744
1025
 
745
1026
  // Bound every fetch() in this pack to a fixed timeout — an upstream that
@@ -785,6 +1066,14 @@ interface PtpEdit {
785
1066
  service_type: string; column1: string; column2: string; modifier_indicator: string;
786
1067
  status: string; effective_date: string; first_seen_quarter: string; last_seen_quarter: string;
787
1068
  }
1069
+ interface OppsRow {
1070
+ year: number; quarter: string; hcpcs: string; status_indicator: string; apc: string | null;
1071
+ relative_weight: number | null; payment_rate_usd: number | null;
1072
+ national_unadjusted_copay_usd: number | null; minimum_unadjusted_copay_usd: number | null;
1073
+ ira_coinsurance_pct: number | null; adjusted_beneficiary_copay_usd: number | null;
1074
+ device_pass_through: boolean; drug_or_biological_pass_through: boolean;
1075
+ note: string | null; changed_this_release: boolean;
1076
+ }
788
1077
 
789
1078
  const PTP_WINDOW_NOTE = 'ncci_ptp_edits holds the FULL NCCI PTP baseline (2026q3, v322r0, effective 2026-07-01) with the 2025q1..2026q3 quarterly change files layered on top — see medicare_pricing_coverage for the loaded vintage.';
790
1079
 
@@ -838,6 +1127,20 @@ function resolveState(q: unknown): { abbr: string | null; note: string } {
838
1127
  if (byName) return { abbr: byName, note: `Resolved "${t}" to ${byName}.` };
839
1128
  return { abbr: null, note: `"${t}" is not a recognized US state, DC, or territory (PR/VI). Pass a 2-letter code or full name.` };
840
1129
  }
1130
+ // OPPS releases are quarterly and calendar-aligned (Q1=Jan, Q2=Apr, Q3=Jul,
1131
+ // Q4=Oct) — accept a bare quarter ("Q3"/"3") or the month CMS actually names
1132
+ // its release after ("july"/"jul"), since callers are more likely to know
1133
+ // the month a code's rate changed than CMS's internal quarter label.
1134
+ const QUARTER_ALIASES: Record<string, string> = {
1135
+ q1: 'Q1', '1': 'Q1', jan: 'Q1', january: 'Q1',
1136
+ q2: 'Q2', '2': 'Q2', apr: 'Q2', april: 'Q2',
1137
+ q3: 'Q3', '3': 'Q3', jul: 'Q3', july: 'Q3',
1138
+ q4: 'Q4', '4': 'Q4', oct: 'Q4', october: 'Q4',
1139
+ };
1140
+ function resolveQuarter(q: unknown): string | null {
1141
+ if (typeof q !== 'string' || !q.trim()) return null;
1142
+ return QUARTER_ALIASES[q.trim().toLowerCase()] ?? null;
1143
+ }
841
1144
  const DME_CATEGORY: Record<string, string> = {
842
1145
  IN: 'Inexpensive and Other Routinely Purchased Items', FS: 'Frequently Serviced Items',
843
1146
  CR: 'Capped Rental Items', OX: 'Oxygen and Oxygen Equipment',
@@ -864,6 +1167,43 @@ const STATUS: Record<string, string> = {
864
1167
  X: 'Statutorily excluded from the fee schedule',
865
1168
  };
866
1169
 
1170
+ // ── OPPS (Hospital Outpatient Prospective Payment System) ────────────────
1171
+ // CMS's own status-indicator legend, stable across years. H and G are the
1172
+ // two that carry PASS-THROUGH status (a separate payment outside the
1173
+ // standard APC amount while a new device/drug holds it) — device_pass_through
1174
+ // and drug_or_biological_pass_through on medicare_opps_rate are derived from
1175
+ // these two codes rather than from CMS's own "pass-through expiration" date
1176
+ // column, which the loaded release does not reliably populate.
1177
+ const OPPS_SI: Record<string, string> = {
1178
+ A: 'Paid under a fee schedule or payment system other than OPPS — not a hospital outpatient payment.',
1179
+ B: 'Not recognized by OPPS on an outpatient claim; another code reports this service.',
1180
+ C: 'Inpatient procedure — not paid under OPPS when billed as outpatient.',
1181
+ D: 'Discontinued code.',
1182
+ E1: 'Not covered by any Medicare outpatient benefit category — not paid under OPPS.',
1183
+ E2: 'No pricing information or claims data available — not paid under OPPS.',
1184
+ F: 'Corneal tissue acquisition, certain CRNA services, or hepatitis B vaccine — paid at reasonable cost, not subject to deductible or coinsurance.',
1185
+ G: 'PASS-THROUGH drug or biological — paid separately, outside the standard APC amount, while it holds pass-through status.',
1186
+ H: 'PASS-THROUGH device category — paid separately, outside the standard APC amount, while the device holds pass-through status.',
1187
+ J1: 'Hospital Part B service paid through a comprehensive APC — packaged with everything else on the claim into one payment.',
1188
+ J2: 'Hospital Part B service that may be paid through a comprehensive APC, depending on what else is billed the same day.',
1189
+ K: 'Non-pass-through drug, biological, or therapeutic radiopharmaceutical — paid separately under OPPS.',
1190
+ L: 'Influenza or pneumococcal pneumonia vaccine — paid at reasonable cost, not subject to deductible or coinsurance.',
1191
+ M: 'Not billable to the Medicare Administrative Contractor under this benefit.',
1192
+ N: 'Packaged into the APC payment for the primary service on the claim — no separate OPPS payment for this code.',
1193
+ P: 'Partial hospitalization.',
1194
+ Q1: 'Conditionally packaged ("STV-packaged") — separately payable only when no other type-S/T/V service is on the same claim.',
1195
+ Q2: 'Conditionally packaged ("T-packaged") — separately payable only when no type-T service is on the same claim.',
1196
+ Q3: 'May be paid through a composite APC with certain other codes billed the same day.',
1197
+ Q4: 'Conditionally packaged laboratory test.',
1198
+ R: 'Blood or blood product — paid separately under OPPS.',
1199
+ S: 'Procedure or service paid separately under OPPS, not subject to multiple-procedure discounting.',
1200
+ T: 'Procedure or service paid separately under OPPS, subject to multiple-procedure discounting when billed with another T-status code the same day.',
1201
+ U: 'Brachytherapy source — paid separately under OPPS.',
1202
+ V: 'Clinic or emergency department visit — paid separately under OPPS.',
1203
+ X: 'Ancillary service — paid separately under OPPS.',
1204
+ Y: 'Non-implantable durable medical equipment — statutorily non-covered on this claim type.',
1205
+ };
1206
+
867
1207
  // ── FQHC PPS base payment rate (fleet #3241) ──────────────────────────────
868
1208
  //
869
1209
  // CMS publishes this ONE number a year, in a CMS Manual System TRANSMITTAL
@@ -1012,6 +1352,21 @@ const tools: McpToolExport['tools'] = [
1012
1352
  required: ['code'],
1013
1353
  },
1014
1354
  },
1355
+ {
1356
+ name: 'medicare_opps_rate',
1357
+ description:
1358
+ 'What does Medicare pay a HOSPITAL OUTPATIENT department for a procedure, device, or drug code? Returns the status indicator, Ambulatory Payment Classification (APC), relative weight, national payment rate, and beneficiary copayment for a HCPCS/CPT code under the Hospital Outpatient Prospective Payment System (OPPS) — the schedule that applies when the same service is billed by a hospital\'s outpatient department rather than a physician office (medicare_physician_payment) or a lab (medicare_lab_rate). Answers "what does Medicare pay for CPT 33289 in a hospital outpatient setting", "what is the APC for C2624", "is this device on pass-through status", "what is the OPPS payment rate and copay for this code". '
1359
+ + 'Flags device_pass_through and drug_or_biological_pass_through — whether the code is CURRENTLY in a CMS pass-through category (status indicator H or G), meaning it is paid separately, outside the normal APC amount, rather than packaged into the procedure it goes with. A packaged code (status indicator N, among others) still has a valid status_indicator but no apc/payment_rate_usd, because OPPS pays for it only as part of whatever procedure it is billed with — that is a normal result, not a missing one. States the fee-schedule YEAR and QUARTER (Q1=Jan, Q2=Apr, Q3=Jul, Q4=Oct) used on every response. Sourced from CMS, OPPS Addendum B.',
1360
+ inputSchema: {
1361
+ type: 'object' as const,
1362
+ properties: {
1363
+ code: { type: 'string', description: 'HCPCS or CPT code, e.g. "33289" (CardioMEMS implant procedure) or "C2624" (implantable wireless pulmonary artery pressure sensor).' },
1364
+ year: { type: 'number', description: 'Calendar year, e.g. 2026. Omit to use the newest loaded year, which is stated in the response.' },
1365
+ quarter: { type: 'string', description: 'Which quarterly release: "Q1".."Q4", or the release month CMS actually uses ("january"/"april"/"july"/"october"). Omit to use the newest loaded quarter for the resolved year.' },
1366
+ },
1367
+ required: ['code'],
1368
+ },
1369
+ },
1015
1370
  {
1016
1371
  name: 'medicare_code_units_limit',
1017
1372
  description:
@@ -1059,7 +1414,7 @@ const tools: McpToolExport['tools'] = [
1059
1414
  name: 'medicare_fqhc_pps_rate',
1060
1415
  description:
1061
1416
  'What does Medicare pay a Federally Qualified Health Center (FQHC) per visit? Returns the CMS FQHC Prospective Payment System (PPS) national BASE payment rate for a calendar year — the figure CMS publishes once a year, before any per-FQHC Geographic Adjustment Factor (GAF) is applied. Answers "what is the FQHC PPS base rate", "how much does Medicare pay an FQHC per encounter", "FQHC PPS rate for 2025 vs 2026". '
1062
- + 'Medicare pays an FQHC the LESSER of its actual charges or this PPS rate for all FQHC services furnished to a beneficiary on a day with a qualifying face-to-face visit — it is a per-DAY encounter rate, not a fee per procedure code (unlike medicare_physician_payment/medicare_lab_rate). This tool does NOT compute the GAF-adjusted, FQHC-specific amount — only the national base rate before that adjustment; the GAF table is not loaded here. Omit year for the newest loaded year plus the full known history (small — CMS publishes one number a year); pass year for one row.',
1417
+ + 'Medicare pays an FQHC the LESSER of its actual charges or this PPS rate for all FQHC services furnished to a beneficiary on a day with a qualifying face-to-face visit — it is a per-DAY encounter rate, not a fee per procedure code (unlike medicare_physician_payment/medicare_lab_rate). This tool returns only the national base rate before the geographic adjustment; for the GAF-adjusted amount in a named locality use medicare_fqhc_gaf. Omit year for the newest loaded year plus the full known history (small — CMS publishes one number a year); pass year for one row.',
1063
1418
  inputSchema: {
1064
1419
  type: 'object' as const,
1065
1420
  properties: {
@@ -1067,6 +1422,35 @@ const tools: McpToolExport['tools'] = [
1067
1422
  },
1068
1423
  },
1069
1424
  },
1425
+ {
1426
+ name: 'medicare_gpci_lookup',
1427
+ description:
1428
+ 'What are the Medicare geographic adjustment indices (GPCIs) for a place — with NO procedure code needed? Returns the work, practice-expense and malpractice GPCI for a Medicare payment locality (or every locality in a state), for a calendar year. Answers "GPCI for Rest of Florida locality 99", "Medicare geographic practice cost index for San Francisco", "work GPCI Manhattan 2025", "which Medicare localities does California have". '
1429
+ + 'Medicare pays by LOCALITY, not state (California has nine), so a state or ambiguous name returns every matching locality rather than one chosen quietly, and the resolution is stated. The work GPCI is the floored value CMS pays on (1.0 floor where it applies). Pass state ("FL" or "Florida"), a locality name ("Bakersfield"), and/or locality_number ("99"); year defaults to the newest loaded and is stated. For the FQHC-specific adjustment see medicare_fqhc_gaf; for a dollar payment see medicare_physician_payment. Sourced from the CMS Physician Fee Schedule geographic practice cost indices.',
1430
+ inputSchema: {
1431
+ type: 'object' as const,
1432
+ properties: {
1433
+ locality: { type: 'string', description: 'State code ("FL"), state name ("Florida"), or Medicare locality name ("Bakersfield", "Manhattan", "Rest of Florida").' },
1434
+ state: { type: 'string', description: 'Optional state code or name; combine with locality_number to pin one locality.' },
1435
+ locality_number: { type: 'string', description: 'Optional Medicare locality number, e.g. "99" (Rest of Florida) — needs state or locality to say which state.' },
1436
+ year: { type: 'number', description: 'Calendar year, e.g. 2025. Omit for the newest loaded year (stated in the response).' },
1437
+ },
1438
+ },
1439
+ },
1440
+ {
1441
+ name: 'medicare_fqhc_gaf',
1442
+ description:
1443
+ 'What is the FQHC Geographic Adjustment Factor (GAF) for a place, and what does Medicare pay a Federally Qualified Health Center per visit THERE? Returns the official CMS FQHC PPS GAF for a Medicare locality and calendar year, and the GAF-adjusted per-visit rate (national base rate x GAF). Answers "FQHC PPS GAF for Orlando, Florida 2025", "CY2026 FQHC geographic adjustment Rest of California", "what is the FQHC PPS rate in Alaska". '
1444
+ + 'The GAF is CMS\'s own published number per locality (adapted from the work and practice-expense GPCIs, with the 1.0 floor), not something this tool estimates. A state returns all its localities; a city is not a locality, so name the locality or pick from the list (Florida has Fort Lauderdale, Miami and Rest of Florida). Complements medicare_fqhc_pps_rate (national base rate) and medicare_gpci_lookup (the underlying GPCIs). Years 2024-2026 loaded. Sourced from the CMS FQHC PPS geographic adjustment factor tables.',
1445
+ inputSchema: {
1446
+ type: 'object' as const,
1447
+ properties: {
1448
+ locality: { type: 'string', description: 'State code ("FL"), state name ("Florida"), or Medicare locality name ("Fort Lauderdale", "Rest of Florida"). A bare state returns all of its localities.' },
1449
+ locality_number: { type: 'string', description: 'Optional Medicare locality number, e.g. "99"; with a state it pins one locality.' },
1450
+ year: { type: 'number', description: 'Calendar year, 2024-2026. Omit for the newest loaded year (stated in the response).' },
1451
+ },
1452
+ },
1453
+ },
1070
1454
  {
1071
1455
  name: 'medicare_pricing_coverage',
1072
1456
  description:
@@ -1141,12 +1525,154 @@ async function resolveLocality(cfg: Cfg, q: unknown, year: number): Promise<{ ch
1141
1525
  return { chosen, alternatives: others, note };
1142
1526
  }
1143
1527
 
1528
+
1529
+ const GPCI_SOURCE = 'CMS, Physician Fee Schedule geographic practice cost indices (GPCIs) by Medicare locality';
1530
+ const FQHC_GAF_SOURCE = 'CMS, FQHC Prospective Payment System geographic adjustment factors by Medicare locality (with 1.0 floor)';
1531
+
1532
+ /** Split a free-text place into {state?, nameQuery?}. A bare state matches the whole state. */
1533
+ function placeQuery(locality: unknown, state: unknown): { abbr: string | null; name: string | null } {
1534
+ let l = typeof locality === 'string' ? locality.trim() : '';
1535
+ let st = typeof state === 'string' ? state.trim() : '';
1536
+ // "Orlando, Florida" / "Orlando, FL": the part after the last comma is the state.
1537
+ const comma = l.lastIndexOf(',');
1538
+ if (comma > 0 && !st) { st = l.slice(comma + 1).trim(); l = l.slice(0, comma).trim(); }
1539
+ const asState = (t: string) => {
1540
+ if (!t) return null;
1541
+ const u = t.toUpperCase();
1542
+ if (VALID_STATE_ABBRS.has(u)) return u;
1543
+ return STATE_NAME_TO_ABBR[t.toLowerCase()] ?? null;
1544
+ };
1545
+ const stAbbr = asState(st);
1546
+ const lAbbr = asState(l);
1547
+ if (stAbbr) return { abbr: stAbbr, name: lAbbr ? null : (l || null) };
1548
+ if (lAbbr) return { abbr: lAbbr, name: null };
1549
+ return { abbr: null, name: l || null };
1550
+ }
1551
+
1552
+ function fqhcGafLookup(args: Record<string, unknown>) {
1553
+ const years = [...new Set(FQHC_GAF_ROWS.map((r) => r[0]))].sort();
1554
+ const askedYear = typeof args.year === 'number' ? Math.floor(args.year) : null;
1555
+ if (askedYear !== null && !years.includes(askedYear)) {
1556
+ return { found: false, reason: 'year_not_loaded', loaded_years: years, hint: `No FQHC GAF table is loaded for ${askedYear}; loaded years: ${years.join(', ')}. GAFs change every year, so another year's value is not substituted.` };
1557
+ }
1558
+ const year = askedYear ?? years[years.length - 1];
1559
+ const { abbr, name } = placeQuery(args.locality, args.state);
1560
+ const num = typeof args.locality_number === 'string' ? args.locality_number.trim().replace(/^0+(?=\d)/, '') : '';
1561
+ if (!abbr && !name) return { found: false, reason: 'no_place', hint: 'Pass locality (state code/name or a Medicare locality name), e.g. {"locality":"Florida","year":2025}.' };
1562
+ let rows = FQHC_GAF_ROWS.filter((r) => r[0] === year);
1563
+ if (abbr) rows = rows.filter((r) => r[1] === abbr);
1564
+ let cityNote = '';
1565
+ if (name) {
1566
+ const n = name.toLowerCase();
1567
+ const named = rows.filter((r) => r[3].toLowerCase().includes(n));
1568
+ if (!named.length && abbr && rows.length) cityNote = `"${name}" is not a Medicare locality name, so every ${abbr} locality is listed — CMS assigns a city to a locality by county; pick the one that contains it. `;
1569
+ else rows = named;
1570
+ }
1571
+ if (num) rows = rows.filter((r) => r[2].replace(/^0+(?=\d)/, '') === num);
1572
+ if (!rows.length) {
1573
+ return { found: false, reason: 'no_locality_match', year, hint: `No Medicare locality matched ${JSON.stringify(args.locality ?? args.state)}${num ? ` number ${num}` : ''} in ${year}. Pass a state ("FL") to list its localities; a city is not a locality.` };
1574
+ }
1575
+ const base = FQHC_PPS_BASE_RATES.find((r) => r.year === year) ?? null;
1576
+ const localities = rows.map((r) => ({
1577
+ state: r[1], locality_number: r[2], locality_name: r[3], fqhc_gaf: r[4],
1578
+ gaf_adjusted_rate_usd: base ? round2(base.base_rate_usd * r[4]) : null,
1579
+ }));
1580
+ return {
1581
+ found: true,
1582
+ year,
1583
+ year_was_defaulted: askedYear === null,
1584
+ localities,
1585
+ locality_count: localities.length,
1586
+ base_rate_usd: base?.base_rate_usd ?? null,
1587
+ formula: base ? `FQHC PPS base rate $${base.base_rate_usd.toFixed(2)} x FQHC GAF = per-visit rate (before the new-patient / IPPE / AWV add-on and the lesser-of-charges rule)` : null,
1588
+ note: localities.length > 1
1589
+ ? `${cityNote}${localities.length} Medicare localities matched and their GAFs differ; a city is not a locality — pick the locality that contains the health center.`
1590
+ : `Resolved to ${localities[0].locality_name} (${localities[0].state}, locality ${localities[0].locality_number}).`,
1591
+ source: FQHC_GAF_SOURCE,
1592
+ data_as_of: `CY ${year}`,
1593
+ loaded_years: years,
1594
+ };
1595
+ }
1596
+
1597
+ async function gpciLookup(cfg: Cfg, args: Record<string, unknown>) {
1598
+ const loaded = await pg<{ year: number }>(cfg, 'pfs_gpci?select=year&order=year.desc&limit=1000');
1599
+ const years = [...new Set(loaded.map((r) => r.year))].sort();
1600
+ const askedYear = typeof args.year === 'number' ? Math.floor(args.year) : null;
1601
+ if (askedYear !== null && !years.includes(askedYear)) {
1602
+ return { found: false, reason: 'year_not_loaded', loaded_years: years, hint: `No GPCI table is loaded for ${askedYear}; loaded years: ${years.join(', ')}. GPCIs change every year, so another year's value is not substituted.` };
1603
+ }
1604
+ const year = askedYear ?? years[years.length - 1];
1605
+ const { abbr, name } = placeQuery(args.locality, args.state);
1606
+ const num = typeof args.locality_number === 'string' ? args.locality_number.trim() : '';
1607
+ if (!abbr && !name) return { found: false, reason: 'no_place', hint: 'Pass locality (state code/name or a Medicare locality name), e.g. {"locality":"Florida","year":2025}.' };
1608
+ let rows: Gpci[];
1609
+ let cityNote = '';
1610
+ // fleet #3475: every row carries HOW it matched, and the order is decided here
1611
+ // (rank, then locality_number), not inherited from the RPC's similarity sort.
1612
+ // state - the query named a state, this is one of its localities
1613
+ // exact - the locality name equals the query
1614
+ // contains - the locality name contains the query
1615
+ // similar - trigram-similar only (e.g. "REST OF MAINE" for "Rest of Florida")
1616
+ type Match = 'exact' | 'state' | 'contains' | 'similar';
1617
+ const matchOf = new Map<Gpci, Match>();
1618
+ if (abbr) {
1619
+ rows = await pg<Gpci>(cfg, `pfs_gpci?year=eq.${year}&state=eq.${abbr}&select=*&order=locality_number.asc&limit=100`);
1620
+ if (name) {
1621
+ const n = name.toLowerCase();
1622
+ const named = rows.filter((r) => r.locality_name.toLowerCase().includes(n));
1623
+ if (!named.length && rows.length) { cityNote = `"${name}" is not a Medicare locality name, so every ${abbr} locality is listed — CMS assigns a city to a locality by county; pick the one that contains it. `; rows.forEach((r) => matchOf.set(r, 'state')); }
1624
+ else { rows = named; rows.forEach((r) => matchOf.set(r, r.locality_name.toLowerCase() === n ? 'exact' : 'contains')); }
1625
+ } else rows.forEach((r) => matchOf.set(r, 'state'));
1626
+ } else {
1627
+ rows = await rpc<Gpci>(cfg, 'resolve_medicare_locality', { q: name, yr: year });
1628
+ const n = (name ?? '').trim().toLowerCase();
1629
+ rows.forEach((r) => {
1630
+ const ln = r.locality_name.toLowerCase();
1631
+ matchOf.set(r, ln === n ? 'exact' : ln.includes(n) ? 'contains' : 'similar');
1632
+ });
1633
+ }
1634
+ if (num) rows = rows.filter((r) => r.locality_number.replace(/^0+(?=\d)/, '') === num.replace(/^0+(?=\d)/, ''));
1635
+ if (!rows.length) return { found: false, reason: 'no_locality_match', year, hint: `No Medicare locality matched ${JSON.stringify(args.locality ?? args.state)}${num ? ` number ${num}` : ''} in ${year}. Pass a state ("FL") to list its localities.` };
1636
+ const rank: Record<Match, number> = { exact: 0, state: 0, contains: 1, similar: 2 };
1637
+ rows = [...rows].sort((a, b) => rank[matchOf.get(a)!] - rank[matchOf.get(b)!] || a.state.localeCompare(b.state) || a.locality_number.localeCompare(b.locality_number));
1638
+ const shape = (r: Gpci) => ({
1639
+ state: r.state, locality_number: r.locality_number, locality_name: r.locality_name,
1640
+ match: matchOf.get(r)!,
1641
+ work_gpci: r.pw_gpci, practice_expense_gpci: r.pe_gpci, malpractice_gpci: r.mp_gpci,
1642
+ });
1643
+ // Near-matches are split out only when something matched the query by name:
1644
+ // a caller who named a locality gets that locality in `localities`, and the
1645
+ // trigram-similar neighbours under a separate key where they cannot be mistaken
1646
+ // for it. With no name match at all, similar rows stay in `localities`, labelled.
1647
+ const strong = rows.filter((r) => matchOf.get(r) !== 'similar');
1648
+ const localities = (strong.length ? strong : rows).slice(0, 40).map(shape);
1649
+ const nearMatches = strong.length ? rows.filter((r) => matchOf.get(r) === 'similar').slice(0, 40).map(shape) : [];
1650
+ return {
1651
+ found: true,
1652
+ year,
1653
+ year_was_defaulted: askedYear === null,
1654
+ localities,
1655
+ locality_count: localities.length,
1656
+ near_matches: nearMatches,
1657
+ near_match_count: nearMatches.length,
1658
+ match_note: 'Each row carries `match`: exact (name equals the query), state (a locality of the state named), contains (name contains the query), similar (spelling-similar only, NOT the place asked for). `near_matches`, when present, holds spelling-similar localities from other places; do not use them as the answer. Rows are ordered exact/state, then contains, then similar, deterministically.',
1659
+ note: localities.length > 1
1660
+ ? `${cityNote}${localities.length} Medicare localities matched and they differ; name a locality or pass locality_number to pin one.`
1661
+ : `Resolved to ${localities[0].locality_name} (${localities[0].state}, locality ${localities[0].locality_number}).`,
1662
+ work_gpci_note: 'Work GPCI is the value CMS pays on, including the 1.0 floor where legislated for the year.',
1663
+ source: GPCI_SOURCE,
1664
+ data_as_of: `CY ${year}`,
1665
+ loaded_years: years,
1666
+ };
1667
+ }
1668
+
1144
1669
  async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
1145
1670
  // FQHC PPS is a hand-maintained constant table (see FQHC_PPS_BASE_RATES
1146
1671
  // above) — not Supabase-backed like the rest of this pack — so it is
1147
1672
  // dispatched before the credential check below, which every OTHER tool
1148
1673
  // here needs.
1149
1674
  if (name === 'medicare_fqhc_pps_rate') return fqhcPpsRate(args);
1675
+ if (name === 'medicare_fqhc_gaf') return fqhcGafLookup(args);
1150
1676
 
1151
1677
  const url = (args._supabaseUrl as string | undefined)?.trim();
1152
1678
  const key = (args._supabaseKey as string | undefined)?.trim();
@@ -1156,6 +1682,9 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
1156
1682
  const cfg: Cfg = { url, key };
1157
1683
 
1158
1684
  switch (name) {
1685
+ case 'medicare_gpci_lookup':
1686
+ return gpciLookup(cfg, args);
1687
+
1159
1688
  case 'medicare_physician_payment': {
1160
1689
  const hcpcs = code(args.code);
1161
1690
  if (!hcpcs) return { found: false, reason: 'no_code', hint: 'Pass code, e.g. "99213".' };
@@ -1407,6 +1936,65 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
1407
1936
  };
1408
1937
  }
1409
1938
 
1939
+ case 'medicare_opps_rate': {
1940
+ const hcpcs = code(args.code);
1941
+ if (!hcpcs) return { found: false, reason: 'no_code', hint: 'Pass code, e.g. "33289" or "C2624".' };
1942
+ const askedYear = typeof args.year === 'number' ? Math.floor(args.year) : null;
1943
+ const askedQuarterRaw = typeof args.quarter === 'string' ? args.quarter : null;
1944
+ const askedQuarter = resolveQuarter(askedQuarterRaw);
1945
+ if (askedQuarterRaw && !askedQuarter) {
1946
+ return { found: false, reason: 'bad_quarter', hint: `"${askedQuarterRaw}" is not a recognized quarter. Pass "Q1".."Q4" or the release month ("january"/"april"/"july"/"october").` };
1947
+ }
1948
+ const rows = await pg<OppsRow>(cfg,
1949
+ `opps_rates?hcpcs=eq.${hcpcs}${askedYear ? `&year=eq.${askedYear}` : ''}${askedQuarter ? `&quarter=eq.${askedQuarter}` : ''}`
1950
+ + '&select=*&order=year.desc,quarter.desc&limit=5');
1951
+ if (!rows.length) {
1952
+ const any = await pg<{ year: number; quarter: string }>(cfg, 'opps_rates?select=year,quarter&order=year.desc,quarter.desc&limit=1');
1953
+ const vintageMismatch = any.length && ((askedYear && any[0].year !== askedYear) || (askedQuarter && any[0].quarter !== askedQuarter));
1954
+ return {
1955
+ found: false,
1956
+ reason: vintageMismatch ? 'year_not_loaded' : 'code_not_found',
1957
+ hint: vintageMismatch
1958
+ ? `No ${askedYear ?? '(any year)'} ${askedQuarter ?? '(any quarter)'} OPPS release is loaded; the newest is ${any[0].year} ${any[0].quarter}. Rates differ every quarter, so this will not answer with a different vintage's numbers.`
1959
+ : `${hcpcs} is not on the OPPS Addendum B HCPCS list for the loaded vintage. This may be a physician, lab, or DMEPOS code instead — try medicare_physician_payment, medicare_lab_rate, or medicare_dmepos_rate.`,
1960
+ };
1961
+ }
1962
+ const r = rows[0];
1963
+ const si = r.status_indicator;
1964
+ return {
1965
+ found: true,
1966
+ code: hcpcs,
1967
+ // Vintage first, deliberately: it qualifies every number below it.
1968
+ fee_schedule_year: r.year,
1969
+ quarter: r.quarter,
1970
+ year_was_defaulted: askedYear === null,
1971
+ quarter_was_defaulted: askedQuarter === null,
1972
+ status_indicator: si,
1973
+ status_indicator_meaning: OPPS_SI[si] ?? `Status indicator ${si} (not in the mapped set)`,
1974
+ apc: r.apc,
1975
+ apc_note: r.apc == null
1976
+ ? 'No APC is assigned for this status indicator — the code is not separately paid under OPPS (often packaged into another service\'s payment, or not an outpatient-department benefit at all). See status_indicator_meaning.'
1977
+ : null,
1978
+ relative_weight: r.relative_weight,
1979
+ payment_rate_usd: r.payment_rate_usd,
1980
+ national_unadjusted_copay_usd: r.national_unadjusted_copay_usd,
1981
+ minimum_unadjusted_copay_usd: r.minimum_unadjusted_copay_usd,
1982
+ ira_coinsurance_pct: r.ira_coinsurance_pct,
1983
+ adjusted_beneficiary_copay_usd: r.adjusted_beneficiary_copay_usd,
1984
+ device_pass_through: r.device_pass_through,
1985
+ drug_or_biological_pass_through: r.drug_or_biological_pass_through,
1986
+ pass_through_note: r.device_pass_through
1987
+ ? 'This device is CURRENTLY on CMS pass-through status (SI=H): paid separately, outside the standard APC amount, for as long as that status lasts — typically 2-3 years from market entry, after which it is repriced (often packaged, SI=N).'
1988
+ : r.drug_or_biological_pass_through
1989
+ ? 'This drug/biological is CURRENTLY on CMS pass-through status (SI=G): paid separately at a rate outside the standard APC amount.'
1990
+ : null,
1991
+ changed_this_release: r.changed_this_release,
1992
+ note: r.note,
1993
+ source: 'CMS, OPPS Addendum B',
1994
+ caveat: 'National OPPS amount before sequestration, beneficiary coinsurance adjustments, and any wage-index/rural adjustment applied at the claim level. This is the hospital-outpatient-department rate; the same code billed in a physician office or ASC uses a different schedule.',
1995
+ };
1996
+ }
1997
+
1410
1998
  case 'medicare_code_units_limit': {
1411
1999
  const hcpcs = code(args.code);
1412
2000
  if (!hcpcs) return { found: false, reason: 'no_code', hint: 'Pass code, e.g. "99213".' };
@@ -1522,13 +2110,14 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
1522
2110
  }
1523
2111
 
1524
2112
  case 'medicare_pricing_coverage': {
1525
- const [rvuN, gpciN, clfsN, mueN, ptpRange, dmeN] = await Promise.all([
2113
+ const [rvuN, gpciN, clfsN, mueN, ptpRange, dmeN, oppsN] = await Promise.all([
1526
2114
  pg<{ year: number; release: string }>(cfg, 'pfs_rvu?select=year,release&order=year.desc&limit=1'),
1527
2115
  pg<{ year: number }>(cfg, 'pfs_gpci?select=year&order=year.desc&limit=1'),
1528
2116
  pg<{ year: number; quarter: string }>(cfg, 'clfs_rates?select=year,quarter&order=year.desc,quarter.desc&limit=1'),
1529
2117
  pg<{ effective_date: string }>(cfg, 'ncci_mue?select=effective_date&order=effective_date.desc&limit=1'),
1530
2118
  pg<{ first_seen_quarter: string; last_seen_quarter: string }>(cfg, 'ncci_ptp_edits?select=first_seen_quarter,last_seen_quarter&order=last_seen_quarter.desc&limit=1'),
1531
2119
  pg<{ year: number; release: string }>(cfg, 'dmepos_rates?select=year,release&order=year.desc,release.desc&limit=1'),
2120
+ pg<{ year: number; quarter: string }>(cfg, 'opps_rates?select=year,quarter&order=year.desc,quarter.desc&limit=1'),
1532
2121
  ]);
1533
2122
  return {
1534
2123
  found: true,
@@ -1536,6 +2125,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
1536
2125
  geographic_localities: gpciN.length ? { year: gpciN[0].year } : null,
1537
2126
  clinical_lab_fee_schedule: clfsN.length ? { year: clfsN[0].year, quarter: clfsN[0].quarter } : null,
1538
2127
  dmepos_fee_schedule: dmeN.length ? { year: dmeN[0].year, release: dmeN[0].release, release_month: DME_RELEASE_MONTH[dmeN[0].release] ?? null, priced_by: 'state (not Medicare locality), with separate rural/non-rural amounts' } : null,
2128
+ hospital_outpatient_pps: oppsN.length ? { year: oppsN[0].year, quarter: oppsN[0].quarter, priced_by: 'HCPCS code, hospital outpatient department setting (status indicator + APC), source: CMS OPPS Addendum B' } : null,
1539
2129
  fqhc_pps_base_rate: { newest_loaded_year: FQHC_PPS_BASE_RATES[FQHC_PPS_BASE_RATES.length - 1].year, loaded_years: FQHC_PPS_BASE_RATES.map((r) => r.year), source: FQHC_SOURCE },
1540
2130
  medically_unlikely_edits: mueN.length ? { effective_from: mueN[0].effective_date } : null,
1541
2131
  procedure_to_procedure_edits: ptpRange.length ? {
@@ -1543,10 +2133,10 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
1543
2133
  quarterly_overlay_window: '2025q1..2026q3 (re-applied on top of the baseline so a pair the quarterly files show deleted cannot be resurrected by an older baseline row)',
1544
2134
  coverage: 'FULL, not a rolling window: a pair with no row here has never had a CMS-recorded PTP edit as of the loaded vintage. Before fleet #1293 (2026-09-07) this only covered the 2025q1..2026q3 change window and a NOT-FOUND answer could not rule out a pre-2025 edit.',
1545
2135
  } : null,
1546
- refresh_cadence: 'Physician fee schedule annually (with intra-year releases); lab fee schedule, DMEPOS fee schedule, MUE units edits, and PTP pair-edit changes all quarterly (as necessary).',
2136
+ refresh_cadence: 'Physician fee schedule annually (with intra-year releases); lab fee schedule, DMEPOS fee schedule, OPPS hospital-outpatient fee schedule, MUE units edits, and PTP pair-edit changes all quarterly (as necessary).',
1547
2137
  no_descriptions_note:
1548
- 'CPT procedure descriptions (physician fee schedule, lab schedule) are not returned — American Medical Association copyright. DMEPOS is the exception: it uses HCPCS Level II codes, which CMS itself maintains, so medicare_dmepos_rate DOES return the code description.',
1549
- source: 'CMS Physician Fee Schedule, Clinical Laboratory Fee Schedule, DMEPOS fee schedule, FQHC PPS base payment rate, and National Correct Coding Initiative (MUE + PTP quarterly change files)',
2138
+ 'CPT procedure descriptions (physician fee schedule, lab schedule, OPPS) are not returned — American Medical Association copyright, and OPPS Addendum B mixes AMA CPT codes with CMS-owned HCPCS Level II codes under one descriptor column with no per-row way to separate them. DMEPOS is the exception: it is HCPCS Level II only, which CMS itself maintains, so medicare_dmepos_rate DOES return the code description.',
2139
+ source: 'CMS Physician Fee Schedule, Clinical Laboratory Fee Schedule, DMEPOS fee schedule, OPPS Addendum B, FQHC PPS base payment rate, and National Correct Coding Initiative (MUE + PTP quarterly change files)',
1550
2140
  };
1551
2141
  }
1552
2142
 
package/src/server.ts CHANGED
@@ -10,7 +10,7 @@ import pack from './index.js';
10
10
 
11
11
 
12
12
  const server = new Server(
13
- { name: '@pipeworx/mcp-medicare-pricing', version: '0.1.4' },
13
+ { name: '@pipeworx/mcp-medicare-pricing', version: '0.1.5' },
14
14
  { capabilities: { tools: {} } },
15
15
  );
16
16