@striderlabs/mcp-spectrum 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.
package/src/index.ts ADDED
@@ -0,0 +1,733 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import {
5
+ CallToolRequestSchema,
6
+ ListToolsRequestSchema,
7
+ Tool,
8
+ } from "@modelcontextprotocol/sdk/types.js";
9
+ import { chromium, Browser, Page } from "playwright";
10
+
11
+ // ── Constants ──────────────────────────────────────────────────────────────────
12
+ const SPECTRUM_BASE = "https://www.spectrum.com";
13
+ const SPECTRUM_MY_ACCOUNT = "https://www.spectrum.net/login";
14
+ const SPECTRUM_ACCOUNT_OVERVIEW = "https://www.spectrum.net/account/overview";
15
+ const SPECTRUM_BILL = "https://www.spectrum.net/account/billing";
16
+ const SPECTRUM_SERVICES = "https://www.spectrum.net/account/services";
17
+ const SPECTRUM_SUPPORT = "https://www.spectrum.net/support";
18
+ const SPECTRUM_OUTAGES = "https://www.spectrum.net/support/internet/spectrum-internet-outages";
19
+ const SPECTRUM_SCHEDULE = "https://www.spectrum.net/account/services/schedule";
20
+
21
+ // ── Browser helper ─────────────────────────────────────────────────────────────
22
+ async function getBrowser(): Promise<Browser> {
23
+ const cdpUrl = process.env.BROWSERBASE_CDP_URL;
24
+ if (!cdpUrl) {
25
+ throw new Error(
26
+ "BROWSERBASE_CDP_URL environment variable is required. " +
27
+ "Set it to your Browserbase CDP WebSocket URL."
28
+ );
29
+ }
30
+ return chromium.connectOverCDP(cdpUrl);
31
+ }
32
+
33
+ async function getPage(browser: Browser): Promise<Page> {
34
+ const contexts = browser.contexts();
35
+ const ctx = contexts.length > 0 ? contexts[0] : await browser.newContext();
36
+ const pages = ctx.pages();
37
+ return pages.length > 0 ? pages[0] : await ctx.newPage();
38
+ }
39
+
40
+ /** Navigate and wait for network idle */
41
+ async function navigate(page: Page, url: string): Promise<void> {
42
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
43
+ }
44
+
45
+ /** Login helper — reuses session if already logged in */
46
+ async function ensureLoggedIn(
47
+ page: Page,
48
+ username: string,
49
+ password: string
50
+ ): Promise<void> {
51
+ const currentUrl = page.url();
52
+ if (currentUrl.includes("spectrum.net/account")) {
53
+ // Already on account pages — likely logged in
54
+ return;
55
+ }
56
+
57
+ await navigate(page, SPECTRUM_MY_ACCOUNT);
58
+
59
+ // Check if already redirected past login
60
+ await page.waitForTimeout(1500);
61
+ if (page.url().includes("/account")) {
62
+ return;
63
+ }
64
+
65
+ // Fill login form
66
+ await page.fill('input[name="IDToken1"], input[type="text"][id*="username"], #IDToken1', username);
67
+ await page.fill('input[name="IDToken2"], input[type="password"], #IDToken2', password);
68
+ await page.click('button[type="submit"], input[type="submit"], button:has-text("Sign In"), button:has-text("Log In")');
69
+
70
+ // Wait for redirect after login
71
+ await page.waitForURL(/spectrum\.net\/(account|my-account)/, { timeout: 20_000 }).catch(() => {
72
+ // May redirect to different page — ignore timeout and check url
73
+ });
74
+ }
75
+
76
+ /** Extract visible text from a selector, or return a fallback */
77
+ async function safeText(page: Page, selector: string, fallback = ""): Promise<string> {
78
+ try {
79
+ return (await page.textContent(selector, { timeout: 3000 }))?.trim() ?? fallback;
80
+ } catch {
81
+ return fallback;
82
+ }
83
+ }
84
+
85
+ // ── Tool implementations ───────────────────────────────────────────────────────
86
+
87
+ async function getAccountOverview(args: {
88
+ username: string;
89
+ password: string;
90
+ }): Promise<string> {
91
+ let browser: Browser | null = null;
92
+ try {
93
+ browser = await getBrowser();
94
+ const page = await getPage(browser);
95
+ await ensureLoggedIn(page, args.username, args.password);
96
+ await navigate(page, SPECTRUM_ACCOUNT_OVERVIEW);
97
+
98
+ // Wait for content to load
99
+ await page.waitForTimeout(2000);
100
+
101
+ // Scrape account summary
102
+ const pageText = await page.evaluate(() => document.body.innerText);
103
+
104
+ // Extract key info using regex patterns
105
+ const planMatch = pageText.match(/(?:plan|package|service)[:\s]+([^\n]+)/i);
106
+ const balanceMatch = pageText.match(/(?:balance|amount due|total due)[:\s]*\$?([\d,.]+)/i);
107
+ const dueDateMatch = pageText.match(/(?:due date|payment due)[:\s]+([^\n]+)/i);
108
+ const dataUsageMatch = pageText.match(/(?:data usage|data used)[:\s]*([\d.]+\s*(?:GB|MB|TB))/i);
109
+
110
+ const result = {
111
+ account_url: page.url(),
112
+ current_plan: planMatch?.[1]?.trim() ?? "Unable to retrieve",
113
+ balance_due: balanceMatch?.[1] ? `$${balanceMatch[1]}` : "Unable to retrieve",
114
+ payment_due_date: dueDateMatch?.[1]?.trim() ?? "Unable to retrieve",
115
+ data_usage: dataUsageMatch?.[1]?.trim() ?? "Unable to retrieve",
116
+ raw_summary: pageText.slice(0, 2000),
117
+ };
118
+
119
+ return JSON.stringify(result, null, 2);
120
+ } finally {
121
+ if (browser) await browser.close();
122
+ }
123
+ }
124
+
125
+ async function getServiceDetails(args: {
126
+ username: string;
127
+ password: string;
128
+ service_type?: string;
129
+ }): Promise<string> {
130
+ let browser: Browser | null = null;
131
+ try {
132
+ browser = await getBrowser();
133
+ const page = await getPage(browser);
134
+ await ensureLoggedIn(page, args.username, args.password);
135
+ await navigate(page, SPECTRUM_SERVICES);
136
+
137
+ await page.waitForTimeout(2000);
138
+
139
+ const pageText = await page.evaluate(() => document.body.innerText);
140
+
141
+ // Parse out service sections
142
+ const services: Record<string, string> = {};
143
+ const serviceTypes = ["internet", "tv", "voice", "phone", "mobile"];
144
+
145
+ for (const svc of serviceTypes) {
146
+ if (
147
+ !args.service_type ||
148
+ args.service_type.toLowerCase() === svc
149
+ ) {
150
+ const svcRegex = new RegExp(
151
+ `${svc}[^\\n]*\\n([\\s\\S]{0,500})`,
152
+ "i"
153
+ );
154
+ const match = pageText.match(svcRegex);
155
+ if (match) {
156
+ services[svc] = match[1].trim().slice(0, 300);
157
+ }
158
+ }
159
+ }
160
+
161
+ return JSON.stringify(
162
+ {
163
+ services_url: page.url(),
164
+ service_type_filter: args.service_type ?? "all",
165
+ services,
166
+ raw_content: pageText.slice(0, 3000),
167
+ },
168
+ null,
169
+ 2
170
+ );
171
+ } finally {
172
+ if (browser) await browser.close();
173
+ }
174
+ }
175
+
176
+ async function payBill(args: {
177
+ username: string;
178
+ password: string;
179
+ amount: string;
180
+ payment_method?: string;
181
+ }): Promise<string> {
182
+ let browser: Browser | null = null;
183
+ try {
184
+ browser = await getBrowser();
185
+ const page = await getPage(browser);
186
+ await ensureLoggedIn(page, args.username, args.password);
187
+ await navigate(page, SPECTRUM_BILL);
188
+
189
+ await page.waitForTimeout(2000);
190
+
191
+ // Look for "Make a Payment" or "Pay Now" button
192
+ const payButton = await page
193
+ .locator(
194
+ 'button:has-text("Make a Payment"), a:has-text("Make a Payment"), button:has-text("Pay Now"), a:has-text("Pay Now")'
195
+ )
196
+ .first();
197
+
198
+ const payButtonExists = (await payButton.count()) > 0;
199
+ if (!payButtonExists) {
200
+ const pageText = await page.evaluate(() => document.body.innerText);
201
+ return JSON.stringify({
202
+ status: "navigation_required",
203
+ message: "Could not locate the payment button automatically. Manual navigation required.",
204
+ billing_url: page.url(),
205
+ page_content_preview: pageText.slice(0, 500),
206
+ });
207
+ }
208
+
209
+ await payButton.click();
210
+ await page.waitForTimeout(2000);
211
+
212
+ // Try to fill amount if field exists
213
+ const amountField = page.locator(
214
+ 'input[name*="amount"], input[placeholder*="amount"], input[id*="amount"]'
215
+ ).first();
216
+
217
+ if ((await amountField.count()) > 0) {
218
+ await amountField.clear();
219
+ await amountField.fill(args.amount);
220
+ }
221
+
222
+ // Select payment method if specified
223
+ if (args.payment_method) {
224
+ const pmSelect = page.locator(
225
+ 'select[name*="payment"], select[id*="payment"]'
226
+ ).first();
227
+ if ((await pmSelect.count()) > 0) {
228
+ await pmSelect.selectOption({ label: args.payment_method });
229
+ }
230
+ }
231
+
232
+ const currentUrl = page.url();
233
+ const pageText = await page.evaluate(() => document.body.innerText);
234
+
235
+ return JSON.stringify({
236
+ status: "payment_form_ready",
237
+ message: `Payment form loaded. Amount field populated with $${args.amount}. Review and confirm to complete payment.`,
238
+ payment_url: currentUrl,
239
+ amount_entered: args.amount,
240
+ payment_method: args.payment_method ?? "default on file",
241
+ page_content_preview: pageText.slice(0, 500),
242
+ warning: "Payment has NOT been submitted. You must click the final confirm/submit button to complete payment.",
243
+ });
244
+ } finally {
245
+ if (browser) await browser.close();
246
+ }
247
+ }
248
+
249
+ async function getBillHistory(args: {
250
+ username: string;
251
+ password: string;
252
+ months?: number;
253
+ }): Promise<string> {
254
+ let browser: Browser | null = null;
255
+ try {
256
+ browser = await getBrowser();
257
+ const page = await getPage(browser);
258
+ await ensureLoggedIn(page, args.username, args.password);
259
+ await navigate(page, SPECTRUM_BILL);
260
+
261
+ await page.waitForTimeout(2000);
262
+
263
+ // Look for bill history section
264
+ const historyLink = page.locator(
265
+ 'a:has-text("Bill History"), a:has-text("Billing History"), a:has-text("View All"), button:has-text("Bill History")'
266
+ ).first();
267
+
268
+ if ((await historyLink.count()) > 0) {
269
+ await historyLink.click();
270
+ await page.waitForTimeout(2000);
271
+ }
272
+
273
+ const pageText = await page.evaluate(() => document.body.innerText);
274
+
275
+ // Try to parse bill entries from table or list
276
+ const bills: Array<{ date: string; amount: string; status: string }> = [];
277
+ const billPattern =
278
+ /(\w+\s+\d{1,2},?\s+\d{4})[^\n]*\$([\d,.]+)[^\n]*\n?([^\n]*(?:paid|pending|due|payment)[^\n]*)?/gi;
279
+
280
+ let match;
281
+ const maxBills = (args.months ?? 12) * 2; // rough upper bound
282
+ while ((match = billPattern.exec(pageText)) !== null && bills.length < maxBills) {
283
+ bills.push({
284
+ date: match[1].trim(),
285
+ amount: `$${match[2]}`,
286
+ status: match[3]?.trim() ?? "unknown",
287
+ });
288
+ }
289
+
290
+ return JSON.stringify(
291
+ {
292
+ billing_url: page.url(),
293
+ months_requested: args.months ?? 12,
294
+ bills_found: bills.length,
295
+ bill_history: bills,
296
+ raw_content: pageText.slice(0, 4000),
297
+ },
298
+ null,
299
+ 2
300
+ );
301
+ } finally {
302
+ if (browser) await browser.close();
303
+ }
304
+ }
305
+
306
+ async function checkOutages(args: {
307
+ zip_code?: string;
308
+ address?: string;
309
+ username?: string;
310
+ password?: string;
311
+ }): Promise<string> {
312
+ let browser: Browser | null = null;
313
+ try {
314
+ browser = await getBrowser();
315
+ const page = await getPage(browser);
316
+
317
+ // Outage check can work without login if zip is provided
318
+ if (args.username && args.password) {
319
+ await ensureLoggedIn(page, args.username, args.password);
320
+ }
321
+
322
+ await navigate(page, SPECTRUM_OUTAGES);
323
+ await page.waitForTimeout(2000);
324
+
325
+ // If zip code provided, try to fill the outage checker
326
+ if (args.zip_code) {
327
+ const zipField = page.locator(
328
+ 'input[placeholder*="zip"], input[name*="zip"], input[id*="zip"], input[placeholder*="ZIP"], input[type="text"]'
329
+ ).first();
330
+
331
+ if ((await zipField.count()) > 0) {
332
+ await zipField.fill(args.zip_code);
333
+ await page.keyboard.press("Enter");
334
+ await page.waitForTimeout(3000);
335
+ }
336
+ }
337
+
338
+ const pageText = await page.evaluate(() => document.body.innerText);
339
+
340
+ // Look for outage indicators
341
+ const hasOutage = /outage|service disruption|service interruption|we.?re aware/i.test(pageText);
342
+ const noOutage = /no outage|no known outage|service is operating normally|no current outage/i.test(pageText);
343
+
344
+ // Extract any outage details
345
+ const outageMatch = pageText.match(
346
+ /(outage|disruption)[^\n.]*[.\n][^\n.]*/i
347
+ );
348
+
349
+ return JSON.stringify(
350
+ {
351
+ outage_check_url: page.url(),
352
+ zip_code: args.zip_code ?? "not provided",
353
+ address: args.address ?? "not provided",
354
+ outage_detected: hasOutage && !noOutage,
355
+ service_normal: noOutage,
356
+ status_summary: outageMatch?.[0]?.trim() ?? (noOutage ? "No outages detected" : "Unable to determine outage status"),
357
+ raw_content: pageText.slice(0, 3000),
358
+ },
359
+ null,
360
+ 2
361
+ );
362
+ } finally {
363
+ if (browser) await browser.close();
364
+ }
365
+ }
366
+
367
+ async function scheduleTechnician(args: {
368
+ username: string;
369
+ password: string;
370
+ issue_description: string;
371
+ preferred_date?: string;
372
+ preferred_time?: string;
373
+ contact_phone?: string;
374
+ }): Promise<string> {
375
+ let browser: Browser | null = null;
376
+ try {
377
+ browser = await getBrowser();
378
+ const page = await getPage(browser);
379
+ await ensureLoggedIn(page, args.username, args.password);
380
+
381
+ // Try scheduling URL first, fall back to support
382
+ await navigate(page, SPECTRUM_SCHEDULE);
383
+ await page.waitForTimeout(2000);
384
+
385
+ if (!page.url().includes("schedule")) {
386
+ // Try support flow
387
+ await navigate(page, SPECTRUM_SUPPORT);
388
+ await page.waitForTimeout(2000);
389
+
390
+ const scheduleLink = page.locator(
391
+ 'a:has-text("Schedule"), a:has-text("Technician"), button:has-text("Schedule Appointment")'
392
+ ).first();
393
+
394
+ if ((await scheduleLink.count()) > 0) {
395
+ await scheduleLink.click();
396
+ await page.waitForTimeout(2000);
397
+ }
398
+ }
399
+
400
+ const pageText = await page.evaluate(() => document.body.innerText);
401
+
402
+ // Try to fill in scheduling form fields if present
403
+ const issueField = page.locator(
404
+ 'textarea, input[name*="description"], input[placeholder*="describe"], textarea[placeholder*="issue"]'
405
+ ).first();
406
+
407
+ if ((await issueField.count()) > 0) {
408
+ await issueField.fill(args.issue_description);
409
+ }
410
+
411
+ if (args.preferred_date) {
412
+ const dateField = page.locator(
413
+ 'input[type="date"], input[name*="date"], input[placeholder*="date"]'
414
+ ).first();
415
+ if ((await dateField.count()) > 0) {
416
+ await dateField.fill(args.preferred_date);
417
+ }
418
+ }
419
+
420
+ if (args.preferred_time) {
421
+ const timeSelect = page.locator(
422
+ 'select[name*="time"], select[id*="time"]'
423
+ ).first();
424
+ if ((await timeSelect.count()) > 0) {
425
+ await timeSelect.selectOption({ label: args.preferred_time });
426
+ }
427
+ }
428
+
429
+ if (args.contact_phone) {
430
+ const phoneField = page.locator(
431
+ 'input[type="tel"], input[name*="phone"], input[placeholder*="phone"]'
432
+ ).first();
433
+ if ((await phoneField.count()) > 0) {
434
+ await phoneField.fill(args.contact_phone);
435
+ }
436
+ }
437
+
438
+ return JSON.stringify(
439
+ {
440
+ scheduling_url: page.url(),
441
+ status: "form_ready",
442
+ issue_description: args.issue_description,
443
+ preferred_date: args.preferred_date ?? "not specified",
444
+ preferred_time: args.preferred_time ?? "not specified",
445
+ contact_phone: args.contact_phone ?? "not provided",
446
+ message:
447
+ "Scheduling form has been populated. Review the form and click the submit/confirm button to complete scheduling.",
448
+ warning: "Appointment has NOT been submitted yet. Manual confirmation required.",
449
+ page_content_preview: pageText.slice(0, 500),
450
+ },
451
+ null,
452
+ 2
453
+ );
454
+ } finally {
455
+ if (browser) await browser.close();
456
+ }
457
+ }
458
+
459
+ // ── Tool definitions ───────────────────────────────────────────────────────────
460
+
461
+ const TOOLS: Tool[] = [
462
+ {
463
+ name: "get_account_overview",
464
+ description:
465
+ "Get Spectrum account overview including current plan, balance due, payment due date, and data usage summary.",
466
+ inputSchema: {
467
+ type: "object",
468
+ properties: {
469
+ username: {
470
+ type: "string",
471
+ description: "Spectrum account username or email address",
472
+ },
473
+ password: {
474
+ type: "string",
475
+ description: "Spectrum account password",
476
+ },
477
+ },
478
+ required: ["username", "password"],
479
+ },
480
+ },
481
+ {
482
+ name: "get_service_details",
483
+ description:
484
+ "Get details about active Spectrum services (internet, TV, phone/voice, mobile). Optionally filter by service type.",
485
+ inputSchema: {
486
+ type: "object",
487
+ properties: {
488
+ username: {
489
+ type: "string",
490
+ description: "Spectrum account username or email address",
491
+ },
492
+ password: {
493
+ type: "string",
494
+ description: "Spectrum account password",
495
+ },
496
+ service_type: {
497
+ type: "string",
498
+ enum: ["internet", "tv", "phone", "voice", "mobile"],
499
+ description: "Filter to a specific service type. Omit to retrieve all services.",
500
+ },
501
+ },
502
+ required: ["username", "password"],
503
+ },
504
+ },
505
+ {
506
+ name: "pay_bill",
507
+ description:
508
+ "Initiate a one-time bill payment for a Spectrum account. Loads the payment form with the specified amount. Note: final submission requires user confirmation.",
509
+ inputSchema: {
510
+ type: "object",
511
+ properties: {
512
+ username: {
513
+ type: "string",
514
+ description: "Spectrum account username or email address",
515
+ },
516
+ password: {
517
+ type: "string",
518
+ description: "Spectrum account password",
519
+ },
520
+ amount: {
521
+ type: "string",
522
+ description: "Payment amount in dollars (e.g., '89.99'). Do not include the $ sign.",
523
+ },
524
+ payment_method: {
525
+ type: "string",
526
+ description: "Payment method label as it appears on the account (e.g., 'Visa ending in 4242'). Omit to use the default payment method on file.",
527
+ },
528
+ },
529
+ required: ["username", "password", "amount"],
530
+ },
531
+ },
532
+ {
533
+ name: "get_bill_history",
534
+ description:
535
+ "Retrieve past billing history for a Spectrum account, including bill dates, amounts, and payment status.",
536
+ inputSchema: {
537
+ type: "object",
538
+ properties: {
539
+ username: {
540
+ type: "string",
541
+ description: "Spectrum account username or email address",
542
+ },
543
+ password: {
544
+ type: "string",
545
+ description: "Spectrum account password",
546
+ },
547
+ months: {
548
+ type: "number",
549
+ description: "Number of months of history to retrieve (default: 12, max: 24)",
550
+ minimum: 1,
551
+ maximum: 24,
552
+ },
553
+ },
554
+ required: ["username", "password"],
555
+ },
556
+ },
557
+ {
558
+ name: "check_outages",
559
+ description:
560
+ "Check for active Spectrum service outages in a given area by ZIP code or address. Login is optional but provides account-specific outage info.",
561
+ inputSchema: {
562
+ type: "object",
563
+ properties: {
564
+ zip_code: {
565
+ type: "string",
566
+ description: "ZIP/postal code to check for outages (e.g., '90210')",
567
+ },
568
+ address: {
569
+ type: "string",
570
+ description: "Full service address to check for outages",
571
+ },
572
+ username: {
573
+ type: "string",
574
+ description: "Optional: Spectrum account username for account-specific outage check",
575
+ },
576
+ password: {
577
+ type: "string",
578
+ description: "Optional: Spectrum account password",
579
+ },
580
+ },
581
+ },
582
+ },
583
+ {
584
+ name: "schedule_technician",
585
+ description:
586
+ "Schedule a Spectrum technician visit for service issues, installation, or equipment problems. Populates the scheduling form; final submission requires user confirmation.",
587
+ inputSchema: {
588
+ type: "object",
589
+ properties: {
590
+ username: {
591
+ type: "string",
592
+ description: "Spectrum account username or email address",
593
+ },
594
+ password: {
595
+ type: "string",
596
+ description: "Spectrum account password",
597
+ },
598
+ issue_description: {
599
+ type: "string",
600
+ description: "Description of the issue requiring a technician visit (e.g., 'Internet dropping intermittently', 'TV signal pixelating on all channels')",
601
+ },
602
+ preferred_date: {
603
+ type: "string",
604
+ description: "Preferred appointment date in YYYY-MM-DD format (e.g., '2025-03-20')",
605
+ },
606
+ preferred_time: {
607
+ type: "string",
608
+ description: "Preferred time window (e.g., 'Morning (8am-12pm)', 'Afternoon (1pm-5pm)', 'Evening (5pm-8pm)')",
609
+ },
610
+ contact_phone: {
611
+ type: "string",
612
+ description: "Contact phone number for the technician appointment (e.g., '555-123-4567')",
613
+ },
614
+ },
615
+ required: ["username", "password", "issue_description"],
616
+ },
617
+ },
618
+ ];
619
+
620
+ // ── MCP Server ─────────────────────────────────────────────────────────────────
621
+
622
+ const server = new Server(
623
+ {
624
+ name: "@striderlabs/mcp-spectrum",
625
+ version: "1.0.0",
626
+ },
627
+ {
628
+ capabilities: {
629
+ tools: {},
630
+ },
631
+ }
632
+ );
633
+
634
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
635
+ tools: TOOLS,
636
+ }));
637
+
638
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
639
+ const { name, arguments: args } = request.params;
640
+
641
+ try {
642
+ let result: string;
643
+
644
+ switch (name) {
645
+ case "get_account_overview":
646
+ result = await getAccountOverview(
647
+ args as { username: string; password: string }
648
+ );
649
+ break;
650
+
651
+ case "get_service_details":
652
+ result = await getServiceDetails(
653
+ args as { username: string; password: string; service_type?: string }
654
+ );
655
+ break;
656
+
657
+ case "pay_bill":
658
+ result = await payBill(
659
+ args as {
660
+ username: string;
661
+ password: string;
662
+ amount: string;
663
+ payment_method?: string;
664
+ }
665
+ );
666
+ break;
667
+
668
+ case "get_bill_history":
669
+ result = await getBillHistory(
670
+ args as { username: string; password: string; months?: number }
671
+ );
672
+ break;
673
+
674
+ case "check_outages":
675
+ result = await checkOutages(
676
+ args as {
677
+ zip_code?: string;
678
+ address?: string;
679
+ username?: string;
680
+ password?: string;
681
+ }
682
+ );
683
+ break;
684
+
685
+ case "schedule_technician":
686
+ result = await scheduleTechnician(
687
+ args as {
688
+ username: string;
689
+ password: string;
690
+ issue_description: string;
691
+ preferred_date?: string;
692
+ preferred_time?: string;
693
+ contact_phone?: string;
694
+ }
695
+ );
696
+ break;
697
+
698
+ default:
699
+ return {
700
+ content: [{ type: "text", text: `Unknown tool: ${name}` }],
701
+ isError: true,
702
+ };
703
+ }
704
+
705
+ return {
706
+ content: [{ type: "text", text: result }],
707
+ };
708
+ } catch (error) {
709
+ const message = error instanceof Error ? error.message : String(error);
710
+ return {
711
+ content: [
712
+ {
713
+ type: "text",
714
+ text: JSON.stringify({ error: message, tool: name }, null, 2),
715
+ },
716
+ ],
717
+ isError: true,
718
+ };
719
+ }
720
+ });
721
+
722
+ // ── Entrypoint ─────────────────────────────────────────────────────────────────
723
+
724
+ async function main(): Promise<void> {
725
+ const transport = new StdioServerTransport();
726
+ await server.connect(transport);
727
+ console.error("@striderlabs/mcp-spectrum MCP server running on stdio");
728
+ }
729
+
730
+ main().catch((err) => {
731
+ console.error("Fatal error:", err);
732
+ process.exit(1);
733
+ });