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(