Skip to main content
Glama
laramarcodes

Plaid Transactions MCP Server

by laramarcodes
plaid_mcp.py54.3 kB
#!/usr/bin/env python3 """ Plaid Transactions MCP Server. This MCP server provides tools to interact with Plaid's Transactions API, enabling transaction syncing, searching, and category management. """ from typing import Optional, List, Dict, Any from enum import Enum from datetime import datetime, timedelta import json import subprocess import httpx import asyncio import webbrowser import http.server import socketserver import urllib.parse import threading from pydantic import BaseModel, Field, field_validator, ConfigDict from mcp.server.fastmcp import FastMCP # Initialize the MCP server mcp = FastMCP("plaid_mcp") # Constants PLAID_API_URL = "https://production.plaid.com" # Change to sandbox for testing CHARACTER_LIMIT = 25000 # Maximum response size in characters # Enums class ResponseFormat(str, Enum): """Output format for tool responses.""" MARKDOWN = "markdown" JSON = "json" # ============================================================================ # Utility Functions # ============================================================================ def _get_keychain_value(service_name: str, account_name: Optional[str] = None) -> str: """ Retrieve a value from macOS Keychain. Args: service_name: The service name to look up in keychain account_name: Optional account name for the keychain entry Returns: The value stored in keychain Raises: Exception if keychain access fails """ try: cmd = ["security", "find-generic-password", "-s", service_name] if account_name: cmd.extend(["-a", account_name]) cmd.append("-w") result = subprocess.run( cmd, capture_output=True, text=True, check=True ) return result.stdout.strip() except subprocess.CalledProcessError as e: raise Exception( f"Failed to retrieve {service_name} from keychain. " f"Please store it using: security add-generic-password -s '{service_name}' -a 'plaid' -w 'your_value'" ) def _get_all_plaid_accounts() -> List[Dict[str, str]]: """ Retrieve all Plaid access tokens from keychain (PlaidTracker service). Returns: List of dictionaries with account_name and access_token """ try: # Dump keychain and search for PlaidTracker access tokens result = subprocess.run( ["security", "dump-keychain"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True ) accounts = [] lines = result.stdout.split('\n') i = 0 while i < len(lines): line = lines[i] # Look for PlaidTracker service marker if '0x00000007 <blob>="PlaidTracker"' in line: # Look forward for the account name (within next 5 lines) for j in range(i+1, min(len(lines), i+6)): if '"acct"<blob>=' in lines[j]: # Extract account name account_line = lines[j] if 'access_token_' in account_line: # Extract the account name start = account_line.find('"access_token_') + 1 end = account_line.find('"', start) account_name = account_line[start:end] # Get the actual token try: token = _get_keychain_value("PlaidTracker", account_name) # Extract friendly name # Format: access_token_<40+char_hash>_<Bank_Name_With_Underscores> # Strategy: Remove prefix, then split on first underscore to remove hash friendly_name = account_name.replace("access_token_", "") # Split on first underscore to separate hash from bank name if "_" in friendly_name: parts = friendly_name.split("_", 1) if len(parts) > 1: # Take everything after the hash and replace underscores with spaces friendly_name = parts[1].replace("_", " ") else: friendly_name = friendly_name.replace("_", " ") else: friendly_name = friendly_name.replace("_", " ") accounts.append({ "account_name": account_name, "friendly_name": friendly_name, "access_token": token }) except: pass break i += 1 return accounts except Exception as e: return [] def _get_access_token_for_account(account_identifier: str) -> str: """ Get access token for a specific account by name or friendly name. Args: account_identifier: Either the full account name or friendly name Returns: The access token Raises: Exception if account not found """ accounts = _get_all_plaid_accounts() # Try to match by friendly name first (case-insensitive) for account in accounts: if account_identifier.lower() in account["friendly_name"].lower(): return account["access_token"] # Try to match by full account name for account in accounts: if account_identifier in account["account_name"]: return account["access_token"] # Not found available = [acc["friendly_name"] for acc in accounts] raise Exception( f"Account '{account_identifier}' not found. Available accounts: {', '.join(available)}" ) async def _make_plaid_request( endpoint: str, method: str = "POST", data: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ Make a request to the Plaid API with authentication. Args: endpoint: API endpoint path (e.g., '/transactions/sync') method: HTTP method (default: POST) data: Request payload Returns: JSON response from Plaid API Raises: httpx.HTTPStatusError: If the API request fails """ # Retrieve credentials from keychain client_id = _get_keychain_value("PlaidTracker", "plaid_client_id") secret = _get_keychain_value("PlaidTracker", "plaid_secret") # Add credentials to request body if data is None: data = {} data["client_id"] = client_id data["secret"] = secret async with httpx.AsyncClient() as client: response = await client.request( method, f"{PLAID_API_URL}{endpoint}", json=data, timeout=60.0 ) response.raise_for_status() return response.json() def _handle_plaid_error(e: Exception) -> str: """ Convert Plaid API errors into clear, actionable error messages. Args: e: The exception that occurred Returns: User-friendly error message with guidance """ if isinstance(e, httpx.HTTPStatusError): status = e.response.status_code try: error_body = e.response.json() error_code = error_body.get("error_code", "") error_message = error_body.get("error_message", "") # Handle specific Plaid error codes if error_code == "INVALID_ACCESS_TOKEN": return ( "Error: Invalid access token. The token may have expired or been revoked. " "You need to re-link the account via Plaid Link to get a new access token." ) elif error_code == "ITEM_LOGIN_REQUIRED": return ( "Error: User needs to re-authenticate via Plaid Link. " "The connection to the financial institution has expired and requires re-login." ) elif error_code == "INVALID_API_KEYS": return ( "Error: Invalid Plaid API credentials. Please verify your PLAID_CLIENT_ID and PLAID_SECRET " "are correct in keychain." ) elif "RATE_LIMIT" in error_code: return ( "Error: Rate limit exceeded. Plaid limits requests to 100/minute in production. " "Please wait 60 seconds before trying again." ) return f"Error: Plaid API error ({error_code}): {error_message}" except Exception: # Fallback if response isn't JSON if status == 401: return "Error: Authentication failed. Please verify your Plaid credentials in keychain." elif status == 404: return "Error: Item not found. The access token may be invalid." elif status == 429: return "Error: Rate limit exceeded. Please wait 60 seconds before retrying." return f"Error: API request failed with status {status}" elif isinstance(e, httpx.TimeoutException): return "Error: Request timed out. The Plaid API may be slow. Please try again." elif "keychain" in str(e).lower(): return str(e) # Return keychain error as-is (already has guidance) return f"Error: Unexpected error occurred: {type(e).__name__} - {str(e)}" async def _get_accounts_with_masks(access_token: str) -> Dict[str, Dict[str, Any]]: """ Fetch account details including masks (last 4 digits) from Plaid. Args: access_token: Plaid access token Returns: Dict mapping account_id to account info including mask """ try: response = await _make_plaid_request("/accounts/get", data={ "access_token": access_token }) accounts = response.get("accounts", []) return { acc["account_id"]: { "mask": acc.get("mask", ""), "name": acc.get("name", ""), "official_name": acc.get("official_name", ""), "type": acc.get("type", ""), "subtype": acc.get("subtype", "") } for acc in accounts } except Exception: return {} def _format_transaction_markdown(txn: Dict[str, Any], account_masks: Dict[str, Dict[str, Any]] = None) -> str: """ Format a single transaction as markdown. Args: txn: Transaction data from Plaid account_masks: Optional dict mapping account_id to account info with mask Returns: Markdown-formatted transaction string """ lines = [] # Amount and merchant name amount = txn.get("amount", 0) name = txn.get("name", "Unknown") lines.append(f"### ${amount:.2f} - {name}") # Date date = txn.get("date", "") if date: lines.append(f"- **Date**: {date}") # Category category = txn.get("category", []) if category: category_str = " > ".join(category) lines.append(f"- **Category**: {category_str}") # Merchant name (if different from name) merchant_name = txn.get("merchant_name") if merchant_name and merchant_name != name: lines.append(f"- **Merchant**: {merchant_name}") # Account - show actual mask (last 4 digits) if available account_id = txn.get("account_id", "") if account_id: if account_masks and account_id in account_masks: mask = account_masks[account_id].get("mask", "") acc_name = account_masks[account_id].get("name", "") if mask: lines.append(f"- **Account**: ****{mask} ({acc_name})") else: lines.append(f"- **Account**: {acc_name}") else: lines.append(f"- **Account**: {account_id[-4:]}") # Fallback to last 4 chars of ID # Payment channel payment_channel = txn.get("payment_channel", "") if payment_channel: lines.append(f"- **Payment Channel**: {payment_channel}") # Pending status if txn.get("pending"): lines.append(f"- **Status**: Pending") # Transaction ID transaction_id = txn.get("transaction_id", "") if transaction_id: lines.append(f"- **ID**: {transaction_id}") lines.append("") # Empty line after each transaction return "\n".join(lines) def _truncate_response(response: str, data_description: str = "results") -> str: """ Truncate response if it exceeds CHARACTER_LIMIT and add helpful message. Args: response: The full response string data_description: Description of what's being truncated Returns: Truncated response with guidance message if needed """ if len(response) <= CHARACTER_LIMIT: return response # Truncate to 50% of limit to leave room for message truncated = response[:CHARACTER_LIMIT // 2] truncation_msg = ( f"\n\n---\n\n" f"⚠️ **Response Truncated**\n\n" f"The full response exceeded {CHARACTER_LIMIT:,} characters and was truncated. " f"To see more {data_description}, try:\n" f"- Use date_start/date_end to narrow the date range\n" f"- Filter by specific account_ids\n" f"- Use plaid_search_transactions to filter by merchant, category, or amount\n" ) return truncated + truncation_msg # ============================================================================ # Pydantic Input Models # ============================================================================ class TransactionSyncInput(BaseModel): """Input model for syncing transactions.""" model_config = ConfigDict( str_strip_whitespace=True, validate_assignment=True, extra='forbid' ) access_token: Optional[str] = Field( default=None, description="Plaid access token for the Item. If not provided, use account_name instead.", min_length=1 ) account_name: Optional[str] = Field( default=None, description="Friendly account name (e.g., 'American Express', 'Citibank'). Use plaid_list_accounts to see available accounts." ) cursor: Optional[str] = Field( default=None, description="Cursor for pagination (omit for initial sync). Example: 'eyJsYXN0X3VwZGF0ZWRfZGF0ZXRpbWUiOiIyMDIwLTA3LTI0VDE4OjQ5OjQ3WiJ9'" ) count: Optional[int] = Field( default=100, description="Number of transactions to fetch per page (default: 100, max: 500)", ge=1, le=500 ) account_ids: Optional[List[str]] = Field( default=None, description="Filter transactions to specific account IDs. Example: ['aAbBcC123dDeEfF456']" ) date_start: Optional[str] = Field( default=None, description="Start date for filtering transactions (YYYY-MM-DD format). Example: '2024-01-01'" ) date_end: Optional[str] = Field( default=None, description="End date for filtering transactions (YYYY-MM-DD format). Example: '2024-12-31'" ) response_format: ResponseFormat = Field( default=ResponseFormat.MARKDOWN, description="Output format: 'markdown' for human-readable or 'json' for machine-readable" ) class SearchTransactionsInput(BaseModel): """Input model for searching/filtering transactions.""" model_config = ConfigDict( str_strip_whitespace=True, validate_assignment=True, extra='forbid' ) access_token: Optional[str] = Field( default=None, description="Plaid access token for the Item. If not provided, use account_name instead.", min_length=1 ) account_name: Optional[str] = Field( default=None, description="Friendly account name (e.g., 'American Express', 'Citibank'). Use plaid_list_accounts to see available accounts." ) account_mask: Optional[str] = Field( default=None, description="Filter by card's last 4 digits (mask). Example: '3620', '1234'. Use plaid_list_accounts to see available masks." ) merchant_name: Optional[str] = Field( default=None, description="Filter by merchant name (partial match). Example: 'Starbucks', 'Amazon'" ) category: Optional[str] = Field( default=None, description="Filter by category (partial match). Example: 'Food and Drink', 'Travel'" ) min_amount: Optional[float] = Field( default=None, description="Minimum transaction amount. Example: 50.0" ) max_amount: Optional[float] = Field( default=None, description="Maximum transaction amount. Example: 500.0" ) date_start: Optional[str] = Field( default=None, description="Start date (YYYY-MM-DD). Example: '2024-01-01'" ) date_end: Optional[str] = Field( default=None, description="End date (YYYY-MM-DD). Example: '2024-12-31'" ) account_ids: Optional[List[str]] = Field( default=None, description="Filter to specific account IDs" ) response_format: ResponseFormat = Field( default=ResponseFormat.MARKDOWN, description="Output format: 'markdown' for human-readable or 'json' for machine-readable" ) # ============================================================================ # Tool Implementations # ============================================================================ @mcp.tool( name="plaid_list_accounts", annotations={ "title": "List Available Plaid Accounts", "readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": False } ) async def plaid_list_accounts() -> str: """ List all available Plaid accounts stored in keychain. This tool retrieves all Plaid access tokens from your keychain and displays the account names. Use these names with other tools to specify which account to query. Returns: str: List of available accounts in Markdown format containing: - Friendly account names (e.g., "American Express", "Citibank Online") - Number of accounts found - Instructions on how to use account names with other tools Examples: - Use when: "What Plaid accounts do I have linked?" - Use when: "Show me my available accounts" - Always use this first to see which accounts you can query Error Handling: - Returns empty list if no accounts found in keychain - Returns error if keychain access fails """ try: accounts = _get_all_plaid_accounts() if not accounts: return ( "# No Plaid Accounts Found\n\n" "No Plaid access tokens found in keychain under 'PlaidTracker' service.\n" "Please ensure your accounts are linked and tokens are stored in keychain." ) lines = ["# Available Plaid Accounts", ""] lines.append(f"Found **{len(accounts)}** linked institution(s):") lines.append("") for i, account in enumerate(accounts, 1): lines.append(f"## {i}. {account['friendly_name']}") lines.append("") # Fetch sub-accounts (individual cards/accounts) with their masks try: sub_accounts = await _get_accounts_with_masks(account['access_token']) if sub_accounts: lines.append("| Card/Account | Type | Last 4 |") lines.append("|--------------|------|--------|") for acc_id, acc_info in sub_accounts.items(): acc_name = acc_info.get('name', 'Unknown') acc_type = acc_info.get('subtype', acc_info.get('type', '')) mask = acc_info.get('mask', 'N/A') lines.append(f"| {acc_name} | {acc_type} | **{mask}** |") lines.append("") else: lines.append("_No sub-accounts found_") lines.append("") except Exception: lines.append("_Could not fetch sub-account details_") lines.append("") lines.append("## How to Use") lines.append("") lines.append("Use these account names with other tools:") lines.append("- `plaid_sync_transactions` - Use `account_name` parameter") lines.append("- `plaid_search_transactions` - Use `account_name` parameter") lines.append("") lines.append("**Example**: To get transactions from American Express:") lines.append('```\naccount_name: "American Express"\n```') return "\n".join(lines) except Exception as e: return f"Error listing accounts: {str(e)}" @mcp.tool( name="plaid_sync_transactions", annotations={ "title": "Sync Plaid Transactions", "readOnlyHint": True, "destructiveHint": False, "idempotentHint": False, "openWorldHint": True } ) async def plaid_sync_transactions(params: TransactionSyncInput) -> str: """ Sync all transactions from Plaid using cursor-based pagination. This tool retrieves transaction data including added, modified, and removed transactions since the last cursor position. It automatically handles pagination and can filter by accounts and date ranges. Args: params (TransactionSyncInput): Validated input parameters containing: - access_token (str): Plaid access token for the Item (required) - cursor (Optional[str]): Pagination cursor from previous sync (omit for initial sync) - count (Optional[int]): Transactions per page (default: 100, max: 500) - account_ids (Optional[List[str]]): Filter to specific accounts - date_start (Optional[str]): Filter start date (YYYY-MM-DD) - date_end (Optional[str]): Filter end date (YYYY-MM-DD) - response_format (ResponseFormat): Output format (default: markdown) Returns: str: Formatted response containing: Markdown format: - Human-readable transaction list grouped by added/modified/removed - Transaction details including amount, merchant, date, category - Pagination info and next cursor JSON format: { "added": [...], // New transactions "modified": [...], // Updated transactions "removed": [...], // Removed transaction IDs "next_cursor": str, // Use for next sync "has_more": bool, // More data available "total_added": int, "total_modified": int, "total_removed": int } Examples: - Use when: "Show me all my recent transactions" - Use when: "Sync my Plaid transactions from December 2024" - Use when: "Get transactions for my checking account" - Don't use when: Searching for specific merchants (use plaid_search_transactions) Error Handling: - Returns "Error: Invalid access token..." if token is expired - Returns "Error: Rate limit exceeded..." if too many requests - Returns "Error: User needs to re-authenticate..." if login required - Automatically truncates large responses with filtering guidance """ try: # Resolve access token from account name if needed access_token = params.access_token if not access_token and params.account_name: access_token = _get_access_token_for_account(params.account_name) elif not access_token: return "Error: Either access_token or account_name must be provided. Use plaid_list_accounts to see available accounts." # Build request payload request_data = { "access_token": access_token, "count": params.count } if params.cursor: request_data["cursor"] = params.cursor # Build options object options = {} if params.account_ids: options["account_ids"] = params.account_ids # Calculate days_requested for historical data # Note: Plaid's days_requested is always relative to TODAY, not an absolute range if params.date_start: start_date = datetime.strptime(params.date_start, "%Y-%m-%d") # Calculate days from start_date to TODAY to ensure we fetch enough historical data today = datetime.now() days_from_start = (today - start_date).days + 1 # Plaid max is 730 days (2 years) if days_from_start > 730: # Can only fetch last 730 days, will filter client-side options["days_requested"] = 730 else: options["days_requested"] = days_from_start elif params.date_end: # If only end date specified, fetch up to that point from today end_date = datetime.strptime(params.date_end, "%Y-%m-%d") today = datetime.now() days_to_end = (today - end_date).days if days_to_end < 0: # Future date, fetch default options["days_requested"] = 90 else: options["days_requested"] = min(days_to_end + 90, 730) if options: request_data["options"] = options # Fetch all pages automatically all_added = [] all_modified = [] all_removed = [] current_cursor = params.cursor has_more = True while has_more: if current_cursor: request_data["cursor"] = current_cursor response = await _make_plaid_request("/transactions/sync", data=request_data) all_added.extend(response.get("added", [])) all_modified.extend(response.get("modified", [])) all_removed.extend(response.get("removed", [])) has_more = response.get("has_more", False) current_cursor = response.get("next_cursor") # Fetch account masks (real last 4 digits) for display account_masks = await _get_accounts_with_masks(access_token) # Format response if params.response_format == ResponseFormat.MARKDOWN: lines = ["# Plaid Transaction Sync Results", ""] # Summary lines.append(f"**Added**: {len(all_added)} transactions") lines.append(f"**Modified**: {len(all_modified)} transactions") lines.append(f"**Removed**: {len(all_removed)} transactions") lines.append("") if current_cursor: lines.append(f"**Next Cursor**: `{current_cursor}`") lines.append("(Save this cursor for incremental syncs)") lines.append("") # Added transactions if all_added: lines.append("## Added Transactions") lines.append("") for txn in all_added: lines.append(_format_transaction_markdown(txn, account_masks)) # Modified transactions if all_modified: lines.append("## Modified Transactions") lines.append("") for txn in all_modified: lines.append(_format_transaction_markdown(txn, account_masks)) # Removed transactions if all_removed: lines.append("## Removed Transactions") lines.append("") for removed in all_removed: transaction_id = removed.get("transaction_id", "Unknown") lines.append(f"- {transaction_id}") lines.append("") if not all_added and not all_modified and not all_removed: lines.append("No new transaction updates found.") result = "\n".join(lines) return _truncate_response(result, "transactions") else: # JSON format result = { "added": all_added, "modified": all_modified, "removed": all_removed, "next_cursor": current_cursor, "has_more": False, # We fetched all pages "total_added": len(all_added), "total_modified": len(all_modified), "total_removed": len(all_removed) } json_str = json.dumps(result, indent=2) return _truncate_response(json_str, "transactions") except Exception as e: return _handle_plaid_error(e) @mcp.tool( name="plaid_get_transaction_categories", annotations={ "title": "Get Plaid Transaction Categories", "readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": True } ) async def plaid_get_transaction_categories() -> str: """ Retrieve all available transaction categories from Plaid. This tool returns the complete taxonomy of transaction categories that Plaid uses to categorize transactions. Useful for understanding categorization and filtering. Returns: str: Formatted list of categories: Markdown format: - Hierarchical list of categories with descriptions - Grouped by top-level category JSON format: { "categories": [ { "category_id": str, "hierarchy": [str], // e.g., ["Food and Drink", "Restaurants", "Coffee Shop"] "group": str } ] } Examples: - Use when: "What categories does Plaid use for transactions?" - Use when: "Show me all food-related categories" - Don't use when: You need actual transaction data (use plaid_sync_transactions) Error Handling: - Returns error if API request fails - No authentication required for this endpoint """ try: # Note: /categories/get doesn't require authentication response = await _make_plaid_request("/categories/get", data={}) categories = response.get("categories", []) # Format as markdown lines = ["# Plaid Transaction Categories", ""] lines.append(f"Total categories: {len(categories)}") lines.append("") # Group by top-level category grouped = {} for cat in categories: hierarchy = cat.get("hierarchy", []) if hierarchy: top_level = hierarchy[0] if top_level not in grouped: grouped[top_level] = [] grouped[top_level].append(cat) # Display grouped for top_level, cats in sorted(grouped.items()): lines.append(f"## {top_level}") lines.append("") for cat in cats: hierarchy = cat.get("hierarchy", []) category_id = cat.get("category_id", "") full_path = " > ".join(hierarchy) lines.append(f"- **{full_path}** (`{category_id}`)") lines.append("") return "\n".join(lines) except Exception as e: return _handle_plaid_error(e) @mcp.tool( name="plaid_search_transactions", annotations={ "title": "Search/Filter Plaid Transactions", "readOnlyHint": True, "destructiveHint": False, "idempotentHint": False, "openWorldHint": True } ) async def plaid_search_transactions(params: SearchTransactionsInput) -> str: """ Search and filter transactions by merchant, category, amount, or date. This tool first syncs all transactions, then intelligently filters them based on your search criteria. Supports partial matching for merchant names and categories. Args: params (SearchTransactionsInput): Validated input parameters containing: - access_token (str): Plaid access token (required) - merchant_name (Optional[str]): Filter by merchant (partial match) - category (Optional[str]): Filter by category (partial match) - min_amount (Optional[float]): Minimum transaction amount - max_amount (Optional[float]): Maximum transaction amount - date_start (Optional[str]): Start date (YYYY-MM-DD) - date_end (Optional[str]): End date (YYYY-MM-DD) - account_ids (Optional[List[str]]): Filter to specific accounts - response_format (ResponseFormat): Output format (default: markdown) Returns: str: Filtered transactions in specified format Markdown format: - Human-readable list of matching transactions - Summary of filter criteria and match count JSON format: { "transactions": [...], "total_matches": int, "filters_applied": {...} } Examples: - Use when: "Find all Starbucks transactions" - Use when: "Show me transactions over $100 in December" - Use when: "List all travel expenses from last quarter" - Don't use when: You just want to sync all transactions (use plaid_sync_transactions) Error Handling: - Returns error if sync fails - Returns "No transactions match your criteria" if no results - Automatically truncates large result sets with filtering guidance """ try: # Resolve access token from account name if needed access_token = params.access_token if not access_token and params.account_name: access_token = _get_access_token_for_account(params.account_name) elif not access_token: return "Error: Either access_token or account_name must be provided. Use plaid_list_accounts to see available accounts." # Build request payload for sync request_data = { "access_token": access_token, "count": 500 # Fetch max per page } # Build options object options = {} if params.account_ids: options["account_ids"] = params.account_ids # Calculate days_requested for historical data # Note: Plaid's days_requested is always relative to TODAY, not an absolute range if params.date_start: start_date = datetime.strptime(params.date_start, "%Y-%m-%d") # Calculate days from start_date to TODAY to ensure we fetch enough historical data today = datetime.now() days_from_start = (today - start_date).days + 1 # Plaid max is 730 days (2 years) if days_from_start > 730: # Can only fetch last 730 days, will filter client-side options["days_requested"] = 730 else: options["days_requested"] = days_from_start elif params.date_end: # If only end date specified, fetch up to that point from today end_date = datetime.strptime(params.date_end, "%Y-%m-%d") today = datetime.now() days_to_end = (today - end_date).days if days_to_end < 0: # Future date, fetch default options["days_requested"] = 90 else: options["days_requested"] = min(days_to_end + 90, 730) if options: request_data["options"] = options # Fetch all transactions directly (work with raw data, not serialized strings) all_added = [] all_modified = [] current_cursor = None has_more = True while has_more: if current_cursor: request_data["cursor"] = current_cursor response = await _make_plaid_request("/transactions/sync", data=request_data) all_added.extend(response.get("added", [])) all_modified.extend(response.get("modified", [])) has_more = response.get("has_more", False) current_cursor = response.get("next_cursor") all_transactions = all_added + all_modified # Fetch account masks (real last 4 digits) for filtering and display account_masks = await _get_accounts_with_masks(access_token) # Build reverse mapping: mask -> list of account_ids (in case multiple accounts have same mask) mask_to_account_ids = {} for acc_id, acc_info in account_masks.items(): mask = acc_info.get("mask", "") if mask: if mask not in mask_to_account_ids: mask_to_account_ids[mask] = [] mask_to_account_ids[mask].append(acc_id) # If account_mask filter is provided, find matching account_ids target_account_ids = None if params.account_mask: target_account_ids = mask_to_account_ids.get(params.account_mask, []) if not target_account_ids: # Try partial match for mask, acc_ids in mask_to_account_ids.items(): if params.account_mask in mask or mask in params.account_mask: target_account_ids = acc_ids break if not target_account_ids: available_masks = list(mask_to_account_ids.keys()) return f"Error: No account found with mask '{params.account_mask}'. Available masks: {', '.join(available_masks)}" # Apply filters filtered = [] for txn in all_transactions: # Account mask filter (by account_id) if target_account_ids: if txn.get("account_id") not in target_account_ids: continue # Merchant name filter if params.merchant_name: name = (txn.get("name") or "").lower() merchant_name = (txn.get("merchant_name") or "").lower() search_term = params.merchant_name.lower() if search_term not in name and search_term not in merchant_name: continue # Category filter if params.category: categories = txn.get("category") or [] category_str = " ".join(categories).lower() if categories else "" if params.category.lower() not in category_str: continue # Amount filters amount = txn.get("amount", 0) if params.min_amount is not None and amount < params.min_amount: continue if params.max_amount is not None and amount > params.max_amount: continue # Date filters (already applied in sync, but double-check) if params.date_start: txn_date = txn.get("date", "") if txn_date < params.date_start: continue if params.date_end: txn_date = txn.get("date", "") if txn_date > params.date_end: continue filtered.append(txn) # Format response (account_masks already fetched above for filtering) if params.response_format == ResponseFormat.MARKDOWN: lines = ["# Transaction Search Results", ""] # Show filters applied lines.append("## Filters Applied") if params.account_mask: lines.append(f"- **Card (last 4)**: {params.account_mask}") if params.merchant_name: lines.append(f"- **Merchant**: {params.merchant_name}") if params.category: lines.append(f"- **Category**: {params.category}") if params.min_amount is not None: lines.append(f"- **Min Amount**: ${params.min_amount:.2f}") if params.max_amount is not None: lines.append(f"- **Max Amount**: ${params.max_amount:.2f}") if params.date_start: lines.append(f"- **Date Start**: {params.date_start}") if params.date_end: lines.append(f"- **Date End**: {params.date_end}") lines.append("") lines.append(f"**Found {len(filtered)} matching transactions** (out of {len(all_transactions)} total)") lines.append("") # Calculate total spend for this filter total_spend = sum(txn.get("amount", 0) for txn in filtered) lines.append(f"**Total**: ${total_spend:,.2f}") lines.append("") if not filtered: lines.append("No transactions match your search criteria. Try adjusting your filters.") return "\n".join(lines) # Display transactions lines.append("## Matching Transactions") lines.append("") for txn in filtered: lines.append(_format_transaction_markdown(txn, account_masks)) result = "\n".join(lines) return _truncate_response(result, "matching transactions") else: # JSON format total_spend = sum(txn.get("amount", 0) for txn in filtered) result = { "transactions": filtered, "total_matches": len(filtered), "total_spend": total_spend, "total_scanned": len(all_transactions), "filters_applied": { "account_mask": params.account_mask, "merchant_name": params.merchant_name, "category": params.category, "min_amount": params.min_amount, "max_amount": params.max_amount, "date_start": params.date_start, "date_end": params.date_end } } json_str = json.dumps(result, indent=2) return _truncate_response(json_str, "matching transactions") except Exception as e: return _handle_plaid_error(e) # ============================================================================ # Plaid Link Portal Tool # ============================================================================ # Global state for Link callback _link_received_public_token: Optional[str] = None _link_server_should_stop = threading.Event() _link_redirect_port = 8765 class _PlaidLinkCallbackHandler(http.server.SimpleHTTPRequestHandler): """Handle the OAuth callback from Plaid Link.""" def log_message(self, format, *args): """Suppress default logging.""" pass def do_GET(self): global _link_received_public_token parsed = urllib.parse.urlparse(self.path) if parsed.path == "/callback": params = urllib.parse.parse_qs(parsed.query) if "public_token" in params: _link_received_public_token = params["public_token"][0] self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() self.wfile.write(b""" <html> <head><title>Success</title></head> <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f0f0f0;"> <div style="text-align: center; background: white; padding: 40px; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);"> <h1 style="color: #22c55e;">Account Linked Successfully!</h1> <p>You can close this window and return to Claude.</p> </div> </body> </html> """) _link_server_should_stop.set() else: self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() self.wfile.write(b""" <html> <head><title>Cancelled</title></head> <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f0f0f0;"> <div style="text-align: center; background: white; padding: 40px; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);"> <h1 style="color: #ef4444;">Linking Cancelled</h1> <p>You can close this window.</p> </div> </body> </html> """) _link_server_should_stop.set() elif parsed.path == "/link": link_token = getattr(self.server, 'link_token', '') self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() html = f""" <!DOCTYPE html> <html> <head> <title>Link Bank Account</title> <script src="https://cdn.plaid.com/link/v2/stable/link-initialize.js"></script> </head> <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f0f0f0;"> <div style="text-align: center; background: white; padding: 40px; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);"> <h1>Link Your Bank Account</h1> <p>Click the button below to securely connect your account.</p> <button id="link-button" style="background: #0066ff; color: white; border: none; padding: 12px 24px; border-radius: 6px; font-size: 16px; cursor: pointer; margin-top: 20px;"> Connect Account </button> </div> <script> const handler = Plaid.create({{ token: '{link_token}', onSuccess: (public_token, metadata) => {{ window.location.href = '/callback?public_token=' + public_token; }}, onExit: (err, metadata) => {{ if (err) {{ console.error(err); }} window.location.href = '/callback?cancelled=true'; }}, }}); document.getElementById('link-button').onclick = () => handler.open(); setTimeout(() => handler.open(), 500); </script> </body> </html> """ self.wfile.write(html.encode()) else: self.send_response(404) self.end_headers() def _run_link_server(link_token: str): """Run the temporary callback server for Plaid Link.""" with socketserver.TCPServer(("", _link_redirect_port), _PlaidLinkCallbackHandler) as httpd: httpd.link_token = link_token httpd.timeout = 1 while not _link_server_should_stop.is_set(): httpd.handle_request() async def _create_link_token() -> str: """Create a Plaid Link token.""" response = await _make_plaid_request("/link/token/create", data={ "user": {"client_user_id": "plaid-mcp-user"}, "client_name": "Plaid MCP", "products": ["transactions"], "country_codes": ["US"], "language": "en" }) if "link_token" not in response: error_msg = response.get("error_message", "Unknown error") raise Exception(f"Failed to create link token: {error_msg}") return response["link_token"] async def _exchange_public_token(public_token: str) -> tuple: """Exchange public token for access token. Returns (access_token, item_id).""" response = await _make_plaid_request("/item/public_token/exchange", data={ "public_token": public_token }) if "access_token" not in response: error_msg = response.get("error_message", "Unknown error") raise Exception(f"Failed to exchange token: {error_msg}") return response["access_token"], response["item_id"] async def _get_institution_name(access_token: str) -> str: """Get the institution name for a linked item.""" # Get item info client_id = _get_keychain_value("PlaidTracker", "plaid_client_id") secret = _get_keychain_value("PlaidTracker", "plaid_secret") async with httpx.AsyncClient() as client: item_response = await client.post( f"{PLAID_API_URL}/item/get", json={"client_id": client_id, "secret": secret, "access_token": access_token}, timeout=60.0 ) item_data = item_response.json() institution_id = item_data.get("item", {}).get("institution_id", "") if not institution_id: return "Unknown_Bank" inst_response = await client.post( f"{PLAID_API_URL}/institutions/get_by_id", json={ "client_id": client_id, "secret": secret, "institution_id": institution_id, "country_codes": ["US"] }, timeout=60.0 ) inst_data = inst_response.json() name = inst_data.get("institution", {}).get("name", "Unknown_Bank") return name.replace(" ", "_") def _store_keychain_value(service_name: str, account_name: str, value: str) -> bool: """Store a value in macOS Keychain. Updates if exists, creates if not.""" subprocess.run( ["security", "delete-generic-password", "-s", service_name, "-a", account_name], capture_output=True ) result = subprocess.run( ["security", "add-generic-password", "-s", service_name, "-a", account_name, "-w", value], capture_output=True, text=True ) return result.returncode == 0 @mcp.tool( name="plaid_add_account", annotations={ "title": "Add New Bank Account via Plaid Link", "readOnlyHint": False, "destructiveHint": False, "idempotentHint": False, "openWorldHint": True } ) async def plaid_add_account() -> str: """ Launch Plaid Link to connect a new bank account. This tool opens your browser to Plaid's secure Link interface where you can connect a new bank account. The access token will be automatically stored in your macOS Keychain. Returns: str: Status message indicating success or failure Examples: - Use when: "Add a new bank account" - Use when: "Link a new credit card" - Use when: "Connect my Chase account" Notes: - Opens a browser window for secure bank authentication - Waits up to 5 minutes for you to complete the flow - Stores credentials securely in Keychain """ global _link_received_public_token, _link_server_should_stop try: # Reset state _link_received_public_token = None _link_server_should_stop.clear() # Verify credentials try: _get_keychain_value("PlaidTracker", "plaid_client_id") _get_keychain_value("PlaidTracker", "plaid_secret") except Exception: return ( "# Error: Missing Plaid Credentials\n\n" "Please ensure your Plaid credentials are stored in Keychain:\n" "```\n" 'security add-generic-password -s "PlaidTracker" -a "plaid_client_id" -w "your_client_id"\n' 'security add-generic-password -s "PlaidTracker" -a "plaid_secret" -w "your_secret"\n' "```" ) # Create link token link_token = await _create_link_token() # Start callback server in background thread server_thread = threading.Thread( target=_run_link_server, args=(link_token,), daemon=True ) server_thread.start() # Give server time to start await asyncio.sleep(0.5) # Open browser webbrowser.open(f"http://localhost:{_link_redirect_port}/link") # Wait for callback (max 5 minutes) timeout = 300 # 5 minutes start_time = asyncio.get_event_loop().time() while not _link_server_should_stop.is_set(): await asyncio.sleep(0.5) if asyncio.get_event_loop().time() - start_time > timeout: _link_server_should_stop.set() return "# Timeout\n\nThe Plaid Link session timed out after 5 minutes. Please try again." # Check if we got a token if not _link_received_public_token: return "# Cancelled\n\nNo account was linked. The Plaid Link flow was cancelled or closed." # Exchange token access_token, item_id = await _exchange_public_token(_link_received_public_token) # Get institution name institution_name = await _get_institution_name(access_token) # Store in keychain account_name = f"access_token_{item_id}_{institution_name}" if _store_keychain_value("PlaidTracker", account_name, access_token): friendly_name = institution_name.replace("_", " ") return ( f"# Account Linked Successfully!\n\n" f"**Institution**: {friendly_name}\n\n" f"Your new account is now available. Use `plaid_list_accounts` to see all linked accounts." ) else: return "# Error\n\nFailed to store access token in Keychain. Please try again." except Exception as e: return _handle_plaid_error(e) # ============================================================================ # Main Entry Point # ============================================================================ if __name__ == "__main__": mcp.run()

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/laramarcodes/plaid-transactions-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server