feat/batu-mcp
needs attention59a73bd · incrementalpre-PRreviewed 2026-07-10 05:23 UTC1H · 6M · 5L · 5I- Purpose
- Ship an official Batu Energy MCP server so AI agents (Claude, Cursor, any MCP client) can work with CFE bills, payment status, files, collection jobs, monitoring, webhooks, energy metrics, savings reports, and asset management — without custom integration code.
- Goal
- Production-ready @batu/mcp-server package: 33 tools over the public-v1 API, correct auth lifecycle (concurrent-mint dedup, TTL floor), error redaction, test suite, governance rule (mcp-server.md), and user-facing docs.
- Sub-goals
- SG-1: Core CFE tools — bill list/get, payment status, files, collection job, monitoring, webhooks (12 tools, earlier commits)
- SG-2: Energy / savings / identity tools — batu_whoami, batu_get_energy_summary, savings CRUD (6 tools)
- SG-3: Asset-management reads + batch provision — sites, assets, streams, sources, integrations, catalogs, batu_provision_assets (15 tools)
- SG-4: Token lifecycle correctness — concurrent-mint dedup (mintingPromise ??=) + MIN_TOKEN_TTL_S floor
- SG-5: Test suite — unit (client, results), tools/list smoke, live-smoke script
- SG-6: Drift governance — .claude/rules/mcp-server.md + sync-mcp-tools skill
- SG-7: User docs — README expansion, /developers MCP section, i18n install strings
- What
- Incremental window (ee41aea..59a73bd): extracted run/jsonResult/errorResult helpers to results.ts for unit-testability; added MIN_TOKEN_TTL_S floor + mintingPromise dedup to client.ts; added 21 new tools (identity, energy summary, savings CRUD, full asset-management read surface, explore, provision); expanded README with new tools table and testing section; added tools-list smoke test; added trust boundary note; added mcp-server.md governance rule and sync-mcp-tools skill.
- Why
- Agent-facing API coverage was incomplete (no energy/savings/asset-management exposure). Token lifecycle had an edge case (sub-60s expires_in caused hot re-mint). Results helpers were inline in index.ts making them untestable. No governance rule meant MCP/API drift was invisible.
- Areas
- packages/mcp-server+1000−50.claude/rules+99−0.claude/skills/sync-mcp-tools+37−0apps/platform/src+26−0pnpm-lock.yaml+545−34
- Blast
- Additive only — packages/mcp-server is a new package with no callers in the platform. The /developers page change adds content but removes nothing. No production request paths modified.
Findings · 16
correctness2
private:true blocks the npx install that README and dashboard UI strings advertise
packages/mcp-server/package.json:4
package.json sets "private": true (intentional staging) but README and en.json/es.json mcpInstall strings both reference `npx -y @batu/mcp-server@0.1.0`. npm refuses to publish a private package, so any user following those instructions today gets a 404. Either gate/remove the install docs until the package is published, or add a clear 'not yet published' notice. The README 'Manual, from any MCP client' section already shows the local-build path — consider promoting that as the current install method.
Concurrent-mint dedup is sound — mintingPromise ??= pattern is correct
packages/mcp-server/src/client.ts:90
Both parallel callers share the same mintToken() Promise. On success, finally() clears mintingPromise after tokenState is set, so subsequent calls take the fast path. On failure, both callers get the rejection; next call retries cleanly. The 401-retry path (tokenState = null + retried:true) is also safe: both retried callers share a fresh mint via mintingPromise ??= again.
security4
No HTTPS enforcement on BATU_API_URL — API key can be sent in cleartext
packages/mcp-server/src/client.ts:54
The constructor accepts any URL scheme from BATU_API_URL without validation. An accidental http:// staging URL sends the API key in cleartext. The README trust-boundary note is documentation-only. Fix: validate `protocol === 'https:'` in the constructor (with a localhost carve-out if needed).
Dashboard mcpInstall strings omit version pin; contradict README guidance
apps/platform/src/messages/en.json:3093
en.json / es.json render `npx -y @batu/mcp-server` (floating latest); README pins `@0.1.0` and explicitly explains why: 'a restart can't silently pull a new (or compromised) publish.' The UI strings should match. When the package is eventually published, users who configured from the dashboard get an unpinned install.
publishConfig.access:public latent conflict with private:true
packages/mcp-server/package.json:31
private:true blocks publishing today, but publishConfig.access:"public" remains and would silently make the scoped @batu package public on npm if private is ever lifted. Either remove publishConfig until ready to publish, or add a comment documenting the intent.
batu_create_webhook MCP tool accepts http:// webhook URLs (Zod .url() allows it)
packages/mcp-server/src/index.ts
The tool description says 'HTTPS endpoint URL' but z.string().url() allows http://. The server handler also doesn't enforce HTTPS. A .refine(u => u.startsWith('https://'), 'URL must use HTTPS') on the tool param would match the stated constraint. (Server-side enforcement is the primary guard.)
conventions1
batu_list_metric_streams: search and status fields missing .describe()
packages/mcp-server/src/index.ts:548
The mcp-server.md rule says '.describe() each field'. batu_list_metric_streams exposes `search: z.string().optional()` and `status: amStatus.optional()` with no description text. The shared `amStatus` definition also has no .describe(), so all 6 tools using it bare expose an undescribed enum to agents. Add descriptions to the shared definition or at the callsite.
tests6
No test for MIN_TOKEN_TTL_S floor — silent hot-remint regression risk
packages/mcp-server/src/client.test.ts
The MIN_TOKEN_TTL_S = 5 floor (client.ts:75) guards against an expires_in smaller than TOKEN_REFRESH_MARGIN_S causing expiresAtMs to land in the past, which would re-mint on every request. No test exercises this path. A test with tokenOk({ expires_in: 30 }) and two sequential requests — asserting one auth/token call — would pin it.
No test for natural token expiry path (tokenState exists but expired)
packages/mcp-server/src/client.test.ts
The getToken() guard `tokenState.expiresAtMs > Date.now()` branches on expiry but no test exercises the 'cached token has expired, re-mint' path. Use vi.setSystemTime / fake timers to advance past expiresAtMs and confirm a fresh mint fires.
Smoke test param-name assertions cover only 4 of 33 tools; new tools unguarded
packages/mcp-server/src/tools-list.smoke.test.ts:62
The original audit found a param-name bug (period_start vs period_start_from). The smoke test guards 4 tools against this, but the 21 new tools from this diff (batu_whoami, batu_get_energy_summary, batu_get_savings, asset-management suite, batu_provision_assets) have only a count-floor check. At minimum add presence + a key param assertion for: batu_get_energy_summary (site_public_id not site_id), batu_provision_assets (sites array), batu_get_savings (from/to/site_public_id).
listTools() swallows JSON-RPC error responses — test silently times out instead of failing fast
packages/mcp-server/src/tools-list.smoke.test.ts:43
If the server returns `{ id: 2, error: {...} }` (no `result` key), `m.result.tools` throws inside the stdout event handler — uncaught, so the Promise never resolves. The test then waits the full 15s timeout. Add: `if (m.error) { clearTimeout(timer); child.kill(); rej(new Error(JSON.stringify(m.error))); return; }` before accessing m.result.
beforeAll execSync stdio:'ignore' makes build failures produce cryptic test errors
packages/mcp-server/src/tools-list.smoke.test.ts:17
A TypeScript compile error causes execSync to throw with only 'Command failed: pnpm run build' — no compiler output visible. The subsequent test failure reads 'timed out waiting for tools/list'. Use stdio: ['ignore', 'inherit', 'inherit'] to surface build errors in the test run output.
console.error spy in results.test.ts is noise suppression only, not load-bearing
packages/mcp-server/src/results.test.ts
vi.spyOn(console, 'error').mockImplementation(() => {}) prevents test output noise but has no corresponding expect() assertion on the calls. The tests pass without the spy; it's purely cosmetic. Fine as-is.
improvement3
batu_explore_integration description omits live vendor API call latency
packages/mcp-server/src/index.ts:683
The /explore endpoint calls the vendor API synchronously via SFN. The description says 'does not persist anything' (accurate) but gives no latency hint. Add: 'Makes a live call to the vendor API — may take several seconds.' Agents that retry aggressively on slow responses could hit rate limits.
tools-list.smoke.test.ts magic count 33 needs a comment explaining >= semantics
packages/mcp-server/src/tools-list.smoke.test.ts:62
expect(tools.length).toBeGreaterThanOrEqual(33) — no comment explains that 33 is the current tool count, why >= (not ===) is used, or that the number must be bumped if a tool is intentionally removed. One comment line would make the maintenance obligation clear.
batu_get_energy_summary granularity enum has one value — consider omitting the param
packages/mcp-server/src/index.ts:375
z.enum(['none']).optional() is accurate but may confuse agents that try to vary it. If the API defaults to 'none' when omitted, the param could be dropped until more granularity values land. The .describe() text already warns it's the only option, which is good.