@@ -99,9 +99,68 @@ def get_chrome_history_path() -> Optional[str]:
9999 logger .warning (f"Chrome history database not found at: { history_path } " )
100100 return None
101101
102+ def get_safari_profile_path () -> Optional [str ]:
103+ """Automatically detect Safari profile directory based on OS"""
104+ system = platform .system ().lower ()
105+
106+ if system == "darwin" : # macOS
107+ # Safari stores its data in the WebKit directory
108+ base_path = os .path .expanduser ("~/Library/WebKit/com.apple.Safari" )
109+
110+ # Also check the traditional Safari location as fallback
111+ if not os .path .exists (base_path ):
112+ base_path = os .path .expanduser ("~/Library/Safari" )
113+ else :
114+ logger .warning (f"Safari is only supported on macOS, not { system } " )
115+ return None
116+
117+ if not os .path .exists (base_path ):
118+ logger .warning (f"Safari profiles directory not found at: { base_path } " )
119+ return None
120+
121+ logger .info (f"Found Safari profile: { base_path } " )
122+ return base_path
123+
124+ def get_safari_history_path () -> Optional [str ]:
125+ """Get the path to Safari history database"""
126+ profile_path = get_safari_profile_path ()
127+ if not profile_path :
128+ return None
129+
130+ # Modern Safari (macOS 10.15+) uses different storage mechanisms
131+ # Try different possible Safari database locations and names
132+ possible_paths = [
133+ # Traditional locations (older Safari versions)
134+ os .path .join (profile_path , "History.db" ),
135+ os .path .join (profile_path , "WebpageIcons.db" ),
136+ os .path .join (profile_path , "Databases.db" ),
137+
138+ # Modern WebKit locations
139+ os .path .join (profile_path , "WebsiteData" , "LocalStorage" ),
140+ os .path .join (profile_path , "WebsiteData" , "IndexedDB" ),
141+ os .path .join (profile_path , "WebsiteData" , "ResourceLoadStatistics" ),
142+
143+ # Alternative locations for modern Safari
144+ os .path .join (os .path .expanduser ("~/Library/Safari" ), "History.db" ),
145+ os .path .join (os .path .expanduser ("~/Library/Safari" ), "WebpageIcons.db" ),
146+
147+ # CloudKit-related locations
148+ os .path .join (os .path .expanduser ("~/Library/Application Support/CloudDocs/session/containers/iCloud.com.apple.Safari" ), "Documents" ),
149+ ]
150+
151+ for history_path in possible_paths :
152+ if os .path .exists (history_path ):
153+ logger .info (f"Found Safari database at: { history_path } " )
154+ return history_path
155+
156+ logger .warning (f"No Safari history database found in: { profile_path } " )
157+ logger .warning ("Modern Safari (macOS 10.15+) uses CloudKit for history syncing and has limited programmatic access" )
158+ return None
159+
102160
103161PATH_TO_FIREFOX_HISTORY = get_firefox_history_path ()
104162PATH_TO_CHROME_HISTORY = get_chrome_history_path ()
163+ PATH_TO_SAFARI_HISTORY = get_safari_history_path ()
105164
106165@dataclass (frozen = True )
107166class HistoryEntry :
@@ -123,8 +182,9 @@ def to_dict(self) -> Dict:
123182class AppContext :
124183 firefox_db : Optional [sqlite3 .Connection ]
125184 chrome_db : Optional [sqlite3 .Connection ]
185+ safari_db : Optional [sqlite3 .Connection ]
126186
127- mcp = FastMCP (name = "Browser History MCP" , instructions = "This server makes it possible to query a user's Firefox or Chrome browser history, analyze it, and create a thoughtful report with an optional lense of productivity or learning." )
187+ mcp = FastMCP (name = "Browser History MCP" , instructions = "This server makes it possible to query a user's Firefox, Chrome, or Safari browser history, analyze it, and create a thoughtful report with an optional lense of productivity or learning." )
128188
129189@mcp .prompt ()
130190def productivity_analysis () -> str :
@@ -337,10 +397,107 @@ def _get_chrome_history(days: int) -> List[HistoryEntry]:
337397 if conn :
338398 conn .close ()
339399
400+ def _get_safari_history (days : int ) -> List [HistoryEntry ]:
401+ """Get Safari history from the last N days"""
402+ if not os .path .exists (PATH_TO_SAFARI_HISTORY ):
403+ raise RuntimeError (f"Safari history not found at { PATH_TO_SAFARI_HISTORY } " )
404+
405+ # Connect to the database
406+ try :
407+ conn = sqlite3 .connect (f"file:{ PATH_TO_SAFARI_HISTORY } ?mode=ro" , uri = True )
408+ except sqlite3 .OperationalError as e :
409+ if "unable to open database file" in str (e ).lower ():
410+ raise RuntimeError (
411+ f"Cannot access Safari database: { e } . "
412+ "Modern Safari (macOS 10.15+) uses CloudKit for history syncing and has limited programmatic access. "
413+ "Consider using Firefox or Chrome for browser history analysis, or export Safari history manually through Safari's interface."
414+ )
415+ else :
416+ raise RuntimeError (f"Failed to connect to Safari database: { e } " )
417+
418+ try :
419+ cursor = conn .cursor ()
420+
421+ # First, let's see what tables are available
422+ cursor .execute ("SELECT name FROM sqlite_master WHERE type='table';" )
423+ tables = [row [0 ] for row in cursor .fetchall ()]
424+ logger .info (f"Available tables in Safari database: { tables } " )
425+
426+ # Safari stores timestamps as seconds since Unix epoch
427+ cutoff_time = (datetime .now () - timedelta (days = days )).timestamp ()
428+
429+ # Try different possible Safari database structures
430+ query = None
431+
432+ # Check if we have the traditional history tables
433+ if 'history_items' in tables and 'history_visits' in tables :
434+ query = """
435+ SELECT DISTINCT hi.url, hi.title, COUNT(hv.id) as visit_count, MAX(hv.visit_time) as last_visit_time
436+ FROM history_items hi
437+ JOIN history_visits hv ON hi.id = hv.history_item
438+ WHERE hv.visit_time > ?
439+ GROUP BY hi.id, hi.url, hi.title
440+ ORDER BY last_visit_time DESC
441+ """
442+ elif 'urls' in tables :
443+ # Fallback to Chrome-like structure
444+ query = """
445+ SELECT DISTINCT u.url, u.title, u.visit_count, u.last_visit_time
446+ FROM urls u
447+ WHERE u.last_visit_time > ?
448+ ORDER BY u.last_visit_time DESC
449+ """
450+ elif 'moz_places' in tables :
451+ # Fallback to Firefox-like structure
452+ query = """
453+ SELECT DISTINCT h.url, h.title, h.visit_count, h.last_visit_date
454+ FROM moz_places h
455+ WHERE h.last_visit_date > ?
456+ AND h.hidden = 0
457+ ORDER BY h.last_visit_date DESC
458+ """
459+
460+ if query is None :
461+ raise RuntimeError (
462+ f"Safari database structure not recognized. Available tables: { tables } . "
463+ "Modern Safari uses CloudKit for history syncing and has limited programmatic access. "
464+ "Consider using Firefox or Chrome for browser history analysis."
465+ )
466+
467+ cursor .execute (query , (cutoff_time ,))
468+ results = cursor .fetchall ()
469+
470+ entries = []
471+ for url , title , visit_count , last_visit_time in results :
472+ # Convert Safari timestamp (seconds) to datetime
473+ visit_time = datetime .fromtimestamp (last_visit_time )
474+
475+ entries .append (HistoryEntry (
476+ url = url or "" ,
477+ title = title or "No Title" ,
478+ visit_count = visit_count or 0 ,
479+ last_visit_time = visit_time
480+ ))
481+
482+ return entries
483+ except Exception as e :
484+ logger .error (f"Error querying Safari history: { e } " )
485+ if "no such table" in str (e ).lower ():
486+ raise RuntimeError (
487+ f"Safari database structure not supported: { e } . "
488+ "Modern Safari uses CloudKit for history syncing and has limited programmatic access. "
489+ "Consider using Firefox or Chrome for browser history analysis."
490+ )
491+ else :
492+ raise RuntimeError (f"Failed to query Safari history: { e } " )
493+ finally :
494+ if conn :
495+ conn .close ()
496+
340497@mcp .tool ()
341498def detect_active_browser () -> Optional [List [str ]]:
342499 """Detects which browser is currently active by attempting to connect to databases.
343- Returns 'firefox', 'chrome', or None if neither is accessible.
500+ Returns 'firefox', 'chrome', 'safari', or None if none are accessible.
344501 Once we know which browser is active, we must tell the user that they will need to close the browser to get the history.
345502 Please remind them that they can restore their tabs by opening the browser again and possibly using Ctrl+Shift+T.
346503 """
@@ -355,6 +512,10 @@ def detect_active_browser() -> Optional[List[str]]:
355512 if PATH_TO_CHROME_HISTORY :
356513 browsers_to_check .append (('chrome' , PATH_TO_CHROME_HISTORY ))
357514
515+ # Check Safari
516+ if PATH_TO_SAFARI_HISTORY :
517+ browsers_to_check .append (('safari' , PATH_TO_SAFARI_HISTORY ))
518+
358519 if not browsers_to_check :
359520 logger .warning ("No browser history databases found" )
360521 return None
@@ -375,54 +536,89 @@ def detect_active_browser() -> Optional[List[str]]:
375536 except Exception as e :
376537 logger .warning (f"Unexpected error connecting to { browser_name } database: { e } " )
377538
378- # If no browser is locked, return both
379- if browsers_to_check :
380- logger .info (f"No active browser detected, defaulting to both Firefox and Chrome" )
381- return ["firefox" , "chrome" ] # TODO: make this dynamic based on the browsers_to_check list
539+ # If no browser is locked, return all available browsers
540+ if not browsers_in_use and browsers_to_check :
541+ available_browsers = [browser [0 ] for browser in browsers_to_check ]
542+ logger .info (f"No active browser detected, available browsers: { available_browsers } " )
543+ return available_browsers
382544
383545 return browsers_in_use
384546
385547
386548@mcp .tool ()
387- async def get_browser_history (time_period_in_days : int , browser_type : Optional [str ] = None ) -> List [Dict ]:
388- """Get browser history from the specified browser for the given time period.
549+ async def get_browser_history (time_period_in_days : int , browser_type : Optional [str ] = None , all_browsers : bool = False ) -> List [Dict ]:
550+ """Get browser history from the specified browser(s) for the given time period.
389551
390552 Args:
391553 time_period_in_days: Number of days of history to retrieve
392- browser_type: Browser type ('firefox', 'chrome', or None for auto-detect)
393-
394-
554+ browser_type: Browser type ('firefox', 'chrome', 'safari', or None for auto-detect)
555+ all_browsers: If True, get history from all available browsers. If False, use browser_type or auto-detect.
395556 """
396557
397558 if time_period_in_days <= 0 :
398559 raise ValueError ("time_period_in_days must be a positive integer" )
399560
400- # Auto-detect browser if not specified
401- if browser_type is None :
402- browser_type = detect_active_browser ()
403- if browser_type is None :
404- raise RuntimeError ("This MCP currently only supports Firefox and Chrome. Please ensure Firefox or Chrome is installed and try again." )
405- logger .info (f"Auto-detected active browser: { browser_type } " )
406-
407561 # Map browser types to their handler functions
408562 browser_handlers = {
409563 "firefox" : _get_firefox_history ,
410- "chrome" : _get_chrome_history
564+ "chrome" : _get_chrome_history ,
565+ "safari" : _get_safari_history
411566 }
412567
413- if browser_type not in browser_handlers :
414- raise ValueError (f"Unsupported browser type: { browser_type } . Supported types: { list (browser_handlers .keys ())} " )
568+ if all_browsers :
569+ # Get history from all available browsers
570+ all_entries = []
571+ available_browsers = detect_active_browser ()
572+
573+ if not available_browsers :
574+ raise RuntimeError ("No browser history databases found. Please ensure Firefox, Chrome, or Safari is installed and try again." )
575+
576+ for browser in available_browsers :
577+ try :
578+ entries = browser_handlers [browser ](time_period_in_days )
579+ logger .info (f"Retrieved { len (entries )} { browser } history entries from last { time_period_in_days } days" )
580+ all_entries .extend ([entry .to_dict () for entry in entries ])
581+ except Exception as e :
582+ logger .warning (f"Failed to get { browser } history: { e } " )
583+ continue
584+
585+ if not all_entries :
586+ raise RuntimeError ("Failed to retrieve history from any browser. Try closing browsers - history is locked while browsers are running." )
587+
588+ logger .info (f"Retrieved total of { len (all_entries )} history entries from all browsers" )
589+ return all_entries
415590
416- try :
417- entries = browser_handlers [browser_type ](time_period_in_days )
418- logger .info (f"Retrieved { len (entries )} { browser_type } history entries from last { time_period_in_days } days" )
419- return [entry .to_dict () for entry in entries ]
420- except sqlite3 .Error as e :
421- logger .error (f"Error querying { browser_type } history: { e } " )
422- raise RuntimeError (f"Failed to query { browser_type } history: { e } . Try closing the browser - history is locked while the browser is running." )
423- except Exception as e :
424- logger .error (f"Unexpected error querying { browser_type } history: { e } " )
425- raise RuntimeError (f"Failed to query { browser_type } history: { e } " )
591+ else :
592+ # Single browser mode (original behavior)
593+ if browser_type is None :
594+ detected_browsers = detect_active_browser ()
595+ if detected_browsers is None :
596+ raise RuntimeError ("This MCP currently only supports Firefox, Chrome, and Safari. Please ensure one of these browsers is installed and try again." )
597+
598+ # If detect_active_browser returns a list, take the first available browser
599+ if isinstance (detected_browsers , list ):
600+ browser_type = detected_browsers [0 ] if detected_browsers else None
601+ else :
602+ browser_type = detected_browsers
603+
604+ if browser_type is None :
605+ raise RuntimeError ("No browser history databases found. Please ensure Firefox, Chrome, or Safari is installed and try again." )
606+
607+ logger .info (f"Auto-detected active browser: { browser_type } " )
608+
609+ if browser_type not in browser_handlers :
610+ raise ValueError (f"Unsupported browser type: { browser_type } . Supported types: { list (browser_handlers .keys ())} " )
611+
612+ try :
613+ entries = browser_handlers [browser_type ](time_period_in_days )
614+ logger .info (f"Retrieved { len (entries )} { browser_type } history entries from last { time_period_in_days } days" )
615+ return [entry .to_dict () for entry in entries ]
616+ except sqlite3 .Error as e :
617+ logger .error (f"Error querying { browser_type } history: { e } " )
618+ raise RuntimeError (f"Failed to query { browser_type } history: { e } . Try closing the browser - history is locked while the browser is running." )
619+ except Exception as e :
620+ logger .error (f"Unexpected error querying { browser_type } history: { e } " )
621+ raise RuntimeError (f"Failed to query { browser_type } history: { e } " )
426622
427623@mcp .tool ()
428624async def group_browsing_history_into_sessions (history_data : List [Dict ], max_gap_hours : float = 2.0 ) -> List [Dict ]:
@@ -681,5 +877,49 @@ async def calculate_productivity_metrics(categorized_data: Dict[str, Dict]) -> D
681877
682878 return metrics
683879
880+ def check_safari_accessibility () -> Dict [str , any ]:
881+ """Check Safari accessibility and provide diagnostics"""
882+ result = {
883+ "safari_installed" : os .path .exists ("/Applications/Safari.app" ),
884+ "profile_path" : get_safari_profile_path (),
885+ "history_path" : PATH_TO_SAFARI_HISTORY ,
886+ "accessible" : False ,
887+ "error" : None ,
888+ "limitations" : "Modern Safari (macOS 10.15+) uses CloudKit for history syncing and has limited programmatic access"
889+ }
890+
891+ if not result ["safari_installed" ]:
892+ result ["error" ] = "Safari is not installed"
893+ return result
894+
895+ if not result ["history_path" ]:
896+ result ["error" ] = "Safari history database not found"
897+ result ["recommendation" ] = "Consider using Firefox or Chrome for browser history analysis, or export Safari history manually through Safari's interface"
898+ return result
899+
900+ try :
901+ # Try to connect to the database
902+ conn = sqlite3 .connect (f"file:{ result ['history_path' ]} ?mode=ro" , uri = True )
903+ cursor = conn .cursor ()
904+ cursor .execute ("SELECT name FROM sqlite_master WHERE type='table';" )
905+ tables = [row [0 ] for row in cursor .fetchall ()]
906+ conn .close ()
907+
908+ result ["accessible" ] = True
909+ result ["tables" ] = tables
910+ result ["message" ] = f"Safari database accessible with { len (tables )} tables"
911+ result ["note" ] = "This may be limited data - modern Safari uses CloudKit for full history syncing"
912+ except Exception as e :
913+ result ["error" ] = str (e )
914+ result ["message" ] = "Safari database not accessible"
915+ result ["recommendation" ] = "Modern Safari has limited programmatic access. Consider using Firefox or Chrome for browser history analysis"
916+
917+ return result
918+
919+ @mcp .tool ()
920+ def diagnose_safari_support () -> Dict [str , any ]:
921+ """Diagnose Safari support and accessibility. Useful for debugging Safari integration."""
922+ return check_safari_accessibility ()
923+
684924if __name__ == "__main__" :
685925 mcp .run ()
0 commit comments