@cyanheads/epa-mcp-server 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/AGENTS.md +13 -10
  2. package/CLAUDE.md +13 -10
  3. package/Dockerfile +108 -37
  4. package/README.md +67 -44
  5. package/changelog/0.3.x/0.3.3.md +34 -0
  6. package/changelog/0.4.x/0.4.0.md +35 -0
  7. package/dist/mcp-server/tools/definitions/get-air-quality.tool.d.ts +23 -9
  8. package/dist/mcp-server/tools/definitions/get-air-quality.tool.d.ts.map +1 -1
  9. package/dist/mcp-server/tools/definitions/get-air-quality.tool.js +166 -98
  10. package/dist/mcp-server/tools/definitions/get-air-quality.tool.js.map +1 -1
  11. package/dist/mcp-server/tools/definitions/get-ejscreen.tool.d.ts.map +1 -1
  12. package/dist/mcp-server/tools/definitions/get-ejscreen.tool.js +1 -1
  13. package/dist/mcp-server/tools/definitions/get-ejscreen.tool.js.map +1 -1
  14. package/dist/mcp-server/tools/definitions/get-facility.tool.d.ts.map +1 -1
  15. package/dist/mcp-server/tools/definitions/get-facility.tool.js +5 -14
  16. package/dist/mcp-server/tools/definitions/get-facility.tool.js.map +1 -1
  17. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.d.ts +2 -1
  18. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.d.ts.map +1 -1
  19. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.js +10 -14
  20. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.js.map +1 -1
  21. package/dist/mcp-server/tools/definitions/index.d.ts +51 -18
  22. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  23. package/dist/mcp-server/tools/definitions/search-facilities.tool.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/definitions/search-facilities.tool.js +9 -16
  25. package/dist/mcp-server/tools/definitions/search-facilities.tool.js.map +1 -1
  26. package/dist/mcp-server/tools/definitions/search-superfund.tool.d.ts.map +1 -1
  27. package/dist/mcp-server/tools/definitions/search-superfund.tool.js +4 -14
  28. package/dist/mcp-server/tools/definitions/search-superfund.tool.js.map +1 -1
  29. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.d.ts +10 -3
  30. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.d.ts.map +1 -1
  31. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.js +50 -10
  32. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.js.map +1 -1
  33. package/dist/mcp-server/tools/definitions/search-violations.tool.d.ts.map +1 -1
  34. package/dist/mcp-server/tools/definitions/search-violations.tool.js +1 -3
  35. package/dist/mcp-server/tools/definitions/search-violations.tool.js.map +1 -1
  36. package/dist/mcp-server/tools/definitions/search-water-systems.tool.d.ts +3 -1
  37. package/dist/mcp-server/tools/definitions/search-water-systems.tool.d.ts.map +1 -1
  38. package/dist/mcp-server/tools/definitions/search-water-systems.tool.js +23 -10
  39. package/dist/mcp-server/tools/definitions/search-water-systems.tool.js.map +1 -1
  40. package/dist/mcp-server/tools/format-lbs.d.ts +14 -0
  41. package/dist/mcp-server/tools/format-lbs.d.ts.map +1 -0
  42. package/dist/mcp-server/tools/format-lbs.js +16 -0
  43. package/dist/mcp-server/tools/format-lbs.js.map +1 -0
  44. package/dist/mcp-server/tools/format-location.d.ts +18 -0
  45. package/dist/mcp-server/tools/format-location.d.ts.map +1 -0
  46. package/dist/mcp-server/tools/format-location.js +26 -0
  47. package/dist/mcp-server/tools/format-location.js.map +1 -0
  48. package/dist/mcp-server/tools/format-tri-release.d.ts +16 -0
  49. package/dist/mcp-server/tools/format-tri-release.d.ts.map +1 -0
  50. package/dist/mcp-server/tools/format-tri-release.js +23 -0
  51. package/dist/mcp-server/tools/format-tri-release.js.map +1 -0
  52. package/dist/services/airnow/airnow-service.d.ts +10 -29
  53. package/dist/services/airnow/airnow-service.d.ts.map +1 -1
  54. package/dist/services/airnow/airnow-service.js +148 -95
  55. package/dist/services/airnow/airnow-service.js.map +1 -1
  56. package/dist/services/airnow/types.d.ts +62 -23
  57. package/dist/services/airnow/types.d.ts.map +1 -1
  58. package/dist/services/airnow/types.js +2 -1
  59. package/dist/services/airnow/types.js.map +1 -1
  60. package/dist/services/dmap/dmap-service.d.ts +41 -2
  61. package/dist/services/dmap/dmap-service.d.ts.map +1 -1
  62. package/dist/services/dmap/dmap-service.js +231 -116
  63. package/dist/services/dmap/dmap-service.js.map +1 -1
  64. package/dist/services/dmap/types.d.ts +17 -4
  65. package/dist/services/dmap/types.d.ts.map +1 -1
  66. package/dist/services/echo/echo-service.d.ts.map +1 -1
  67. package/dist/services/echo/echo-service.js +8 -5
  68. package/dist/services/echo/echo-service.js.map +1 -1
  69. package/dist/services/ejscreen/ejscreen-service.js +1 -1
  70. package/dist/services/ejscreen/ejscreen-service.js.map +1 -1
  71. package/package.json +11 -10
  72. package/server.json +16 -24
package/AGENTS.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** epa-mcp-server
4
- **Version:** 0.3.2
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.6`
4
+ **Version:** 0.4.0
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.14`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
- **MCP SDK:** `@modelcontextprotocol/server` ^2.0.0
7
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.2.0
8
8
  **Zod:** ^4.6.5
9
9
 
10
10
  > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
@@ -54,7 +54,7 @@ export const searchItems = tool('search_items', {
54
54
  annotations: { readOnlyHint: true },
55
55
  input: z.object({
56
56
  query: z.string().describe('Search terms'),
57
- limit: z.number().default(10).describe('Max results'),
57
+ limit: z.number().int().min(1).max(100).default(10).describe('Max results (1–100)'),
58
58
  }),
59
59
  output: z.object({
60
60
  items: z.array(z.object({
@@ -173,13 +173,14 @@ Handlers receive a unified `ctx` object. Key properties:
173
173
  | Property | Description |
174
174
  |:---------|:------------|
175
175
  | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. Dual-sink: Pino **and** `notifications/message` to the client, so treat it as client-visible. |
176
- | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Accepts any serializable value. |
176
+ | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Accepts any JSON-serializable value; reads return its JSON form (a `Date` comes back as an ISO string). |
177
177
  | `ctx.requestInput` | Suspend and ask the caller for more input — `return ctx.requestInput({ inputRequests: { key: inputRequired.elicit({ message, requestedSchema }) } })`. Never returns; the handler is re-entered with the answers. Always present. |
178
178
  | `ctx.inputs` | Reader over a retried request's responses — `.accepted(key, schema)`, `.view(key)`, `.state()`, `.dropped`. Empty on the first round. |
179
+ | `ctx.clientCapabilities` | Capabilities the client declared for this request, or `undefined` when unavailable. Use to choose optional input requests, never to skip consent. |
179
180
  | `ctx.enrich` | Success-path agent context (empty-result notices, query echo, pagination totals) — `ctx.enrich(...)` or `.notice()` / `.total()` / `.echo()` / `.truncated()`. Reaches `structuredContent` and `content[]`; lands only when the definition declares an `enrichment` block (no-op otherwise). |
180
181
  | `ctx.content` | Non-text content blocks — `.image(data, mimeType)`, `.audio(data, mimeType)`, or `ctx.content(block)` for a raw block. Prepended to `content[]` after `format()`; never enters `structuredContent`. |
181
182
  | `ctx.signal` | `AbortSignal` for cancellation. |
182
- | `ctx.requestId` | Unique request ID. |
183
+ | `ctx.requestId` | Framework-generated request ID, also returned as `data.requestId` on error envelopes; client JSON-RPC ids are logged separately as `jsonRpcId`. |
183
184
  | `ctx.tenantId` | Tenant ID from JWT; `'default'` for stdio or HTTP with auth off. |
184
185
 
185
186
  ---
@@ -188,7 +189,7 @@ Handlers receive a unified `ctx` object. Key properties:
188
189
 
189
190
  Handlers throw — the framework catches, classifies, and formats.
190
191
 
191
- **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. `recovery` is required (≥ 5 words, lint-validated). Forward it with `ctx.recoveryFor('reason')` in the throw's data; both client surfaces receive it, with duplicate hints omitted from the text. Use explicit `{ recovery: { hint } }` for runtime guidance. Mark reasons produced below the handler with `thrownBy: 'service'`; remove unused entries. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) need no declaration.
192
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. `recovery` is required (≥ 5 words, lint-validated). The framework fills the declared hint when the failure carries its `reason` and no recovery hint; both client surfaces receive it. Error envelopes carry the generated `data.requestId`. Use explicit `{ recovery: { hint } }` for runtime guidance. Mark reasons produced below the handler with `thrownBy: 'service'`; remove unused entries. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) need no declaration.
192
193
 
193
194
  ```ts
194
195
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
@@ -200,7 +201,7 @@ errors: [
200
201
  ],
201
202
  async handler(input, ctx) {
202
203
  const item = await db.find(input.id);
203
- if (!item) throw ctx.fail('no_match', `No item ${input.id}`, ctx.recoveryFor('no_match'));
204
+ if (!item) throw ctx.fail('no_match', `No item ${input.id}`);
204
205
  return item;
205
206
  }
206
207
  ```
@@ -221,7 +222,7 @@ throw new Error('Invalid query format'); // → ValidationError
221
222
 
222
223
  // McpError — when no factory exists for the code
223
224
  import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
224
- throw new McpError(JsonRpcErrorCode.DatabaseError, 'Connection failed', { pool: 'primary' });
225
+ throw new McpError(JsonRpcErrorCode.InitializationFailed, 'Connection failed', { pool: 'primary' });
225
226
  ```
226
227
 
227
228
  See framework CLAUDE.md and the `api-errors` skill for the full auto-classification table, all available factories, and the contract reference.
@@ -336,6 +337,8 @@ When you complete a skill's checklist, check the boxes and add a completion time
336
337
  | `bun run changelog:check` | Verify `CHANGELOG.md` is in sync (used by devcheck) |
337
338
  | `bun run bundle` | Build, pack, and clean a `.mcpb` for one-click Claude Desktop install |
338
339
 
340
+ **CI is one file.** `.github/workflows/codeql.yml` is the only GitHub Actions workflow; verification gates run locally.
341
+
339
342
  ---
340
343
 
341
344
  ## Bundling
@@ -377,7 +380,7 @@ security: false # optional — true ONLY for a source
377
380
 
378
381
  ## Publishing
379
382
 
380
- **Every release goes through a release PR, straight-through** — `git-wrapup`'s "Release PR mode", mode `straight-through`. One run: `git-wrapup` lands the commit stack on `release/<version>`, pushes it, and opens the PR (title = the release commit subject, body = the changelog entry plus a gates section); `release-and-publish` then fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. A caller's brief may run a given release as `gated` instead — a `release-pr-review` pass on the open PR before `release-and-publish`. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history.
383
+ **Every release goes through a release PR, straight-through** — `git-wrapup`'s "Release PR mode", mode `straight-through`. One run: `git-wrapup` lands the commit stack on `release/<version>`, pushes it, and opens the PR (title = the release commit subject, body = the release digest: theme line, `## Changes`, `## Gates`, changelog link last); `release-and-publish` then fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. A caller's brief may run a given release as `gated` instead — a `release-pr-review` pass on the open PR before `release-and-publish`. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history.
381
384
 
382
385
  ---
383
386
 
package/CLAUDE.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** epa-mcp-server
4
- **Version:** 0.3.2
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.6`
4
+ **Version:** 0.4.0
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.14`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
- **MCP SDK:** `@modelcontextprotocol/server` ^2.0.0
7
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.2.0
8
8
  **Zod:** ^4.6.5
9
9
 
10
10
  > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
@@ -54,7 +54,7 @@ export const searchItems = tool('search_items', {
54
54
  annotations: { readOnlyHint: true },
55
55
  input: z.object({
56
56
  query: z.string().describe('Search terms'),
57
- limit: z.number().default(10).describe('Max results'),
57
+ limit: z.number().int().min(1).max(100).default(10).describe('Max results (1–100)'),
58
58
  }),
59
59
  output: z.object({
60
60
  items: z.array(z.object({
@@ -173,13 +173,14 @@ Handlers receive a unified `ctx` object. Key properties:
173
173
  | Property | Description |
174
174
  |:---------|:------------|
175
175
  | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. Dual-sink: Pino **and** `notifications/message` to the client, so treat it as client-visible. |
176
- | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Accepts any serializable value. |
176
+ | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Accepts any JSON-serializable value; reads return its JSON form (a `Date` comes back as an ISO string). |
177
177
  | `ctx.requestInput` | Suspend and ask the caller for more input — `return ctx.requestInput({ inputRequests: { key: inputRequired.elicit({ message, requestedSchema }) } })`. Never returns; the handler is re-entered with the answers. Always present. |
178
178
  | `ctx.inputs` | Reader over a retried request's responses — `.accepted(key, schema)`, `.view(key)`, `.state()`, `.dropped`. Empty on the first round. |
179
+ | `ctx.clientCapabilities` | Capabilities the client declared for this request, or `undefined` when unavailable. Use to choose optional input requests, never to skip consent. |
179
180
  | `ctx.enrich` | Success-path agent context (empty-result notices, query echo, pagination totals) — `ctx.enrich(...)` or `.notice()` / `.total()` / `.echo()` / `.truncated()`. Reaches `structuredContent` and `content[]`; lands only when the definition declares an `enrichment` block (no-op otherwise). |
180
181
  | `ctx.content` | Non-text content blocks — `.image(data, mimeType)`, `.audio(data, mimeType)`, or `ctx.content(block)` for a raw block. Prepended to `content[]` after `format()`; never enters `structuredContent`. |
181
182
  | `ctx.signal` | `AbortSignal` for cancellation. |
182
- | `ctx.requestId` | Unique request ID. |
183
+ | `ctx.requestId` | Framework-generated request ID, also returned as `data.requestId` on error envelopes; client JSON-RPC ids are logged separately as `jsonRpcId`. |
183
184
  | `ctx.tenantId` | Tenant ID from JWT; `'default'` for stdio or HTTP with auth off. |
184
185
 
185
186
  ---
@@ -188,7 +189,7 @@ Handlers receive a unified `ctx` object. Key properties:
188
189
 
189
190
  Handlers throw — the framework catches, classifies, and formats.
190
191
 
191
- **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. `recovery` is required (≥ 5 words, lint-validated). Forward it with `ctx.recoveryFor('reason')` in the throw's data; both client surfaces receive it, with duplicate hints omitted from the text. Use explicit `{ recovery: { hint } }` for runtime guidance. Mark reasons produced below the handler with `thrownBy: 'service'`; remove unused entries. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) need no declaration.
192
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. `recovery` is required (≥ 5 words, lint-validated). The framework fills the declared hint when the failure carries its `reason` and no recovery hint; both client surfaces receive it. Error envelopes carry the generated `data.requestId`. Use explicit `{ recovery: { hint } }` for runtime guidance. Mark reasons produced below the handler with `thrownBy: 'service'`; remove unused entries. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) need no declaration.
192
193
 
193
194
  ```ts
194
195
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
@@ -200,7 +201,7 @@ errors: [
200
201
  ],
201
202
  async handler(input, ctx) {
202
203
  const item = await db.find(input.id);
203
- if (!item) throw ctx.fail('no_match', `No item ${input.id}`, ctx.recoveryFor('no_match'));
204
+ if (!item) throw ctx.fail('no_match', `No item ${input.id}`);
204
205
  return item;
205
206
  }
206
207
  ```
@@ -221,7 +222,7 @@ throw new Error('Invalid query format'); // → ValidationError
221
222
 
222
223
  // McpError — when no factory exists for the code
223
224
  import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
224
- throw new McpError(JsonRpcErrorCode.DatabaseError, 'Connection failed', { pool: 'primary' });
225
+ throw new McpError(JsonRpcErrorCode.InitializationFailed, 'Connection failed', { pool: 'primary' });
225
226
  ```
226
227
 
227
228
  See framework CLAUDE.md and the `api-errors` skill for the full auto-classification table, all available factories, and the contract reference.
@@ -336,6 +337,8 @@ When you complete a skill's checklist, check the boxes and add a completion time
336
337
  | `bun run changelog:check` | Verify `CHANGELOG.md` is in sync (used by devcheck) |
337
338
  | `bun run bundle` | Build, pack, and clean a `.mcpb` for one-click Claude Desktop install |
338
339
 
340
+ **CI is one file.** `.github/workflows/codeql.yml` is the only GitHub Actions workflow; verification gates run locally.
341
+
339
342
  ---
340
343
 
341
344
  ## Bundling
@@ -377,7 +380,7 @@ security: false # optional — true ONLY for a source
377
380
 
378
381
  ## Publishing
379
382
 
380
- **Every release goes through a release PR, straight-through** — `git-wrapup`'s "Release PR mode", mode `straight-through`. One run: `git-wrapup` lands the commit stack on `release/<version>`, pushes it, and opens the PR (title = the release commit subject, body = the changelog entry plus a gates section); `release-and-publish` then fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. A caller's brief may run a given release as `gated` instead — a `release-pr-review` pass on the open PR before `release-and-publish`. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history.
383
+ **Every release goes through a release PR, straight-through** — `git-wrapup`'s "Release PR mode", mode `straight-through`. One run: `git-wrapup` lands the commit stack on `release/<version>`, pushes it, and opens the PR (title = the release commit subject, body = the release digest: theme line, `## Changes`, `## Gates`, changelog link last); `release-and-publish` then fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. A caller's brief may run a given release as `gated` instead — a `release-pr-review` pass on the open PR before `release-and-publish`. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history.
381
384
 
382
385
  ---
383
386
 
package/Dockerfile CHANGED
@@ -4,11 +4,17 @@
4
4
  # This stage installs all dependencies (including dev), builds the TypeScript
5
5
  # source code into JavaScript, and prepares the production assets.
6
6
  #
7
- # The build emits platform-independent JavaScript, so pin it to the native
8
- # BuildKit platform. Bun's JavaScriptCore does not run reliably under QEMU
9
- # during cross-architecture builds.
7
+ # Pinned to $BUILDPLATFORM rather than the target platform: `bun run build` emits
8
+ # JavaScript, and only `dist/` crosses into the production stage. Built for the
9
+ # target instead, the non-native leg of a `--platform linux/amd64,linux/arm64`
10
+ # build runs under QEMU, where bun >= 1.4 aborts with a JavaScriptCore allocator
11
+ # assertion and fails the multi-arch push.
12
+ #
13
+ # The constraint this assumes: the build stage produces platform-independent
14
+ # output. A stage that compiles a native addon needs the target-arch toolchain
15
+ # and cannot cross-compile this way — drop the flag there.
10
16
  # ==============================================================================
11
- FROM --platform=$BUILDPLATFORM oven/bun:1.4.0 AS build
17
+ FROM --platform=$BUILDPLATFORM oven/bun:1.4.2 AS build
12
18
 
13
19
  WORKDIR /usr/src/app
14
20
 
@@ -27,19 +33,92 @@ COPY . .
27
33
  RUN bun run build
28
34
 
29
35
 
36
+ # ==============================================================================
37
+ # Production Dependencies Stage
38
+ #
39
+ # Installs the production dependency tree for the target platform. Every step
40
+ # here can run JavaScript — bunfig.toml's security scanner runs as a Bun
41
+ # program, and so do the OTel and musl-prune scripts — so the stage runs on
42
+ # $BUILDPLATFORM and cross-installs with `--os`/`--cpu`, which pick each
43
+ # platform-specific optional dependency (native bindings such as DuckDB's) for
44
+ # the target. Only `node_modules` leaves this stage.
45
+ #
46
+ # A clean image rather than `FROM build`: the build stage's node_modules holds
47
+ # devDependencies.
48
+ # ==============================================================================
49
+ FROM --platform=$BUILDPLATFORM oven/bun:1.4.2 AS deps
50
+
51
+ WORKDIR /usr/src/app
52
+
53
+ # Copy dependency manifests. `bunfig.toml` rides along so every install below
54
+ # passes its release-age gate and security scanner, as a local install does.
55
+ COPY package.json bun.lock bunfig.toml ./
56
+
57
+ # The scanner bunfig.toml names is a devDependency, and Bun installs a missing
58
+ # scanner through the same production-filtered install, which omits it and
59
+ # aborts. Seed it from the build stage's full install instead. Remove this line,
60
+ # and the `rm` at the end of this stage, if bunfig.toml stops naming a scanner.
61
+ COPY --from=build /usr/src/app/node_modules/@socketsecurity/bun-security-scanner ./node_modules/@socketsecurity/bun-security-scanner
62
+
63
+ # Docker names the target architecture `amd64`/`arm64`; Bun's `--cpu` takes
64
+ # `x64`/`arm64`. Mapped once here, read by both installs below. `oven/bun`
65
+ # publishes only these two architectures, so any other target fails here.
66
+ ARG TARGETOS
67
+ ARG TARGETARCH
68
+ RUN case "$TARGETARCH" in \
69
+ amd64) echo x64 ;; \
70
+ arm64) echo arm64 ;; \
71
+ *) echo "Unsupported TARGETARCH '$TARGETARCH': expected amd64 or arm64" >&2; exit 1 ;; \
72
+ esac > .bun-cpu
73
+
74
+ # Install only production dependencies, ignoring any lifecycle scripts (like 'prepare')
75
+ # that are not needed in the final production image.
76
+ # `--omit=peer` drops the framework's optional peer tiers (test runner, service
77
+ # SDKs, parsers) that Bun would otherwise auto-install. Anything this server
78
+ # actually imports belongs in its own `dependencies`, so nothing needed at
79
+ # runtime is lost.
80
+ RUN --mount=type=cache,target=/root/.bun/install/cache \
81
+ bun install --production --omit=peer --frozen-lockfile --ignore-scripts \
82
+ --os="$TARGETOS" --cpu="$(cat .bun-cpu)"
83
+
84
+ # Conditionally install OpenTelemetry optional peer dependencies (Tier 3).
85
+ # Installed by default. Omit them for a leaner image at build time
86
+ # with: docker build --build-arg OTEL_ENABLED=false
87
+ # The script reads the list and each range from the installed framework's
88
+ # `peerDependencies` and passes the target flags on to its `bun install`.
89
+ COPY scripts/install-otel.ts ./scripts/
90
+ ARG OTEL_ENABLED=true
91
+ RUN --mount=type=cache,target=/root/.bun/install/cache \
92
+ if [ "$OTEL_ENABLED" = "true" ]; then \
93
+ bun scripts/install-otel.ts --os="$TARGETOS" --cpu="$(cat .bun-cpu)"; \
94
+ fi
95
+
96
+ # `--os`/`--cpu` have no libc counterpart, so a native dependency published in
97
+ # glibc and musl variants (DuckDB's bindings, for one) installs both. The
98
+ # runtime image is Debian (glibc) and never loads the musl copy; the script
99
+ # deletes every package whose own `libc` admits only musl. It follows every
100
+ # install, since a later `bun install` restores what it removes. A server on a
101
+ # musl (Alpine) runtime image drops these two lines.
102
+ COPY scripts/prune-musl-packages.ts ./scripts/
103
+ RUN bun scripts/prune-musl-packages.ts
104
+
105
+ # The seeded scanner served only the installs above; keep it out of the image.
106
+ RUN rm -rf node_modules/@socketsecurity/bun-security-scanner
107
+
108
+
30
109
  # ==============================================================================
31
110
  # Production Stage
32
111
  #
33
112
  # This stage creates a minimal, optimized, and secure image for running the
34
113
  # application. It uses a slim base image and only includes production
35
- # dependencies and build artifacts.
114
+ # dependencies and build artifacts. Its only Bun invocations are HEALTHCHECK
115
+ # and CMD, which run on the real target at container start.
36
116
  # ==============================================================================
37
- FROM oven/bun:1.4.0-slim AS production
117
+ FROM oven/bun:1.4.2-slim AS production
38
118
 
39
119
  WORKDIR /usr/src/app
40
120
 
41
- # Set the environment to production for performance and to ensure only
42
- # production dependencies are installed.
121
+ # Set the environment to production for performance.
43
122
  ENV NODE_ENV=production
44
123
 
45
124
  # OCI image metadata (https://github.com/opencontainers/image-spec/blob/main/annotations.md)
@@ -50,44 +129,37 @@ LABEL org.opencontainers.image.licenses="Apache-2.0"
50
129
  LABEL org.opencontainers.image.version="${APP_VERSION}"
51
130
  LABEL org.opencontainers.image.source="https://github.com/cyanheads/epa-mcp-server"
52
131
 
53
- # Copy dependency manifests
54
- COPY package.json bun.lock ./
55
-
56
- # Install only production dependencies, ignoring any lifecycle scripts (like 'prepare')
57
- # that are not needed in the final production image.
58
- # `--omit=peer` drops the framework's optional peer tiers that Bun would
59
- # otherwise auto-install. The OTEL step below carries the same flag so that
60
- # install does not re-resolve the optional peer graph.
61
- RUN --mount=type=cache,target=/root/.bun/install/cache \
62
- bun install --production --omit=peer --frozen-lockfile --ignore-scripts
63
-
64
- # Conditionally install OpenTelemetry optional peer dependencies (Tier 3).
65
- # These are not bundled by default to keep the base image lean. Enable at build time
66
- # with: docker build --build-arg OTEL_ENABLED=true
67
- ARG OTEL_ENABLED=true
68
- RUN --mount=type=cache,target=/root/.bun/install/cache \
69
- if [ "$OTEL_ENABLED" = "true" ]; then \
70
- bun add --omit=dev --omit=peer --ignore-scripts @hono/otel \
71
- @opentelemetry/instrumentation-http \
72
- @opentelemetry/exporter-metrics-otlp-http \
73
- @opentelemetry/exporter-trace-otlp-http \
74
- @opentelemetry/instrumentation-pino \
75
- @opentelemetry/resources \
76
- @opentelemetry/sdk-metrics \
77
- @opentelemetry/sdk-node \
78
- @opentelemetry/sdk-trace-node \
79
- @opentelemetry/semantic-conventions; \
80
- fi
132
+ # The manifest comes from the build context: the deps stage's copy was rewritten
133
+ # by the OTel install, and the runtime reads only its name, version, and type.
134
+ COPY package.json ./
135
+ COPY --from=deps /usr/src/app/node_modules ./node_modules
81
136
 
82
137
  # Copy the compiled application code from the build stage
83
138
  COPY --from=build /usr/src/app/dist ./dist
84
139
 
140
+ # Mirror CLI (MirrorService adopters only — Tier 3, opt-in):
141
+ # Copy your mirror lifecycle scripts and emit a runtime tsconfig so Bun resolves
142
+ # the @/ path alias against ./dist/ rather than ./src/.
143
+ # See the api-mirror skill for the full recipe.
144
+ #
145
+ # COPY --from=build /usr/src/app/scripts/<your>-mirror-init.ts \
146
+ # /usr/src/app/scripts/<your>-mirror-refresh.ts \
147
+ # /usr/src/app/scripts/<your>-mirror-verify.ts \
148
+ # /usr/src/app/scripts/_mirror-context.ts \
149
+ # ./scripts/
150
+ # RUN echo '{"compilerOptions":{"baseUrl":".","paths":{"@/*":["./dist/*"]}}}' > tsconfig.json
151
+
85
152
  # The 'oven/bun' image already provides a non-root user named 'bun'.
86
153
  # We will use this existing user for enhanced security.
87
154
 
88
155
  # Create and set permissions for the log directory, assigning ownership to the 'bun' user.
89
156
  RUN mkdir -p /var/log/epa-mcp-server && chown -R bun:bun /var/log/epa-mcp-server
90
157
 
158
+ # Writable data dirs for on-disk SQLite stores (catalog index / observations
159
+ # mirror), owned by the runtime user. Mount a volume over either in production.
160
+ RUN mkdir -p /usr/src/app/.cache /usr/src/app/.mirror \
161
+ && chown -R bun:bun /usr/src/app/.cache /usr/src/app/.mirror
162
+
91
163
  # Switch to the non-root user
92
164
  USER bun
93
165
 
@@ -103,7 +175,6 @@ ENV MCP_TRANSPORT_TYPE="http"
103
175
  ENV MCP_SESSION_MODE="stateless"
104
176
  ENV MCP_LOG_LEVEL="info"
105
177
  ENV LOGS_DIR="/var/log/epa-mcp-server"
106
- ENV MCP_FORCE_CONSOLE_LOGGING="true"
107
178
 
108
179
  # Expose the port the server listens on
109
180
  EXPOSE ${MCP_HTTP_PORT}
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  <div align="center">
9
9
 
10
- [![Version](https://img.shields.io/badge/Version-0.3.2-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/epa-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.0.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/epa-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/epa-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.0-blueviolet.svg?style=flat-square)](https://bun.sh/)
10
+ [![Version](https://img.shields.io/badge/Version-0.4.0-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/epa-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.2.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/epa-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/epa-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.2-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
11
 
12
12
  </div>
13
13
 
@@ -19,11 +19,17 @@
19
19
 
20
20
  </div>
21
21
 
22
+ <div align="center">
23
+
24
+ **Public Hosted Server:** [https://epa.caseyjhand.com/mcp](https://epa.caseyjhand.com/mcp)
25
+
26
+ </div>
27
+
22
28
  ---
23
29
 
24
30
  ## Overview
25
31
 
26
- EPA environmental data across five federal programs — facility compliance (ECHO), toxic chemical releases (TRI), Superfund cleanup sites, drinking water systems (SDWIS), and environmental-justice screening (EJScreen) — plus real-time air quality via AirNow. Search facilities by location or compliance status, pull inspection and enforcement history, track toxic releases across a region, and screen a point for environmental-justice risk from any MCP client. Runs as a stdio process or a local Streamable HTTP server.
32
+ EPA environmental data across five federal programs — facility compliance (ECHO), toxic chemical releases (TRI), Superfund cleanup sites, drinking water systems (SDWIS), and environmental-justice screening (EJScreen) — plus real-time air quality via AirNow. Search facilities by location or compliance status, pull inspection and enforcement history, track toxic releases across a region, and screen a point for environmental-justice risk from any MCP client. Runs as a stdio process, a local Streamable HTTP server, or the public hosted endpoint above.
27
33
 
28
34
  ### Tools
29
35
 
@@ -32,7 +38,7 @@ EPA environmental data across five federal programs — facility compliance (ECH
32
38
  | `epa_search_facilities` | Search EPA-regulated facilities by location, industry program, or compliance status across CAA, CWA, RCRA, TRI, and SDWA |
33
39
  | `epa_get_facility` | Full compliance profile for one facility by EPA Registry ID — inspections, enforcement actions, and penalties |
34
40
  | `epa_search_violations` | Search EPA civil and criminal enforcement cases by state, program, or date range |
35
- | `epa_get_air_quality` | Current AQI observations or next-day forecasts from AirNow |
41
+ | `epa_get_air_quality` | Current AQI observations or the issued AQI forecast from AirNow |
36
42
  | `epa_get_tri_releases` | Per-chemical Toxic Release Inventory data for a single facility |
37
43
  | `epa_search_tri_releases` | Toxic Release Inventory records across facilities in a state or county |
38
44
  | `epa_search_superfund` | Search Superfund (CERCLA/SEMS) sites by location or NPL listing status |
@@ -52,96 +58,90 @@ All resource data is also reachable via tools — use `epa_get_facility` and `ep
52
58
 
53
59
  ### `epa_search_facilities` <sub>tool</sub>
54
60
 
55
- - Geographic filters: ZIP code, state, city (pair with state), or latitude + longitude + radius_miles (max 100 miles) for proximity search — at least one is required
56
- - Optional `programs` filter narrows to CAA, CWA, RCRA, TRI, or SDWA registrants; `has_violation` surfaces only non-compliant facilities
57
- - Returns `registryId` for `epa_get_facility`, plus `fipsCode` when available for Census chaining
58
- - Up to 100 results per call (default 50)
61
+ - Provide ZIP code, state, city (preferably with state), or latitude + longitude + radius_miles (max 100 miles); up to 100 results (default 50)
62
+ - Returns `registryId` for `epa_get_facility`, plus `fipsCode` when available for Census queries
63
+ - `programs` filters CAA, CWA, RCRA, TRI, or SDWA; `has_violation` filters significant violations
59
64
 
60
65
  ---
61
66
 
62
67
  ### `epa_get_facility` <sub>tool</sub>
63
68
 
64
- - Input: `registry_id`, obtained from `epa_search_facilities`
65
- - Aggregates 3–5 ECHO DFR endpoints in parallel — program flags and TRI totals, compliance summary, inspection/enforcement history, CAA details, CWA/NPDES permit details
66
- - Uses `Promise.allSettled` — partial data is returned even when one upstream endpoint fails
67
- - `airCompliance` / `waterCompliance` are present only when the facility is registered under that program
68
- - `facility_not_found` when ECHO has no record for the Registry ID
69
+ - `registry_id` comes from `epa_search_facilities`
70
+ - Returns compliance, inspections, enforcement actions, penalties, and TRI totals; available program data survives partial upstream failures
71
+ - `airCompliance` / `waterCompliance` appear only for registered programs; `facility_not_found` identifies a missing record
69
72
 
70
73
  ---
71
74
 
72
75
  ### `epa_search_violations` <sub>tool</sub>
73
76
 
74
- - At least one of `state` or `zip_code` is required
75
- - `program` filter covers CAA, CWA, RCRA, SDWA, CERCLA, FIFRA, or TSCA; `case_type` is civil, criminal, or all (default all)
76
- - Date range filter by filing date (ISO 8601 `date_filed_start` / `date_filed_end`)
77
- - `facilityName` and `registryId` are not populated by ECHO's enforcement-case endpoint — chain into `epa_get_facility` for facility detail
78
- - Up to 100 cases per call (default 50)
77
+ - Requires `state` or `zip_code`; up to 100 cases (default 50)
78
+ - `program` filters CAA, CWA, RCRA, SDWA, CERCLA, FIFRA, or TSCA; `case_type` is civil, criminal, or all (default); `date_filed_start` / `date_filed_end` filter ISO dates
79
+ - `facilityName` and `registryId` are unavailable from this endpoint; discover facilities with `epa_search_facilities`
79
80
 
80
81
  ---
81
82
 
82
83
  ### `epa_get_air_quality` <sub>tool</sub>
83
84
 
84
- - Provide `zip_code` or both `latitude` and `longitude`
85
- - `mode`: `current` (default) or `forecast` (requires `forecast_date`, ISO 8601)
86
- - Per-pollutant AQI (PM2.5, ozone, CO, SO2, NO2) with numeric `categoryNumber` (1 Good – 6 Hazardous) and `categoryName`
87
- - `distance_miles` sets the reporting-station search radius (default 25, max 300)
88
- - Data is preliminary — informational use only, not for regulatory decisions; responses are cached ~1 hour
89
- - Only registered when `AIRNOW_API_KEY` is set
85
+ - Provide `zip_code` or both `latitude` and `longitude`; `mode` is current (default) or forecast
86
+ - Current mode returns each pollutant's latest hourly NowCast AQI from the closest monitor within 50 miles, with `siteName`, `siteID`, and `reportingAgency`
87
+ - Forecast mode returns every day the reporting area's agency issued, with `dateValid`, `actionDay`, and the agency's `discussion`; optional `forecast_date` (YYYY-MM-DD) keeps one day and is rejected in current mode. Category-only forecasts carry no `aqi`
88
+ - Readings carry `categoryName` and `categoryNumber` (1 Good–6 Hazardous); `attribution` credits the agencies and the U.S. EPA AirNow program and marks the data preliminary, unsuitable for regulatory, trend, or enforcement decisions
89
+ - Registered only when `AIRNOW_API_KEY` is set; responses cache for about one hour
90
90
 
91
91
  ---
92
92
 
93
93
  ### `epa_get_tri_releases` <sub>tool</sub>
94
94
 
95
- - `facility_id` is the TRI `facilityId` from `epa_search_tri_releases`; optional `year` (1987–2030, defaults to all available years) and `chemical_name` (partial match)
96
- - Per-chemical breakdown by medium — air, water, land, underground injection — plus a separate one-time/non-routine release total
97
- - TRI data lags ~18 months; the most recent available year is typically 2 years prior to the current year
95
+ - `facility_id` is the TRI `facilityId` from `epa_search_tri_releases`; optional `year` (1987–2030, all available by default) and partial `chemical_name`
96
+ - Returns per-chemical air, water, land, and underground-injection releases, plus a separate one-time/non-routine total
97
+ - Quantities are in pounds. TRI has facilities report dioxin and dioxin-like compounds in grams; those records are converted to pounds and carry `reportedUnit: "grams"`
98
98
 
99
99
  ---
100
100
 
101
101
  ### `epa_search_tri_releases` <sub>tool</sub>
102
102
 
103
- - `state` is required (2-letter); optional `county` (partial match), `year`, and `chemical_name`
104
- - Up to 200 records per call (default 50); an enrichment flag marks the result truncated when it hits the limit
105
- - Complement to `epa_get_tri_releases` — use this for area discovery, then drill into a specific facility
103
+ - Requires 2-letter `state`; optional `county` (bare name, case-insensitive partial match, so `Lake` also matches LAKE OF THE WOODS — each record's `countyName` says which; a trailing County, Parish, or Borough is dropped), partial `chemical_name`, and `year`; up to 200 records (default 50)
104
+ - One upstream request however sparse the filters or large the state — EPA joins facilities to their reporting forms server-side
105
+ - Returns each record's one-time/non-routine total; `include_release_breakdown: true` adds air, water, land, and underground-injection releases at the cost of one more upstream request (up to ~20 seconds when EPA has not cached it)
106
+ - Returns `facilityId` for `epa_get_tri_releases`; enrichment marks results truncated when the limit is reached
107
+ - Dioxin and dioxin-like compounds, reported to TRI in grams, are converted to pounds and carry `reportedUnit: "grams"`
106
108
 
107
109
  ---
108
110
 
109
111
  ### `epa_search_superfund` <sub>tool</sub>
110
112
 
111
- - Two input shapes: `state`/`city`/`zip_code`, or `latitude`+`longitude`+`radius_miles` (0.1–500 miles) — one is required
112
- - `npl_status` filter: `listed`, `not-listed`, `proposed`, or `all` (default `all`)
113
- - Up to 200 sites per call (default 50)
113
+ - Provide `state`/`city`/`zip_code`, or `latitude` + `longitude` + `radius_miles` (0.1–500); up to 200 sites (default 50)
114
+ - Returns site identifiers, location, NPL, and cleanup status; `npl_status` filters listed, not-listed, proposed, or all (default)
114
115
 
115
116
  ---
116
117
 
117
118
  ### `epa_search_water_systems` <sub>tool</sub>
118
119
 
119
- - At least one of `state` or `zip_code` is required
120
- - `has_violation` surfaces only systems with active violations; `pws_type` filters to `community`, `non-transient`, or `transient` (output `type` reports the SDWIS codes CWS / NTNCWS / TNCWS)
121
- - Up to 200 systems per call (default 50)
120
+ - Requires `state` or `zip_code`; up to 200 systems (default 50). `zip_code` matches each system's address of record (often the owner's or operator's office), not its service area
121
+ - `has_violation` keeps systems with an open SDWIS violation; violations returned to compliance or closed out when the system was deactivated don't count. `pws_type` is community, non-transient, or transient, returned as CWS / NTNCWS / TNCWS in `type`
122
+ - With `state` alone, `has_violation` reads the state's first 1,000 open violation records; with `zip_code`, it reads up to 1,000 systems in the ZIP and checks them by PWSID. When either read fills, an enrichment `notice` says the list may miss violating systems
122
123
 
123
124
  ---
124
125
 
125
126
  ### `epa_get_ejscreen` <sub>tool</sub>
126
127
 
127
- - Input: `latitude`, `longitude`, `distance` (default 1), and `unit` (`miles` or `kilometers`, default `miles`); kilometers are converted to miles before the request, and the buffer is capped at 15 miles
128
- - Returns 13 environmental and 6 demographic indicators, each with national/state percentiles, plus the Demographic Index and Supplemental Demographic Index
129
- - Points outside US coverage return `coverage.valid: false` with a note instead of fabricated indicators
130
- - Data source: EJScreen v2.2 (2022) via the community-maintained EJAM API (Public Environmental Data Partners) — not a live EPA endpoint
128
+ - Requires `latitude` and `longitude`; `distance` defaults to 1, `unit` to miles (kilometers accepted); buffer capped at 15 miles
129
+ - Returns 13 environmental and 6 demographic indicators with national/state percentiles and demographic indices; outside coverage, `coverage.valid: false` carries a note
130
+ - EJScreen v2.2 (2022) comes from the community-maintained EJAM API (Public Environmental Data Partners)
131
131
 
132
132
  ---
133
133
 
134
134
  ### `epa://facility/{registry_id}` <sub>resource</sub>
135
135
 
136
- - Same data as `epa_get_facility`; `registry_id` comes from `epa_search_facilities`
137
- - Errors when the Registry ID has no ECHO record
136
+ - `registry_id` comes from `epa_search_facilities`
137
+ - Returns the `epa_get_facility` compliance profile; errors when no facility resolves
138
138
 
139
139
  ---
140
140
 
141
141
  ### `epa://superfund/{site_id}` <sub>resource</sub>
142
142
 
143
- - Same data as `epa_search_superfund` records; `site_id` comes from `epa_search_superfund`
144
- - Errors when the site ID has no SEMS record
143
+ - `site_id` comes from `epa_search_superfund`
144
+ - Returns the Superfund site record; errors when no SEMS record resolves
145
145
 
146
146
  ## Features
147
147
 
@@ -152,6 +152,7 @@ EPA-specific:
152
152
  - Multiple environmental data sources unified behind a single `epa_` tool surface: ECHO (facility compliance), Envirofacts DMAP (TRI, Superfund, SDWIS), AirNow (real-time air quality), and the community-maintained EJAM API rehosting EJScreen data (v2.2, 2022)
153
153
  - Parallel ECHO DFR aggregation in `epa_get_facility` — 3–5 upstream calls resolved concurrently with `Promise.allSettled`
154
154
  - AirNow response caching (~1 hour TTL) to stay within per-key rate limits
155
+ - TRI reporting lags about 18 months; the latest available reporting year is typically two years behind
155
156
 
156
157
  Agent-friendly output:
157
158
 
@@ -161,6 +162,23 @@ Agent-friendly output:
161
162
 
162
163
  ## Getting started
163
164
 
165
+ ### Public Hosted Instance
166
+
167
+ A public instance is available at `https://epa.caseyjhand.com/mcp` — no installation required. It runs without an AirNow key, so `epa_get_air_quality` is not available there; the other 8 tools are. Point any MCP client at it via Streamable HTTP:
168
+
169
+ ```json
170
+ {
171
+ "mcpServers": {
172
+ "epa-mcp-server": {
173
+ "type": "streamable-http",
174
+ "url": "https://epa.caseyjhand.com/mcp"
175
+ }
176
+ }
177
+ }
178
+ ```
179
+
180
+ ### Self-Hosted / Local
181
+
164
182
  Add the following to your MCP client configuration file. An AirNow API key is optional — set `AIRNOW_API_KEY` to enable `epa_get_air_quality` (register free at [docs.airnowapi.org](https://docs.airnowapi.org/account/request/)); without it the server starts with the other 8 tools. ECHO, DMAP, and EJScreen tools work without authentication.
165
183
 
166
184
  ```json
@@ -227,7 +245,7 @@ MCP_TRANSPORT_TYPE=http MCP_HTTP_PORT=3010 AIRNOW_API_KEY=... bun run start:http
227
245
 
228
246
  ### Prerequisites
229
247
 
230
- - [Bun v1.4.0](https://bun.sh/) or higher (or Node.js v24+).
248
+ - [Bun v1.4.0](https://bun.sh/) or higher (or Node.js v24+); development and Docker use Bun v1.4.2.
231
249
  - (Optional) An AirNow API key to enable `epa_get_air_quality` — register free at [docs.airnowapi.org/account/request](https://docs.airnowapi.org/account/request/). Without it the server runs the other 8 tools. ECHO, DMAP, and EJScreen tools require no API key.
232
250
 
233
251
  ### Installation
@@ -274,9 +292,14 @@ All configuration is validated at startup via Zod schemas in `src/config/`. Key
274
292
  | `MCP_SESSION_MODE` | HTTP sessions: `auto`, `stateful`, or `stateless`. Explicit environment values override the server default; `auto` resolves to `stateful`. Tenant-scoped caching is independent of sessions. | `stateless` |
275
293
  | `MCP_AUTH_MODE` | Auth mode: `none`, `jwt`, or `oauth` | `none` |
276
294
  | `MCP_LOG_LEVEL` | Log level (RFC 5424) | `info` |
295
+ | `LOG_TOOL_FAILURE_PAYLOADS` | Log failed calls' arguments and results with key-name redaction. Secrets inside free-form values remain visible. | `false` |
296
+ | `LOG_TOOL_FAILURE_PAYLOAD_MAX_BYTES` | UTF-8 byte cap per logged payload. | `16384` |
277
297
  | `LOGS_DIR` | Directory for log files (Node.js only) | `<project-root>/logs` |
278
298
  | `STORAGE_PROVIDER_TYPE` | Storage backend: `in-memory`, `filesystem`, `supabase`, `cloudflare-kv/r2/d1` | `in-memory` |
279
299
  | `OTEL_ENABLED` | Enable [OpenTelemetry](https://github.com/cyanheads/mcp-ts-core/tree/main/docs/telemetry) tracing and metrics | `false` |
300
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP base URL; traces use `/v1/traces`, metrics `/v1/metrics`. | — |
301
+ | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` / `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | Signal-specific endpoint overrides, used as-is. | — |
302
+ | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | Enables OTLP log export; the base endpoint alone does not enable it. | — |
280
303
 
281
304
  See [`.env.example`](./.env.example) for the full list of optional overrides.
282
305