fetchpet-mcp-server 0.1.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/README.md +181 -0
- package/build/index.integration-with-mock.js +166 -0
- package/build/index.js +129 -0
- package/package.json +53 -0
- package/shared/index.d.ts +5 -0
- package/shared/index.js +6 -0
- package/shared/logging.d.ts +20 -0
- package/shared/logging.js +34 -0
- package/shared/server.d.ts +135 -0
- package/shared/server.js +831 -0
- package/shared/tools.d.ts +8 -0
- package/shared/tools.js +403 -0
- package/shared/types.d.ts +105 -0
- package/shared/types.js +24 -0
package/shared/server.js
ADDED
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { createRegisterTools } from './tools.js';
|
|
3
|
+
import { randomBytes } from 'crypto';
|
|
4
|
+
import { mkdirSync, existsSync, writeFileSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
const BASE_URL = 'https://my.fetchpet.com';
|
|
7
|
+
/**
|
|
8
|
+
* Fetch Pet client implementation using Playwright
|
|
9
|
+
*/
|
|
10
|
+
export class FetchPetClient {
|
|
11
|
+
browser = null;
|
|
12
|
+
context = null;
|
|
13
|
+
page = null;
|
|
14
|
+
config;
|
|
15
|
+
isInitialized = false;
|
|
16
|
+
// Store pending claim data for submission confirmation
|
|
17
|
+
pendingClaimData = null;
|
|
18
|
+
pendingConfirmationToken = null;
|
|
19
|
+
pendingTokenCreatedAt = null;
|
|
20
|
+
// Token expires after 2 minutes (browser modal may close after this)
|
|
21
|
+
static TOKEN_EXPIRY_MS = 120_000;
|
|
22
|
+
constructor(config) {
|
|
23
|
+
this.config = config;
|
|
24
|
+
// Ensure download directory exists
|
|
25
|
+
if (!existsSync(config.downloadDir)) {
|
|
26
|
+
mkdirSync(config.downloadDir, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async ensureBrowser() {
|
|
30
|
+
if (!this.page) {
|
|
31
|
+
throw new Error('Browser not initialized. Call initialize() first.');
|
|
32
|
+
}
|
|
33
|
+
return this.page;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Extract claim data from active claim cards on the current page.
|
|
37
|
+
* The history tab uses extractHistoricalClaimsFromPage instead,
|
|
38
|
+
* due to a completely different DOM structure.
|
|
39
|
+
*/
|
|
40
|
+
async extractClaimsFromPage(page, defaultStatus) {
|
|
41
|
+
const claims = [];
|
|
42
|
+
const claimCards = await page.$$('.claim-card-data-list');
|
|
43
|
+
for (let i = 0; i < claimCards.length; i++) {
|
|
44
|
+
const card = claimCards[i];
|
|
45
|
+
// Get pet name from .pet-name or .claims-title
|
|
46
|
+
const petEl = await card.$('.pet-name, .claims-title');
|
|
47
|
+
const petName = petEl ? (await petEl.textContent())?.trim() || 'Unknown Pet' : 'Unknown Pet';
|
|
48
|
+
// Get status from .status-text.status
|
|
49
|
+
const statusEl = await card.$('.status-text.status');
|
|
50
|
+
const status = statusEl
|
|
51
|
+
? (await statusEl.textContent())?.trim().toLowerCase() || defaultStatus
|
|
52
|
+
: defaultStatus;
|
|
53
|
+
// Get payout/amount from .claim-invoice-details.fw-700
|
|
54
|
+
const amountEl = await card.$('.claim-invoice-details.fw-700');
|
|
55
|
+
const claimAmount = amountEl ? (await amountEl.textContent())?.trim() || '' : '';
|
|
56
|
+
// Get reason for visit from .treated-for, .disease
|
|
57
|
+
const reasonEl = await card.$('.treated-for, .disease, .claim-id.treated-for');
|
|
58
|
+
const description = reasonEl ? (await reasonEl.textContent())?.trim() : undefined;
|
|
59
|
+
// Include index to ensure uniqueness when pet+description are the same
|
|
60
|
+
const claimId = `claim-${i}-${petName}-${description || 'unknown'}`
|
|
61
|
+
.replace(/\s+/g, '-')
|
|
62
|
+
.toLowerCase();
|
|
63
|
+
claims.push({
|
|
64
|
+
claimId,
|
|
65
|
+
petName,
|
|
66
|
+
claimDate: '', // Date not visible on card, only in modal
|
|
67
|
+
claimAmount,
|
|
68
|
+
status,
|
|
69
|
+
description,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return claims;
|
|
73
|
+
}
|
|
74
|
+
async initialize() {
|
|
75
|
+
if (this.isInitialized) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// Use playwright-extra with stealth plugin for better bot detection avoidance
|
|
79
|
+
const { chromium } = await import('playwright-extra');
|
|
80
|
+
const StealthPlugin = (await import('puppeteer-extra-plugin-stealth')).default;
|
|
81
|
+
chromium.use(StealthPlugin());
|
|
82
|
+
this.browser = await chromium.launch({
|
|
83
|
+
headless: this.config.headless,
|
|
84
|
+
args: [
|
|
85
|
+
'--disable-blink-features=AutomationControlled',
|
|
86
|
+
'--disable-dev-shm-usage',
|
|
87
|
+
'--no-sandbox',
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
this.context = await this.browser.newContext({
|
|
91
|
+
viewport: { width: 1920, height: 1080 },
|
|
92
|
+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
93
|
+
acceptDownloads: true,
|
|
94
|
+
});
|
|
95
|
+
this.page = await this.context.newPage();
|
|
96
|
+
this.page.setDefaultTimeout(this.config.timeout);
|
|
97
|
+
// Navigate to login page
|
|
98
|
+
await this.page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
|
99
|
+
await this.page.waitForTimeout(3000);
|
|
100
|
+
// Fill in login credentials - Fetch Pet uses a React app
|
|
101
|
+
// Try to find email input
|
|
102
|
+
const emailInput = await this.page.$('input[type="email"], input[name="email"], #email');
|
|
103
|
+
if (emailInput) {
|
|
104
|
+
await emailInput.fill(this.config.username);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// Try alternative selectors
|
|
108
|
+
await this.page.fill('input[placeholder*="email" i], input[autocomplete="email"]', this.config.username);
|
|
109
|
+
}
|
|
110
|
+
// Fill password
|
|
111
|
+
const passwordInput = await this.page.$('input[type="password"], input[name="password"], #password');
|
|
112
|
+
if (passwordInput) {
|
|
113
|
+
await passwordInput.fill(this.config.password);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
await this.page.fill('input[placeholder*="password" i]', this.config.password);
|
|
117
|
+
}
|
|
118
|
+
// Click sign in button and wait for navigation simultaneously
|
|
119
|
+
// The login click triggers a full page navigation that destroys the execution context,
|
|
120
|
+
// so we must use Promise.all to avoid "Execution context was destroyed" errors.
|
|
121
|
+
const signInButton = await this.page.$('button[type="submit"], button:has-text("Sign In"), button:has-text("Log In"), button:has-text("Login")');
|
|
122
|
+
if (signInButton) {
|
|
123
|
+
await Promise.all([
|
|
124
|
+
this.page.waitForURL((url) => !url.toString().includes('login'), {
|
|
125
|
+
timeout: this.config.timeout,
|
|
126
|
+
}),
|
|
127
|
+
signInButton.click(),
|
|
128
|
+
]);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
await Promise.all([
|
|
132
|
+
this.page.waitForURL((url) => !url.toString().includes('login'), {
|
|
133
|
+
timeout: this.config.timeout,
|
|
134
|
+
}),
|
|
135
|
+
this.page.click('button:has-text("Sign"), button:has-text("Log")'),
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
// Wait for SPA to settle after redirect
|
|
139
|
+
await this.page.waitForTimeout(3000);
|
|
140
|
+
// Verify login was successful by checking URL or presence of dashboard elements
|
|
141
|
+
const currentUrl = this.page.url();
|
|
142
|
+
if (currentUrl.includes('login') || currentUrl.includes('signin')) {
|
|
143
|
+
// Check for error messages
|
|
144
|
+
const errorMsg = await this.page.$('.error, [class*="error"], [role="alert"]');
|
|
145
|
+
if (errorMsg) {
|
|
146
|
+
const errorText = await errorMsg.textContent();
|
|
147
|
+
throw new Error(`Login failed: ${errorText || 'Invalid credentials'}`);
|
|
148
|
+
}
|
|
149
|
+
throw new Error('Login failed - still on login page. Check your credentials.');
|
|
150
|
+
}
|
|
151
|
+
this.isInitialized = true;
|
|
152
|
+
}
|
|
153
|
+
async prepareClaimToSubmit(petName, invoiceDate, invoiceAmount, providerName, claimDescription, invoiceFilePath, medicalRecordsPath) {
|
|
154
|
+
const page = await this.ensureBrowser();
|
|
155
|
+
const validationErrors = [];
|
|
156
|
+
// Navigate to claims page and click "Submit a claim" button
|
|
157
|
+
await page.goto(`${BASE_URL}/claims/active`, { waitUntil: 'domcontentloaded' });
|
|
158
|
+
// Wait for the page to render (claim cards or the submit button)
|
|
159
|
+
await page
|
|
160
|
+
.waitForSelector('.claim-card-data-list, button:has-text("Submit a claim"), .filled-btn', {
|
|
161
|
+
timeout: 10000,
|
|
162
|
+
})
|
|
163
|
+
.catch(() => { });
|
|
164
|
+
await page.waitForTimeout(2000);
|
|
165
|
+
// Look for "Submit a claim" button to open the modal (it's a teal/filled button)
|
|
166
|
+
const submitClaimButton = await page.$('button.filled-btn:has-text("Submit a claim"), button:has-text("Submit a claim")');
|
|
167
|
+
if (submitClaimButton) {
|
|
168
|
+
await submitClaimButton.click();
|
|
169
|
+
await page.waitForTimeout(2000);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
validationErrors.push('Could not find "Submit a claim" button');
|
|
173
|
+
// Return early - no point filling out a form that doesn't exist
|
|
174
|
+
return this.buildClaimResult(petName, invoiceDate, invoiceAmount, providerName, claimDescription, invoiceFilePath, medicalRecordsPath, validationErrors);
|
|
175
|
+
}
|
|
176
|
+
// Note: petName is used for claim identification but pet selection in the form
|
|
177
|
+
// is typically pre-populated based on the user's account. If the account has
|
|
178
|
+
// multiple pets, the form may default to the first pet regardless of petName.
|
|
179
|
+
// 1. Select primary vet - uses a typeahead input with class rbt-input-main
|
|
180
|
+
const vetInput = await page.$('.rbt-input-main, input[placeholder="Search vets"]');
|
|
181
|
+
if (vetInput) {
|
|
182
|
+
await vetInput.click({ clickCount: 3 }); // Select all
|
|
183
|
+
await vetInput.fill(providerName);
|
|
184
|
+
await page.waitForTimeout(1000);
|
|
185
|
+
// Look for autocomplete dropdown option and click it
|
|
186
|
+
const vetOption = await page.$('[role="listbox"] [role="option"], .rbt-menu [role="option"]');
|
|
187
|
+
if (vetOption) {
|
|
188
|
+
await vetOption.click();
|
|
189
|
+
await page.waitForTimeout(500);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
const existingVet = await page.$('.rbt-input-main');
|
|
194
|
+
if (!existingVet) {
|
|
195
|
+
validationErrors.push('Could not find vet selection field');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// 2. Select diagnosis/reason for visit - uses MuiAutocomplete with "Search diagnoses" placeholder
|
|
199
|
+
const diagnosisInput = await page.$('.MuiAutocomplete-input, input[placeholder="Search diagnoses"]');
|
|
200
|
+
if (diagnosisInput) {
|
|
201
|
+
await diagnosisInput.click();
|
|
202
|
+
await diagnosisInput.fill(claimDescription);
|
|
203
|
+
await page.waitForTimeout(1000);
|
|
204
|
+
const diagOption = await page.$('.MuiAutocomplete-listbox [role="option"], [role="listbox"] [role="option"]');
|
|
205
|
+
if (diagOption) {
|
|
206
|
+
await diagOption.click();
|
|
207
|
+
await page.waitForTimeout(500);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// 3. Fill in additional details (optional textarea)
|
|
211
|
+
const detailsTextarea = await page.$('textarea[placeholder="Describe the visit"], textarea');
|
|
212
|
+
if (detailsTextarea) {
|
|
213
|
+
await detailsTextarea.fill(`Date: ${invoiceDate}, Amount: ${invoiceAmount}. ${claimDescription}`);
|
|
214
|
+
}
|
|
215
|
+
// 4. Handle invoice file upload
|
|
216
|
+
if (invoiceFilePath) {
|
|
217
|
+
const invoiceUpload = await page.$('input[type="file"]');
|
|
218
|
+
if (invoiceUpload) {
|
|
219
|
+
await invoiceUpload.setInputFiles(invoiceFilePath);
|
|
220
|
+
await page.waitForTimeout(1000);
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
validationErrors.push('Could not find invoice file upload field');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
validationErrors.push('Invoice file is required for claim submission');
|
|
228
|
+
}
|
|
229
|
+
// 5. Handle medical records upload (optional)
|
|
230
|
+
if (medicalRecordsPath) {
|
|
231
|
+
const uploadInputs = await page.$$('input[type="file"]');
|
|
232
|
+
if (uploadInputs.length > 1) {
|
|
233
|
+
await uploadInputs[1].setInputFiles(medicalRecordsPath);
|
|
234
|
+
await page.waitForTimeout(1000);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Check for form validation errors (scope to form elements only)
|
|
238
|
+
await page.waitForTimeout(1000);
|
|
239
|
+
const formErrors = await page.$$('.invalid-feedback, .form-error, [class*="field-error"]');
|
|
240
|
+
for (const errorEl of formErrors) {
|
|
241
|
+
const errorText = await errorEl.textContent();
|
|
242
|
+
if (errorText && errorText.trim() && !validationErrors.includes(errorText.trim())) {
|
|
243
|
+
validationErrors.push(errorText.trim());
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return this.buildClaimResult(petName, invoiceDate, invoiceAmount, providerName, claimDescription, invoiceFilePath, medicalRecordsPath, validationErrors);
|
|
247
|
+
}
|
|
248
|
+
buildClaimResult(petName, invoiceDate, invoiceAmount, providerName, claimDescription, invoiceFilePath, medicalRecordsPath, validationErrors) {
|
|
249
|
+
// Generate confirmation token
|
|
250
|
+
const confirmationToken = randomBytes(16).toString('hex');
|
|
251
|
+
const claimData = {
|
|
252
|
+
petName,
|
|
253
|
+
invoiceDate,
|
|
254
|
+
invoiceAmount,
|
|
255
|
+
providerName,
|
|
256
|
+
claimDescription,
|
|
257
|
+
invoiceFile: invoiceFilePath,
|
|
258
|
+
medicalRecordsFile: medicalRecordsPath,
|
|
259
|
+
isReadyToSubmit: validationErrors.length === 0,
|
|
260
|
+
validationErrors,
|
|
261
|
+
confirmationMessage: validationErrors.length === 0
|
|
262
|
+
? `IMPORTANT: This claim has been prepared but NOT submitted yet.
|
|
263
|
+
|
|
264
|
+
To submit this claim, call submit_claim with confirmation_token: "${confirmationToken}"
|
|
265
|
+
|
|
266
|
+
Claim Details:
|
|
267
|
+
- Pet: ${petName}
|
|
268
|
+
- Invoice Date: ${invoiceDate}
|
|
269
|
+
- Amount: ${invoiceAmount}
|
|
270
|
+
- Provider: ${providerName}
|
|
271
|
+
- Description: ${claimDescription}
|
|
272
|
+
${invoiceFilePath ? `- Invoice File: ${invoiceFilePath}` : ''}
|
|
273
|
+
${medicalRecordsPath ? `- Medical Records: ${medicalRecordsPath}` : ''}
|
|
274
|
+
|
|
275
|
+
The user MUST explicitly confirm they want to submit this claim before calling submit_claim.`
|
|
276
|
+
: `Cannot submit claim due to validation errors:\n${validationErrors.join('\n')}`,
|
|
277
|
+
};
|
|
278
|
+
// Store pending claim data for later submission
|
|
279
|
+
if (validationErrors.length === 0) {
|
|
280
|
+
this.pendingClaimData = claimData;
|
|
281
|
+
this.pendingConfirmationToken = confirmationToken;
|
|
282
|
+
this.pendingTokenCreatedAt = Date.now();
|
|
283
|
+
}
|
|
284
|
+
return claimData;
|
|
285
|
+
}
|
|
286
|
+
async submitClaim(confirmationToken) {
|
|
287
|
+
// NOTE: This method assumes the claim form is still open from prepareClaimToSubmit.
|
|
288
|
+
// The browser state (open modal with filled form) must be preserved between calls.
|
|
289
|
+
// If too much time passes, the SPA may close the modal or navigate away, causing
|
|
290
|
+
// the submit button to not be found. The AI agent should call this promptly after
|
|
291
|
+
// receiving user confirmation.
|
|
292
|
+
const page = await this.ensureBrowser();
|
|
293
|
+
// Verify confirmation token matches and hasn't expired
|
|
294
|
+
if (!this.pendingConfirmationToken || this.pendingConfirmationToken !== confirmationToken) {
|
|
295
|
+
return {
|
|
296
|
+
success: false,
|
|
297
|
+
message: 'Invalid or expired confirmation token. Please call prepare_claim_to_submit first to get a new token.',
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
if (this.pendingTokenCreatedAt &&
|
|
301
|
+
Date.now() - this.pendingTokenCreatedAt > FetchPetClient.TOKEN_EXPIRY_MS) {
|
|
302
|
+
this.pendingClaimData = null;
|
|
303
|
+
this.pendingConfirmationToken = null;
|
|
304
|
+
this.pendingTokenCreatedAt = null;
|
|
305
|
+
return {
|
|
306
|
+
success: false,
|
|
307
|
+
message: 'Confirmation token has expired. The claim form may no longer be open. Please call prepare_claim_to_submit again.',
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
if (!this.pendingClaimData || !this.pendingClaimData.isReadyToSubmit) {
|
|
311
|
+
return {
|
|
312
|
+
success: false,
|
|
313
|
+
message: 'No valid claim prepared for submission. Please call prepare_claim_to_submit first.',
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
// Find and click the submit button
|
|
317
|
+
const submitButton = await page.$('button[type="submit"], button:has-text("Submit"), button:has-text("File Claim"), button:has-text("Submit Claim")');
|
|
318
|
+
if (!submitButton) {
|
|
319
|
+
return {
|
|
320
|
+
success: false,
|
|
321
|
+
message: 'Could not find submit button on the page',
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
await submitButton.click();
|
|
325
|
+
await page.waitForTimeout(3000);
|
|
326
|
+
// Check for success confirmation
|
|
327
|
+
const successMessage = await page.$('.success, [class*="success"], [class*="confirmation"], [role="alert"]:has-text("success")');
|
|
328
|
+
if (successMessage) {
|
|
329
|
+
const successText = await successMessage.textContent();
|
|
330
|
+
// Try to extract claim ID or confirmation number
|
|
331
|
+
let claimId;
|
|
332
|
+
const claimIdMatch = successText?.match(/claim[:\s#]*([A-Z0-9-]+)/i);
|
|
333
|
+
if (claimIdMatch) {
|
|
334
|
+
claimId = claimIdMatch[1];
|
|
335
|
+
}
|
|
336
|
+
// Clear pending data
|
|
337
|
+
this.pendingClaimData = null;
|
|
338
|
+
this.pendingConfirmationToken = null;
|
|
339
|
+
this.pendingTokenCreatedAt = null;
|
|
340
|
+
return {
|
|
341
|
+
success: true,
|
|
342
|
+
message: successText || 'Claim submitted successfully',
|
|
343
|
+
claimId,
|
|
344
|
+
confirmationNumber: claimId,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
// Check for error
|
|
348
|
+
const errorMessage = await page.$('.error, [class*="error"], [role="alert"]:not(:has-text("success"))');
|
|
349
|
+
if (errorMessage) {
|
|
350
|
+
const errorText = await errorMessage.textContent();
|
|
351
|
+
return {
|
|
352
|
+
success: false,
|
|
353
|
+
message: `Submission failed: ${errorText}`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
// Check URL for indication of success (redirected to claims list, etc.)
|
|
357
|
+
const currentUrl = page.url();
|
|
358
|
+
if (currentUrl.includes('claims') &&
|
|
359
|
+
!currentUrl.includes('new') &&
|
|
360
|
+
!currentUrl.includes('submit')) {
|
|
361
|
+
// Clear pending data
|
|
362
|
+
this.pendingClaimData = null;
|
|
363
|
+
this.pendingConfirmationToken = null;
|
|
364
|
+
this.pendingTokenCreatedAt = null;
|
|
365
|
+
return {
|
|
366
|
+
success: true,
|
|
367
|
+
message: 'Claim appears to have been submitted successfully (redirected to claims page)',
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
success: false,
|
|
372
|
+
message: 'Could not confirm claim submission. Please check your claims list.',
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
async getClaims() {
|
|
376
|
+
const page = await this.ensureBrowser();
|
|
377
|
+
const allClaims = [];
|
|
378
|
+
// 1. Get active claims from the Active tab
|
|
379
|
+
await page.goto(`${BASE_URL}/claims/active`, { waitUntil: 'domcontentloaded' });
|
|
380
|
+
await page
|
|
381
|
+
.waitForSelector('.claim-card-data-list, .no-claims, .claims-empty', { timeout: 10000 })
|
|
382
|
+
.catch(() => { });
|
|
383
|
+
await page.waitForTimeout(2000);
|
|
384
|
+
const activeClaims = await this.extractClaimsFromPage(page, 'pending');
|
|
385
|
+
allClaims.push(...activeClaims);
|
|
386
|
+
// 2. Get historical claims from the History tab
|
|
387
|
+
await page.goto(`${BASE_URL}/claims/closed`, { waitUntil: 'domcontentloaded' });
|
|
388
|
+
await page
|
|
389
|
+
.waitForSelector('.claims-coverage-container, .claim-number-closed, .no-claims', {
|
|
390
|
+
timeout: 10000,
|
|
391
|
+
})
|
|
392
|
+
.catch(() => { });
|
|
393
|
+
await page.waitForTimeout(2000);
|
|
394
|
+
// Click "View all" to expand beyond the default 3 most recent claims per pet
|
|
395
|
+
const viewAllLinks = await page.$$('.view-all-label .cursor-pointer, .view-all-label span');
|
|
396
|
+
for (const link of viewAllLinks) {
|
|
397
|
+
await link.click();
|
|
398
|
+
await page.waitForTimeout(1000);
|
|
399
|
+
}
|
|
400
|
+
const historicalClaims = await this.extractHistoricalClaimsFromPage(page);
|
|
401
|
+
allClaims.push(...historicalClaims);
|
|
402
|
+
return allClaims;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Extract claims from the History tab's table layout.
|
|
406
|
+
* History uses .claim-number-closed, .closed-claim-price, .claims-history-title
|
|
407
|
+
* which is completely different from the Active tab's .claim-card-data-list structure.
|
|
408
|
+
*/
|
|
409
|
+
async extractHistoricalClaimsFromPage(page) {
|
|
410
|
+
return page.evaluate(() => {
|
|
411
|
+
const claims = [];
|
|
412
|
+
// Each pet has a container with .claims-history-title for the pet name
|
|
413
|
+
// followed by rows of claims with .claim-number-closed and .closed-claim-price
|
|
414
|
+
const containers = Array.from(document.querySelectorAll('.claims-coverage-container'));
|
|
415
|
+
for (const container of containers) {
|
|
416
|
+
const petNameEl = container.querySelector('.claims-history-title');
|
|
417
|
+
const petName = petNameEl?.textContent?.trim() || 'Unknown Pet';
|
|
418
|
+
// Each claim row has .claim-number-closed elements (claim ID and date)
|
|
419
|
+
// and .closed-claim-price for the amount
|
|
420
|
+
const claimNumbers = container.querySelectorAll('.claim-number-closed');
|
|
421
|
+
const claimPrices = container.querySelectorAll('.closed-claim-price');
|
|
422
|
+
// Claim numbers come in pairs: [claimId, date, claimId, date, ...]
|
|
423
|
+
for (let i = 0; i < claimNumbers.length; i += 2) {
|
|
424
|
+
const claimIdText = claimNumbers[i]?.textContent?.trim() || '';
|
|
425
|
+
const dateText = claimNumbers[i + 1]?.textContent?.trim() || '';
|
|
426
|
+
const priceIndex = Math.floor(i / 2);
|
|
427
|
+
const amount = claimPrices[priceIndex]?.textContent?.trim() || '';
|
|
428
|
+
// Clean claim ID (remove # prefix)
|
|
429
|
+
const cleanClaimId = claimIdText.replace('#', '');
|
|
430
|
+
claims.push({
|
|
431
|
+
claimId: cleanClaimId || `history-${priceIndex}`,
|
|
432
|
+
petName,
|
|
433
|
+
claimDate: dateText,
|
|
434
|
+
claimAmount: amount,
|
|
435
|
+
status: 'closed',
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return claims;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Download a document by clicking its link, which opens a popup with a blob: iframe.
|
|
444
|
+
* Fetches the blob data from the popup and saves it to disk.
|
|
445
|
+
*/
|
|
446
|
+
async downloadDocumentFromPopup(page, selector, filePrefix) {
|
|
447
|
+
try {
|
|
448
|
+
const descEl = await page.$(selector);
|
|
449
|
+
if (!descEl) {
|
|
450
|
+
return { summary: `Document link not found` };
|
|
451
|
+
}
|
|
452
|
+
// Click the parent .cursor-pointer container to trigger the popup
|
|
453
|
+
const parent = await descEl.evaluateHandle((el) => el.closest('.cursor-pointer'));
|
|
454
|
+
if (!parent) {
|
|
455
|
+
return { summary: `Document container not found` };
|
|
456
|
+
}
|
|
457
|
+
// Listen for new popup page before clicking
|
|
458
|
+
const popupPromise = page.context().waitForEvent('page', { timeout: 15000 });
|
|
459
|
+
await parent.click();
|
|
460
|
+
const popup = await popupPromise;
|
|
461
|
+
// Wait for the popup to load its content
|
|
462
|
+
await popup.waitForLoadState('domcontentloaded').catch(() => { });
|
|
463
|
+
await popup.waitForTimeout(3000);
|
|
464
|
+
// The popup contains an <iframe> with a blob: URL pointing to the PDF
|
|
465
|
+
const blobData = await popup.evaluate(async () => {
|
|
466
|
+
const iframe = document.querySelector('iframe');
|
|
467
|
+
if (!iframe?.src)
|
|
468
|
+
return null;
|
|
469
|
+
const response = await fetch(iframe.src);
|
|
470
|
+
const blob = await response.blob();
|
|
471
|
+
const buffer = await blob.arrayBuffer();
|
|
472
|
+
// Convert to base64 for transfer back to Node.js
|
|
473
|
+
const bytes = new Uint8Array(buffer);
|
|
474
|
+
let binary = '';
|
|
475
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
476
|
+
binary += String.fromCharCode(bytes[i]);
|
|
477
|
+
}
|
|
478
|
+
return { base64: btoa(binary), type: blob.type, size: blob.size };
|
|
479
|
+
});
|
|
480
|
+
// Close the popup
|
|
481
|
+
await popup.close();
|
|
482
|
+
if (!blobData) {
|
|
483
|
+
return { summary: 'Document popup opened but no PDF content found' };
|
|
484
|
+
}
|
|
485
|
+
// Save the blob to disk
|
|
486
|
+
const ext = blobData.type.includes('pdf') ? 'pdf' : 'bin';
|
|
487
|
+
const filePath = join(this.config.downloadDir, `${filePrefix}_${Date.now()}.${ext}`);
|
|
488
|
+
const buffer = Buffer.from(blobData.base64, 'base64');
|
|
489
|
+
writeFileSync(filePath, buffer);
|
|
490
|
+
return {
|
|
491
|
+
localPath: filePath,
|
|
492
|
+
summary: `Downloaded (${Math.round(blobData.size / 1024)}KB) to: ${filePath}`,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
return {
|
|
497
|
+
summary: `Document download failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
async getClaimDetails(claimId) {
|
|
502
|
+
const page = await this.ensureBrowser();
|
|
503
|
+
// Determine if this is a history claim (numeric ID like "006207086") or active claim
|
|
504
|
+
const isHistoryClaim = /^\d+$/.test(claimId);
|
|
505
|
+
if (isHistoryClaim) {
|
|
506
|
+
// For history claims, navigate to history tab and click the matching "Details" button
|
|
507
|
+
await page.goto(`${BASE_URL}/claims/closed`, { waitUntil: 'domcontentloaded' });
|
|
508
|
+
await page
|
|
509
|
+
.waitForSelector('.claims-coverage-container, .claim-number-closed', { timeout: 10000 })
|
|
510
|
+
.catch(() => { });
|
|
511
|
+
await page.waitForTimeout(2000);
|
|
512
|
+
// Find the claim row matching this ID and click its Details button
|
|
513
|
+
const claimNumber = `#${claimId}`;
|
|
514
|
+
const clicked = await page.evaluate((targetId) => {
|
|
515
|
+
const claimNums = document.querySelectorAll('.claim-number-closed');
|
|
516
|
+
for (let i = 0; i < claimNums.length; i++) {
|
|
517
|
+
if (claimNums[i].textContent?.trim() === targetId) {
|
|
518
|
+
// The Details button is in the sibling .right-box of the parent flex container
|
|
519
|
+
const row = claimNums[i].closest('.d-flex.align-items-center');
|
|
520
|
+
const detailsBtn = row?.querySelector('.closed-claim-details-popup');
|
|
521
|
+
if (detailsBtn instanceof HTMLElement) {
|
|
522
|
+
detailsBtn.click();
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return false;
|
|
528
|
+
}, claimNumber);
|
|
529
|
+
if (!clicked) {
|
|
530
|
+
return {
|
|
531
|
+
claimId,
|
|
532
|
+
petName: '',
|
|
533
|
+
claimDate: '',
|
|
534
|
+
claimAmount: '',
|
|
535
|
+
status: '',
|
|
536
|
+
error: `Could not find historical claim ${claimId} on the claims page`,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
await page.waitForSelector('.MuiDialog-root.generic-dialog', { timeout: 5000 });
|
|
540
|
+
await page.waitForTimeout(1000);
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
// For active claims, navigate to active tab and click "See summary"
|
|
544
|
+
await page.goto(`${BASE_URL}/claims/active`, { waitUntil: 'domcontentloaded' });
|
|
545
|
+
await page.waitForSelector('.claim-card-data-list', { timeout: 10000 }).catch(() => { });
|
|
546
|
+
await page.waitForTimeout(2000);
|
|
547
|
+
let foundModal = false;
|
|
548
|
+
// Try to match by content from generated claimId (e.g. "claim-0-nova-lameness")
|
|
549
|
+
const searchTerms = claimId
|
|
550
|
+
.replace(/^claim-\d+-/, '')
|
|
551
|
+
.replace(/-/g, ' ')
|
|
552
|
+
.trim();
|
|
553
|
+
const claimCards = await page.$$('.claim-card-data-list');
|
|
554
|
+
if (searchTerms && searchTerms !== 'unknown') {
|
|
555
|
+
for (const card of claimCards) {
|
|
556
|
+
const cardText = ((await card.textContent()) || '').toLowerCase();
|
|
557
|
+
if (cardText.includes(searchTerms.toLowerCase())) {
|
|
558
|
+
const seeSummaryLink = await card.$('.details-link');
|
|
559
|
+
if (seeSummaryLink) {
|
|
560
|
+
await seeSummaryLink.click();
|
|
561
|
+
await page.waitForSelector('.MuiDialog-root.generic-dialog', { timeout: 5000 });
|
|
562
|
+
await page.waitForTimeout(1000);
|
|
563
|
+
foundModal = true;
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (!foundModal) {
|
|
570
|
+
return {
|
|
571
|
+
claimId,
|
|
572
|
+
petName: '',
|
|
573
|
+
claimDate: '',
|
|
574
|
+
claimAmount: '',
|
|
575
|
+
status: '',
|
|
576
|
+
error: `Could not find active claim matching "${claimId}" on the claims page`,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
// Extract claim details from the MuiDialog popup
|
|
581
|
+
// Both active and history popups use MuiDialog-root.generic-dialog
|
|
582
|
+
const details = await page.evaluate(() => {
|
|
583
|
+
const dialog = document.querySelector('.MuiDialog-root.generic-dialog .MuiDialog-paper');
|
|
584
|
+
if (!dialog)
|
|
585
|
+
return null;
|
|
586
|
+
const text = dialog.textContent || '';
|
|
587
|
+
// Claim ID: either "CLAIM #006207086" (history) or "#006271662" (active)
|
|
588
|
+
const claimIdMatch = text.match(/#(\d+)/);
|
|
589
|
+
// Pet name: use DOM selectors for reliable extraction
|
|
590
|
+
// Active popup: "Nova's claim" in .fw-700 title
|
|
591
|
+
// History popup: <span class="claim-id-number" title="Nova">Nova</span> under Pet label
|
|
592
|
+
let petName;
|
|
593
|
+
const petIdEl = dialog.querySelector('.claim-part');
|
|
594
|
+
if (petIdEl?.textContent?.trim() === 'Pet') {
|
|
595
|
+
// History popup: pet name is in the next sibling's .claim-id-number
|
|
596
|
+
const petValueEl = petIdEl.parentElement?.querySelector('.claim-id-number');
|
|
597
|
+
petName = petValueEl?.textContent?.trim() || petValueEl?.getAttribute('title') || undefined;
|
|
598
|
+
}
|
|
599
|
+
if (!petName) {
|
|
600
|
+
// Active popup: "Nova's claim" or "Mr Whiskers's claim" pattern
|
|
601
|
+
const petMatch = text.match(/(.+?)'s claim/i);
|
|
602
|
+
petName = petMatch?.[1];
|
|
603
|
+
}
|
|
604
|
+
// Status: "Status: Approved" (active) or "approved" in message (history)
|
|
605
|
+
const statusEl = dialog.querySelector('.status-text.status');
|
|
606
|
+
let status = statusEl?.textContent?.trim().toLowerCase();
|
|
607
|
+
if (!status) {
|
|
608
|
+
if (text.includes('approved'))
|
|
609
|
+
status = 'approved';
|
|
610
|
+
else if (text.includes('denied'))
|
|
611
|
+
status = 'denied';
|
|
612
|
+
else
|
|
613
|
+
status = 'unknown';
|
|
614
|
+
}
|
|
615
|
+
// Reason for visit (active popup only)
|
|
616
|
+
const reasonMatch = text.match(/Reason for visit\s*([^\n]+)/i);
|
|
617
|
+
// Date: "Date of visit 01/19/2026" (active) or "Invoice date 12/31/2025" (history)
|
|
618
|
+
const dateMatch = text.match(/Date of visit\s*(\d{1,2}\/\d{1,2}\/\d{4})/i) ||
|
|
619
|
+
text.match(/Invoice date\s*(\d{1,2}\/\d{1,2}\/\d{4})/i) ||
|
|
620
|
+
text.match(/(\d{1,2}\/\d{1,2}\/\d{4})/);
|
|
621
|
+
// Amount: "Payout $94.88" (active) or "Invoice amount $876.71" (history)
|
|
622
|
+
const payoutEl = dialog.querySelector('.claim-invoice-details.fw-700');
|
|
623
|
+
let claimAmount = payoutEl?.textContent?.trim();
|
|
624
|
+
if (!claimAmount) {
|
|
625
|
+
const amountEl = dialog.querySelector('.claim-value');
|
|
626
|
+
claimAmount = amountEl?.textContent?.trim();
|
|
627
|
+
}
|
|
628
|
+
if (!claimAmount) {
|
|
629
|
+
const amountMatch = text.match(/\$[\d,.]+/);
|
|
630
|
+
claimAmount = amountMatch?.[0];
|
|
631
|
+
}
|
|
632
|
+
// Policy number (history popup)
|
|
633
|
+
const policyMatch = text.match(/#(WAG\w+-\d+)/);
|
|
634
|
+
// List all document types available
|
|
635
|
+
const docElements = Array.from(dialog.querySelectorAll('.claims-record-description'));
|
|
636
|
+
const documents = [];
|
|
637
|
+
for (const doc of docElements) {
|
|
638
|
+
const docName = doc.textContent?.trim();
|
|
639
|
+
if (docName)
|
|
640
|
+
documents.push(docName);
|
|
641
|
+
}
|
|
642
|
+
return {
|
|
643
|
+
claimId: claimIdMatch?.[1] || 'unknown',
|
|
644
|
+
petName: petName || 'Unknown Pet',
|
|
645
|
+
claimDate: dateMatch?.[1] || '',
|
|
646
|
+
claimAmount: claimAmount || '',
|
|
647
|
+
status,
|
|
648
|
+
description: reasonMatch?.[1]?.trim(),
|
|
649
|
+
policyNumber: policyMatch ? `#${policyMatch[1]}` : undefined,
|
|
650
|
+
documents,
|
|
651
|
+
};
|
|
652
|
+
});
|
|
653
|
+
if (!details) {
|
|
654
|
+
return {
|
|
655
|
+
claimId,
|
|
656
|
+
petName: 'Unknown Pet',
|
|
657
|
+
claimDate: '',
|
|
658
|
+
claimAmount: '',
|
|
659
|
+
status: 'unknown',
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
// Download documents from the popup.
|
|
663
|
+
// Fetch Pet opens documents in a new popup tab with a blob: URL inside an iframe.
|
|
664
|
+
// We click the document link, wait for the popup, fetch the blob, and save to disk.
|
|
665
|
+
let localEobPath;
|
|
666
|
+
let eobSummary;
|
|
667
|
+
let localInvoicePath;
|
|
668
|
+
let invoiceSummary;
|
|
669
|
+
const dialogSelector = '.MuiDialog-root.generic-dialog .MuiDialog-paper';
|
|
670
|
+
// Download EOB if listed in documents
|
|
671
|
+
if (details.documents.includes('Explanation of Benefits')) {
|
|
672
|
+
const result = await this.downloadDocumentFromPopup(page, `${dialogSelector} .claims-record-description:text("Explanation of Benefits")`, `eob_${details.claimId.replace(/[^a-zA-Z0-9]/g, '')}`);
|
|
673
|
+
localEobPath = result.localPath;
|
|
674
|
+
eobSummary = result.summary;
|
|
675
|
+
}
|
|
676
|
+
// Download first Invoice if listed in documents
|
|
677
|
+
if (details.documents.includes('Invoice')) {
|
|
678
|
+
const result = await this.downloadDocumentFromPopup(page, `${dialogSelector} .claims-record-description:text-is("Invoice")`, `invoice_${details.claimId.replace(/[^a-zA-Z0-9]/g, '')}`);
|
|
679
|
+
localInvoicePath = result.localPath;
|
|
680
|
+
invoiceSummary = result.summary;
|
|
681
|
+
}
|
|
682
|
+
// Close the dialog
|
|
683
|
+
const closeButton = await page.$(`${dialogSelector} .simple-dialog-close-icon, ${dialogSelector} img[alt="img"], ${dialogSelector} img[alt="Close"]`);
|
|
684
|
+
if (closeButton) {
|
|
685
|
+
await closeButton.click();
|
|
686
|
+
await page.waitForTimeout(500);
|
|
687
|
+
}
|
|
688
|
+
return {
|
|
689
|
+
claimId: details.claimId,
|
|
690
|
+
petName: details.petName,
|
|
691
|
+
claimDate: details.claimDate,
|
|
692
|
+
claimAmount: details.claimAmount,
|
|
693
|
+
status: details.status,
|
|
694
|
+
description: details.description,
|
|
695
|
+
policyNumber: details.policyNumber,
|
|
696
|
+
eobSummary,
|
|
697
|
+
invoiceSummary,
|
|
698
|
+
localEobPath,
|
|
699
|
+
localInvoicePath,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
async getCurrentUrl() {
|
|
703
|
+
const page = await this.ensureBrowser();
|
|
704
|
+
return page.url();
|
|
705
|
+
}
|
|
706
|
+
async close() {
|
|
707
|
+
if (this.browser) {
|
|
708
|
+
await this.browser.close();
|
|
709
|
+
this.browser = null;
|
|
710
|
+
this.context = null;
|
|
711
|
+
this.page = null;
|
|
712
|
+
this.isInitialized = false;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
getConfig() {
|
|
716
|
+
return this.config;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
export function createMCPServer(options) {
|
|
720
|
+
const server = new Server({
|
|
721
|
+
name: 'fetchpet-mcp-server',
|
|
722
|
+
version: options.version,
|
|
723
|
+
}, {
|
|
724
|
+
capabilities: {
|
|
725
|
+
tools: {},
|
|
726
|
+
},
|
|
727
|
+
});
|
|
728
|
+
// Track active client for cleanup
|
|
729
|
+
let activeClient = null;
|
|
730
|
+
// Track background login state
|
|
731
|
+
let loginPromise = null;
|
|
732
|
+
let loginFailed = false;
|
|
733
|
+
let loginError = null;
|
|
734
|
+
let onLoginFailed = null;
|
|
735
|
+
/**
|
|
736
|
+
* Create the client instance (but don't initialize/login yet)
|
|
737
|
+
*/
|
|
738
|
+
const createClient = () => {
|
|
739
|
+
const username = process.env.FETCHPET_USERNAME;
|
|
740
|
+
const password = process.env.FETCHPET_PASSWORD;
|
|
741
|
+
const headless = process.env.HEADLESS !== 'false';
|
|
742
|
+
const timeout = parseInt(process.env.TIMEOUT || '30000', 10);
|
|
743
|
+
const downloadDir = process.env.FETCHPET_DOWNLOAD_DIR || '/tmp/fetchpet-downloads';
|
|
744
|
+
if (!username || !password) {
|
|
745
|
+
throw new Error('FETCHPET_USERNAME and FETCHPET_PASSWORD environment variables must be configured');
|
|
746
|
+
}
|
|
747
|
+
activeClient = new FetchPetClient({
|
|
748
|
+
username,
|
|
749
|
+
password,
|
|
750
|
+
headless,
|
|
751
|
+
timeout,
|
|
752
|
+
downloadDir,
|
|
753
|
+
});
|
|
754
|
+
return activeClient;
|
|
755
|
+
};
|
|
756
|
+
/**
|
|
757
|
+
* Start background login process
|
|
758
|
+
* This should be called after the server is connected to start authentication
|
|
759
|
+
* without blocking the stdio connection.
|
|
760
|
+
*
|
|
761
|
+
* @param onFailed Callback invoked if login fails - use this to close the server
|
|
762
|
+
*/
|
|
763
|
+
const startBackgroundLogin = (onFailed) => {
|
|
764
|
+
if (loginPromise) {
|
|
765
|
+
// Already started
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
onLoginFailed = onFailed || null;
|
|
769
|
+
// Create client if not already created
|
|
770
|
+
if (!activeClient) {
|
|
771
|
+
createClient();
|
|
772
|
+
}
|
|
773
|
+
// Start login in background
|
|
774
|
+
loginPromise = activeClient.initialize().catch((error) => {
|
|
775
|
+
loginFailed = true;
|
|
776
|
+
loginError = error instanceof Error ? error : new Error(String(error));
|
|
777
|
+
// Invoke callback to notify about login failure
|
|
778
|
+
if (onLoginFailed) {
|
|
779
|
+
onLoginFailed(loginError);
|
|
780
|
+
}
|
|
781
|
+
// Re-throw to make the promise rejected
|
|
782
|
+
throw loginError;
|
|
783
|
+
});
|
|
784
|
+
};
|
|
785
|
+
/**
|
|
786
|
+
* Get a client that is ready to use (login completed)
|
|
787
|
+
* If background login was started, this waits for it to complete.
|
|
788
|
+
* If not started, this will initialize synchronously (blocking).
|
|
789
|
+
*/
|
|
790
|
+
const getReadyClient = async () => {
|
|
791
|
+
// If login already failed, throw immediately
|
|
792
|
+
if (loginFailed && loginError) {
|
|
793
|
+
throw new Error(`Login failed: ${loginError.message}`);
|
|
794
|
+
}
|
|
795
|
+
// If background login is in progress, wait for it
|
|
796
|
+
if (loginPromise) {
|
|
797
|
+
await loginPromise;
|
|
798
|
+
if (!activeClient) {
|
|
799
|
+
throw new Error('Client was not created during login');
|
|
800
|
+
}
|
|
801
|
+
return activeClient;
|
|
802
|
+
}
|
|
803
|
+
// No background login started - create and initialize client now (fallback)
|
|
804
|
+
if (!activeClient) {
|
|
805
|
+
createClient();
|
|
806
|
+
}
|
|
807
|
+
if (!activeClient) {
|
|
808
|
+
throw new Error('Failed to create client');
|
|
809
|
+
}
|
|
810
|
+
await activeClient.initialize();
|
|
811
|
+
return activeClient;
|
|
812
|
+
};
|
|
813
|
+
const registerHandlers = async (server, clientFactory) => {
|
|
814
|
+
// Use provided factory or create our managed client getter
|
|
815
|
+
const factory = clientFactory || (() => activeClient || createClient());
|
|
816
|
+
// Create tools with a special async getter that waits for background login
|
|
817
|
+
const registerTools = createRegisterTools(factory, getReadyClient);
|
|
818
|
+
registerTools(server);
|
|
819
|
+
};
|
|
820
|
+
const cleanup = async () => {
|
|
821
|
+
if (activeClient) {
|
|
822
|
+
await activeClient.close();
|
|
823
|
+
activeClient = null;
|
|
824
|
+
}
|
|
825
|
+
// Reset login state
|
|
826
|
+
loginPromise = null;
|
|
827
|
+
loginFailed = false;
|
|
828
|
+
loginError = null;
|
|
829
|
+
};
|
|
830
|
+
return { server, registerHandlers, cleanup, startBackgroundLogin };
|
|
831
|
+
}
|