jpdcl 1.0.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 (103) hide show
  1. package/.env.example +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +219 -0
  4. package/dist/catalog.d.ts +141 -0
  5. package/dist/catalog.js +261 -0
  6. package/dist/catalog.js.map +1 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +395 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config.d.ts +6 -0
  11. package/dist/config.js +13 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/credentials.d.ts +13 -0
  14. package/dist/credentials.js +100 -0
  15. package/dist/credentials.js.map +1 -0
  16. package/dist/crypto.d.ts +3 -0
  17. package/dist/crypto.js +21 -0
  18. package/dist/crypto.js.map +1 -0
  19. package/dist/dates.d.ts +5 -0
  20. package/dist/dates.js +20 -0
  21. package/dist/dates.js.map +1 -0
  22. package/dist/errors.d.ts +11 -0
  23. package/dist/errors.js +23 -0
  24. package/dist/errors.js.map +1 -0
  25. package/dist/http-handler.d.ts +1 -0
  26. package/dist/http-handler.js +46 -0
  27. package/dist/http-handler.js.map +1 -0
  28. package/dist/http.d.ts +9 -0
  29. package/dist/http.js +43 -0
  30. package/dist/http.js.map +1 -0
  31. package/dist/index.d.ts +13 -0
  32. package/dist/index.js +14 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/launcher.d.ts +2 -0
  35. package/dist/launcher.js +11 -0
  36. package/dist/launcher.js.map +1 -0
  37. package/dist/ledger-client.d.ts +112 -0
  38. package/dist/ledger-client.js +221 -0
  39. package/dist/ledger-client.js.map +1 -0
  40. package/dist/main-client.d.ts +25 -0
  41. package/dist/main-client.js +221 -0
  42. package/dist/main-client.js.map +1 -0
  43. package/dist/mcp-stdio.d.ts +2 -0
  44. package/dist/mcp-stdio.js +6 -0
  45. package/dist/mcp-stdio.js.map +1 -0
  46. package/dist/mcp.d.ts +8 -0
  47. package/dist/mcp.js +517 -0
  48. package/dist/mcp.js.map +1 -0
  49. package/dist/runtime.d.ts +27 -0
  50. package/dist/runtime.js +316 -0
  51. package/dist/runtime.js.map +1 -0
  52. package/dist/server.d.ts +4 -0
  53. package/dist/server.js +221 -0
  54. package/dist/server.js.map +1 -0
  55. package/dist/session.d.ts +4 -0
  56. package/dist/session.js +30 -0
  57. package/dist/session.js.map +1 -0
  58. package/dist/smart-client.d.ts +50 -0
  59. package/dist/smart-client.js +238 -0
  60. package/dist/smart-client.js.map +1 -0
  61. package/dist/tariff.d.ts +121 -0
  62. package/dist/tariff.js +124 -0
  63. package/dist/tariff.js.map +1 -0
  64. package/dist/types.d.ts +51 -0
  65. package/dist/types.js +2 -0
  66. package/dist/types.js.map +1 -0
  67. package/dist/worker.d.ts +5 -0
  68. package/dist/worker.js +3 -0
  69. package/dist/worker.js.map +1 -0
  70. package/openapi.yaml +284 -0
  71. package/package.json +89 -0
  72. package/scripts/audit-http.ts +36 -0
  73. package/scripts/audit-launcher.mjs +28 -0
  74. package/scripts/audit-ledger-portal.ts +23 -0
  75. package/scripts/audit-mcp.mjs +140 -0
  76. package/scripts/audit-smart-portal.mjs +63 -0
  77. package/scripts/build.mjs +19 -0
  78. package/scripts/publish-smithery.mjs +47 -0
  79. package/src/catalog.ts +291 -0
  80. package/src/cli.ts +397 -0
  81. package/src/config.ts +15 -0
  82. package/src/credentials.ts +114 -0
  83. package/src/crypto.ts +21 -0
  84. package/src/dates.ts +19 -0
  85. package/src/errors.ts +26 -0
  86. package/src/http-handler.ts +48 -0
  87. package/src/http.ts +49 -0
  88. package/src/index.ts +13 -0
  89. package/src/launcher.ts +9 -0
  90. package/src/ledger-client.ts +258 -0
  91. package/src/main-client.ts +230 -0
  92. package/src/mcp-stdio.ts +6 -0
  93. package/src/mcp.ts +546 -0
  94. package/src/runtime.ts +329 -0
  95. package/src/server.ts +218 -0
  96. package/src/session.ts +29 -0
  97. package/src/smart-client.ts +249 -0
  98. package/src/tariff.ts +148 -0
  99. package/src/toolkit.test.ts +136 -0
  100. package/src/types.ts +51 -0
  101. package/src/worker.ts +3 -0
  102. package/tsconfig.build.json +11 -0
  103. package/tsconfig.json +16 -0
package/src/mcp.ts ADDED
@@ -0,0 +1,546 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { z } from "zod";
4
+ import { endpointCatalog, isEndpointName, listEndpoints } from "./catalog.js";
5
+ import { credentialStatus, storeEnvCredentials } from "./credentials.js";
6
+ import { assertDateRange, isIsoDate } from "./dates.js";
7
+ import { JpdclError } from "./errors.js";
8
+ import { JpdclRuntime } from "./runtime.js";
9
+ import { JPDCL_TARIFF_ORDER_2025_26 } from "./tariff.js";
10
+
11
+ const MCP_INSTRUCTIONS = `This server integrates Jammu Power Distribution Corporation Limited (JPDCL) consumer and smart-meter services. Use jpdcl_snapshot for a general account question. Use jpdcl_meter_health for supply, voltage, outages, alarms, or freshness; jpdcl_energy_ledger for dated import/export/net-import usage; jpdcl_tariff_estimate for provisional charges; and jpdcl_account_digest or billing tools for utility-issued bills and payments. WSS bills are authoritative for billed amounts. Genus supplies recent readings, voltage, intervals, and power events. The daily ledger supplies cumulative registers whose deltas are deterministic. Never describe a tariff estimate as an issued bill, or stale/on-demand data as a continuous live feed. Forecasts, recommendations, and smart tips are excluded unless explicitly requested. Use jpdcl_guide for the complete embedded decision guide and jpdcl_catalog only for uncommon raw fields. Mutations require explicit user intent and JPDCL_ENABLE_MUTATIONS=true.`;
12
+
13
+ const MCP_GUIDE = {
14
+ preferredTools: {
15
+ generalAccountState: "jpdcl_snapshot",
16
+ supplyVoltageOutagesAndFreshness: "jpdcl_meter_health",
17
+ importExportAndPeriodUsage: "jpdcl_energy_ledger",
18
+ provisionalCharges: "jpdcl_tariff_estimate",
19
+ officialBillsAndPayments: "jpdcl_account_digest",
20
+ tariffRatesAndSource: "jpdcl_tariff_schedule",
21
+ uncommonPortalField: "jpdcl_catalog then jpdcl_read",
22
+ },
23
+ sourceAuthority: [
24
+ "WSS is authoritative for profile, sanctioned load, issued bills, billed units, payments, arrears, and account status.",
25
+ "Genus supplies recent meter readings, voltage profiles, half-hour usage, power events, alerts, and meter metadata.",
26
+ "The daily ledger supplies cumulative import/export/net-import kWh and kVAh; usage is derived only by register subtraction.",
27
+ "The tariff engine is a deterministic calculation from the encoded official order, never a utility-issued bill.",
28
+ ],
29
+ tariffPolicy: {
30
+ automaticUsage: "Prefer net-import register difference for a net meter; otherwise use import-register difference, with Genus current-month usage as fallback.",
31
+ provisionalBecause: ["billing cutoffs", "export-credit settlement", "carry-forward balances", "duty", "adjustments", "later tariff revisions"],
32
+ actualBillAuthority: "WSS billing records",
33
+ },
34
+ liveDataPolicy: "No consumer endpoint exposes a continuous stream or explicit communications-network online flag. Report each source timestamp and status separately.",
35
+ defaultExclusions: ["forecasts", "recommendations", "energy-saving advice", "smart tips"],
36
+ safety: "Consumer IDs, account IDs, meter numbers, readings, and bills are private. Mutations require explicit intent and JPDCL_ENABLE_MUTATIONS=true.",
37
+ } as const;
38
+
39
+ export async function createJpdclMcpServer(options: {
40
+ runtime?: JpdclRuntime;
41
+ allowCredentialStorage?: boolean;
42
+ credentialSource?: string;
43
+ } = {}): Promise<McpServer> {
44
+ const server = new McpServer(
45
+ { name: "JPDCL Smart Meter", version: "1.0.0" },
46
+ { instructions: MCP_INSTRUCTIONS },
47
+ );
48
+ const runtime = options.runtime ?? await JpdclRuntime.create();
49
+ const isoDateSchema = z.string().refine(isIsoDate, "Use a real date in YYYY-MM-DD format");
50
+
51
+ const textResult = (value: unknown) => ({
52
+ content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
53
+ structuredContent: toObject(value),
54
+ });
55
+
56
+ const run = async (operation: () => Promise<unknown>) => {
57
+ try {
58
+ return textResult(await operation());
59
+ } catch (error) {
60
+ const known = error instanceof JpdclError;
61
+ return {
62
+ isError: true,
63
+ content: [{
64
+ type: "text" as const,
65
+ text: JSON.stringify({
66
+ error: known ? error.name : "Error",
67
+ message: error instanceof Error ? error.message : String(error),
68
+ status: known ? error.status : 500,
69
+ details: known ? error.details : undefined,
70
+ }, null, 2),
71
+ }],
72
+ };
73
+ }
74
+ };
75
+
76
+ server.registerTool("jpdcl_catalog", {
77
+ title: "JPDCL API catalog",
78
+ description: "List all mapped JPDCL WSS, Genus smart-meter, and daily import/export ledger endpoints, including required paths and whether an action mutates data.",
79
+ inputSchema: { portal: z.enum(["main", "smart", "ledger"]).optional() },
80
+ annotations: { readOnlyHint: true, openWorldHint: true },
81
+ }, async ({ portal }) => textResult(listEndpoints(portal)));
82
+
83
+ server.registerTool("jpdcl_guide", {
84
+ title: "JPDCL AI operation guide",
85
+ description: "Return the MCP's embedded source-selection, provenance, freshness, tariff, privacy, and safety rules. Use this when deciding which JPDCL tool answers a question.",
86
+ inputSchema: {},
87
+ annotations: { readOnlyHint: true, openWorldHint: false },
88
+ }, async () => textResult(MCP_GUIDE));
89
+
90
+ server.registerTool("jpdcl_session_status", {
91
+ title: "JPDCL session status",
92
+ description: "Check whether the saved main and smart-meter API sessions are ready. Passwords are never returned.",
93
+ inputSchema: {},
94
+ annotations: { readOnlyHint: true, openWorldHint: false },
95
+ }, async () => {
96
+ const session = runtime.main.currentSession;
97
+ const automatic = options.credentialSource
98
+ ? { automaticRelogin: true, source: options.credentialSource, envFile: undefined }
99
+ : await credentialStatus();
100
+ return textResult({
101
+ authenticated: Boolean(session),
102
+ loginId: session?.loginId,
103
+ primaryAccountId: session?.primaryAccountId,
104
+ smartAuthenticated: Boolean(session?.smart?.token),
105
+ smartExpiresAt: session?.smart?.expiresAt,
106
+ automaticRelogin: automatic.automaticRelogin,
107
+ credentialSource: automatic.source,
108
+ credentialFile: automatic.envFile,
109
+ updatedAt: session?.updatedAt,
110
+ });
111
+ });
112
+
113
+ server.registerTool("jpdcl_auth_login", {
114
+ title: "JPDCL first-time authentication",
115
+ description: "Log in to JPDCL using credentials explicitly supplied by the user. Optionally saves them to the MCP project's private .env for automatic future main-portal re-login and smart-token renewal. The password is never returned.",
116
+ inputSchema: {
117
+ loginId: z.string().min(1).describe("JPDCL mobile number or email"),
118
+ password: z.string().min(1).describe("JPDCL password"),
119
+ saveToEnv: z.boolean().default(true).describe("Save locally to the git-ignored .env for unattended future authentication"),
120
+ },
121
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
122
+ }, async ({ loginId, password, saveToEnv }) => run(async () => {
123
+ const result = await runtime.login(loginId, password);
124
+ const savedTo = saveToEnv && options.allowCredentialStorage !== false
125
+ ? await storeEnvCredentials(loginId, password)
126
+ : undefined;
127
+ return {
128
+ authenticated: Boolean(result.status),
129
+ message: result.message ?? "Login successful",
130
+ credentialsSaved: Boolean(savedTo),
131
+ credentialFile: savedTo,
132
+ automaticRelogin: Boolean(savedTo),
133
+ credentialManagement: options.allowCredentialStorage === false ? "connection-configuration" : "local-env",
134
+ };
135
+ }));
136
+
137
+ server.registerTool("jpdcl_snapshot", {
138
+ title: "JPDCL factual AI snapshot",
139
+ description: "Preferred general-purpose tool: fetch one normalized, provenance-labelled snapshot combining billing records, Genus smart-meter data, the separate daily import/export register ledger, and a clearly labelled deterministic tariff estimate. Forecasts, recommendations, and smart tips are excluded.",
140
+ inputSchema: { accountId: z.string().optional().describe("Defaults to the primary account") },
141
+ annotations: { readOnlyHint: true, openWorldHint: true },
142
+ }, async ({ accountId }) => run(async () => runtime.aiSnapshot(accountId)));
143
+
144
+ server.registerTool("jpdcl_tariff_estimate", {
145
+ title: "JPDCL domestic tariff and charge estimate",
146
+ description: "Automatically detect the account's domestic tariff and sanctioned load, prefer the current-period net-import ledger for a net meter, and calculate official FY 2025-26 slab and fixed charges. Optional government duty, arrears-related amounts and rebates are only applied when explicitly supplied. Returns measurement and tariff provenance.",
147
+ inputSchema: {
148
+ accountId: z.string().optional(),
149
+ unitsKwh: z.number().nonnegative().optional().describe("Defaults to the best current-period measured quantity: net-import ledger for net meters, otherwise import/Genus usage"),
150
+ sanctionedLoadKw: z.number().nonnegative().optional().describe("Defaults to the portal account load"),
151
+ prepaid: z.boolean().optional().describe("Defaults from the portal plan"),
152
+ solarWaterHeaterEligible: z.boolean().optional().describe("Apply Rs.150 only if JPDCL has verified eligibility"),
153
+ electricityDutyAmount: z.number().nonnegative().optional().describe("Explicit amount; the tariff order does not define the levy rate"),
154
+ otherChargesAmount: z.number().nonnegative().optional(),
155
+ unpaidPrincipalAmount: z.number().nonnegative().optional(),
156
+ lateMonths: z.number().nonnegative().optional(),
157
+ },
158
+ annotations: { readOnlyHint: true, openWorldHint: true },
159
+ }, async ({ accountId, ...overrides }) => run(async () => runtime.tariffEstimate(accountId, overrides)));
160
+
161
+ server.registerTool("jpdcl_tariff_schedule", {
162
+ title: "JPDCL encoded FY 2025-26 tariff schedule",
163
+ description: "Return the exact encoded domestic slabs, fixed charge, load rounding, rebates, late-payment rate, official PDF URL, and cited pages used by jpdcl_tariff_estimate.",
164
+ inputSchema: {},
165
+ annotations: { readOnlyHint: true, openWorldHint: false },
166
+ }, async () => textResult(JPDCL_TARIFF_ORDER_2025_26));
167
+
168
+ server.registerTool("jpdcl_energy_ledger", {
169
+ title: "JPDCL normalized daily energy ledger",
170
+ description: "Fetch the separate smartmeter1 JPDCL ledger using the account's consumer ID automatically. Returns cumulative import/export/net-import kWh and kVAh, deterministic daily deltas, period totals, freshness, net-meter identity, and a provisional billing quantity with limitations.",
171
+ inputSchema: {
172
+ accountId: z.string().optional(),
173
+ from: isoDateSchema.optional().describe("YYYY-MM-DD; defaults to the first day of the latest available month"),
174
+ to: isoDateSchema.optional().describe("YYYY-MM-DD; defaults to the latest observation"),
175
+ limit: z.number().int().nonnegative().max(500).default(35).describe("Daily rows to return; zero returns summary only"),
176
+ },
177
+ annotations: { readOnlyHint: true, openWorldHint: true },
178
+ }, async ({ accountId, from, to, limit }) => run(async () => runtime.energyLedger(accountId, { from, to, limit })));
179
+
180
+ server.registerTool("jpdcl_meter_health", {
181
+ title: "JPDCL unified meter and supply health",
182
+ description: "Best status tool for AI: combine current electrical connection state, on-demand meter request state and values, voltage freshness, outage events, daily-ledger freshness, and alarm records. Explicitly distinguishes unavailable network connectivity from inferred data freshness.",
183
+ inputSchema: { accountId: z.string().optional() },
184
+ annotations: { readOnlyHint: true, openWorldHint: true },
185
+ }, async ({ accountId }) => run(async () => runtime.meterHealth(accountId)));
186
+
187
+ server.registerTool("jpdcl_account_info", {
188
+ title: "JPDCL consumer account",
189
+ description: "Get consumer profile, current bill, outstanding amount, meter, tariff, load, subdivision, and account-type details.",
190
+ inputSchema: { accountId: z.string().optional().describe("10-digit account ID; defaults to the primary account") },
191
+ annotations: { readOnlyHint: true, openWorldHint: true },
192
+ }, async ({ accountId }) => run(async () => {
193
+ await runtime.ensureLogin();
194
+ const result = await runtime.main.customerInfo(accountId);
195
+ await runtime.persist();
196
+ return result;
197
+ }));
198
+
199
+ server.registerTool("jpdcl_account_digest", {
200
+ title: "JPDCL complete account digest",
201
+ description: "Return one digestible response combining profile, current bill, meter, billing history, payment history, consumption, and linked accounts.",
202
+ inputSchema: { accountId: z.string().optional() },
203
+ annotations: { readOnlyHint: true, openWorldHint: true },
204
+ }, async ({ accountId }) => run(async () => {
205
+ await runtime.ensureLogin();
206
+ const result = await runtime.main.digest(accountId);
207
+ await runtime.persist();
208
+ return result;
209
+ }));
210
+
211
+ for (const [toolName, type, title] of [
212
+ ["jpdcl_bills", "BILL", "JPDCL billing history"],
213
+ ["jpdcl_payments", "PAYM", "JPDCL payment history"],
214
+ ] as const) {
215
+ server.registerTool(toolName, {
216
+ title,
217
+ description: `Get ${type === "BILL" ? "bill assessments" : "payments and receipts"} for an account and date range (maximum six months per portal request).`,
218
+ inputSchema: {
219
+ accountId: z.string().optional(),
220
+ from: isoDateSchema.optional().describe("YYYY-MM-DD"),
221
+ to: isoDateSchema.optional().describe("YYYY-MM-DD"),
222
+ },
223
+ annotations: { readOnlyHint: true, openWorldHint: true },
224
+ }, async ({ accountId, from, to }) => run(async () => {
225
+ assertDateRange(from, to);
226
+ await runtime.ensureLogin();
227
+ return runtime.main.history(type, accountId, from, to);
228
+ }));
229
+ }
230
+
231
+ server.registerTool("jpdcl_consumption", {
232
+ title: "JPDCL consumption history",
233
+ description: "Get billed import/export register readings and electricity consumption for an account and date range.",
234
+ inputSchema: {
235
+ accountId: z.string().optional(),
236
+ from: isoDateSchema.optional().describe("YYYY-MM-DD"),
237
+ to: isoDateSchema.optional().describe("YYYY-MM-DD"),
238
+ },
239
+ annotations: { readOnlyHint: true, openWorldHint: true },
240
+ }, async ({ accountId, from, to }) => run(async () => {
241
+ assertDateRange(from, to);
242
+ await runtime.ensureLogin();
243
+ return runtime.main.consumption(accountId, from, to);
244
+ }));
245
+
246
+ server.registerTool("jpdcl_smart_session", {
247
+ title: "Smart-meter session and accounts",
248
+ description: "Get current Genus SSO metadata: account, meter, plan, metering mode, tenant, token expiry, and linked smart accounts. This describes the session, not live electrical state.",
249
+ inputSchema: {},
250
+ annotations: { readOnlyHint: true, openWorldHint: true },
251
+ }, async () => run(async () => (await runtime.ensureSmart()).connections()));
252
+
253
+ server.registerTool("jpdcl_smart_dashboard", {
254
+ title: "Smart-meter factual data digest",
255
+ description: "Get measured readings and account records. Predictions, recommendations, estimates, and smart tips are excluded unless includeDerived is explicitly true.",
256
+ inputSchema: {
257
+ accountId: z.string().optional().describe("Defaults to the SSO account"),
258
+ includeDerived: z.boolean().default(false).describe("Opt in to clearly labelled forecasts/insights/tips that are not meter evidence"),
259
+ },
260
+ annotations: { readOnlyHint: true, openWorldHint: true },
261
+ }, async ({ accountId, includeDerived }) => run(async () =>
262
+ (await runtime.ensureSmart(accountId)).dashboard(accountId, { includeDerived })));
263
+
264
+ server.registerTool("jpdcl_smart_consumption", {
265
+ title: "Smart-meter recorded consumption",
266
+ description: "Get the utility's latest recorded consumption comparison for a daily, weekly, or monthly period. Timestamps in the response determine freshness; this is not a continuous live stream.",
267
+ inputSchema: {
268
+ accountId: z.string().optional(),
269
+ type: z.enum(["daily", "weekly", "monthly"]).default("monthly"),
270
+ value: z.number().int().positive().max(366).default(12),
271
+ },
272
+ annotations: { readOnlyHint: true, openWorldHint: true },
273
+ }, async ({ accountId, type, value }) => run(async () =>
274
+ (await runtime.ensureSmart(accountId)).consumption(accountId, type, value)));
275
+
276
+ server.registerTool("jpdcl_smart_intervals", {
277
+ title: "Smart-meter half-hour readings",
278
+ description: "Get half-hour import/export readings over an ISO date range.",
279
+ inputSchema: {
280
+ accountId: z.string().optional(),
281
+ from: isoDateSchema.describe("YYYY-MM-DD"),
282
+ to: isoDateSchema.describe("YYYY-MM-DD"),
283
+ sortOrder: z.string().default("date"),
284
+ },
285
+ annotations: { readOnlyHint: true, openWorldHint: true },
286
+ }, async ({ accountId, from, to, sortOrder }) => run(async () => {
287
+ assertDateRange(from, to, { requireBoth: true });
288
+ return (await runtime.ensureSmart(accountId)).intervalConsumption(accountId, from, to, sortOrder);
289
+ }));
290
+
291
+ server.registerTool("jpdcl_smart_meter_profile", {
292
+ title: "Smart-meter technical profile",
293
+ description: "Get the live portal's meter and connection data: phase, metering mode, manufacturer, voltage, sanctioned load, SDO, installation date, address, tariff, and current reading.",
294
+ inputSchema: { accountId: z.string().optional(), meterNumber: z.string().optional() },
295
+ annotations: { readOnlyHint: true, openWorldHint: true },
296
+ }, async ({ accountId, meterNumber }) => run(async () => {
297
+ const client = await runtime.ensureSmart(accountId, meterNumber);
298
+ const meter = meterNumber ?? client.meterNumber;
299
+ const account = accountId ?? client.accountId;
300
+ if (!meter || !account) throw new JpdclError("Account ID and meter number are required", 400);
301
+ const [details, reading] = await Promise.all([
302
+ client.request("smart_meter_details", { params: { meterNumber: meter } }),
303
+ client.request("smart_current_meter_reading", { params: { accountId: account } }),
304
+ ]);
305
+ return { details: details.data, currentReading: reading.data };
306
+ }));
307
+
308
+ server.registerTool("jpdcl_smart_forecasts", {
309
+ title: "Smart-meter derived forecasts and advice",
310
+ description: "Explicitly fetch predictions, estimates, comparisons, savings suggestions, and smart tips. These are derived/advisory outputs and must not be presented as measured facts.",
311
+ inputSchema: { accountId: z.string().optional() },
312
+ annotations: { readOnlyHint: true, openWorldHint: true },
313
+ }, async ({ accountId }) => run(async () => {
314
+ const client = await runtime.ensureSmart(accountId);
315
+ if (!client.meterNumber || !client.accountId) throw new JpdclError("Smart account is incomplete", 400);
316
+ const [today, weekly, monthly, insights] = await Promise.all([
317
+ client.request("smart_forecast_today", { params: { meterNumber: client.meterNumber } }),
318
+ client.request("smart_forecast_weekly", { params: { meterNumber: client.meterNumber } }),
319
+ client.request("smart_forecast_monthly", { params: { meterNumber: client.meterNumber } }),
320
+ client.request("smart_insights", { params: { accountId: client.accountId } }),
321
+ ]);
322
+ return {
323
+ _meta: {
324
+ dataClass: "derived-and-advisory",
325
+ warning: "Predictions, estimates, comparisons, savings suggestions, and smart tips are not meter evidence.",
326
+ },
327
+ today: today.data,
328
+ weekly: weekly.data,
329
+ monthly: monthly.data,
330
+ insights: insights.data,
331
+ };
332
+ }));
333
+
334
+ server.registerTool("jpdcl_smart_billing", {
335
+ title: "Smart-meter billing and payment history",
336
+ description: "Get the correct postpaid bills/payments or prepaid balance/recharges/bills for the current plan.",
337
+ inputSchema: { accountId: z.string().optional() },
338
+ annotations: { readOnlyHint: true, openWorldHint: true },
339
+ }, async ({ accountId }) => run(async () => {
340
+ const client = await runtime.ensureSmart(accountId);
341
+ if (!client.accountId || !client.meterNumber) throw new JpdclError("Smart account is incomplete", 400);
342
+ const prepaid = String(client.claims.currentAccountIsMeterPrepaid).toLowerCase() === "true";
343
+ if (prepaid) {
344
+ const [balance, rechargeBalance, recharges, bills] = await Promise.all([
345
+ client.request("smart_prepaid_balance", { params: { meterNumber: client.meterNumber } }),
346
+ client.request("smart_prepaid_recharge_balance", { params: { accountId: client.accountId } }),
347
+ client.request("smart_prepaid_recharge_history", { params: { meterNumber: client.meterNumber } }),
348
+ client.request("smart_prepaid_bill_history", { params: { meterNumber: client.meterNumber } }),
349
+ ]);
350
+ return { plan: "prepaid", balance: balance.data, rechargeBalance: rechargeBalance.data, recharges: recharges.data, bills: bills.data };
351
+ }
352
+ const [lastBill, bills, payments] = await Promise.all([
353
+ client.request("smart_postpaid_last_bill", { params: { accountId: client.accountId } }),
354
+ client.request("smart_postpaid_bill_history", { params: { accountId: client.accountId } }),
355
+ client.request("smart_postpaid_payment_history", { params: { accountId: client.accountId } }),
356
+ ]);
357
+ return { plan: "postpaid", lastBill: lastBill.data, bills: bills.data, payments: payments.data };
358
+ }));
359
+
360
+ server.registerTool("jpdcl_smart_alerts", {
361
+ title: "Smart-meter usage alerts",
362
+ description: "Get live consumption and configured daily/monthly alert thresholds and descriptions.",
363
+ inputSchema: { accountId: z.string().optional() },
364
+ annotations: { readOnlyHint: true, openWorldHint: true },
365
+ }, async ({ accountId }) => run(async () => {
366
+ const client = await runtime.ensureSmart(accountId);
367
+ if (!client.accountId || !client.meterNumber) throw new JpdclError("Smart account is incomplete", 400);
368
+ return client.request("smart_my_alerts", { params: { accountId: client.accountId, meterNumber: client.meterNumber } });
369
+ }));
370
+
371
+ server.registerTool("jpdcl_smart_preferences", {
372
+ title: "Smart-meter notification preferences",
373
+ description: "Get every notification category and channel switch shown on the live accounts page.",
374
+ inputSchema: { accountId: z.string().optional(), isPrepaid: z.boolean().optional() },
375
+ annotations: { readOnlyHint: true, openWorldHint: true },
376
+ }, async ({ accountId, isPrepaid }) => run(async () => {
377
+ const client = await runtime.ensureSmart(accountId);
378
+ const plan = isPrepaid ?? String(client.claims.currentAccountIsMeterPrepaid).toLowerCase() === "true";
379
+ return client.request("smart_preferences", { params: { isPrepaid: plan } });
380
+ }));
381
+
382
+ server.registerTool("jpdcl_smart_support", {
383
+ title: "Smart-meter support center",
384
+ description: "Get FAQs, contact details, complaint categories, and the current user's complaint list.",
385
+ inputSchema: { accountId: z.string().optional(), pageNumber: z.number().int().positive().default(1), pageSize: z.number().int().positive().max(100).default(20), statusCodes: z.string().optional() },
386
+ annotations: { readOnlyHint: true, openWorldHint: true },
387
+ }, async ({ accountId, pageNumber, pageSize, statusCodes }) => run(async () => {
388
+ const client = await runtime.ensureSmart(accountId);
389
+ const userId = typeof client.claims.sub === "string" ? client.claims.sub : undefined;
390
+ if (!userId) throw new JpdclError("Smart user ID is unavailable", 400);
391
+ const [faqs, contact, categories, complaints] = await Promise.all([
392
+ client.request("smart_faqs"),
393
+ client.request("smart_contact_support").catch(() => ({ status: true, data: null })),
394
+ client.request("smart_complaint_categories"),
395
+ client.request("smart_complaints", { params: { userId, pageNumber, pageSize, statusCodes } }),
396
+ ]);
397
+ return {
398
+ _meta: { dataClass: "mixed", advisoryFields: ["faqs"], observedFields: ["complaints"], configurationFields: ["contact", "categories"] },
399
+ faqs: faqs.data,
400
+ contact: contact.data,
401
+ categories: categories.data,
402
+ complaints: complaints.data,
403
+ };
404
+ }));
405
+
406
+ server.registerTool("jpdcl_smart_notifications", {
407
+ title: "Smart-meter notifications",
408
+ description: "Get smart-portal notifications and the unread count for the current user.",
409
+ inputSchema: { accountId: z.string().optional() },
410
+ annotations: { readOnlyHint: true, openWorldHint: true },
411
+ }, async ({ accountId }) => run(async () => {
412
+ const client = await runtime.ensureSmart(accountId);
413
+ const userId = typeof client.claims.sub === "string" ? client.claims.sub : undefined;
414
+ if (!userId) throw new JpdclError("Smart user ID is unavailable", 400);
415
+ const [items, unread] = await Promise.all([
416
+ client.request("smart_notifications", { params: { userId } }).catch(() => ({ status: true, data: [] })),
417
+ client.request("smart_notification_unread_count", { params: { userId } }).catch(() => ({ status: true, data: { unreadCount: 0 } })),
418
+ ]);
419
+ return { items: items.data, unread: unread.data };
420
+ }));
421
+
422
+ server.registerTool("jpdcl_smart_nearby_offices", {
423
+ title: "Nearby JPDCL offices",
424
+ description: "Find nearby service offices using latitude, longitude, and a search query. If JPDCL's office service is unavailable, returns a structured unavailable result rather than inventing locations.",
425
+ inputSchema: { latitude: z.number().min(-90).max(90), longitude: z.number().min(-180).max(180), query: z.string().min(1).default("JPDCL") },
426
+ annotations: { readOnlyHint: true, openWorldHint: true },
427
+ }, async ({ latitude, longitude, query }) => run(async () => {
428
+ const client = await runtime.ensureSmart();
429
+ try {
430
+ return await client.request("smart_nearby_offices", { params: { lat: latitude, lng: longitude, query: query || "JPDCL" } });
431
+ } catch (error) {
432
+ if (!(error instanceof JpdclError)) throw error;
433
+ return {
434
+ _meta: {
435
+ dataClass: "observed-service-status",
436
+ source: "smart_nearby_offices",
437
+ available: false,
438
+ checkedAt: new Date().toISOString(),
439
+ },
440
+ offices: [],
441
+ error: { status: error.status, message: error.message },
442
+ warning: "The JPDCL office service is currently unavailable; no office locations were inferred or fabricated.",
443
+ };
444
+ }
445
+ }));
446
+
447
+ server.registerTool("jpdcl_smart_report", {
448
+ title: "Smart-meter analytical report",
449
+ description: "Get monthly/daily TOD, power event, peak-slot, voltage, or sanctioned-load-versus-demand reports.",
450
+ inputSchema: {
451
+ accountId: z.string().optional(),
452
+ report: z.enum(["monthly_tod", "daily_tod", "power_events", "peak_slots", "peak_monthly", "voltage", "demand"]),
453
+ from: isoDateSchema.optional().describe("YYYY-MM-DD; supply both from and to, or neither"),
454
+ to: isoDateSchema.optional().describe("YYYY-MM-DD; supply both from and to, or neither"),
455
+ start: z.number().int().positive().default(1),
456
+ end: z.number().int().positive().optional(),
457
+ filter: z.string().optional().describe("Advanced: pre-encoded portal report filter"),
458
+ format: z.enum(["xlsx", "pdf"]).optional(),
459
+ },
460
+ annotations: { readOnlyHint: true, openWorldHint: true },
461
+ }, async ({ accountId, report, from, to, start, end, filter, format }) => run(async () => {
462
+ assertDateRange(from, to, { pairedWhenPresent: true });
463
+ const names = {
464
+ monthly_tod: "MonthlyTOD",
465
+ daily_tod: "DayWiseTOD",
466
+ power_events: "PowerOnOff",
467
+ peak_slots: "PeakSlotConsumption",
468
+ peak_monthly: "PeakSlotConsumptionMonthly",
469
+ voltage: "ConsumerVoltageDataProfile",
470
+ demand: "SanctionLoadVSMaxDemand",
471
+ } as const;
472
+ return (await runtime.ensureSmart(accountId)).report(names[report], accountId, { from, to, start, end, filter, format });
473
+ }));
474
+
475
+ server.registerTool("jpdcl_read", {
476
+ title: "Any JPDCL read endpoint",
477
+ description: "Call any non-mutating endpoint from jpdcl_catalog. Derived/advisory endpoints require an explicit allowDerived opt-in.",
478
+ inputSchema: {
479
+ endpoint: z.string().describe("Catalog endpoint name"),
480
+ params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(),
481
+ body: z.record(z.string(), z.unknown()).optional(),
482
+ allowDerived: z.boolean().default(false).describe("Required for endpoints classified as derived or advisory"),
483
+ },
484
+ annotations: { readOnlyHint: true, openWorldHint: true },
485
+ }, async ({ endpoint, params, body, allowDerived }) => run(async () => {
486
+ if (!isEndpointName(endpoint)) throw new JpdclError(`Unknown endpoint: ${endpoint}`, 400);
487
+ const definition = endpointCatalog[endpoint];
488
+ if (definition.mutation) throw new JpdclError("Use jpdcl_mutate for mutating endpoints", 400);
489
+ if (["derived", "advisory"].includes(definition.dataClass ?? "") && !allowDerived) {
490
+ throw new JpdclError(`${endpoint} is classified as ${definition.dataClass}; set allowDerived=true to request non-factual content`, 400);
491
+ }
492
+ await runtime.ensureLogin();
493
+ return definition.portal === "main"
494
+ ? runtime.main.request(endpoint, { params, body })
495
+ : definition.portal === "smart"
496
+ ? (await runtime.ensureSmart()).request(endpoint, { params, body })
497
+ : runtime.ledger.request(endpoint, { params, body });
498
+ }));
499
+
500
+ server.registerTool("jpdcl_mutate", {
501
+ title: "JPDCL account action",
502
+ description: "Call a cataloged account-changing API such as linking an account, updating contacts/preferences, registering a complaint, or creating a payment intent. Requires both JPDCL_ENABLE_MUTATIONS=true and confirm=true after explicit user approval.",
503
+ inputSchema: {
504
+ endpoint: z.string(),
505
+ params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(),
506
+ body: z.record(z.string(), z.unknown()).optional(),
507
+ confirm: z.boolean().default(false).describe("Must be true to confirm the named account-changing operation"),
508
+ },
509
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
510
+ }, async ({ endpoint, params, body, confirm }) => run(async () => {
511
+ if (!isEndpointName(endpoint)) throw new JpdclError(`Unknown endpoint: ${endpoint}`, 400);
512
+ const definition = endpointCatalog[endpoint];
513
+ if (!definition.mutation) throw new JpdclError("This endpoint is read-only; use jpdcl_read", 400);
514
+ if (definition.portal === "ledger") throw new JpdclError("The daily meter ledger exposes no mutating operations", 400);
515
+ if (!confirm) throw new JpdclError("Set confirm=true only after the user explicitly approves this exact action", 400);
516
+ await runtime.ensureLogin();
517
+ const result = definition.portal === "main"
518
+ ? await runtime.main.request(endpoint, { params, body })
519
+ : await (await runtime.ensureSmart()).request(endpoint, { params, body });
520
+ await runtime.persist();
521
+ return result;
522
+ }));
523
+
524
+ server.registerResource("jpdcl-api-catalog", "jpdcl://catalog", {
525
+ title: "JPDCL API catalog",
526
+ description: "Complete discovered JPDCL and smart-meter endpoint registry",
527
+ mimeType: "application/json",
528
+ }, async (uri) => ({
529
+ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(listEndpoints(), null, 2) }],
530
+ }));
531
+
532
+ server.registerResource("jpdcl-embedded-guide", "jpdcl://guide", {
533
+ title: "JPDCL AI operation guide",
534
+ description: "Embedded source-selection, provenance, tariff, freshness, and safety manual",
535
+ mimeType: "application/json",
536
+ }, async (uri) => ({
537
+ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(MCP_GUIDE, null, 2) }],
538
+ }));
539
+
540
+ return server;
541
+ }
542
+
543
+ function toObject(value: unknown): Record<string, unknown> {
544
+ if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
545
+ return { value };
546
+ }