diff --git a/src/mcp_workboard_crunchtools/errors.py b/src/mcp_workboard_crunchtools/errors.py index ff80d15..6b4309e 100644 --- a/src/mcp_workboard_crunchtools/errors.py +++ b/src/mcp_workboard_crunchtools/errors.py @@ -58,6 +58,13 @@ def __init__(self) -> None: super().__init__("Invalid activity_id format. Expected positive integer.") +class InvalidTeamIdError(UserError): + """Invalid team ID format.""" + + def __init__(self) -> None: + super().__init__("Invalid team_id format. Expected positive integer.") + + SAFE_ID_MAX_LENGTH = 20 diff --git a/src/mcp_workboard_crunchtools/models.py b/src/mcp_workboard_crunchtools/models.py index dab7268..423f42a 100644 --- a/src/mcp_workboard_crunchtools/models.py +++ b/src/mcp_workboard_crunchtools/models.py @@ -8,8 +8,10 @@ InvalidActivityIdError, InvalidMetricIdError, InvalidObjectiveIdError, + InvalidTeamIdError, InvalidUserIdError, InvalidWorkstreamIdError, + ValidationError, ) MAX_NAME_LENGTH = 255 @@ -55,6 +57,23 @@ def validate_activity_id(activity_id: int) -> int: return activity_id +def validate_team_id(team_id: int) -> int: + """Validate a team ID is a positive integer.""" + if not isinstance(team_id, int) or team_id <= 0: + raise InvalidTeamIdError + return team_id + + +_MM_DD_YYYY_RE = re.compile(r"^\d{2}/\d{2}/\d{4}$") + + +def validate_mm_dd_yyyy(value: str, field_name: str = "date") -> str: + """Validate a date string is in MM/DD/YYYY format.""" + if not isinstance(value, str) or not _MM_DD_YYYY_RE.match(value): + raise ValidationError(f"Invalid {field_name} format. Expected MM/DD/YYYY (e.g. '04/01/2026').") + return value + + class CreateUserInput(BaseModel): """Validated input for creating a new WorkBoard user.""" diff --git a/src/mcp_workboard_crunchtools/server.py b/src/mcp_workboard_crunchtools/server.py index 0dd64e7..85295c7 100644 --- a/src/mcp_workboard_crunchtools/server.py +++ b/src/mcp_workboard_crunchtools/server.py @@ -16,6 +16,7 @@ get_objective_details, get_objectives, get_team_members, + get_team_objectives, get_team_workstreams, get_teams, get_user, @@ -59,7 +60,12 @@ "filters, workboard_get_activity_tool to get a single action item by ID, " "workboard_create_activity_tool to create a new action item, and " "workboard_update_activity_tool to update an existing action item's state, priority, " - "effort, due date, or owner." + "effort, due date, or owner.\n\n" + "TEAM OKR SNAPSHOTS: For quarterly OKR reviews or weekly snapshots that include " + "past-quarter objectives, use workboard_get_team_objectives_tool with a team_id " + "and explicit MM/DD/YYYY date bounds (e.g. startDate='01/01/2026', endDate='03/31/2026'). " + "This tool calls the date-range-scoped goalSummary endpoint and never silently drops " + "objectives whose target date has passed." ), ) @@ -414,6 +420,41 @@ async def workboard_get_team_workstreams_tool( return await get_team_workstreams(team_id=team_id) +@mcp.tool() +async def workboard_get_team_objectives_tool( + team_id: int, + start_date: str, + end_date: str, + get_nested_teams: bool = True, +) -> dict[str, Any]: + """Get all objectives for a WorkBoard team within a quarter date range. + + Use this instead of workboard_get_objectives_tool when you need team-scoped + or cross-quarter OKR snapshots. The existing user-scoped tool silently + excludes objectives whose target date has passed; this tool delegates the + date window entirely to the WorkBoard goalSummary API so past-quarter + objectives (e.g. Q1 after March 31) are returned correctly. + + Use workboard_get_teams_tool to discover team IDs. + + Args: + team_id: WorkBoard team ID (positive integer). + start_date: Quarter start date in MM/DD/YYYY format, e.g. "04/01/2026". + end_date: Quarter end date in MM/DD/YYYY format, e.g. "06/30/2026". + get_nested_teams: Include objectives from sub-teams (default True). + + Returns: + List of objectives with name, owner, status_color, progress, and + nested key_results (name, progress, last_updated, target). + """ + return await get_team_objectives( + team_id=team_id, + start_date=start_date, + end_date=end_date, + get_nested_teams=get_nested_teams, + ) + + @mcp.tool() async def workboard_create_workstream_tool( ws_name: str, diff --git a/src/mcp_workboard_crunchtools/tools/__init__.py b/src/mcp_workboard_crunchtools/tools/__init__.py index b998e4e..3393029 100644 --- a/src/mcp_workboard_crunchtools/tools/__init__.py +++ b/src/mcp_workboard_crunchtools/tools/__init__.py @@ -15,6 +15,7 @@ get_my_objectives, get_objective_details, get_objectives, + get_team_objectives, get_user_key_results, update_key_result, ) @@ -44,6 +45,7 @@ "get_my_objectives", "get_my_key_results", "get_user_key_results", + "get_team_objectives", "update_key_result", "create_objective", "get_workstreams", diff --git a/src/mcp_workboard_crunchtools/tools/objectives.py b/src/mcp_workboard_crunchtools/tools/objectives.py index c32c39b..a103ead 100644 --- a/src/mcp_workboard_crunchtools/tools/objectives.py +++ b/src/mcp_workboard_crunchtools/tools/objectives.py @@ -16,7 +16,9 @@ CreateObjectiveInput, UpdateKeyResultInput, validate_metric_id, + validate_mm_dd_yyyy, validate_objective_id, + validate_team_id, validate_user_id, ) @@ -610,3 +612,62 @@ async def create_objective( ) return {"objective": response} + + +async def get_team_objectives( + team_id: int, + start_date: str, + end_date: str, + get_nested_teams: bool = True, +) -> dict[str, Any]: + """Get objectives for a WorkBoard team within a date range. + + Calls the ``GET /goal/goalSummary`` endpoint used by the WorkBoard web UI. + Unlike ``get_objectives``, which fetches by owner user ID and silently + excludes past-quarter objectives, this call is date-range scoped so it + reliably returns objectives for any quarter — including expired ones. + + The date window is enforced by the API; no additional expiry filtering + is applied here. + + If the ``goalSummary`` response does not include nested metric objects, + ``key_results`` will be an empty list for each objective rather than + raising an error. + + Args: + team_id: WorkBoard team ID (positive integer). Use + ``workboard_get_teams_tool`` to discover team IDs. + start_date: Quarter start date in MM/DD/YYYY format, e.g. "04/01/2026". + end_date: Quarter end date in MM/DD/YYYY format, e.g. "06/30/2026". + get_nested_teams: When True (default), includes objectives from + sub-teams beneath the given team. + + Returns: + Dictionary with ``objectives`` list. Each objective includes name, + owner, status_color, progress, and nested key_results. + """ + team_id = validate_team_id(team_id) + start_date = validate_mm_dd_yyyy(start_date, "start_date") + end_date = validate_mm_dd_yyyy(end_date, "end_date") + + client = get_client() + + params: dict[str, Any] = { + "team_people": 1, + "team_s": 0, + "userTeamid": team_id, + "userTeamType": 2, + "getNestedTeams": "true" if get_nested_teams else "false", + "status": 1, + "performance": "4,3,2,1,0", + "startDate": start_date, + "endDate": end_date, + "exDate": 1, + } + + response = await client.get("https://www.myworkboard.com/wb/goal/goalSummary", params=params) + + body = response.get("data", {}) + goals = _extract_goals_from_goal_response(body) + + return {"objectives": [_format_goal(g) for g in goals]} diff --git a/tests/test_tools.py b/tests/test_tools.py index 8dca162..db665fd 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -21,10 +21,10 @@ def test_server_has_tools(self) -> None: assert mcp is not None def test_tool_count(self) -> None: - """Server should have exactly 22 tools registered.""" + """Server should have exactly 23 tools registered.""" from mcp_workboard_crunchtools.tools import __all__ - assert len(__all__) == 22 + assert len(__all__) == 23 def test_imports(self) -> None: """All tool functions should be importable.""" @@ -484,6 +484,110 @@ async def test_create_objective(self) -> None: assert "objective" in result + @pytest.mark.asyncio + async def test_get_team_objectives(self) -> None: + """get_team_objectives should call goalSummary with required params and return objectives.""" + from mcp_workboard_crunchtools.tools import get_team_objectives + + resp = _mock_response( + json_data={ + "data": { + "goal": { + "0": { + "goal_id": 600, + "goal_name": "Grow Revenue", + "goal_progress": "50", + "goal_owner_full_name": "Alice Smith", + "goal_progress_color": "#339933", + "goal_metrics": [ + { + "metric_id": 700, + "metric_name": "ARR", + "metric_target": "1000000", + "metric_achieve_target": "500000", + "metric_unit": {"name": "Currency"}, + }, + ], + }, + }, + }, + } + ) + + with _patch_client(resp) as mock_client: + result = await get_team_objectives( + team_id=559244, + start_date="04/01/2026", + end_date="06/30/2026", + ) + + assert "objectives" in result + assert len(result["objectives"]) == 1 + obj = result["objectives"][0] + assert obj["name"] == "Grow Revenue" + assert obj["owner"] == "Alice Smith" + assert obj["status_color"] == "#339933" + assert obj["progress"] == "50%" + assert len(obj["key_results"]) == 1 + assert obj["key_results"][0]["name"] == "ARR" + assert "$" in obj["key_results"][0]["progress"] + + # Verify the correct API path and required query params were sent + call_args = mock_client.return_value.request.call_args + assert call_args is not None + params = call_args.kwargs.get("params") or {} + assert params.get("userTeamid") == 559244 + assert params.get("startDate") == "04/01/2026" + assert params.get("endDate") == "06/30/2026" + assert params.get("performance") == "4,3,2,1,0" + assert params.get("exDate") == 1 + + @pytest.mark.asyncio + async def test_get_team_objectives_no_nested_teams(self) -> None: + """get_team_objectives with get_nested_teams=False should pass 'false' string.""" + from mcp_workboard_crunchtools.tools import get_team_objectives + + resp = _mock_response(json_data={"data": {}}) + + with _patch_client(resp) as mock_client: + result = await get_team_objectives( + team_id=1, + start_date="01/01/2026", + end_date="03/31/2026", + get_nested_teams=False, + ) + + assert result["objectives"] == [] + call_args = mock_client.return_value.request.call_args + params = call_args.kwargs.get("params") or {} + assert params.get("getNestedTeams") == "false" + + @pytest.mark.asyncio + async def test_get_team_objectives_invalid_team_id(self) -> None: + """get_team_objectives should raise on invalid team_id.""" + from mcp_workboard_crunchtools.errors import InvalidTeamIdError + from mcp_workboard_crunchtools.tools import get_team_objectives + + with pytest.raises(InvalidTeamIdError): + await get_team_objectives( + team_id=0, + start_date="04/01/2026", + end_date="06/30/2026", + ) + + @pytest.mark.asyncio + async def test_get_team_objectives_invalid_date_format(self) -> None: + """get_team_objectives should raise on date in wrong format.""" + from mcp_workboard_crunchtools.errors import ValidationError + from mcp_workboard_crunchtools.tools import get_team_objectives + + with pytest.raises(ValidationError): + await get_team_objectives( + team_id=1, + start_date="2026-04-01", # wrong format — should be MM/DD/YYYY + end_date="06/30/2026", + ) + class TestKeyResultTools: """Tests for key result tools with mocked API responses.""" diff --git a/tests/test_validation.py b/tests/test_validation.py index 68ab14c..d12926b 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -7,8 +7,10 @@ InvalidActivityIdError, InvalidMetricIdError, InvalidObjectiveIdError, + InvalidTeamIdError, InvalidUserIdError, InvalidWorkstreamIdError, + ValidationError as WBValidationError, ) from mcp_workboard_crunchtools.models import ( CreateActivityInput, @@ -19,7 +21,9 @@ UpdateWorkstreamInput, validate_activity_id, validate_metric_id, + validate_mm_dd_yyyy, validate_objective_id, + validate_team_id, validate_user_id, validate_workstream_id, ) @@ -457,3 +461,53 @@ def test_extra_fields_rejected(self) -> None: ai_state="done", ai_hidden=True, # type: ignore[call-arg] ) + + +class TestTeamIdValidation: + """Tests for team_id validation.""" + + def test_valid_team_id(self) -> None: + """Valid positive integer should pass.""" + assert validate_team_id(559244) == 559244 + + def test_invalid_team_id_zero(self) -> None: + """Zero should fail.""" + with pytest.raises(InvalidTeamIdError): + validate_team_id(0) + + def test_invalid_team_id_negative(self) -> None: + """Negative integer should fail.""" + with pytest.raises(InvalidTeamIdError): + validate_team_id(-5) + + +class TestMmDdYyyyValidation: + """Tests for MM/DD/YYYY date string validation.""" + + def test_valid_date(self) -> None: + """Valid MM/DD/YYYY string should pass and be returned unchanged.""" + assert validate_mm_dd_yyyy("04/01/2026") == "04/01/2026" + + def test_valid_year_end(self) -> None: + """Another valid date should pass.""" + assert validate_mm_dd_yyyy("12/31/2025", "end_date") == "12/31/2025" + + def test_iso_format_rejected(self) -> None: + """YYYY-MM-DD (ISO) format should fail.""" + with pytest.raises(WBValidationError): + validate_mm_dd_yyyy("2026-04-01") + + def test_partial_date_rejected(self) -> None: + """Incomplete date should fail.""" + with pytest.raises(WBValidationError): + validate_mm_dd_yyyy("04/2026") + + def test_non_string_rejected(self) -> None: + """Non-string input should fail.""" + with pytest.raises(WBValidationError): + validate_mm_dd_yyyy(20260401) # type: ignore[arg-type] + + def test_empty_string_rejected(self) -> None: + """Empty string should fail.""" + with pytest.raises(WBValidationError): + validate_mm_dd_yyyy("")