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:
2026-01-06 21:27:35 -05:00
parent 2cd54118a1
commit ccc8296418
56 changed files with 4140 additions and 0 deletions

392
src/xer_mcp/db/queries.py Normal file
View File

@@ -0,0 +1,392 @@
"""Database query functions for XER data."""
from xer_mcp.db import db
def query_activities(
limit: int = 100,
offset: int = 0,
start_date: str | None = None,
end_date: str | None = None,
wbs_id: str | None = None,
activity_type: str | None = None,
) -> tuple[list[dict], int]:
"""Query activities with pagination and filtering.
Args:
limit: Maximum number of results to return
offset: Number of results to skip
start_date: Filter activities starting on or after this date (YYYY-MM-DD)
end_date: Filter activities ending on or before this date (YYYY-MM-DD)
wbs_id: Filter by WBS ID
activity_type: Filter by task type (TT_Task, TT_Mile, etc.)
Returns:
Tuple of (list of activity dicts, total count matching filters)
"""
# Build WHERE clause
conditions = []
params: list = []
if start_date:
conditions.append("target_start_date >= ?")
params.append(f"{start_date}T00:00:00")
if end_date:
conditions.append("target_end_date <= ?")
params.append(f"{end_date}T23:59:59")
if wbs_id:
conditions.append("wbs_id = ?")
params.append(wbs_id)
if activity_type:
conditions.append("task_type = ?")
params.append(activity_type)
where_clause = " AND ".join(conditions) if conditions else "1=1"
# Get total count
with db.cursor() as cur:
cur.execute(f"SELECT COUNT(*) FROM activities WHERE {where_clause}", params) # noqa: S608
total = cur.fetchone()[0]
# Get paginated results
query = f"""
SELECT task_id, task_code, task_name, task_type,
target_start_date, target_end_date, status_code,
driving_path_flag, wbs_id, total_float_hr_cnt
FROM activities
WHERE {where_clause}
ORDER BY target_start_date, task_code
LIMIT ? OFFSET ?
""" # noqa: S608
with db.cursor() as cur:
cur.execute(query, [*params, limit, offset])
rows = cur.fetchall()
activities = [
{
"task_id": row[0],
"task_code": row[1],
"task_name": row[2],
"task_type": row[3],
"target_start_date": row[4],
"target_end_date": row[5],
"status_code": row[6],
"driving_path_flag": bool(row[7]),
}
for row in rows
]
return activities, total
def get_activity_by_id(activity_id: str) -> dict | None:
"""Get a single activity by ID with full details.
Args:
activity_id: The task_id to look up
Returns:
Activity dict with all fields, or None if not found
"""
query = """
SELECT a.task_id, a.task_code, a.task_name, a.task_type,
a.wbs_id, w.wbs_name,
a.target_start_date, a.target_end_date,
a.act_start_date, a.act_end_date,
a.total_float_hr_cnt, a.status_code, a.driving_path_flag
FROM activities a
LEFT JOIN wbs w ON a.wbs_id = w.wbs_id
WHERE a.task_id = ?
"""
with db.cursor() as cur:
cur.execute(query, (activity_id,))
row = cur.fetchone()
if row is None:
return None
# Count predecessors and successors
with db.cursor() as cur:
cur.execute(
"SELECT COUNT(*) FROM relationships WHERE task_id = ?",
(activity_id,),
)
predecessor_count = cur.fetchone()[0]
cur.execute(
"SELECT COUNT(*) FROM relationships WHERE pred_task_id = ?",
(activity_id,),
)
successor_count = cur.fetchone()[0]
return {
"task_id": row[0],
"task_code": row[1],
"task_name": row[2],
"task_type": row[3],
"wbs_id": row[4],
"wbs_name": row[5],
"target_start_date": row[6],
"target_end_date": row[7],
"act_start_date": row[8],
"act_end_date": row[9],
"total_float_hr_cnt": row[10],
"status_code": row[11],
"driving_path_flag": bool(row[12]),
"predecessor_count": predecessor_count,
"successor_count": successor_count,
}
def query_relationships(
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict], int]:
"""Query relationships with pagination.
Args:
limit: Maximum number of results to return
offset: Number of results to skip
Returns:
Tuple of (list of relationship dicts, total count)
"""
# Get total count
with db.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM relationships")
total = cur.fetchone()[0]
# Get paginated results with activity names
query = """
SELECT r.task_pred_id, r.task_id, a1.task_name,
r.pred_task_id, a2.task_name,
r.pred_type, r.lag_hr_cnt
FROM relationships r
LEFT JOIN activities a1 ON r.task_id = a1.task_id
LEFT JOIN activities a2 ON r.pred_task_id = a2.task_id
ORDER BY r.task_pred_id
LIMIT ? OFFSET ?
"""
with db.cursor() as cur:
cur.execute(query, (limit, offset))
rows = cur.fetchall()
# Convert pred_type from internal format (PR_FS) to API format (FS)
def format_pred_type(pred_type: str) -> str:
if pred_type.startswith("PR_"):
return pred_type[3:]
return pred_type
relationships = [
{
"task_pred_id": row[0],
"task_id": row[1],
"task_name": row[2],
"pred_task_id": row[3],
"pred_task_name": row[4],
"pred_type": format_pred_type(row[5]),
"lag_hr_cnt": row[6],
}
for row in rows
]
return relationships, total
def get_predecessors(activity_id: str) -> list[dict]:
"""Get predecessor activities for a given activity.
Args:
activity_id: The task_id to find predecessors for
Returns:
List of predecessor activity dicts with relationship info
"""
query = """
SELECT a.task_id, a.task_code, a.task_name,
r.pred_type, r.lag_hr_cnt
FROM relationships r
JOIN activities a ON r.pred_task_id = a.task_id
WHERE r.task_id = ?
ORDER BY a.task_code
"""
with db.cursor() as cur:
cur.execute(query, (activity_id,))
rows = cur.fetchall()
def format_pred_type(pred_type: str) -> str:
if pred_type.startswith("PR_"):
return pred_type[3:]
return pred_type
return [
{
"task_id": row[0],
"task_code": row[1],
"task_name": row[2],
"relationship_type": format_pred_type(row[3]),
"lag_hr_cnt": row[4],
}
for row in rows
]
def get_successors(activity_id: str) -> list[dict]:
"""Get successor activities for a given activity.
Args:
activity_id: The task_id to find successors for
Returns:
List of successor activity dicts with relationship info
"""
query = """
SELECT a.task_id, a.task_code, a.task_name,
r.pred_type, r.lag_hr_cnt
FROM relationships r
JOIN activities a ON r.task_id = a.task_id
WHERE r.pred_task_id = ?
ORDER BY a.task_code
"""
with db.cursor() as cur:
cur.execute(query, (activity_id,))
rows = cur.fetchall()
def format_pred_type(pred_type: str) -> str:
if pred_type.startswith("PR_"):
return pred_type[3:]
return pred_type
return [
{
"task_id": row[0],
"task_code": row[1],
"task_name": row[2],
"relationship_type": format_pred_type(row[3]),
"lag_hr_cnt": row[4],
}
for row in rows
]
def get_project_summary(project_id: str) -> dict | None:
"""Get project summary information.
Args:
project_id: The project ID to get summary for
Returns:
Dictionary with project summary or None if not found
"""
# Get project info
with db.cursor() as cur:
cur.execute(
"""
SELECT proj_id, proj_short_name, plan_start_date, plan_end_date
FROM projects
WHERE proj_id = ?
""",
(project_id,),
)
project_row = cur.fetchone()
if project_row is None:
return None
# Get activity count
with db.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM activities")
activity_count = cur.fetchone()[0]
# Get milestone count
with db.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM activities WHERE task_type = 'TT_Mile'")
milestone_count = cur.fetchone()[0]
# Get critical activity count
with db.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM activities WHERE driving_path_flag = 1")
critical_count = cur.fetchone()[0]
return {
"project_id": project_row[0],
"project_name": project_row[1],
"plan_start_date": project_row[2],
"plan_end_date": project_row[3],
"activity_count": activity_count,
"milestone_count": milestone_count,
"critical_activity_count": critical_count,
}
def query_milestones() -> list[dict]:
"""Query all milestone activities.
Returns:
List of milestone activity dicts
"""
query = """
SELECT task_id, task_code, task_name,
target_start_date, target_end_date, status_code
FROM activities
WHERE task_type = 'TT_Mile'
ORDER BY target_start_date, task_code
"""
with db.cursor() as cur:
cur.execute(query)
rows = cur.fetchall()
return [
{
"task_id": row[0],
"task_code": row[1],
"task_name": row[2],
"target_start_date": row[3],
"target_end_date": row[4],
"status_code": row[5],
}
for row in rows
]
def query_critical_path() -> list[dict]:
"""Query all activities on the critical path.
Returns:
List of critical path activity dicts ordered by start date
"""
query = """
SELECT task_id, task_code, task_name, task_type,
target_start_date, target_end_date,
total_float_hr_cnt, status_code
FROM activities
WHERE driving_path_flag = 1
ORDER BY target_start_date, task_code
"""
with db.cursor() as cur:
cur.execute(query)
rows = cur.fetchall()
return [
{
"task_id": row[0],
"task_code": row[1],
"task_name": row[2],
"task_type": row[3],
"target_start_date": row[4],
"target_end_date": row[5],
"total_float_hr_cnt": row[6],
"status_code": row[7],
}
for row in rows
]