feat: implement XER MCP Server with 9 schedule query tools
Implement complete MCP server for parsing Primavera P6 XER files and exposing schedule data through MCP tools. All 4 user stories complete. Tools implemented: - load_xer: Parse XER files into SQLite database - list_activities: Query activities with pagination and filtering - get_activity: Get activity details by ID - list_relationships: Query activity dependencies - get_predecessors/get_successors: Query activity relationships - get_project_summary: Project overview with counts - list_milestones: Query milestone activities - get_critical_path: Query driving path activities Features: - Tab-delimited XER format parsing with pluggable table handlers - In-memory SQLite database for fast queries - Pagination with 100-item default limit - Multi-project file support with project selection - ISO8601 date formatting - NO_FILE_LOADED error handling for all query tools Test coverage: 81 tests (contract, integration, unit)
This commit is contained in:
27
src/xer_mcp/parser/table_handlers/__init__.py
Normal file
27
src/xer_mcp/parser/table_handlers/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""XER table handlers module."""
|
||||
|
||||
from xer_mcp.parser.table_handlers.base import TableHandler
|
||||
from xer_mcp.parser.table_handlers.calendar import CalendarHandler
|
||||
from xer_mcp.parser.table_handlers.project import ProjectHandler
|
||||
from xer_mcp.parser.table_handlers.projwbs import ProjwbsHandler
|
||||
from xer_mcp.parser.table_handlers.task import TaskHandler
|
||||
from xer_mcp.parser.table_handlers.taskpred import TaskpredHandler
|
||||
|
||||
# Registry mapping table names to handlers
|
||||
TABLE_HANDLERS: dict[str, type[TableHandler]] = {
|
||||
"PROJECT": ProjectHandler,
|
||||
"TASK": TaskHandler,
|
||||
"TASKPRED": TaskpredHandler,
|
||||
"PROJWBS": ProjwbsHandler,
|
||||
"CALENDAR": CalendarHandler,
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"CalendarHandler",
|
||||
"ProjectHandler",
|
||||
"ProjwbsHandler",
|
||||
"TABLE_HANDLERS",
|
||||
"TableHandler",
|
||||
"TaskHandler",
|
||||
"TaskpredHandler",
|
||||
]
|
||||
30
src/xer_mcp/parser/table_handlers/base.py
Normal file
30
src/xer_mcp/parser/table_handlers/base.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Base class for XER table handlers."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class TableHandler(ABC):
|
||||
"""Abstract base class for XER table handlers.
|
||||
|
||||
Each handler is responsible for parsing a specific table type
|
||||
from the XER file and returning structured data.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def table_name(self) -> str:
|
||||
"""Return the XER table name this handler processes (e.g., 'PROJECT', 'TASK')."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def parse_row(self, fields: list[str], values: list[str]) -> dict | None:
|
||||
"""Parse a single row of data from the XER file.
|
||||
|
||||
Args:
|
||||
fields: List of column names from the %F line
|
||||
values: List of values from the %R line
|
||||
|
||||
Returns:
|
||||
Dictionary of parsed data, or None if the row should be skipped
|
||||
"""
|
||||
...
|
||||
32
src/xer_mcp/parser/table_handlers/calendar.py
Normal file
32
src/xer_mcp/parser/table_handlers/calendar.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""CALENDAR table handler."""
|
||||
|
||||
from xer_mcp.parser.table_handlers.base import TableHandler
|
||||
|
||||
|
||||
class CalendarHandler(TableHandler):
|
||||
"""Handler for CALENDAR table in XER files."""
|
||||
|
||||
@property
|
||||
def table_name(self) -> str:
|
||||
return "CALENDAR"
|
||||
|
||||
def parse_row(self, fields: list[str], values: list[str]) -> dict | None:
|
||||
"""Parse a CALENDAR row."""
|
||||
if len(values) < len(fields):
|
||||
values = values + [""] * (len(fields) - len(values))
|
||||
|
||||
data = dict(zip(fields, values, strict=False))
|
||||
|
||||
# Parse numeric fields
|
||||
day_hr_str = data.get("day_hr_cnt", "")
|
||||
day_hr = float(day_hr_str) if day_hr_str else None
|
||||
|
||||
week_hr_str = data.get("week_hr_cnt", "")
|
||||
week_hr = float(week_hr_str) if week_hr_str else None
|
||||
|
||||
return {
|
||||
"clndr_id": data.get("clndr_id", ""),
|
||||
"clndr_name": data.get("clndr_name", ""),
|
||||
"day_hr_cnt": day_hr,
|
||||
"week_hr_cnt": week_hr,
|
||||
}
|
||||
38
src/xer_mcp/parser/table_handlers/project.py
Normal file
38
src/xer_mcp/parser/table_handlers/project.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""PROJECT table handler."""
|
||||
|
||||
from xer_mcp.parser.table_handlers.base import TableHandler
|
||||
|
||||
|
||||
def convert_date(date_str: str | None) -> str | None:
|
||||
"""Convert XER date format to ISO8601.
|
||||
|
||||
XER format: "YYYY-MM-DD HH:MM"
|
||||
ISO8601 format: "YYYY-MM-DDTHH:MM:SS"
|
||||
"""
|
||||
if not date_str or date_str.strip() == "":
|
||||
return None
|
||||
# Replace space with T and add seconds
|
||||
return date_str.replace(" ", "T") + ":00"
|
||||
|
||||
|
||||
class ProjectHandler(TableHandler):
|
||||
"""Handler for PROJECT table in XER files."""
|
||||
|
||||
@property
|
||||
def table_name(self) -> str:
|
||||
return "PROJECT"
|
||||
|
||||
def parse_row(self, fields: list[str], values: list[str]) -> dict | None:
|
||||
"""Parse a PROJECT row."""
|
||||
if len(values) < len(fields):
|
||||
# Pad with empty strings if needed
|
||||
values = values + [""] * (len(fields) - len(values))
|
||||
|
||||
data = dict(zip(fields, values, strict=False))
|
||||
|
||||
return {
|
||||
"proj_id": data.get("proj_id", ""),
|
||||
"proj_short_name": data.get("proj_short_name", ""),
|
||||
"plan_start_date": convert_date(data.get("plan_start_date")),
|
||||
"plan_end_date": convert_date(data.get("plan_end_date")),
|
||||
}
|
||||
26
src/xer_mcp/parser/table_handlers/projwbs.py
Normal file
26
src/xer_mcp/parser/table_handlers/projwbs.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""PROJWBS table handler."""
|
||||
|
||||
from xer_mcp.parser.table_handlers.base import TableHandler
|
||||
|
||||
|
||||
class ProjwbsHandler(TableHandler):
|
||||
"""Handler for PROJWBS (WBS) table in XER files."""
|
||||
|
||||
@property
|
||||
def table_name(self) -> str:
|
||||
return "PROJWBS"
|
||||
|
||||
def parse_row(self, fields: list[str], values: list[str]) -> dict | None:
|
||||
"""Parse a PROJWBS row."""
|
||||
if len(values) < len(fields):
|
||||
values = values + [""] * (len(fields) - len(values))
|
||||
|
||||
data = dict(zip(fields, values, strict=False))
|
||||
|
||||
return {
|
||||
"wbs_id": data.get("wbs_id", ""),
|
||||
"proj_id": data.get("proj_id", ""),
|
||||
"parent_wbs_id": data.get("parent_wbs_id", ""),
|
||||
"wbs_short_name": data.get("wbs_short_name", ""),
|
||||
"wbs_name": data.get("wbs_name") or None,
|
||||
}
|
||||
43
src/xer_mcp/parser/table_handlers/task.py
Normal file
43
src/xer_mcp/parser/table_handlers/task.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""TASK table handler."""
|
||||
|
||||
from xer_mcp.parser.table_handlers.base import TableHandler
|
||||
from xer_mcp.parser.table_handlers.project import convert_date
|
||||
|
||||
|
||||
class TaskHandler(TableHandler):
|
||||
"""Handler for TASK table in XER files."""
|
||||
|
||||
@property
|
||||
def table_name(self) -> str:
|
||||
return "TASK"
|
||||
|
||||
def parse_row(self, fields: list[str], values: list[str]) -> dict | None:
|
||||
"""Parse a TASK row."""
|
||||
if len(values) < len(fields):
|
||||
values = values + [""] * (len(fields) - len(values))
|
||||
|
||||
data = dict(zip(fields, values, strict=False))
|
||||
|
||||
# Parse driving_path_flag (Y/N -> bool)
|
||||
driving_flag = data.get("driving_path_flag", "N")
|
||||
driving_path = driving_flag.upper() == "Y" if driving_flag else False
|
||||
|
||||
# Parse total_float_hr_cnt
|
||||
float_str = data.get("total_float_hr_cnt", "")
|
||||
total_float = float(float_str) if float_str else None
|
||||
|
||||
return {
|
||||
"task_id": data.get("task_id", ""),
|
||||
"proj_id": data.get("proj_id", ""),
|
||||
"wbs_id": data.get("wbs_id") or None,
|
||||
"task_code": data.get("task_code", ""),
|
||||
"task_name": data.get("task_name", ""),
|
||||
"task_type": data.get("task_type", ""),
|
||||
"status_code": data.get("status_code") or None,
|
||||
"target_start_date": convert_date(data.get("target_start_date")),
|
||||
"target_end_date": convert_date(data.get("target_end_date")),
|
||||
"act_start_date": convert_date(data.get("act_start_date")),
|
||||
"act_end_date": convert_date(data.get("act_end_date")),
|
||||
"total_float_hr_cnt": total_float,
|
||||
"driving_path_flag": driving_path,
|
||||
}
|
||||
31
src/xer_mcp/parser/table_handlers/taskpred.py
Normal file
31
src/xer_mcp/parser/table_handlers/taskpred.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""TASKPRED table handler."""
|
||||
|
||||
from xer_mcp.parser.table_handlers.base import TableHandler
|
||||
|
||||
|
||||
class TaskpredHandler(TableHandler):
|
||||
"""Handler for TASKPRED (relationships) table in XER files."""
|
||||
|
||||
@property
|
||||
def table_name(self) -> str:
|
||||
return "TASKPRED"
|
||||
|
||||
def parse_row(self, fields: list[str], values: list[str]) -> dict | None:
|
||||
"""Parse a TASKPRED row."""
|
||||
if len(values) < len(fields):
|
||||
values = values + [""] * (len(fields) - len(values))
|
||||
|
||||
data = dict(zip(fields, values, strict=False))
|
||||
|
||||
# Parse lag_hr_cnt
|
||||
lag_str = data.get("lag_hr_cnt", "0")
|
||||
lag_hr = float(lag_str) if lag_str else 0.0
|
||||
|
||||
return {
|
||||
"task_pred_id": data.get("task_pred_id", ""),
|
||||
"task_id": data.get("task_id", ""),
|
||||
"pred_task_id": data.get("pred_task_id", ""),
|
||||
"proj_id": data.get("proj_id", ""),
|
||||
"pred_type": data.get("pred_type", ""),
|
||||
"lag_hr_cnt": lag_hr,
|
||||
}
|
||||
Reference in New Issue
Block a user