server.py•61.6 kB
import logging
import os
from typing import Any, List, Dict, Optional
from mcp.server.fastmcp import FastMCP
# Excel 엔진: xlwings 구현으로 완전 전환 완료
# Import exceptions
from xlwings_mcp.exceptions import (
ValidationError,
WorkbookError,
SheetError,
DataError,
FormattingError,
CalculationError,
PivotError,
ChartError
)
# All functions now use xlwings_impl - legacy imports removed for clean architecture
# Import session management
from xlwings_mcp.session import SESSION_MANAGER
from xlwings_mcp.force_close import force_close_workbook_by_path
# Get project root directory path for log file path.
# When using the stdio transmission method,
# relative paths may cause log files to fail to create
# due to the client's running location and permission issues,
# resulting in the program not being able to run.
# Thus using os.path.join(ROOT_DIR, "excel-mcp.log") instead.
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
LOGS_DIR = os.path.join(ROOT_DIR, "logs")
os.makedirs(LOGS_DIR, exist_ok=True)
LOG_FILE = os.path.join(LOGS_DIR, "excel-mcp.log")
# Initialize EXCEL_FILES_PATH variable without assigning a value
EXCEL_FILES_PATH = None
# xlwings 구현 사용 (openpyxl 마이그레이션 완료)
# Configure logging with rotation to prevent infinite log growth
from logging.handlers import RotatingFileHandler
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[
# Referring to https://github.com/modelcontextprotocol/python-sdk/issues/409#issuecomment-2816831318
# The stdio mode server MUST NOT write anything to its stdout that is not a valid MCP message.
# Use RotatingFileHandler to prevent infinite log growth (5MB max, keep 3 backup files)
RotatingFileHandler(LOG_FILE, maxBytes=5*1024*1024, backupCount=3)
],
)
logger = logging.getLogger("excel-mcp")
logger.info("🚀 Excel MCP Server starting - xlwings 모드 활성화")
# Error message templates for consistent error reporting
ERROR_TEMPLATES = {
'SESSION_NOT_FOUND': "SESSION_NOT_FOUND: Session '{session_id}' not found. It may have expired after {ttl} minutes of inactivity. Use open_workbook() to create a new session.",
'SESSION_TIMEOUT': "SESSION_TIMEOUT: Session '{session_id}' expired at {time}. Create new session with open_workbook()",
'FILE_LOCKED': "FILE_ACCESS_ERROR: '{filepath}' is locked by another process. Use force_close_workbook_by_path() to force close it first.",
'FILE_NOT_FOUND': "FILE_NOT_FOUND: '{filepath}' does not exist. Check the path or create a new workbook with create_workbook().",
'SHEET_NOT_FOUND': "SHEET_NOT_FOUND: Sheet '{sheet_name}' not found in workbook. Available sheets: {sheets}",
'INVALID_RANGE': "INVALID_RANGE: Range '{range}' is not valid. Use format like 'A1' or 'A1:B10'.",
'PARAMETER_MISSING': "PARAMETER_MISSING: Either {param1} or {param2} must be provided.",
}
# Session validation decorator for DRY principle
def get_validated_session(session_id: str):
"""
Helper function to validate session_id and return session object.
Centralizes session validation logic for DRY principle.
Args:
session_id: Session ID to validate
Returns:
Session object if valid, error message string if invalid
"""
if not session_id:
return ERROR_TEMPLATES['PARAMETER_MISSING'].format(
param1='session_id', param2='valid session'
)
session = SESSION_MANAGER.get_session(session_id)
if not session:
return ERROR_TEMPLATES['SESSION_NOT_FOUND'].format(
session_id=session_id, ttl=10
)
return session
# Initialize FastMCP server
mcp = FastMCP(
"excel-mcp",
instructions="Excel MCP Server for manipulating Excel files"
)
def get_excel_path(filename: str) -> str:
"""Get full path to Excel file.
Args:
filename: Name of Excel file
Returns:
Full path to Excel file
"""
# If filename is already an absolute path, return it
if os.path.isabs(filename):
return filename
# Check if in SSE mode (EXCEL_FILES_PATH is not None)
if EXCEL_FILES_PATH is None:
# Must use absolute path
raise ValueError(f"Invalid filename: {filename}, must be an absolute path when not in SSE mode")
# In SSE mode, if it's a relative path, resolve it based on EXCEL_FILES_PATH
return os.path.join(EXCEL_FILES_PATH, filename)
# ============================================================================
# SESSION MANAGEMENT TOOLS (NEW)
# ============================================================================
@mcp.tool()
def open_workbook(
filepath: str,
visible: bool = False,
read_only: bool = False
) -> Dict[str, Any]:
"""
Open an Excel workbook and create a session.
Args:
filepath: Path to Excel file
visible: Whether to show Excel window (default: False)
read_only: Whether to open in read-only mode (default: False)
Returns:
Dictionary with session_id, filepath, visible, read_only, and sheets
"""
try:
full_path = get_excel_path(filepath)
session_id = SESSION_MANAGER.open_workbook(full_path, visible, read_only)
# Get session info
session = SESSION_MANAGER.get_session(session_id)
if not session:
raise WorkbookError(f"Failed to create session for {filepath}")
return {
"session_id": session_id,
"filepath": session.filepath,
"visible": session.visible,
"read_only": session.read_only,
"sheets": [sheet.name for sheet in session.workbook.sheets]
}
except Exception as e:
logger.error(f"Error opening workbook: {e}")
raise WorkbookError(f"Failed to open workbook: {str(e)}")
@mcp.tool()
def close_workbook(
session_id: str,
save: bool = True
) -> str:
"""
Close a workbook session.
Args:
session_id: Session ID from open_workbook
save: Whether to save changes (default: True)
Returns:
Success message
"""
try:
success = SESSION_MANAGER.close_workbook(session_id, save)
if not success:
raise WorkbookError(f"Session {session_id} not found")
return f"Workbook session {session_id} closed successfully"
except Exception as e:
logger.error(f"Error closing workbook: {e}")
raise WorkbookError(f"Failed to close workbook: {str(e)}")
@mcp.tool()
def list_workbooks() -> List[Dict[str, Any]]:
"""
List all open workbook sessions.
Returns:
List of session information dictionaries
"""
try:
return SESSION_MANAGER.list_sessions()
except Exception as e:
logger.error(f"Error listing workbooks: {e}")
raise WorkbookError(f"Failed to list workbooks: {str(e)}")
@mcp.tool()
def force_close_workbook_by_path_tool(
filepath: str
) -> Dict[str, Any]:
"""
Force close a specific workbook by file path (without saving).
Args:
filepath: Path to the workbook to force close
Returns:
Dictionary with 'closed' (bool) and 'message' (str)
"""
try:
full_path = get_excel_path(filepath)
return force_close_workbook_by_path(full_path)
except Exception as e:
logger.error(f"Error force closing workbook: {e}")
return {
"closed": False,
"message": f"Failed to force close workbook: {str(e)}"
}
# ============================================================================
# EXISTING TOOLS (TO BE UPDATED WITH SESSION SUPPORT)
# ============================================================================
@mcp.tool()
def apply_formula(
session_id: str,
sheet_name: str,
cell: str,
formula: str
) -> str:
"""
Apply Excel formula to cell.
Args:
session_id: Session ID from open_workbook (required)
sheet_name: Name of worksheet
cell: Cell address (e.g., "A1")
formula: Excel formula to apply
"""
try:
# Validate session using centralized helper
session = get_validated_session(session_id)
if isinstance(session, str): # Error message returned
return session
with session.lock:
from xlwings_mcp.xlwings_impl.calculations_xlw import apply_formula_xlw_with_wb
result = apply_formula_xlw_with_wb(session.workbook, sheet_name, cell, formula)
return result.get("message", "Formula applied successfully") if "error" not in result else f"Error: {result['error']}"
except (ValidationError, CalculationError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error applying formula: {e}")
raise
@mcp.tool()
def validate_formula_syntax(
sheet_name: str,
cell: str,
formula: str,
session_id: Optional[str] = None,
filepath: Optional[str] = None
) -> str:
"""
Validate Excel formula syntax without applying it.
Args:
sheet_name: Name of worksheet
cell: Cell address (e.g., "A1")
formula: Excel formula to validate
session_id: Session ID from open_workbook (preferred)
filepath: Path to Excel file (legacy, deprecated)
Note: Use session_id for better performance. filepath parameter is deprecated.
"""
try:
# Support both new (session_id) and old (filepath) API
if session_id:
# New API: use session
session = SESSION_MANAGER.get_session(session_id)
if not session:
return ERROR_TEMPLATES['SESSION_NOT_FOUND'].format(
session_id=session_id,
ttl=10 # Default TTL is 10 minutes (600 seconds)
)
with session.lock:
from xlwings_mcp.xlwings_impl.calculations_xlw import validate_formula_syntax_xlw_with_wb
result = validate_formula_syntax_xlw_with_wb(session.workbook, sheet_name, cell, formula)
elif filepath:
# Legacy API: backwards compatibility
logger.warning("Using deprecated filepath parameter. Please use session_id instead.")
full_path = get_excel_path(filepath)
from xlwings_mcp.xlwings_impl.calculations_xlw import validate_formula_syntax_xlw
result = validate_formula_syntax_xlw(full_path, sheet_name, cell, formula)
else:
return ERROR_TEMPLATES['PARAMETER_MISSING'].format(
param1='session_id',
param2='filepath'
)
return result.get("message", "Formula validation completed") if "error" not in result else f"Error: {result['error']}"
except (ValidationError, CalculationError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error validating formula: {e}")
raise
@mcp.tool()
def format_range(
sheet_name: str,
start_cell: str,
session_id: Optional[str] = None,
filepath: Optional[str] = None,
end_cell: Optional[str] = None,
bold: bool = False,
italic: bool = False,
underline: bool = False,
font_size: Optional[int] = None,
font_color: Optional[str] = None,
bg_color: Optional[str] = None,
border_style: Optional[str] = None,
border_color: Optional[str] = None,
number_format: Optional[str] = None,
alignment: Optional[str] = None,
wrap_text: bool = False,
merge_cells: bool = False,
protection: Optional[Dict[str, Any]] = None,
conditional_format: Optional[Dict[str, Any]] = None
) -> str:
"""
Apply formatting to a range of cells.
Args:
sheet_name: Name of worksheet
start_cell: Starting cell
session_id: Session ID from open_workbook (preferred)
filepath: Path to Excel file (legacy, deprecated)
end_cell: Ending cell (optional)
bold: Apply bold formatting
italic: Apply italic formatting
underline: Apply underline formatting
font_size: Font size
font_color: Font color
bg_color: Background color
border_style: Border style
border_color: Border color
number_format: Number format
alignment: Text alignment
wrap_text: Enable text wrapping
merge_cells: Merge cells in range
protection: Cell protection settings (optional)
conditional_format: Conditional formatting settings (optional)
Note: Use session_id for better performance. filepath parameter is deprecated.
"""
try:
# Support both new (session_id) and old (filepath) API
if session_id:
# New API: use session
session = SESSION_MANAGER.get_session(session_id)
if not session:
return ERROR_TEMPLATES['SESSION_NOT_FOUND'].format(
session_id=session_id,
ttl=10 # Default TTL is 10 minutes (600 seconds)
)
with session.lock:
from xlwings_mcp.xlwings_impl.formatting_xlw import format_range_xlw_with_wb
result = format_range_xlw_with_wb(
session.workbook,
sheet_name=sheet_name,
start_cell=start_cell,
end_cell=end_cell,
bold=bold,
italic=italic,
underline=underline,
font_size=font_size,
font_color=font_color,
bg_color=bg_color,
border_style=border_style,
border_color=border_color,
number_format=number_format,
alignment=alignment,
wrap_text=wrap_text,
merge_cells=merge_cells
)
elif filepath:
# Legacy API: backwards compatibility
logger.warning("Using deprecated filepath parameter. Please use session_id instead.")
full_path = get_excel_path(filepath)
from xlwings_mcp.xlwings_impl.formatting_xlw import format_range_xlw
result = format_range_xlw(
filepath=full_path,
sheet_name=sheet_name,
start_cell=start_cell,
end_cell=end_cell,
bold=bold,
italic=italic,
underline=underline,
font_size=font_size,
font_color=font_color,
bg_color=bg_color,
border_style=border_style,
border_color=border_color,
number_format=number_format,
alignment=alignment,
wrap_text=wrap_text,
merge_cells=merge_cells
)
else:
return ERROR_TEMPLATES['PARAMETER_MISSING'].format(
param1='session_id',
param2='filepath'
)
return result.get("message", "Range formatted successfully") if "error" not in result else f"Error: {result['error']}"
except (ValidationError, FormattingError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error formatting range: {e}")
raise
@mcp.tool()
def read_data_from_excel(
session_id: str,
sheet_name: str,
start_cell: Optional[str] = None,
end_cell: Optional[str] = None,
preview_only: bool = False
) -> str:
"""
Read data from Excel worksheet with cell metadata including validation rules.
Args:
session_id: Session ID from open_workbook (required)
sheet_name: Name of worksheet
start_cell: Starting cell (default A1)
end_cell: Ending cell (optional, auto-expands if not provided)
preview_only: Whether to return preview only
"""
try:
# Validate session using centralized helper
session = get_validated_session(session_id)
if isinstance(session, str): # Error message returned
return session
with session.lock:
from xlwings_mcp.xlwings_impl.data_xlw import read_data_from_excel_xlw_with_wb
return read_data_from_excel_xlw_with_wb(session.workbook, sheet_name, start_cell, end_cell, preview_only)
except (ValidationError, DataError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error reading data: {e}")
raise
@mcp.tool()
def write_data_to_excel(
session_id: str,
sheet_name: str,
data: List[List],
start_cell: Optional[str] = None
) -> str:
"""
Write data to Excel worksheet.
Excel formula will write to cell without any verification.
Args:
session_id: Session ID from open_workbook (required)
sheet_name: Name of worksheet to write to
data: List of lists containing data to write to the worksheet, sublists are assumed to be rows
start_cell: Cell to start writing to (optional, auto-finds appropriate location)
"""
try:
# Validate session using centralized helper
session = get_validated_session(session_id)
if isinstance(session, str): # Error message returned
return session
with session.lock:
from xlwings_mcp.xlwings_impl.data_xlw import write_data_to_excel_xlw_with_wb
result = write_data_to_excel_xlw_with_wb(session.workbook, sheet_name, data, start_cell)
return result.get("message", "Data written successfully") if "error" not in result else f"Error: {result['error']}"
except (ValidationError, DataError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error writing data: {e}")
raise
@mcp.tool()
def create_workbook(
session_id: Optional[str] = None,
filepath: Optional[str] = None
) -> str:
"""
Create new Excel workbook.
Args:
session_id: Session ID for creating workbook in existing session (optional)
filepath: Path to create new Excel file (legacy, deprecated)
Note: Use session_id for better performance. filepath parameter is deprecated.
"""
try:
# Support both new (session_id) and old (filepath) API
if session_id:
# New API: use session (though this is less common for creating new workbooks)
session = SESSION_MANAGER.get_session(session_id)
if not session:
return f"Error: Session {session_id} not found. Please open the workbook first using open_workbook()."
with session.lock:
from xlwings_mcp.xlwings_impl.workbook_xlw import create_workbook_xlw_with_wb
result = create_workbook_xlw_with_wb(session.workbook)
return result.get("message", "Workbook created successfully") if "error" not in result else f"Error: {result['error']}"
elif filepath:
# Legacy API: backwards compatibility
logger.warning("Using deprecated filepath parameter. Please use session_id instead.")
full_path = get_excel_path(filepath)
from xlwings_mcp.workbook import create_workbook as create_workbook_impl
create_workbook_impl(full_path)
return f"Created workbook at {full_path}"
else:
return ERROR_TEMPLATES['PARAMETER_MISSING'].format(
param1='session_id',
param2='filepath'
)
except WorkbookError as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error creating workbook: {e}")
raise
@mcp.tool()
def create_worksheet(
session_id: str,
sheet_name: str
) -> str:
"""
Create new worksheet in workbook.
Args:
session_id: Session ID from open_workbook (required)
sheet_name: Name of the new worksheet
"""
try:
# Validate session using centralized helper
session = get_validated_session(session_id)
if isinstance(session, str): # Error message returned
return session
with session.lock:
from xlwings_mcp.xlwings_impl.sheet_xlw import create_worksheet_xlw_with_wb
result = create_worksheet_xlw_with_wb(session.workbook, sheet_name)
return result.get("message", "Worksheet created successfully") if "error" not in result else f"Error: {result['error']}"
except (ValidationError, WorkbookError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error creating worksheet: {e}")
raise
@mcp.tool()
def create_chart(
sheet_name: str,
data_range: str,
chart_type: str,
target_cell: str,
session_id: Optional[str] = None,
filepath: Optional[str] = None,
title: str = "",
x_axis: str = "",
y_axis: str = ""
) -> str:
"""
Create chart in worksheet.
Args:
sheet_name: Name of worksheet
data_range: Data range for chart
chart_type: Type of chart
target_cell: Cell where chart will be placed
session_id: Session ID from open_workbook (preferred)
filepath: Path to Excel file (legacy, deprecated)
title: Chart title (optional)
x_axis: X-axis label (optional)
y_axis: Y-axis label (optional)
Note: Use session_id for better performance. filepath parameter is deprecated.
"""
try:
# Support both new (session_id) and old (filepath) API
if session_id:
# New API: use session
session = SESSION_MANAGER.get_session(session_id)
if not session:
return ERROR_TEMPLATES['SESSION_NOT_FOUND'].format(
session_id=session_id,
ttl=10 # Default TTL is 10 minutes (600 seconds)
)
with session.lock:
from xlwings_mcp.xlwings_impl.advanced_xlw import create_chart_xlw_with_wb
result = create_chart_xlw_with_wb(
session.workbook,
sheet_name=sheet_name,
data_range=data_range,
chart_type=chart_type,
target_cell=target_cell,
title=title,
x_axis=x_axis,
y_axis=y_axis
)
elif filepath:
# Legacy API: backwards compatibility
logger.warning("Using deprecated filepath parameter. Please use session_id instead.")
full_path = get_excel_path(filepath)
from xlwings_mcp.xlwings_impl.advanced_xlw import create_chart_xlw
result = create_chart_xlw(
filepath=full_path,
sheet_name=sheet_name,
data_range=data_range,
chart_type=chart_type,
target_cell=target_cell,
title=title,
x_axis=x_axis,
y_axis=y_axis
)
else:
return ERROR_TEMPLATES['PARAMETER_MISSING'].format(
param1='session_id',
param2='filepath'
)
return result.get("message", "Chart created successfully") if "error" not in result else f"Error: {result['error']}"
except (ValidationError, ChartError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error creating chart: {e}")
raise
@mcp.tool()
def create_pivot_table(
sheet_name: str,
data_range: str,
rows: List[str],
values: List[str],
session_id: Optional[str] = None,
filepath: Optional[str] = None,
columns: Optional[List[str]] = None,
agg_func: str = "mean",
target_sheet: Optional[str] = None,
target_cell: Optional[str] = None,
pivot_name: Optional[str] = None
) -> str:
"""
Create pivot table in worksheet.
Args:
sheet_name: Name of worksheet containing source data
data_range: Source data range (e.g., "A1:E100" or "Sheet2!A1:E100")
rows: Field names for row labels
values: Field names for values
session_id: Session ID from open_workbook (preferred)
filepath: Path to Excel file (legacy, deprecated)
columns: Field names for column labels (optional)
agg_func: Aggregation function (sum, count, average, max, min)
target_sheet: Target sheet for pivot table (optional, auto-created if not exists)
target_cell: Target cell for pivot table (optional, finds empty area if not provided)
pivot_name: Custom name for pivot table (optional, auto-generated if not provided)
Note: Use session_id for better performance. filepath parameter is deprecated.
"""
try:
# Support both new (session_id) and old (filepath) API
if session_id:
# New API: use session
session = SESSION_MANAGER.get_session(session_id)
if not session:
return ERROR_TEMPLATES['SESSION_NOT_FOUND'].format(
session_id=session_id,
ttl=10 # Default TTL is 10 minutes (600 seconds)
)
with session.lock:
from xlwings_mcp.xlwings_impl.advanced_xlw import create_pivot_table_xlw_with_wb
result = create_pivot_table_xlw_with_wb(
session.workbook,
sheet_name=sheet_name,
data_range=data_range,
rows=rows,
values=values,
columns=columns,
agg_func=agg_func,
target_sheet=target_sheet,
target_cell=target_cell,
pivot_name=pivot_name
)
elif filepath:
# Legacy API: backwards compatibility
logger.warning("Using deprecated filepath parameter. Please use session_id instead.")
full_path = get_excel_path(filepath)
from xlwings_mcp.xlwings_impl.advanced_xlw import create_pivot_table_xlw
result = create_pivot_table_xlw(
filepath=full_path,
sheet_name=sheet_name,
data_range=data_range,
rows=rows,
values=values,
columns=columns,
agg_func=agg_func,
target_sheet=target_sheet,
target_cell=target_cell,
pivot_name=pivot_name
)
else:
return ERROR_TEMPLATES['PARAMETER_MISSING'].format(
param1='session_id',
param2='filepath'
)
# Handle warnings in response
if "warnings" in result and result["warnings"]:
warning_msg = "; ".join(result["warnings"])
return f"{result.get('message', 'Pivot table created')} (Warnings: {warning_msg})"
return result.get("message", "Pivot table created successfully") if "error" not in result else f"Error: {result['error']}"
except (ValidationError, PivotError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error creating pivot table: {e}")
raise
@mcp.tool()
def create_table(
sheet_name: str,
data_range: str,
session_id: Optional[str] = None,
filepath: Optional[str] = None,
table_name: Optional[str] = None,
table_style: str = "TableStyleMedium9"
) -> str:
"""
Creates a native Excel table from a specified range of data.
Args:
sheet_name: Name of worksheet
data_range: Range of data to create table from
session_id: Session ID from open_workbook (preferred)
filepath: Path to Excel file (legacy, deprecated)
table_name: Name for the table (optional)
table_style: Style for the table (optional)
Note: Use session_id for better performance. filepath parameter is deprecated.
"""
try:
# Support both new (session_id) and old (filepath) API
if session_id:
# New API: use session
session = SESSION_MANAGER.get_session(session_id)
if not session:
return ERROR_TEMPLATES['SESSION_NOT_FOUND'].format(
session_id=session_id,
ttl=10 # Default TTL is 10 minutes (600 seconds)
)
with session.lock:
from xlwings_mcp.xlwings_impl.advanced_xlw import create_table_xlw_with_wb
result = create_table_xlw_with_wb(
session.workbook,
sheet_name=sheet_name,
data_range=data_range,
table_name=table_name,
table_style=table_style
)
elif filepath:
# Legacy API: backwards compatibility
logger.warning("Using deprecated filepath parameter. Please use session_id instead.")
full_path = get_excel_path(filepath)
from xlwings_mcp.xlwings_impl.advanced_xlw import create_table_xlw
result = create_table_xlw(
filepath=full_path,
sheet_name=sheet_name,
data_range=data_range,
table_name=table_name,
table_style=table_style
)
else:
return ERROR_TEMPLATES['PARAMETER_MISSING'].format(
param1='session_id',
param2='filepath'
)
return result.get("message", "Table created successfully") if "error" not in result else f"Error: {result['error']}"
except DataError as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error creating table: {e}")
raise
@mcp.tool()
def copy_worksheet(
session_id: str,
source_sheet: str,
target_sheet: str
) -> str:
"""
Copy worksheet within workbook.
Args:
session_id: Session ID from open_workbook (required)
source_sheet: Name of the source worksheet
target_sheet: Name of the target worksheet
"""
try:
# Validate session using centralized helper
session = get_validated_session(session_id)
if isinstance(session, str): # Error message returned
return session
with session.lock:
from xlwings_mcp.xlwings_impl.sheet_xlw import copy_worksheet_xlw_with_wb
result = copy_worksheet_xlw_with_wb(session.workbook, source_sheet, target_sheet)
return result.get("message", "Worksheet copied successfully") if "error" not in result else f"Error: {result['error']}"
except (ValidationError, SheetError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error copying worksheet: {e}")
raise
@mcp.tool()
def delete_worksheet(
session_id: str,
sheet_name: str
) -> str:
"""
Delete worksheet from workbook.
Args:
session_id: Session ID from open_workbook (required)
sheet_name: Name of the worksheet to delete
"""
try:
# Validate session using centralized helper
session = get_validated_session(session_id)
if isinstance(session, str): # Error message returned
return session
with session.lock:
from xlwings_mcp.xlwings_impl.sheet_xlw import delete_worksheet_xlw_with_wb
result = delete_worksheet_xlw_with_wb(session.workbook, sheet_name)
return result.get("message", "Worksheet deleted successfully") if "error" not in result else f"Error: {result['error']}"
except (ValidationError, SheetError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error deleting worksheet: {e}")
raise
@mcp.tool()
def rename_worksheet(
session_id: str,
old_name: str,
new_name: str
) -> str:
"""
Rename worksheet in workbook.
Args:
session_id: Session ID from open_workbook (required)
old_name: Current name of the worksheet
new_name: New name for the worksheet
"""
try:
# Validate session using centralized helper
session = get_validated_session(session_id)
if isinstance(session, str): # Error message returned
return session
with session.lock:
from xlwings_mcp.xlwings_impl.sheet_xlw import rename_worksheet_xlw_with_wb
result = rename_worksheet_xlw_with_wb(session.workbook, old_name, new_name)
return result.get("message", "Worksheet renamed successfully") if "error" not in result else f"Error: {result['error']}"
except (ValidationError, SheetError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error renaming worksheet: {e}")
raise
@mcp.tool()
def get_workbook_metadata(
session_id: str,
include_ranges: bool = False
) -> str:
"""
Get metadata about workbook including sheets, ranges, etc.
Args:
session_id: Session ID from open_workbook (required)
include_ranges: Whether to include range information
"""
try:
# Validate session using centralized helper
session = get_validated_session(session_id)
if isinstance(session, str): # Error message returned
return session
with session.lock:
from xlwings_mcp.xlwings_impl.workbook_xlw import get_workbook_metadata_xlw_with_wb
result = get_workbook_metadata_xlw_with_wb(session.workbook, include_ranges=include_ranges)
if "error" in result:
return f"Error: {result['error']}"
import json
return json.dumps(result, indent=2, default=str, ensure_ascii=False)
except (ValidationError, WorkbookError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error getting workbook metadata: {e}")
raise
@mcp.tool()
def merge_cells(
sheet_name: str,
start_cell: str,
end_cell: str,
session_id: Optional[str] = None,
filepath: Optional[str] = None
) -> str:
"""
Merge a range of cells.
Args:
sheet_name: Name of worksheet
start_cell: Starting cell
end_cell: Ending cell
session_id: Session ID from open_workbook (preferred)
filepath: Path to Excel file (legacy, deprecated)
Note: Use session_id for better performance. filepath parameter is deprecated.
"""
try:
# Support both new (session_id) and old (filepath) API
if session_id:
# New API: use session
session = SESSION_MANAGER.get_session(session_id)
if not session:
return ERROR_TEMPLATES['SESSION_NOT_FOUND'].format(
session_id=session_id,
ttl=10 # Default TTL is 10 minutes (600 seconds)
)
with session.lock:
from xlwings_mcp.xlwings_impl.range_xlw import merge_cells_xlw_with_wb
result = merge_cells_xlw_with_wb(session.workbook, sheet_name, start_cell, end_cell)
elif filepath:
# Legacy API: backwards compatibility
logger.warning("Using deprecated filepath parameter. Please use session_id instead.")
full_path = get_excel_path(filepath)
from xlwings_mcp.xlwings_impl.range_xlw import merge_cells_xlw
result = merge_cells_xlw(full_path, sheet_name, start_cell, end_cell)
else:
return ERROR_TEMPLATES['PARAMETER_MISSING'].format(
param1='session_id',
param2='filepath'
)
return result.get("message", "Cells merged successfully") if "error" not in result else f"Error: {result['error']}"
except (ValidationError, SheetError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error merging cells: {e}")
raise
@mcp.tool()
def unmerge_cells(
sheet_name: str,
start_cell: str,
end_cell: str,
session_id: Optional[str] = None,
filepath: Optional[str] = None
) -> str:
"""
Unmerge a range of cells.
Args:
sheet_name: Name of worksheet
start_cell: Starting cell
end_cell: Ending cell
session_id: Session ID from open_workbook (preferred)
filepath: Path to Excel file (legacy, deprecated)
Note: Use session_id for better performance. filepath parameter is deprecated.
"""
try:
# Support both new (session_id) and old (filepath) API
if session_id:
# New API: use session
session = SESSION_MANAGER.get_session(session_id)
if not session:
return ERROR_TEMPLATES['SESSION_NOT_FOUND'].format(
session_id=session_id,
ttl=10 # Default TTL is 10 minutes (600 seconds)
)
with session.lock:
from xlwings_mcp.xlwings_impl.range_xlw import unmerge_cells_xlw_with_wb
result = unmerge_cells_xlw_with_wb(session.workbook, sheet_name, start_cell, end_cell)
elif filepath:
# Legacy API: backwards compatibility
logger.warning("Using deprecated filepath parameter. Please use session_id instead.")
full_path = get_excel_path(filepath)
from xlwings_mcp.xlwings_impl.range_xlw import unmerge_cells_xlw
result = unmerge_cells_xlw(full_path, sheet_name, start_cell, end_cell)
else:
return ERROR_TEMPLATES['PARAMETER_MISSING'].format(
param1='session_id',
param2='filepath'
)
return result.get("message", "Cells unmerged successfully") if "error" not in result else f"Error: {result['error']}"
except (ValidationError, SheetError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error unmerging cells: {e}")
raise
@mcp.tool()
def get_merged_cells(
sheet_name: str,
session_id: Optional[str] = None,
filepath: Optional[str] = None
) -> str:
"""
Get merged cells in a worksheet.
Args:
sheet_name: Name of worksheet
session_id: Session ID from open_workbook (preferred)
filepath: Path to Excel file (legacy, deprecated)
Note: Use session_id for better performance. filepath parameter is deprecated.
"""
try:
# Support both new (session_id) and old (filepath) API
if session_id:
# New API: use session
session = SESSION_MANAGER.get_session(session_id)
if not session:
return ERROR_TEMPLATES['SESSION_NOT_FOUND'].format(
session_id=session_id,
ttl=10 # Default TTL is 10 minutes (600 seconds)
)
with session.lock:
from xlwings_mcp.xlwings_impl.range_xlw import get_merged_cells_xlw_with_wb
result = get_merged_cells_xlw_with_wb(session.workbook, sheet_name)
if "error" in result:
return f"Error: {result['error']}"
import json
return json.dumps(result, indent=2, default=str)
elif filepath:
# Legacy API: backwards compatibility
logger.warning("Using deprecated filepath parameter. Please use session_id instead.")
full_path = get_excel_path(filepath)
from xlwings_mcp.xlwings_impl.range_xlw import get_merged_cells_xlw
result = get_merged_cells_xlw(full_path, sheet_name)
if "error" in result:
return f"Error: {result['error']}"
import json
return json.dumps(result, indent=2, default=str)
else:
return ERROR_TEMPLATES['PARAMETER_MISSING'].format(
param1='session_id',
param2='filepath'
)
except (ValidationError, SheetError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error getting merged cells: {e}")
raise
@mcp.tool()
def copy_range(
sheet_name: str,
source_start: str,
source_end: str,
target_start: str,
session_id: Optional[str] = None,
filepath: Optional[str] = None,
target_sheet: Optional[str] = None
) -> str:
"""
Copy a range of cells to another location.
Args:
sheet_name: Name of source worksheet
source_start: Starting cell of source range
source_end: Ending cell of source range
target_start: Starting cell of target range
session_id: Session ID from open_workbook (preferred)
filepath: Path to Excel file (legacy, deprecated)
target_sheet: Target worksheet (optional, uses source sheet if not provided)
Note: Use session_id for better performance. filepath parameter is deprecated.
"""
try:
# Support both new (session_id) and old (filepath) API
if session_id:
# New API: use session
session = SESSION_MANAGER.get_session(session_id)
if not session:
return ERROR_TEMPLATES['SESSION_NOT_FOUND'].format(
session_id=session_id,
ttl=10 # Default TTL is 10 minutes (600 seconds)
)
with session.lock:
from xlwings_mcp.xlwings_impl.range_xlw import copy_range_xlw_with_wb
result = copy_range_xlw_with_wb(
session.workbook,
sheet_name,
source_start,
source_end,
target_start,
target_sheet or sheet_name # Use source sheet if target_sheet is None
)
elif filepath:
# Legacy API: backwards compatibility
logger.warning("Using deprecated filepath parameter. Please use session_id instead.")
full_path = get_excel_path(filepath)
from xlwings_mcp.xlwings_impl.range_xlw import copy_range_xlw
result = copy_range_xlw(
full_path,
sheet_name,
source_start,
source_end,
target_start,
target_sheet or sheet_name # Use source sheet if target_sheet is None
)
else:
return ERROR_TEMPLATES['PARAMETER_MISSING'].format(
param1='session_id',
param2='filepath'
)
return result.get("message", "Range copied successfully") if "error" not in result else f"Error: {result['error']}"
except (ValidationError, SheetError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error copying range: {e}")
raise
@mcp.tool()
def delete_range(
sheet_name: str,
start_cell: str,
end_cell: str,
session_id: Optional[str] = None,
filepath: Optional[str] = None,
shift_direction: str = "up"
) -> str:
"""
Delete a range of cells and shift remaining cells.
Args:
sheet_name: Name of worksheet
start_cell: Starting cell
end_cell: Ending cell
session_id: Session ID from open_workbook (preferred)
filepath: Path to Excel file (legacy, deprecated)
shift_direction: Direction to shift cells ("up" or "left")
Note: Use session_id for better performance. filepath parameter is deprecated.
"""
try:
# Support both new (session_id) and old (filepath) API
if session_id:
# New API: use session
session = SESSION_MANAGER.get_session(session_id)
if not session:
return ERROR_TEMPLATES['SESSION_NOT_FOUND'].format(
session_id=session_id,
ttl=10 # Default TTL is 10 minutes (600 seconds)
)
with session.lock:
from xlwings_mcp.xlwings_impl.range_xlw import delete_range_xlw_with_wb
result = delete_range_xlw_with_wb(
session.workbook,
sheet_name,
start_cell,
end_cell,
shift_direction
)
elif filepath:
# Legacy API: backwards compatibility
logger.warning("Using deprecated filepath parameter. Please use session_id instead.")
full_path = get_excel_path(filepath)
from xlwings_mcp.xlwings_impl.range_xlw import delete_range_xlw
result = delete_range_xlw(
full_path,
sheet_name,
start_cell,
end_cell,
shift_direction
)
else:
return ERROR_TEMPLATES['PARAMETER_MISSING'].format(
param1='session_id',
param2='filepath'
)
return result.get("message", "Range deleted successfully") if "error" not in result else f"Error: {result['error']}"
except (ValidationError, SheetError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error deleting range: {e}")
raise
@mcp.tool()
def validate_excel_range(
sheet_name: str,
start_cell: str,
session_id: Optional[str] = None,
filepath: Optional[str] = None,
end_cell: Optional[str] = None
) -> str:
"""
Validate if a range exists and is properly formatted.
Args:
sheet_name: Name of worksheet
start_cell: Starting cell
session_id: Session ID from open_workbook (preferred)
filepath: Path to Excel file (legacy, deprecated)
end_cell: Ending cell (optional)
Note: Use session_id for better performance. filepath parameter is deprecated.
"""
try:
# Support both new (session_id) and old (filepath) API
if session_id:
# New API: use session
session = SESSION_MANAGER.get_session(session_id)
if not session:
return ERROR_TEMPLATES['SESSION_NOT_FOUND'].format(
session_id=session_id,
ttl=10 # Default TTL is 10 minutes (600 seconds)
)
with session.lock:
from xlwings_mcp.xlwings_impl.validation_xlw import validate_excel_range_xlw_with_wb
result = validate_excel_range_xlw_with_wb(session.workbook, sheet_name, start_cell, end_cell)
elif filepath:
# Legacy API: backwards compatibility
logger.warning("Using deprecated filepath parameter. Please use session_id instead.")
full_path = get_excel_path(filepath)
from xlwings_mcp.xlwings_impl.validation_xlw import validate_excel_range_xlw
result = validate_excel_range_xlw(full_path, sheet_name, start_cell, end_cell)
else:
return ERROR_TEMPLATES['PARAMETER_MISSING'].format(
param1='session_id',
param2='filepath'
)
return result.get("message", "Range validation completed") if "error" not in result else f"Error: {result['error']}"
except (ValidationError, DataError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error validating range: {e}")
raise
@mcp.tool()
def get_data_validation_info(
sheet_name: str,
session_id: Optional[str] = None,
filepath: Optional[str] = None
) -> str:
"""
Get all data validation rules in a worksheet.
This tool helps identify which cell ranges have validation rules
and what types of validation are applied.
Args:
sheet_name: Name of worksheet
session_id: Session ID from open_workbook (preferred)
filepath: Path to Excel file (legacy, deprecated)
Note: Use session_id for better performance. filepath parameter is deprecated.
Returns:
JSON string containing all validation rules in the worksheet
"""
try:
# Support both new (session_id) and old (filepath) API
if session_id:
# New API: use session
session = SESSION_MANAGER.get_session(session_id)
if not session:
return ERROR_TEMPLATES['SESSION_NOT_FOUND'].format(
session_id=session_id,
ttl=10 # Default TTL is 10 minutes (600 seconds)
)
with session.lock:
from xlwings_mcp.xlwings_impl.validation_xlw import get_data_validation_info_xlw_with_wb
result = get_data_validation_info_xlw_with_wb(session.workbook, sheet_name)
elif filepath:
# Legacy API: backwards compatibility
logger.warning("Using deprecated filepath parameter. Please use session_id instead.")
full_path = get_excel_path(filepath)
from xlwings_mcp.xlwings_impl.validation_xlw import get_data_validation_info_xlw
result = get_data_validation_info_xlw(full_path, sheet_name)
else:
return ERROR_TEMPLATES['PARAMETER_MISSING'].format(
param1='session_id',
param2='filepath'
)
if "error" in result:
return f"Error: {result['error']}"
import json
return json.dumps(result, indent=2, default=str)
except (ValidationError, DataError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error getting validation info: {e}")
raise
@mcp.tool()
def insert_rows(
sheet_name: str,
start_row: int,
session_id: Optional[str] = None,
filepath: Optional[str] = None,
count: int = 1
) -> str:
"""
Insert one or more rows starting at the specified row.
Args:
sheet_name: Name of worksheet
start_row: Row number to start inserting at
session_id: Session ID from open_workbook (preferred)
filepath: Path to Excel file (legacy, deprecated)
count: Number of rows to insert
Note: Use session_id for better performance. filepath parameter is deprecated.
"""
try:
# Support both new (session_id) and old (filepath) API
if session_id:
# New API: use session
session = SESSION_MANAGER.get_session(session_id)
if not session:
return ERROR_TEMPLATES['SESSION_NOT_FOUND'].format(
session_id=session_id,
ttl=10 # Default TTL is 10 minutes (600 seconds)
)
with session.lock:
from xlwings_mcp.xlwings_impl.rows_cols_xlw import insert_rows_xlw_with_wb
result = insert_rows_xlw_with_wb(session.workbook, sheet_name, start_row, count)
elif filepath:
# Legacy API: backwards compatibility
logger.warning("Using deprecated filepath parameter. Please use session_id instead.")
full_path = get_excel_path(filepath)
from xlwings_mcp.xlwings_impl.rows_cols_xlw import insert_rows_xlw
result = insert_rows_xlw(full_path, sheet_name, start_row, count)
else:
return ERROR_TEMPLATES['PARAMETER_MISSING'].format(
param1='session_id',
param2='filepath'
)
if "error" in result:
return f"Error: {result['error']}"
return result["message"]
except (ValidationError, SheetError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error inserting rows: {e}")
raise
@mcp.tool()
def insert_columns(
sheet_name: str,
start_col: int,
session_id: Optional[str] = None,
filepath: Optional[str] = None,
count: int = 1
) -> str:
"""
Insert one or more columns starting at the specified column.
Args:
sheet_name: Name of worksheet
start_col: Column number to start inserting at
session_id: Session ID from open_workbook (preferred)
filepath: Path to Excel file (legacy, deprecated)
count: Number of columns to insert
Note: Use session_id for better performance. filepath parameter is deprecated.
"""
try:
# Support both new (session_id) and old (filepath) API
if session_id:
# New API: use session
session = SESSION_MANAGER.get_session(session_id)
if not session:
return ERROR_TEMPLATES['SESSION_NOT_FOUND'].format(
session_id=session_id,
ttl=10 # Default TTL is 10 minutes (600 seconds)
)
with session.lock:
from xlwings_mcp.xlwings_impl.rows_cols_xlw import insert_columns_xlw_with_wb
result = insert_columns_xlw_with_wb(session.workbook, sheet_name, start_col, count)
elif filepath:
# Legacy API: backwards compatibility
logger.warning("Using deprecated filepath parameter. Please use session_id instead.")
full_path = get_excel_path(filepath)
from xlwings_mcp.xlwings_impl.rows_cols_xlw import insert_columns_xlw
result = insert_columns_xlw(full_path, sheet_name, start_col, count)
else:
return ERROR_TEMPLATES['PARAMETER_MISSING'].format(
param1='session_id',
param2='filepath'
)
if "error" in result:
return f"Error: {result['error']}"
return result["message"]
except (ValidationError, SheetError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error inserting columns: {e}")
raise
@mcp.tool()
def delete_sheet_rows(
sheet_name: str,
start_row: int,
session_id: Optional[str] = None,
filepath: Optional[str] = None,
count: int = 1
) -> str:
"""
Delete one or more rows starting at the specified row.
Args:
sheet_name: Name of worksheet
start_row: Row number to start deleting from
session_id: Session ID from open_workbook (preferred)
filepath: Path to Excel file (legacy, deprecated)
count: Number of rows to delete
Note: Use session_id for better performance. filepath parameter is deprecated.
"""
try:
# Support both new (session_id) and old (filepath) API
if session_id:
# New API: use session
session = SESSION_MANAGER.get_session(session_id)
if not session:
return ERROR_TEMPLATES['SESSION_NOT_FOUND'].format(
session_id=session_id,
ttl=10 # Default TTL is 10 minutes (600 seconds)
)
with session.lock:
from xlwings_mcp.xlwings_impl.rows_cols_xlw import delete_sheet_rows_xlw_with_wb
result = delete_sheet_rows_xlw_with_wb(session.workbook, sheet_name, start_row, count)
elif filepath:
# Legacy API: backwards compatibility
logger.warning("Using deprecated filepath parameter. Please use session_id instead.")
full_path = get_excel_path(filepath)
from xlwings_mcp.xlwings_impl.rows_cols_xlw import delete_sheet_rows_xlw
result = delete_sheet_rows_xlw(full_path, sheet_name, start_row, count)
else:
return ERROR_TEMPLATES['PARAMETER_MISSING'].format(
param1='session_id',
param2='filepath'
)
if "error" in result:
return f"Error: {result['error']}"
return result["message"]
except (ValidationError, SheetError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error deleting rows: {e}")
raise
@mcp.tool()
def delete_sheet_columns(
sheet_name: str,
start_col: int,
session_id: Optional[str] = None,
filepath: Optional[str] = None,
count: int = 1
) -> str:
"""
Delete one or more columns starting at the specified column.
Args:
sheet_name: Name of worksheet
start_col: Column number to start deleting from
session_id: Session ID from open_workbook (preferred)
filepath: Path to Excel file (legacy, deprecated)
count: Number of columns to delete
Note: Use session_id for better performance. filepath parameter is deprecated.
"""
try:
# Support both new (session_id) and old (filepath) API
if session_id:
# New API: use session
session = SESSION_MANAGER.get_session(session_id)
if not session:
return ERROR_TEMPLATES['SESSION_NOT_FOUND'].format(
session_id=session_id,
ttl=10 # Default TTL is 10 minutes (600 seconds)
)
with session.lock:
from xlwings_mcp.xlwings_impl.rows_cols_xlw import delete_sheet_columns_xlw_with_wb
result = delete_sheet_columns_xlw_with_wb(session.workbook, sheet_name, start_col, count)
elif filepath:
# Legacy API: backwards compatibility
logger.warning("Using deprecated filepath parameter. Please use session_id instead.")
full_path = get_excel_path(filepath)
from xlwings_mcp.xlwings_impl.rows_cols_xlw import delete_sheet_columns_xlw
result = delete_sheet_columns_xlw(full_path, sheet_name, start_col, count)
else:
return ERROR_TEMPLATES['PARAMETER_MISSING'].format(
param1='session_id',
param2='filepath'
)
if "error" in result:
return f"Error: {result['error']}"
return result["message"]
except (ValidationError, SheetError) as e:
return f"Error: {str(e)}"
except Exception as e:
logger.error(f"Error deleting columns: {e}")
raise
async def run_sse():
"""Run Excel MCP server in SSE mode."""
# Assign value to EXCEL_FILES_PATH in SSE mode
global EXCEL_FILES_PATH
EXCEL_FILES_PATH = os.environ.get("EXCEL_FILES_PATH", "./excel_files")
# Create directory if it doesn't exist
os.makedirs(EXCEL_FILES_PATH, exist_ok=True)
try:
logger.info(f"Starting Excel MCP server with SSE transport (files directory: {EXCEL_FILES_PATH})")
await mcp.run_sse_async()
except KeyboardInterrupt:
logger.info("Server stopped by user")
except Exception as e:
logger.error(f"Server failed: {e}")
raise
finally:
# Clean up all sessions on shutdown
try:
SESSION_MANAGER.close_all_sessions()
logger.info("All Excel sessions closed")
except Exception as e:
logger.error(f"Error closing sessions during shutdown: {e}")
logger.info("Server shutdown complete")
async def run_streamable_http():
"""Run Excel MCP server in streamable HTTP mode."""
# Assign value to EXCEL_FILES_PATH in streamable HTTP mode
global EXCEL_FILES_PATH
EXCEL_FILES_PATH = os.environ.get("EXCEL_FILES_PATH", "./excel_files")
# Create directory if it doesn't exist
os.makedirs(EXCEL_FILES_PATH, exist_ok=True)
try:
logger.info(f"Starting Excel MCP server with streamable HTTP transport (files directory: {EXCEL_FILES_PATH})")
await mcp.run_streamable_http_async()
except KeyboardInterrupt:
logger.info("Server stopped by user")
except Exception as e:
logger.error(f"Server failed: {e}")
raise
finally:
# Clean up all sessions on shutdown
try:
SESSION_MANAGER.close_all_sessions()
logger.info("All Excel sessions closed")
except Exception as e:
logger.error(f"Error closing sessions during shutdown: {e}")
logger.info("Server shutdown complete")
def run_stdio():
"""Run Excel MCP server in stdio mode."""
# No need to assign EXCEL_FILES_PATH in stdio mode
try:
logger.info("Starting Excel MCP server with stdio transport")
mcp.run(transport="stdio")
except KeyboardInterrupt:
logger.info("Server stopped by user")
except Exception as e:
logger.error(f"Server failed: {e}")
raise
finally:
# Clean up all sessions on shutdown
try:
SESSION_MANAGER.close_all_sessions()
logger.info("All Excel sessions closed")
except Exception as e:
logger.error(f"Error closing sessions during shutdown: {e}")
logger.info("Server shutdown complete")