fidelity_get_positions
Retrieve complete list of positions across all Fidelity accounts. Displays stock ticker, quantity, price, and value for each holding to assess your portfolio.
Instructions
Get all positions (holdings) across all Fidelity accounts. Returns account details with stock ticker, quantity, price, and value for each holding.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
No arguments | |||
Implementation Reference
- src/index.ts:170-199 (registration)Registration of the 'fidelity_get_positions' tool on the MCP server with description, empty schema, and handler calling getPositions().
// ─── Get Positions ────────────────────────────────────────────────────────────── server.tool( "fidelity_get_positions", "Get all positions (holdings) across all Fidelity accounts. Returns account details with stock ticker, quantity, price, and value for each holding.", {}, async () => { try { const positions = await getPositions(); return { content: [ { type: "text", text: JSON.stringify(positions, null, 2), }, ], }; } catch (e) { return { content: [ { type: "text", text: `Failed to get positions: ${e instanceof Error ? e.message : String(e)}`, }, ], isError: true, }; } } ); - src/positions.ts:8-18 (handler)Main getPositions() function - tries CSV download first, falls back to page scraping.
export async function getPositions(): Promise<Account[]> { // Try CSV download first, fall back to page scraping try { const csvResult = await getPositionsViaCsv(); if (csvResult.length > 0) return csvResult; } catch { // CSV download failed, try scraping } return await getPositionsViaScrape(); } - src/positions.ts:20-101 (handler)getPositionsViaCsv() - Downloads positions CSV from Fidelity by interacting with the UI, parsing the CSV with parsePositionsCsv().
async function getPositionsViaCsv(): Promise<Account[]> { const page = await getPage(); await page.goto(POSITIONS_URL, { waitUntil: "domcontentloaded" }); await waitForLoadingCompleteDouble(page, 150000); // Set up download listener BEFORE clicking const downloadPromise = page.waitForEvent("download", { timeout: 20000 }); // Open the Available Actions kebab menu, then click Download // Strategy 1: Use the known kebab menu item ID let clicked = false; try { // Click "Available Actions" button to open menu const actionsBtn = page.locator( "button[aria-label='Available Actions'], button:has-text('Available Actions')" ); await actionsBtn.first().click({ timeout: 5000 }); await page.waitForTimeout(500); // Click the Download menu item by its ID pattern const downloadBtn = page.locator( "#kebabmenuitem-download, button:has-text('Download')[id*='kebab']" ); await downloadBtn.first().click({ timeout: 5000 }); clicked = true; } catch { // Strategy 2: Try role-based selectors try { const actionsBtn = page.getByRole("button", { name: "Available Actions" }); await actionsBtn.click({ timeout: 5000 }); await page.waitForTimeout(500); const downloadItem = page.getByRole("menuitem", { name: "Download" }); await downloadItem.click({ timeout: 5000 }); clicked = true; } catch { // Strategy 3: Try label-based try { const downloadBtn = page.getByLabel("Download Positions"); await downloadBtn.click({ timeout: 5000 }); clicked = true; } catch { // Strategy 4: Any element with download text try { const btn = page.locator("[id*='download'], [aria-label*='ownload']"); await btn.first().click({ timeout: 5000 }); clicked = true; } catch { throw new Error("Could not find download button."); } } } } if (!clicked) { throw new Error("Failed to click download."); } const download = await downloadPromise; const dlPath = await download.path(); if (!dlPath) { throw new Error("Downloaded CSV path is null."); } const fs = await import("fs"); const csvContent = fs.readFileSync(dlPath, "utf-8"); // Delete temp file try { fs.unlinkSync(dlPath); } catch { // Cleanup optional } if (!csvContent || csvContent.trim().length === 0) { throw new Error("Downloaded CSV is empty."); } return parsePositionsCsv(csvContent); } - src/positions.ts:103-225 (handler)getPositionsViaScrape() - Scrapes positions table directly from the DOM as fallback when CSV download fails.
async function getPositionsViaScrape(): Promise<Account[]> { const page = await getPage(); if (!page.url().includes("positions")) { await page.goto(POSITIONS_URL, { waitUntil: "domcontentloaded" }); await waitForLoadingCompleteDouble(page, 60000); } // Scrape directly from the positions table (Table 0 with 304 rows) const data = await page.evaluate(() => { const results: { accountNumber: string; accountName: string; symbol: string; lastPrice: string; lastPriceChange: string; currentValue: string; quantity: string; costBasis: string; gainLoss: string; gainLossPct: string; }[] = []; // Find all table rows const rows = document.querySelectorAll("tr, [role='row']"); let currentAccount = ""; let currentAccountName = ""; for (const row of rows) { const cells = row.querySelectorAll("td, th, [role='cell'], [role='gridcell']"); const text = (row.textContent ?? "").trim(); // Account header rows typically have account name and number // Look for patterns like "Individual (Z23385543)" or account numbers const acctMatch = text.match(/(Z?\d{6,})/); // Check if this is an account header row (fewer cells, contains account info) if (cells.length <= 3 && acctMatch) { currentAccount = acctMatch[1]; // Try to get account name (text before the account number) const nameMatch = text.match(/^(.+?)(?:\s*[-–(]?\s*(?:Z?\d{6,}))/); currentAccountName = nameMatch ? nameMatch[1].trim() : currentAccount; continue; } // Position rows have many cells (symbol, price, quantity, etc.) if (cells.length >= 5 && currentAccount) { const cellTexts: string[] = []; cells.forEach((c) => cellTexts.push((c.textContent ?? "").trim())); // First cell is usually the symbol const symbol = cellTexts[0]?.replace(/\s+/g, " ").split(" ")[0] ?? ""; // Skip non-ticker rows if (!symbol || symbol.length > 10 || !/^[A-Z0-9.*]+$/i.test(symbol)) continue; if (symbol === "Symbol" || symbol === "Total") continue; results.push({ accountNumber: currentAccount, accountName: currentAccountName, symbol: symbol.toUpperCase(), lastPrice: cellTexts[1] ?? "", lastPriceChange: cellTexts[2] ?? "", currentValue: cellTexts[7] ?? "", quantity: cellTexts[9] ?? "", costBasis: cellTexts[10] ?? "", gainLoss: cellTexts[5] ?? "", gainLossPct: cellTexts[6] ?? "", }); } } return results; }); const accountMap = new Map<string, Account>(); const cleanNum = (s: string): number => { if (!s) return 0; const cleaned = s.replace(/[$,%]/g, "").trim(); if (cleaned === "" || cleaned === "n/a" || cleaned === "--") return 0; const n = parseFloat(cleaned); return isNaN(n) ? 0 : n; }; for (const row of data) { if (!accountMap.has(row.accountNumber)) { accountMap.set(row.accountNumber, { accountNumber: row.accountNumber, accountName: row.accountName, nickname: row.accountName, balance: 0, withdrawalBalance: 0, stocks: [], }); } const account = accountMap.get(row.accountNumber)!; const ticker = row.symbol; if ( ticker === "SPAXX" || ticker === "FCASH" || ticker === "FDRXX" || ticker === "FZFXX" ) { const val = cleanNum(row.currentValue) || cleanNum(row.quantity); account.balance += val; account.withdrawalBalance += val; } account.stocks.push({ ticker, description: "", quantity: cleanNum(row.quantity), lastPrice: cleanNum(row.lastPrice), lastPriceChange: cleanNum(row.lastPriceChange), currentValue: cleanNum(row.currentValue), }); } return Array.from(accountMap.values()); } - src/types.ts:18-25 (schema)The Account type definition that getPositions() returns, containing accountNumber, accountName, nickname, balance, withdrawalBalance, and stocks array.
export interface Account { accountNumber: string; accountName: string; nickname: string; balance: number; withdrawalBalance: number; stocks: Stock[]; }