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:
392
src/xer_mcp/db/queries.py
Normal file
392
src/xer_mcp/db/queries.py
Normal 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
|
||||
]
|
||||
Reference in New Issue
Block a user