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:
1
tests/unit/__init__.py
Normal file
1
tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit tests for XER MCP Server."""
|
||||
111
tests/unit/test_db_queries.py
Normal file
111
tests/unit/test_db_queries.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Unit tests for database query functions."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from xer_mcp.db import db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""Initialize and clear database for each test."""
|
||||
db.initialize()
|
||||
yield
|
||||
db.clear()
|
||||
|
||||
|
||||
class TestActivityQueries:
|
||||
"""Tests for activity query functions."""
|
||||
|
||||
def test_query_activities_with_pagination(self, sample_xer_single_project: Path) -> None:
|
||||
"""Should return paginated activity results."""
|
||||
from xer_mcp.db.loader import load_parsed_data
|
||||
from xer_mcp.db.queries import query_activities
|
||||
from xer_mcp.parser.xer_parser import XerParser
|
||||
|
||||
parser = XerParser()
|
||||
parsed = parser.parse(sample_xer_single_project)
|
||||
load_parsed_data(parsed, project_id="1001")
|
||||
|
||||
activities, total = query_activities(limit=2, offset=0)
|
||||
|
||||
assert len(activities) == 2
|
||||
assert total == 5
|
||||
|
||||
def test_query_activities_filter_by_type(self, sample_xer_single_project: Path) -> None:
|
||||
"""Should filter activities by task type."""
|
||||
from xer_mcp.db.loader import load_parsed_data
|
||||
from xer_mcp.db.queries import query_activities
|
||||
from xer_mcp.parser.xer_parser import XerParser
|
||||
|
||||
parser = XerParser()
|
||||
parsed = parser.parse(sample_xer_single_project)
|
||||
load_parsed_data(parsed, project_id="1001")
|
||||
|
||||
activities, total = query_activities(activity_type="TT_Mile")
|
||||
|
||||
assert total == 2
|
||||
for act in activities:
|
||||
assert act["task_type"] == "TT_Mile"
|
||||
|
||||
def test_query_activities_filter_by_date_range(self, sample_xer_single_project: Path) -> None:
|
||||
"""Should filter activities by date range."""
|
||||
from xer_mcp.db.loader import load_parsed_data
|
||||
from xer_mcp.db.queries import query_activities
|
||||
from xer_mcp.parser.xer_parser import XerParser
|
||||
|
||||
parser = XerParser()
|
||||
parsed = parser.parse(sample_xer_single_project)
|
||||
load_parsed_data(parsed, project_id="1001")
|
||||
|
||||
# Filter to very narrow range
|
||||
activities, total = query_activities(start_date="2026-01-01", end_date="2026-01-01")
|
||||
|
||||
# Only activities starting on 2026-01-01
|
||||
for act in activities:
|
||||
assert "2026-01-01" in act["target_start_date"]
|
||||
|
||||
def test_query_activities_filter_by_wbs(self, sample_xer_single_project: Path) -> None:
|
||||
"""Should filter activities by WBS ID."""
|
||||
from xer_mcp.db.loader import load_parsed_data
|
||||
from xer_mcp.db.queries import query_activities
|
||||
from xer_mcp.parser.xer_parser import XerParser
|
||||
|
||||
parser = XerParser()
|
||||
parsed = parser.parse(sample_xer_single_project)
|
||||
load_parsed_data(parsed, project_id="1001")
|
||||
|
||||
activities, total = query_activities(wbs_id="102")
|
||||
|
||||
# WBS 102 has 2 activities
|
||||
assert total == 2
|
||||
|
||||
def test_get_activity_by_id(self, sample_xer_single_project: Path) -> None:
|
||||
"""Should return single activity by ID."""
|
||||
from xer_mcp.db.loader import load_parsed_data
|
||||
from xer_mcp.db.queries import get_activity_by_id
|
||||
from xer_mcp.parser.xer_parser import XerParser
|
||||
|
||||
parser = XerParser()
|
||||
parsed = parser.parse(sample_xer_single_project)
|
||||
load_parsed_data(parsed, project_id="1001")
|
||||
|
||||
activity = get_activity_by_id("2002")
|
||||
|
||||
assert activity is not None
|
||||
assert activity["task_code"] == "A1010"
|
||||
|
||||
def test_get_activity_by_id_not_found(self, sample_xer_single_project: Path) -> None:
|
||||
"""Should return None for non-existent activity."""
|
||||
from xer_mcp.db.loader import load_parsed_data
|
||||
from xer_mcp.db.queries import get_activity_by_id
|
||||
from xer_mcp.parser.xer_parser import XerParser
|
||||
|
||||
parser = XerParser()
|
||||
parsed = parser.parse(sample_xer_single_project)
|
||||
load_parsed_data(parsed, project_id="1001")
|
||||
|
||||
activity = get_activity_by_id("nonexistent")
|
||||
|
||||
assert activity is None
|
||||
146
tests/unit/test_parser.py
Normal file
146
tests/unit/test_parser.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Unit tests for XER parser."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestXerParser:
|
||||
"""Tests for the XER file parser."""
|
||||
|
||||
def test_parse_single_project_file(self, sample_xer_single_project: Path) -> None:
|
||||
"""Parser should extract project data from single-project XER file."""
|
||||
from xer_mcp.parser.xer_parser import XerParser
|
||||
|
||||
parser = XerParser()
|
||||
result = parser.parse(sample_xer_single_project)
|
||||
|
||||
assert len(result.projects) == 1
|
||||
assert result.projects[0]["proj_id"] == "1001"
|
||||
assert result.projects[0]["proj_short_name"] == "Test Project"
|
||||
|
||||
def test_parse_multi_project_file(self, sample_xer_multi_project: Path) -> None:
|
||||
"""Parser should extract all projects from multi-project XER file."""
|
||||
from xer_mcp.parser.xer_parser import XerParser
|
||||
|
||||
parser = XerParser()
|
||||
result = parser.parse(sample_xer_multi_project)
|
||||
|
||||
assert len(result.projects) == 2
|
||||
project_names = {p["proj_short_name"] for p in result.projects}
|
||||
assert project_names == {"Project Alpha", "Project Beta"}
|
||||
|
||||
def test_parse_activities(self, sample_xer_single_project: Path) -> None:
|
||||
"""Parser should extract activities from XER file."""
|
||||
from xer_mcp.parser.xer_parser import XerParser
|
||||
|
||||
parser = XerParser()
|
||||
result = parser.parse(sample_xer_single_project)
|
||||
|
||||
# Single project fixture has 5 activities
|
||||
assert len(result.tasks) == 5
|
||||
|
||||
# Check first milestone
|
||||
milestone = next(t for t in result.tasks if t["task_code"] == "A1000")
|
||||
assert milestone["task_name"] == "Project Start"
|
||||
assert milestone["task_type"] == "TT_Mile"
|
||||
|
||||
def test_parse_relationships(self, sample_xer_single_project: Path) -> None:
|
||||
"""Parser should extract relationships from XER file."""
|
||||
from xer_mcp.parser.xer_parser import XerParser
|
||||
|
||||
parser = XerParser()
|
||||
result = parser.parse(sample_xer_single_project)
|
||||
|
||||
# Single project fixture has 5 relationships
|
||||
assert len(result.taskpreds) == 5
|
||||
|
||||
# Check a FS relationship
|
||||
fs_rel = next(r for r in result.taskpreds if r["pred_type"] == "PR_FS")
|
||||
assert fs_rel["lag_hr_cnt"] == 0
|
||||
|
||||
# Check a SS relationship
|
||||
ss_rel = next(r for r in result.taskpreds if r["pred_type"] == "PR_SS")
|
||||
assert ss_rel["lag_hr_cnt"] == 40
|
||||
|
||||
def test_parse_wbs(self, sample_xer_single_project: Path) -> None:
|
||||
"""Parser should extract WBS hierarchy from XER file."""
|
||||
from xer_mcp.parser.xer_parser import XerParser
|
||||
|
||||
parser = XerParser()
|
||||
result = parser.parse(sample_xer_single_project)
|
||||
|
||||
# Single project fixture has 3 WBS elements
|
||||
assert len(result.projwbs) == 3
|
||||
|
||||
# Check root WBS
|
||||
root = next(w for w in result.projwbs if w["wbs_short_name"] == "ROOT")
|
||||
assert root["parent_wbs_id"] is None or root["parent_wbs_id"] == ""
|
||||
|
||||
def test_parse_calendars(self, sample_xer_single_project: Path) -> None:
|
||||
"""Parser should extract calendars from XER file."""
|
||||
from xer_mcp.parser.xer_parser import XerParser
|
||||
|
||||
parser = XerParser()
|
||||
result = parser.parse(sample_xer_single_project)
|
||||
|
||||
# Single project fixture has 1 calendar
|
||||
assert len(result.calendars) == 1
|
||||
cal = result.calendars[0]
|
||||
assert cal["clndr_name"] == "Standard 5 Day"
|
||||
assert cal["day_hr_cnt"] == 8
|
||||
|
||||
def test_parse_empty_project(self, sample_xer_empty: Path) -> None:
|
||||
"""Parser should handle XER file with no activities."""
|
||||
from xer_mcp.parser.xer_parser import XerParser
|
||||
|
||||
parser = XerParser()
|
||||
result = parser.parse(sample_xer_empty)
|
||||
|
||||
assert len(result.projects) == 1
|
||||
assert len(result.tasks) == 0
|
||||
assert len(result.taskpreds) == 0
|
||||
|
||||
def test_parse_invalid_file_raises_error(self, invalid_xer_file: Path) -> None:
|
||||
"""Parser should raise ParseError for invalid XER content."""
|
||||
from xer_mcp.errors import ParseError
|
||||
from xer_mcp.parser.xer_parser import XerParser
|
||||
|
||||
parser = XerParser()
|
||||
with pytest.raises(ParseError):
|
||||
parser.parse(invalid_xer_file)
|
||||
|
||||
def test_parse_nonexistent_file_raises_error(self, nonexistent_xer_path: Path) -> None:
|
||||
"""Parser should raise FileNotFoundError for missing file."""
|
||||
from xer_mcp.errors import FileNotFoundError
|
||||
from xer_mcp.parser.xer_parser import XerParser
|
||||
|
||||
parser = XerParser()
|
||||
with pytest.raises(FileNotFoundError):
|
||||
parser.parse(nonexistent_xer_path)
|
||||
|
||||
def test_parse_dates_converted_to_iso8601(self, sample_xer_single_project: Path) -> None:
|
||||
"""Parser should convert XER dates to ISO8601 format."""
|
||||
from xer_mcp.parser.xer_parser import XerParser
|
||||
|
||||
parser = XerParser()
|
||||
result = parser.parse(sample_xer_single_project)
|
||||
|
||||
# Check date conversion (XER: "2026-01-01 07:00" -> ISO: "2026-01-01T07:00:00")
|
||||
task = next(t for t in result.tasks if t["task_code"] == "A1000")
|
||||
assert "T" in task["target_start_date"]
|
||||
|
||||
def test_parse_driving_path_flag(self, sample_xer_single_project: Path) -> None:
|
||||
"""Parser should correctly parse driving_path_flag as boolean."""
|
||||
from xer_mcp.parser.xer_parser import XerParser
|
||||
|
||||
parser = XerParser()
|
||||
result = parser.parse(sample_xer_single_project)
|
||||
|
||||
# A1000 has driving_path_flag = Y
|
||||
critical_task = next(t for t in result.tasks if t["task_code"] == "A1000")
|
||||
assert critical_task["driving_path_flag"] is True
|
||||
|
||||
# A1020 has driving_path_flag = N
|
||||
non_critical = next(t for t in result.tasks if t["task_code"] == "A1020")
|
||||
assert non_critical["driving_path_flag"] is False
|
||||
192
tests/unit/test_table_handlers.py
Normal file
192
tests/unit/test_table_handlers.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Unit tests for XER table handlers."""
|
||||
|
||||
|
||||
class TestProjectHandler:
|
||||
"""Tests for PROJECT table handler."""
|
||||
|
||||
def test_parse_project_row(self) -> None:
|
||||
"""Handler should parse PROJECT row correctly."""
|
||||
from xer_mcp.parser.table_handlers.project import ProjectHandler
|
||||
|
||||
handler = ProjectHandler()
|
||||
|
||||
# Minimal PROJECT fields
|
||||
fields = [
|
||||
"proj_id",
|
||||
"proj_short_name",
|
||||
"plan_start_date",
|
||||
"plan_end_date",
|
||||
]
|
||||
values = ["1001", "Test Project", "2026-01-01 00:00", "2026-06-30 00:00"]
|
||||
|
||||
result = handler.parse_row(fields, values)
|
||||
|
||||
assert result is not None
|
||||
assert result["proj_id"] == "1001"
|
||||
assert result["proj_short_name"] == "Test Project"
|
||||
assert result["plan_start_date"] == "2026-01-01T00:00:00"
|
||||
assert result["plan_end_date"] == "2026-06-30T00:00:00"
|
||||
|
||||
def test_table_name(self) -> None:
|
||||
"""Handler should report correct table name."""
|
||||
from xer_mcp.parser.table_handlers.project import ProjectHandler
|
||||
|
||||
handler = ProjectHandler()
|
||||
assert handler.table_name == "PROJECT"
|
||||
|
||||
|
||||
class TestTaskHandler:
|
||||
"""Tests for TASK table handler."""
|
||||
|
||||
def test_parse_task_row(self) -> None:
|
||||
"""Handler should parse TASK row correctly."""
|
||||
from xer_mcp.parser.table_handlers.task import TaskHandler
|
||||
|
||||
handler = TaskHandler()
|
||||
|
||||
fields = [
|
||||
"task_id",
|
||||
"proj_id",
|
||||
"wbs_id",
|
||||
"task_code",
|
||||
"task_name",
|
||||
"task_type",
|
||||
"status_code",
|
||||
"target_start_date",
|
||||
"target_end_date",
|
||||
"total_float_hr_cnt",
|
||||
"driving_path_flag",
|
||||
]
|
||||
values = [
|
||||
"2001",
|
||||
"1001",
|
||||
"100",
|
||||
"A1000",
|
||||
"Site Prep",
|
||||
"TT_Task",
|
||||
"TK_NotStart",
|
||||
"2026-01-02 07:00",
|
||||
"2026-01-08 15:00",
|
||||
"0",
|
||||
"Y",
|
||||
]
|
||||
|
||||
result = handler.parse_row(fields, values)
|
||||
|
||||
assert result is not None
|
||||
assert result["task_id"] == "2001"
|
||||
assert result["task_code"] == "A1000"
|
||||
assert result["task_type"] == "TT_Task"
|
||||
assert result["driving_path_flag"] is True
|
||||
assert result["total_float_hr_cnt"] == 0.0
|
||||
|
||||
def test_table_name(self) -> None:
|
||||
"""Handler should report correct table name."""
|
||||
from xer_mcp.parser.table_handlers.task import TaskHandler
|
||||
|
||||
handler = TaskHandler()
|
||||
assert handler.table_name == "TASK"
|
||||
|
||||
|
||||
class TestTaskpredHandler:
|
||||
"""Tests for TASKPRED table handler."""
|
||||
|
||||
def test_parse_relationship_row(self) -> None:
|
||||
"""Handler should parse TASKPRED row correctly."""
|
||||
from xer_mcp.parser.table_handlers.taskpred import TaskpredHandler
|
||||
|
||||
handler = TaskpredHandler()
|
||||
|
||||
fields = [
|
||||
"task_pred_id",
|
||||
"task_id",
|
||||
"pred_task_id",
|
||||
"proj_id",
|
||||
"pred_proj_id",
|
||||
"pred_type",
|
||||
"lag_hr_cnt",
|
||||
]
|
||||
values = ["3001", "2002", "2001", "1001", "1001", "PR_FS", "8"]
|
||||
|
||||
result = handler.parse_row(fields, values)
|
||||
|
||||
assert result is not None
|
||||
assert result["task_pred_id"] == "3001"
|
||||
assert result["task_id"] == "2002"
|
||||
assert result["pred_task_id"] == "2001"
|
||||
assert result["pred_type"] == "PR_FS"
|
||||
assert result["lag_hr_cnt"] == 8.0
|
||||
|
||||
def test_table_name(self) -> None:
|
||||
"""Handler should report correct table name."""
|
||||
from xer_mcp.parser.table_handlers.taskpred import TaskpredHandler
|
||||
|
||||
handler = TaskpredHandler()
|
||||
assert handler.table_name == "TASKPRED"
|
||||
|
||||
|
||||
class TestProjwbsHandler:
|
||||
"""Tests for PROJWBS table handler."""
|
||||
|
||||
def test_parse_wbs_row(self) -> None:
|
||||
"""Handler should parse PROJWBS row correctly."""
|
||||
from xer_mcp.parser.table_handlers.projwbs import ProjwbsHandler
|
||||
|
||||
handler = ProjwbsHandler()
|
||||
|
||||
fields = [
|
||||
"wbs_id",
|
||||
"proj_id",
|
||||
"parent_wbs_id",
|
||||
"wbs_short_name",
|
||||
"wbs_name",
|
||||
]
|
||||
values = ["100", "1001", "", "ROOT", "Project Root"]
|
||||
|
||||
result = handler.parse_row(fields, values)
|
||||
|
||||
assert result is not None
|
||||
assert result["wbs_id"] == "100"
|
||||
assert result["proj_id"] == "1001"
|
||||
assert result["parent_wbs_id"] == ""
|
||||
assert result["wbs_short_name"] == "ROOT"
|
||||
|
||||
def test_table_name(self) -> None:
|
||||
"""Handler should report correct table name."""
|
||||
from xer_mcp.parser.table_handlers.projwbs import ProjwbsHandler
|
||||
|
||||
handler = ProjwbsHandler()
|
||||
assert handler.table_name == "PROJWBS"
|
||||
|
||||
|
||||
class TestCalendarHandler:
|
||||
"""Tests for CALENDAR table handler."""
|
||||
|
||||
def test_parse_calendar_row(self) -> None:
|
||||
"""Handler should parse CALENDAR row correctly."""
|
||||
from xer_mcp.parser.table_handlers.calendar import CalendarHandler
|
||||
|
||||
handler = CalendarHandler()
|
||||
|
||||
fields = [
|
||||
"clndr_id",
|
||||
"clndr_name",
|
||||
"day_hr_cnt",
|
||||
"week_hr_cnt",
|
||||
]
|
||||
values = ["1", "Standard 5 Day", "8", "40"]
|
||||
|
||||
result = handler.parse_row(fields, values)
|
||||
|
||||
assert result is not None
|
||||
assert result["clndr_id"] == "1"
|
||||
assert result["clndr_name"] == "Standard 5 Day"
|
||||
assert result["day_hr_cnt"] == 8.0
|
||||
assert result["week_hr_cnt"] == 40.0
|
||||
|
||||
def test_table_name(self) -> None:
|
||||
"""Handler should report correct table name."""
|
||||
from xer_mcp.parser.table_handlers.calendar import CalendarHandler
|
||||
|
||||
handler = CalendarHandler()
|
||||
assert handler.table_name == "CALENDAR"
|
||||
Reference in New Issue
Block a user