Skip to content

Commit 9b48960

Browse files
committed
feat: add staff availability and allocation tool
- Add 'check_staff_availability' tool to MCP server - Implements cross-referencing /v1/employee and /v1/allocation_reporting - Calculates availability percentage (100% - allocated) - Supports filtering by skill and minimum availability threshold
1 parent fed5add commit 9b48960

File tree

2 files changed

+168
-15
lines changed

2 files changed

+168
-15
lines changed

src/agileday_server.py

Lines changed: 123 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import argparse
44
import requests
55
import uvicorn
6+
from datetime import date, timedelta
67
from starlette.applications import Starlette
78
from starlette.routing import Route
89
from starlette.responses import Response
@@ -58,13 +59,27 @@ async def list_tools() -> list[Tool]:
5859
"required": ["name_query"]
5960
}
6061
),
61-
# NEW TOOL ADDED HERE
6262
Tool(
6363
name="list_organization_skills",
6464
description="Retrieves a full list of all available skills/competences defined in the organization.",
6565
inputSchema={
6666
"type": "object",
67-
"properties": {}, # No arguments needed
67+
"properties": {},
68+
}
69+
),
70+
# NEW TOOL: Availability Checker
71+
Tool(
72+
name="check_staff_availability",
73+
description="Checks who is free and views current allocations for a date range. Can filter by skill.",
74+
inputSchema={
75+
"type": "object",
76+
"properties": {
77+
"start_date": {"type": "string", "description": "Start date in YYYY-MM-DD format"},
78+
"end_date": {"type": "string", "description": "End date in YYYY-MM-DD format"},
79+
"skill_filter": {"type": "string", "description": "Optional: Filter for employees with this skill (e.g. 'React')"},
80+
"min_availability": {"type": "integer", "description": "Optional: Minimum free percentage to show (0-100). Default 0."}
81+
},
82+
"required": ["start_date", "end_date"]
6883
}
6984
)
7085
]
@@ -75,10 +90,16 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent | ImageConte
7590
return [TextContent(type="text", text=find_experts_logic(arguments["skill_name"]))]
7691
if name == "get_employee_competence_profile":
7792
return [TextContent(type="text", text=profile_logic(arguments["name_query"]))]
78-
79-
# NEW LOGIC HANDLER
8093
if name == "list_organization_skills":
8194
return [TextContent(type="text", text=list_skills_logic())]
95+
# NEW HANDLER
96+
if name == "check_staff_availability":
97+
return [TextContent(type="text", text=check_availability_logic(
98+
arguments["start_date"],
99+
arguments["end_date"],
100+
arguments.get("skill_filter"),
101+
arguments.get("min_availability", 0)
102+
))]
82103

83104
raise ValueError(f"Unknown tool: {name}")
84105

@@ -138,7 +159,6 @@ def profile_logic(name_query: str) -> str:
138159

139160
return "\n\n".join(results) if results else f"No employee found for '{name_query}'."
140161

141-
# NEW LOGIC FUNCTION
142162
def list_skills_logic() -> str:
143163
try:
144164
url = f"{BASE_URL}/v1/skill"
@@ -151,24 +171,114 @@ def list_skills_logic() -> str:
151171
if not skills:
152172
return "No skills defined in the library."
153173

154-
# Group skills by category for better readability
155174
categories = {}
156175
for s in skills:
157-
# Default to "Other" if category is missing/null
158176
cat = s.get('category') or "Uncategorized"
159-
if cat not in categories:
160-
categories[cat] = []
177+
if cat not in categories: categories[cat] = []
161178
categories[cat].append(s.get('name', 'Unnamed Skill'))
162179

163180
output = ["**Organization Skill Library**\n"]
164-
165-
# Sort categories alphabetically
166181
for cat in sorted(categories.keys()):
167182
skill_names = sorted(categories[cat])
168-
# Format: "### Category Name \n - Skill 1, Skill 2..."
169183
output.append(f"### {cat}")
170184
output.append(", ".join(skill_names))
171-
output.append("") # Empty line for spacing
185+
output.append("")
186+
187+
return "\n".join(output)
188+
189+
def check_availability_logic(start_date: str, end_date: str, skill_filter: str = None, min_availability: int = 0) -> str:
190+
# 1. Fetch all employees first
191+
try:
192+
emp_url = f"{BASE_URL}/v1/employee"
193+
emp_response = requests.get(emp_url, headers=get_headers(), timeout=15)
194+
emp_response.raise_for_status()
195+
all_employees = emp_response.json()
196+
except Exception as e:
197+
return f"Error fetching employees: {str(e)}"
198+
199+
# 2. Filter employees if skill_filter is active
200+
relevant_employee_ids = set()
201+
relevant_employees = {}
202+
203+
for emp in all_employees:
204+
emp_id = emp.get('id')
205+
if not emp_id: continue
206+
207+
# If skill filter applies, check skills
208+
if skill_filter:
209+
skills = emp.get('skills', [])
210+
has_skill = any(skill_filter.lower() in s.get('name', '').lower() for s in skills)
211+
if not has_skill:
212+
continue
213+
214+
relevant_employee_ids.add(emp_id)
215+
relevant_employees[emp_id] = emp
216+
217+
if not relevant_employee_ids:
218+
return f"No employees found matching skill: {skill_filter}" if skill_filter else "No employees found."
219+
220+
# 3. Fetch allocations for the period
221+
try:
222+
alloc_url = f"{BASE_URL}/v1/allocation_reporting"
223+
params = {"startDate": start_date, "endDate": end_date}
224+
alloc_response = requests.get(alloc_url, headers=get_headers(), params=params, timeout=15)
225+
alloc_response.raise_for_status()
226+
allocations = alloc_response.json()
227+
except Exception as e:
228+
return f"Error fetching allocations: {str(e)}"
229+
230+
# 4. Aggregate allocations by employee
231+
# Map: employee_id -> list of project allocations
232+
emp_workload = {eid: [] for eid in relevant_employee_ids}
233+
emp_total_alloc = {eid: 0.0 for eid in relevant_employee_ids}
234+
235+
for alloc in allocations:
236+
eid = alloc.get('employeeId')
237+
# Only process if this employee is in our filtered list
238+
if eid in relevant_employee_ids:
239+
# According to schema, 'allocation' is the percentage
240+
percentage = alloc.get('allocation', 0)
241+
project_name = alloc.get('projectName', 'Unknown Project')
242+
243+
emp_total_alloc[eid] += percentage
244+
emp_workload[eid].append(f"{project_name} ({percentage}%)")
245+
246+
# 5. Format Output
247+
output = []
248+
output.append(f"**Availability Report ({start_date} to {end_date})**")
249+
if skill_filter:
250+
output.append(f"Filter: Skill matching '{skill_filter}'\n")
251+
252+
# Sort by availability (most free first)
253+
sorted_eids = sorted(relevant_employee_ids, key=lambda x: emp_total_alloc[x])
254+
255+
for eid in sorted_eids:
256+
total_load = emp_total_alloc[eid]
257+
availability = 100 - total_load
258+
259+
# Skip if below requested availability
260+
if availability < min_availability:
261+
continue
262+
263+
emp = relevant_employees[eid]
264+
full_name = f"{emp.get('firstName', '')} {emp.get('lastName', '')}".strip()
265+
title = emp.get('title', 'No Title')
266+
267+
# Status Icon
268+
if availability >= 80:
269+
icon = "🟢" # Mostly free
270+
elif availability >= 20:
271+
icon = "🟡" # Partially booked
272+
else:
273+
icon = "🔴" # Busy
274+
275+
# Work details
276+
work_desc = ", ".join(emp_workload[eid]) if emp_workload[eid] else "No allocations"
277+
278+
output.append(f"{icon} **{full_name}** ({title})")
279+
output.append(f" Availability: **{availability:.0f}%** (Allocated: {total_load:.0f}%)")
280+
output.append(f" Current Work: {work_desc}")
281+
output.append("")
172282

173283
return "\n".join(output)
174284

tests/test_agileday_server.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
sys.path.append(os.path.join(os.path.dirname(__file__), '../src'))
1212

1313
# Add list_skills_logic to imports
14-
from agileday_server import find_experts_logic, profile_logic, list_skills_logic, BASE_URL
14+
from agileday_server import find_experts_logic, profile_logic, list_skills_logic, check_availability_logic, BASE_URL
1515

1616
# --- Fixtures ---
1717

@@ -131,4 +131,47 @@ def test_list_skills_grouping(mock_api_response):
131131
assert "### Frontend" in result
132132
assert "React" in result
133133
assert "### Uncategorized" in result
134-
assert "Figma" in result
134+
assert "Figma" in result
135+
136+
def test_check_availability(mock_api_response):
137+
"""Test the availability calculation logic."""
138+
# 1. Setup Mock for Employees (First call)
139+
employees_data = [
140+
{"id": "user1", "firstName": "Alice", "lastName": "Free", "title": "Dev"},
141+
{"id": "user2", "firstName": "Bob", "lastName": "Busy", "title": "Lead"}
142+
]
143+
144+
# 2. Setup Mock for Allocations (Second call)
145+
allocations_data = [
146+
# Bob is busy with 100% allocation
147+
{"employeeId": "user2", "projectName": "Project X", "allocation": 100}
148+
]
149+
150+
# Configure side_effect to return different data for different URLs
151+
def side_effect(*args, **kwargs):
152+
if "employee" in args[0]:
153+
mock = MagicMock()
154+
mock.status_code = 200
155+
mock.json.return_value = employees_data
156+
return mock
157+
if "allocation_reporting" in args[0]:
158+
mock = MagicMock()
159+
mock.status_code = 200
160+
mock.json.return_value = allocations_data
161+
return mock
162+
return MagicMock()
163+
164+
mock_api_response.side_effect = side_effect
165+
166+
# 3. Run Logic
167+
result = check_availability_logic("2024-01-01", "2024-01-31")
168+
169+
# 4. Assertions
170+
# Alice should be green (100% free)
171+
assert "🟢 **Alice Free**" in result
172+
assert "Availability: **100%**" in result
173+
174+
# Bob should be red (0% free)
175+
assert "🔴 **Bob Busy**" in result
176+
assert "Allocated: 100%" in result
177+
assert "Project X" in result

0 commit comments

Comments
 (0)