Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/mcp_workboard_crunchtools/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
19 changes: 19 additions & 0 deletions src/mcp_workboard_crunchtools/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
InvalidActivityIdError,
InvalidMetricIdError,
InvalidObjectiveIdError,
InvalidTeamIdError,
InvalidUserIdError,
InvalidWorkstreamIdError,
ValidationError,
)

MAX_NAME_LENGTH = 255
Expand Down Expand Up @@ -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."""

Expand Down
43 changes: 42 additions & 1 deletion src/mcp_workboard_crunchtools/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
get_objective_details,
get_objectives,
get_team_members,
get_team_objectives,
get_team_workstreams,
get_teams,
get_user,
Expand Down Expand Up @@ -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'). "
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The parameter names in the example instructions (startDate, endDate) do not match the actual tool parameter names (start_date, end_date). This will cause the LLM to provide incorrect arguments when calling the tool.

Suggested change
"and explicit MM/DD/YYYY date bounds (e.g. startDate='01/01/2026', endDate='03/31/2026'). "
"and explicit MM/DD/YYYY date bounds (e.g. start_date='01/01/2026', end_date='03/31/2026'). "

"This tool calls the date-range-scoped goalSummary endpoint and never silently drops "
"objectives whose target date has passed."
),
)

Expand Down Expand Up @@ -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).
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The docstring lists target as a field in key_results, but the underlying _format_metric function returns this field as target_date. This discrepancy can lead to confusion for users or AI models consuming the tool output.

Suggested change
nested key_results (name, progress, last_updated, target).
nested key_results (name, progress, last_updated, target_date).

"""
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,
Expand Down
2 changes: 2 additions & 0 deletions src/mcp_workboard_crunchtools/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
get_my_objectives,
get_objective_details,
get_objectives,
get_team_objectives,
get_user_key_results,
update_key_result,
)
Expand Down Expand Up @@ -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",
Expand Down
61 changes: 61 additions & 0 deletions src/mcp_workboard_crunchtools/tools/objectives.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
CreateObjectiveInput,
UpdateKeyResultInput,
validate_metric_id,
validate_mm_dd_yyyy,
validate_objective_id,
validate_team_id,
validate_user_id,
)

Expand Down Expand Up @@ -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]}
108 changes: 106 additions & 2 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
Loading
Loading