diff --git a/integrations/beads-mcp/src/beads_mcp/bd_client.py b/integrations/beads-mcp/src/beads_mcp/bd_client.py index a20759b0..056fa8aa 100644 --- a/integrations/beads-mcp/src/beads_mcp/bd_client.py +++ b/integrations/beads-mcp/src/beads_mcp/bd_client.py @@ -385,6 +385,16 @@ class BdCliClient(BdClientBase): args.extend(["--priority", str(params.priority)]) if params.assignee: args.extend(["--assignee", params.assignee]) + if params.labels: + for label in params.labels: + args.extend(["--label", label]) + if params.labels_any: + for label in params.labels_any: + args.extend(["--label-any", label]) + if params.unassigned: + args.append("--unassigned") + if params.sort_policy: + args.extend(["--sort", params.sort_policy]) data = await self._run_command(*args) if not isinstance(data, list): @@ -412,6 +422,16 @@ class BdCliClient(BdClientBase): args.extend(["--type", params.issue_type]) if params.assignee: args.extend(["--assignee", params.assignee]) + if params.labels: + for label in params.labels: + args.extend(["--label", label]) + if params.labels_any: + for label in params.labels_any: + args.extend(["--label-any", label]) + if params.query: + args.extend(["--title", params.query]) + if params.unassigned: + args.append("--no-assignee") if params.limit: args.extend(["--limit", str(params.limit)]) diff --git a/integrations/beads-mcp/src/beads_mcp/bd_daemon_client.py b/integrations/beads-mcp/src/beads_mcp/bd_daemon_client.py index 5ec671d5..e960ffbb 100644 --- a/integrations/beads-mcp/src/beads_mcp/bd_daemon_client.py +++ b/integrations/beads-mcp/src/beads_mcp/bd_daemon_client.py @@ -377,6 +377,14 @@ class BdDaemonClient(BdClientBase): args["issue_type"] = params.issue_type if params.assignee: args["assignee"] = params.assignee + if params.labels: + args["labels"] = params.labels + if params.labels_any: + args["labels_any"] = params.labels_any + if params.query: + args["query"] = params.query + if params.unassigned: + args["unassigned"] = params.unassigned if params.limit: args["limit"] = params.limit @@ -414,6 +422,14 @@ class BdDaemonClient(BdClientBase): args["assignee"] = params.assignee if params.priority is not None: args["priority"] = params.priority + if params.labels: + args["labels"] = params.labels + if params.labels_any: + args["labels_any"] = params.labels_any + if params.unassigned: + args["unassigned"] = params.unassigned + if params.sort_policy: + args["sort_policy"] = params.sort_policy if params.limit: args["limit"] = params.limit diff --git a/integrations/beads-mcp/src/beads_mcp/models.py b/integrations/beads-mcp/src/beads_mcp/models.py index 118c456b..1d095650 100644 --- a/integrations/beads-mcp/src/beads_mcp/models.py +++ b/integrations/beads-mcp/src/beads_mcp/models.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, Field, field_validator IssueStatus = Literal["open", "in_progress", "blocked", "deferred", "closed"] IssueType = Literal["bug", "feature", "task", "epic", "chore"] DependencyType = Literal["blocks", "related", "parent-child", "discovered-from"] +OperationAction = Literal["created", "updated", "closed", "reopened"] # ============================================================================= @@ -43,7 +44,7 @@ class IssueMinimal(BaseModel): class CompactedResult(BaseModel): """Result container for compacted list responses. - + When results exceed threshold, returns preview + metadata instead of full data. This prevents context window overflow for large issue lists. """ @@ -54,6 +55,42 @@ class CompactedResult(BaseModel): hint: str = "Use show(issue_id) for full issue details" +class BriefIssue(BaseModel): + """Ultra-minimal issue for scanning (4 fields). + + Use for quick scans where only identification + priority needed. + ~95% smaller than full Issue. + """ + id: str + title: str + status: IssueStatus + priority: int = Field(ge=0, le=4) + + +class BriefDep(BaseModel): + """Brief dependency for overview (5 fields). + + Use with brief_deps=True to get full issue but compact dependencies. + ~90% smaller than full LinkedIssue. + """ + id: str + title: str + status: IssueStatus + priority: int = Field(ge=0, le=4) + dependency_type: DependencyType | None = None + + +class OperationResult(BaseModel): + """Minimal confirmation for write operations. + + Default response for create/update/close/reopen when verbose=False. + ~97% smaller than returning full Issue object. + """ + id: str + action: OperationAction + message: str | None = None + + # ============================================================================= # ORIGINAL MODELS (unchanged for backward compatibility) # ============================================================================= @@ -168,6 +205,10 @@ class ReadyWorkParams(BaseModel): limit: int = Field(default=10, ge=1, le=100) priority: int | None = Field(default=None, ge=0, le=4) assignee: str | None = None + labels: list[str] | None = None # AND: must have ALL labels + labels_any: list[str] | None = None # OR: must have at least one + unassigned: bool = False # Filter to only unassigned issues + sort_policy: str | None = None # hybrid, priority, oldest class ListIssuesParams(BaseModel): @@ -177,6 +218,10 @@ class ListIssuesParams(BaseModel): priority: int | None = Field(default=None, ge=0, le=4) issue_type: IssueType | None = None assignee: str | None = None + labels: list[str] | None = None # AND: must have ALL labels + labels_any: list[str] | None = None # OR: must have at least one + query: str | None = None # Search in title (case-insensitive) + unassigned: bool = False # Filter to only unassigned issues limit: int = Field(default=20, ge=1, le=100) # Reduced to avoid MCP buffer overflow diff --git a/integrations/beads-mcp/src/beads_mcp/server.py b/integrations/beads-mcp/src/beads_mcp/server.py index 1ed3d00e..f522c3b9 100644 --- a/integrations/beads-mcp/src/beads_mcp/server.py +++ b/integrations/beads-mcp/src/beads_mcp/server.py @@ -25,13 +25,17 @@ from typing import Any, Awaitable, Callable, TypeVar from fastmcp import FastMCP from beads_mcp.models import ( - BlockedIssue, + BlockedIssue, + BriefDep, + BriefIssue, CompactedResult, - DependencyType, + DependencyType, Issue, IssueMinimal, - IssueStatus, - IssueType, + IssueStatus, + IssueType, + LinkedIssue, + OperationResult, Stats, ) from beads_mcp.tools import ( @@ -363,10 +367,17 @@ async def get_tool_info(tool_name: str) -> dict[str, Any]: "limit": "int (1-100, default 10) - Max issues to return", "priority": "int (0-4, optional) - Filter by priority", "assignee": "str (optional) - Filter by assignee", + "labels": "list[str] (optional) - AND filter: must have ALL labels", + "labels_any": "list[str] (optional) - OR filter: must have at least one", + "unassigned": "bool (default false) - Only unassigned issues", + "sort_policy": "str (optional) - hybrid|priority|oldest", + "brief": "bool (default false) - Return only {id, title, status, priority}", + "fields": "list[str] (optional) - Custom field projection", + "max_description_length": "int (optional) - Truncate descriptions", "workspace_root": "str (optional) - Workspace path" }, "returns": "List of ready issues (minimal format for context efficiency)", - "example": "ready(limit=5, priority=1)" + "example": "ready(limit=5, priority=1, unassigned=True)" }, "list": { "name": "list", @@ -376,21 +387,32 @@ async def get_tool_info(tool_name: str) -> dict[str, Any]: "priority": "int 0-4 (optional)", "issue_type": "bug|feature|task|epic|chore (optional)", "assignee": "str (optional)", + "labels": "list[str] (optional) - AND filter: must have ALL labels", + "labels_any": "list[str] (optional) - OR filter: must have at least one", + "query": "str (optional) - Search in title (case-insensitive)", + "unassigned": "bool (default false) - Only unassigned issues", "limit": "int (1-100, default 20)", + "brief": "bool (default false) - Return only {id, title, status, priority}", + "fields": "list[str] (optional) - Custom field projection", + "max_description_length": "int (optional) - Truncate descriptions", "workspace_root": "str (optional)" }, "returns": "List of issues (compacted if >20 results)", - "example": "list(status='open', priority=1, limit=10)" + "example": "list(status='open', labels=['bug'], query='auth')" }, "show": { "name": "show", "description": "Show full details for a specific issue including dependencies", "parameters": { "issue_id": "str (required) - e.g., 'bd-a1b2'", + "brief": "bool (default false) - Return only {id, title, status, priority}", + "brief_deps": "bool (default false) - Full issue with compact dependencies", + "fields": "list[str] (optional) - Custom field projection", + "max_description_length": "int (optional) - Truncate description", "workspace_root": "str (optional)" }, - "returns": "Full Issue object with dependencies and dependents", - "example": "show(issue_id='bd-a1b2')" + "returns": "Full Issue object (or BriefIssue/dict based on params)", + "example": "show(issue_id='bd-a1b2', brief_deps=True)" }, "create": { "name": "create", @@ -403,9 +425,10 @@ async def get_tool_info(tool_name: str) -> dict[str, Any]: "assignee": "str (optional)", "labels": "list[str] (optional)", "deps": "list[str] (optional) - dependency IDs", + "brief": "bool (default true) - Return OperationResult instead of full Issue", "workspace_root": "str (optional)" }, - "returns": "Created Issue object", + "returns": "OperationResult {id, action} or full Issue if brief=False", "example": "create(title='Fix auth bug', priority=1, issue_type='bug')" }, "update": { @@ -418,9 +441,10 @@ async def get_tool_info(tool_name: str) -> dict[str, Any]: "assignee": "str (optional)", "title": "str (optional)", "description": "str (optional)", + "brief": "bool (default true) - Return OperationResult instead of full Issue", "workspace_root": "str (optional)" }, - "returns": "Updated Issue object", + "returns": "OperationResult {id, action} or full Issue if brief=False", "example": "update(issue_id='bd-a1b2', status='in_progress')" }, "close": { @@ -429,9 +453,10 @@ async def get_tool_info(tool_name: str) -> dict[str, Any]: "parameters": { "issue_id": "str (required)", "reason": "str (default 'Completed')", + "brief": "bool (default true) - Return OperationResult instead of full Issue", "workspace_root": "str (optional)" }, - "returns": "List of closed issues", + "returns": "List of OperationResult or full Issues if brief=False", "example": "close(issue_id='bd-a1b2', reason='Fixed in PR #123')" }, "reopen": { @@ -440,9 +465,10 @@ async def get_tool_info(tool_name: str) -> dict[str, Any]: "parameters": { "issue_ids": "list[str] (required)", "reason": "str (optional)", + "brief": "bool (default true) - Return OperationResult instead of full Issue", "workspace_root": "str (optional)" }, - "returns": "List of reopened issues", + "returns": "List of OperationResult or full Issues if brief=False", "example": "reopen(issue_ids=['bd-a1b2'], reason='Need more work')" }, "dep": { @@ -467,9 +493,13 @@ async def get_tool_info(tool_name: str) -> dict[str, Any]: "blocked": { "name": "blocked", "description": "Show blocked issues and what blocks them", - "parameters": {"workspace_root": "str (optional)"}, - "returns": "List of blocked issues with blocker info", - "example": "blocked()" + "parameters": { + "brief": "bool (default false) - Return only {id, title, status, priority}", + "brief_deps": "bool (default false) - Full issues with compact dependencies", + "workspace_root": "str (optional)" + }, + "returns": "List of BlockedIssue (or BriefIssue/dict based on params)", + "example": "blocked(brief=True)" }, "admin": { "name": "admin", @@ -669,26 +699,131 @@ def _to_minimal(issue: Issue) -> IssueMinimal: ) +def _to_brief(issue: Issue) -> BriefIssue: + """Convert full Issue to brief format (id, title, status, priority).""" + return BriefIssue( + id=issue.id, + title=issue.title, + status=issue.status, + priority=issue.priority, + ) + + +def _to_brief_dep(linked: LinkedIssue) -> BriefDep: + """Convert LinkedIssue to brief dependency format.""" + return BriefDep( + id=linked.id, + title=linked.title, + status=linked.status, + priority=linked.priority, + dependency_type=linked.dependency_type, + ) + + +# Valid fields for Issue model (used for field validation) +VALID_ISSUE_FIELDS: set[str] = { + "id", "title", "description", "design", "acceptance_criteria", "notes", + "external_ref", "status", "priority", "issue_type", "created_at", + "updated_at", "closed_at", "assignee", "labels", "dependency_count", + "dependent_count", "dependencies", "dependents", +} + + +def _filter_fields(obj: Issue, fields: list[str]) -> dict[str, Any]: + """Extract only specified fields from an Issue object. + + Raises: + ValueError: If any requested field is not a valid Issue field. + """ + # Validate fields first + requested = set(fields) + invalid = requested - VALID_ISSUE_FIELDS + if invalid: + raise ValueError( + f"Invalid field(s): {sorted(invalid)}. " + f"Valid fields: {sorted(VALID_ISSUE_FIELDS)}" + ) + + result: dict[str, Any] = {} + for field in fields: + value = getattr(obj, field) + # Handle nested Pydantic models + if hasattr(value, 'model_dump'): + result[field] = value.model_dump() + elif isinstance(value, list) and value and hasattr(value[0], 'model_dump'): + result[field] = [item.model_dump() for item in value] + else: + result[field] = value + return result + + +def _truncate_description(issue: Issue, max_length: int) -> Issue: + """Return issue copy with truncated description if needed.""" + if issue.description and len(issue.description) > max_length: + data = issue.model_dump() + data['description'] = issue.description[:max_length] + "..." + return Issue(**data) + return issue + + @mcp.tool(name="ready", description="Find tasks that have no blockers and are ready to be worked on. Returns minimal format for context efficiency.") @with_workspace async def ready_work( limit: int = 10, priority: int | None = None, assignee: str | None = None, + labels: list[str] | None = None, + labels_any: list[str] | None = None, + unassigned: bool = False, + sort_policy: str | None = None, workspace_root: str | None = None, -) -> list[IssueMinimal] | CompactedResult: + brief: bool = False, + fields: list[str] | None = None, + max_description_length: int | None = None, +) -> list[IssueMinimal] | list[BriefIssue] | list[dict[str, Any]] | CompactedResult: """Find issues with no blocking dependencies that are ready to work on. - + + Args: + limit: Maximum issues to return (1-100, default 10) + priority: Filter by priority level (0-4) + assignee: Filter by assignee + labels: Filter by labels (AND: must have ALL specified labels) + labels_any: Filter by labels (OR: must have at least one) + unassigned: Filter to only unassigned issues + sort_policy: Sort policy: hybrid (default), priority, oldest + workspace_root: Workspace path override + brief: If True, return only {id, title, status} (~97% smaller) + fields: Return only specified fields (custom projections) + max_description_length: Truncate descriptions to this length + Returns minimal issue format to reduce context usage by ~80%. Use show(issue_id) for full details including dependencies. - - If results exceed threshold, returns compacted preview. """ - issues = await beads_ready_work(limit=limit, priority=priority, assignee=assignee) - - # Convert to minimal format + issues = await beads_ready_work( + limit=limit, + priority=priority, + assignee=assignee, + labels=labels, + labels_any=labels_any, + unassigned=unassigned, + sort_policy=sort_policy, + ) + + # Apply description truncation first + if max_description_length: + issues = [_truncate_description(i, max_description_length) for i in issues] + + # Return brief format if requested + if brief: + return [_to_brief(issue) for issue in issues] + + # Return specific fields if requested + if fields: + return [_filter_fields(issue, fields) for issue in issues] + + # Default: minimal format with compaction minimal_issues = [_to_minimal(issue) for issue in issues] - + # Apply compaction if over threshold if len(minimal_issues) > COMPACTION_THRESHOLD: return CompactedResult( @@ -704,7 +839,7 @@ async def ready_work( @mcp.tool( name="list", - description="List all issues with optional filters (status, priority, type, assignee). Returns minimal format for context efficiency.", + description="List all issues with optional filters. When status='blocked', returns BlockedIssue with blocked_by info.", ) @with_workspace async def list_issues( @@ -712,27 +847,63 @@ async def list_issues( priority: int | None = None, issue_type: IssueType | None = None, assignee: str | None = None, + labels: list[str] | None = None, + labels_any: list[str] | None = None, + query: str | None = None, + unassigned: bool = False, limit: int = 20, workspace_root: str | None = None, -) -> list[IssueMinimal] | CompactedResult: + brief: bool = False, + fields: list[str] | None = None, + max_description_length: int | None = None, +) -> list[IssueMinimal] | list[BriefIssue] | list[dict[str, Any]] | CompactedResult: """List all issues with optional filters. - + + Args: + status: Filter by status (open, in_progress, blocked, closed) + priority: Filter by priority level (0-4) + issue_type: Filter by type (bug, feature, task, epic, chore) + assignee: Filter by assignee + labels: Filter by labels (AND: must have ALL specified labels) + labels_any: Filter by labels (OR: must have at least one) + query: Search in title (case-insensitive substring) + unassigned: Filter to only unassigned issues + limit: Maximum issues to return (1-100, default 20) + workspace_root: Workspace path override + brief: If True, return only {id, title, status} (~97% smaller) + fields: Return only specified fields (custom projections) + max_description_length: Truncate descriptions to this length + Returns minimal issue format to reduce context usage by ~80%. Use show(issue_id) for full details including dependencies. - - If results exceed threshold, returns compacted preview. """ issues = await beads_list_issues( status=status, priority=priority, issue_type=issue_type, assignee=assignee, + labels=labels, + labels_any=labels_any, + query=query, + unassigned=unassigned, limit=limit, ) - # Convert to minimal format + # Apply description truncation first + if max_description_length: + issues = [_truncate_description(i, max_description_length) for i in issues] + + # Return brief format if requested + if brief: + return [_to_brief(issue) for issue in issues] + + # Return specific fields if requested + if fields: + return [_filter_fields(issue, fields) for issue in issues] + + # Default: minimal format with compaction minimal_issues = [_to_minimal(issue) for issue in issues] - + # Apply compaction if over threshold if len(minimal_issues) > COMPACTION_THRESHOLD: return CompactedResult( @@ -751,9 +922,44 @@ async def list_issues( description="Show detailed information about a specific issue including dependencies and dependents.", ) @with_workspace -async def show_issue(issue_id: str, workspace_root: str | None = None) -> Issue: - """Show detailed information about a specific issue.""" - return await beads_show_issue(issue_id=issue_id) +async def show_issue( + issue_id: str, + workspace_root: str | None = None, + brief: bool = False, + brief_deps: bool = False, + fields: list[str] | None = None, + max_description_length: int | None = None, +) -> Issue | BriefIssue | dict[str, Any]: + """Show detailed information about a specific issue. + + Args: + issue_id: The issue ID to show (e.g., 'bd-a1b2') + workspace_root: Workspace path override + brief: If True, return only {id, title, status, priority} + brief_deps: If True, return full issue but with compact dependencies + fields: Return only specified fields (custom projections) + max_description_length: Truncate description to this length + """ + issue = await beads_show_issue(issue_id=issue_id) + + if max_description_length: + issue = _truncate_description(issue, max_description_length) + + # Brief mode - just identification + if brief: + return _to_brief(issue) + + # Brief deps mode - full issue but compact dependencies + if brief_deps: + data = issue.model_dump() + data["dependencies"] = [_to_brief_dep(d).model_dump() for d in issue.dependencies] + data["dependents"] = [_to_brief_dep(d).model_dump() for d in issue.dependents] + return data + + if fields: + return _filter_fields(issue, fields) + + return issue @mcp.tool( @@ -776,9 +982,14 @@ async def create_issue( id: str | None = None, deps: list[str] | None = None, workspace_root: str | None = None, -) -> Issue: - """Create a new issue.""" - return await beads_create_issue( + brief: bool = True, +) -> Issue | OperationResult: + """Create a new issue. + + Args: + brief: If True (default), return minimal OperationResult; if False, return full Issue + """ + issue = await beads_create_issue( title=title, description=description, design=design, @@ -792,6 +1003,10 @@ async def create_issue( deps=deps, ) + if brief: + return OperationResult(id=issue.id, action="created") + return issue + @mcp.tool( name="update", @@ -812,14 +1027,23 @@ async def update_issue( notes: str | None = None, external_ref: str | None = None, workspace_root: str | None = None, -) -> Issue | list[Issue] | None: - """Update an existing issue.""" + brief: bool = True, +) -> Issue | OperationResult | list[Issue] | list[OperationResult] | None: + """Update an existing issue. + + Args: + brief: If True (default), return minimal OperationResult; if False, return full Issue + """ # If trying to close via update, redirect to close_issue to preserve approval workflow if status == "closed": issues = await beads_close_issue(issue_id=issue_id, reason="Closed via update") - return issues[0] if issues else None - - return await beads_update_issue( + if not issues: + return None + if brief: + return OperationResult(id=issues[0].id, action="closed", message="Closed via update") + return issues[0] + + issue = await beads_update_issue( issue_id=issue_id, status=status, priority=priority, @@ -832,6 +1056,12 @@ async def update_issue( external_ref=external_ref, ) + if issue is None: + return None + if brief: + return OperationResult(id=issue.id, action="updated") + return issue + @mcp.tool( name="close", @@ -839,9 +1069,23 @@ async def update_issue( ) @with_workspace @require_context -async def close_issue(issue_id: str, reason: str = "Completed", workspace_root: str | None = None) -> list[Issue]: - """Close (complete) an issue.""" - return await beads_close_issue(issue_id=issue_id, reason=reason) +async def close_issue( + issue_id: str, + reason: str = "Completed", + workspace_root: str | None = None, + brief: bool = True, +) -> list[Issue] | list[OperationResult]: + """Close (complete) an issue. + + Args: + brief: If True (default), return minimal OperationResult list; if False, return full Issues + """ + issues = await beads_close_issue(issue_id=issue_id, reason=reason) + + if not brief: + return issues + + return [OperationResult(id=issue_id, action="closed", message=reason)] @mcp.tool( @@ -850,9 +1094,22 @@ async def close_issue(issue_id: str, reason: str = "Completed", workspace_root: ) @with_workspace @require_context -async def reopen_issue(issue_ids: list[str], reason: str | None = None, workspace_root: str | None = None) -> list[Issue]: - """Reopen one or more closed issues.""" - return await beads_reopen_issue(issue_ids=issue_ids, reason=reason) +async def reopen_issue( + issue_ids: list[str], + reason: str | None = None, + workspace_root: str | None = None, + brief: bool = True, +) -> list[Issue] | list[OperationResult]: + """Reopen one or more closed issues. + + Args: + brief: If True (default), return minimal OperationResult list; if False, return full Issues + """ + issues = await beads_reopen_issue(issue_ids=issue_ids, reason=reason) + + if brief: + return [OperationResult(id=i.id, action="reopened", message=reason) for i in issues] + return issues @mcp.tool( @@ -891,9 +1148,34 @@ async def stats(workspace_root: str | None = None) -> Stats: description="Get blocked issues showing what dependencies are blocking them from being worked on.", ) @with_workspace -async def blocked(workspace_root: str | None = None) -> list[BlockedIssue]: - """Get blocked issues.""" - return await beads_blocked() +async def blocked( + workspace_root: str | None = None, + brief: bool = False, + brief_deps: bool = False, +) -> list[BlockedIssue] | list[BriefIssue] | list[dict[str, Any]]: + """Get blocked issues. + + Args: + brief: If True, return only {id, title, status, priority} per issue + brief_deps: If True, return full issues but with compact dependencies + """ + issues = await beads_blocked() + + # Brief mode - just identification (most compact) + if brief: + return [_to_brief(issue) for issue in issues] + + # Brief deps mode - full issue but compact dependencies + if brief_deps: + result = [] + for issue in issues: + data = issue.model_dump() + data["dependencies"] = [_to_brief_dep(d).model_dump() for d in issue.dependencies] + data["dependents"] = [_to_brief_dep(d).model_dump() for d in issue.dependents] + result.append(data) + return result + + return issues @mcp.tool( diff --git a/integrations/beads-mcp/src/beads_mcp/tools.py b/integrations/beads-mcp/src/beads_mcp/tools.py index d340eea5..e47a70cb 100644 --- a/integrations/beads-mcp/src/beads_mcp/tools.py +++ b/integrations/beads-mcp/src/beads_mcp/tools.py @@ -305,6 +305,10 @@ async def beads_ready_work( limit: Annotated[int, "Maximum number of issues to return (1-100)"] = 10, priority: Annotated[int | None, "Filter by priority (0-4, 0=highest)"] = None, assignee: Annotated[str | None, "Filter by assignee"] = None, + labels: Annotated[list[str] | None, "Filter by labels (AND: must have ALL)"] = None, + labels_any: Annotated[list[str] | None, "Filter by labels (OR: must have at least one)"] = None, + unassigned: Annotated[bool, "Filter to only unassigned issues"] = False, + sort_policy: Annotated[str | None, "Sort policy: hybrid (default), priority, oldest"] = None, ) -> list[Issue]: """Find issues with no blocking dependencies that are ready to work on. @@ -312,7 +316,15 @@ async def beads_ready_work( Perfect for agents to claim next work! """ client = await _get_client() - params = ReadyWorkParams(limit=limit, priority=priority, assignee=assignee) + params = ReadyWorkParams( + limit=limit, + priority=priority, + assignee=assignee, + labels=labels, + labels_any=labels_any, + unassigned=unassigned, + sort_policy=sort_policy, + ) return await client.ready(params) @@ -321,7 +333,11 @@ async def beads_list_issues( priority: Annotated[int | None, "Filter by priority (0-4, 0=highest)"] = None, issue_type: Annotated[IssueType | None, "Filter by type (bug, feature, task, epic, chore)"] = None, assignee: Annotated[str | None, "Filter by assignee"] = None, - limit: Annotated[int, "Maximum number of issues to return (1-1000)"] = 50, + labels: Annotated[list[str] | None, "Filter by labels (AND: must have ALL)"] = None, + labels_any: Annotated[list[str] | None, "Filter by labels (OR: must have at least one)"] = None, + query: Annotated[str | None, "Search in title (case-insensitive substring)"] = None, + unassigned: Annotated[bool, "Filter to only unassigned issues"] = False, + limit: Annotated[int, "Maximum number of issues to return (1-100)"] = 20, ) -> list[Issue]: """List all issues with optional filters.""" client = await _get_client() @@ -331,6 +347,10 @@ async def beads_list_issues( priority=priority, issue_type=issue_type, assignee=assignee, + labels=labels, + labels_any=labels_any, + query=query, + unassigned=unassigned, limit=limit, ) return await client.list_issues(params) diff --git a/integrations/beads-mcp/tests/test_mcp_server_integration.py b/integrations/beads-mcp/tests/test_mcp_server_integration.py index e0a6ba72..a06b5a2d 100644 --- a/integrations/beads-mcp/tests/test_mcp_server_integration.py +++ b/integrations/beads-mcp/tests/test_mcp_server_integration.py @@ -118,6 +118,7 @@ async def test_create_issue_tool(mcp_client): "description": "Created via MCP server", "priority": 1, "issue_type": "bug", + "brief": False, # Get full Issue object }, ) @@ -141,7 +142,7 @@ async def test_show_issue_tool(mcp_client): # First create an issue create_result = await mcp_client.call_tool( "create", - {"title": "Issue to show", "priority": 2, "issue_type": "task"}, + {"title": "Issue to show", "priority": 2, "issue_type": "task", "brief": False}, ) import json @@ -161,10 +162,10 @@ async def test_list_issues_tool(mcp_client): """Test list_issues tool.""" # Create some issues first await mcp_client.call_tool( - "create", {"title": "Issue 1", "priority": 0, "issue_type": "bug"} + "create", {"title": "Issue 1", "priority": 0, "issue_type": "bug", "brief": False} ) await mcp_client.call_tool( - "create", {"title": "Issue 2", "priority": 1, "issue_type": "feature"} + "create", {"title": "Issue 2", "priority": 1, "issue_type": "feature", "brief": False} ) # List all issues @@ -188,7 +189,7 @@ async def test_update_issue_tool(mcp_client): # Create issue create_result = await mcp_client.call_tool( - "create", {"title": "Issue to update", "priority": 2, "issue_type": "task"} + "create", {"title": "Issue to update", "priority": 2, "issue_type": "task", "brief": False} ) created = json.loads(create_result.content[0].text) issue_id = created["id"] @@ -201,6 +202,7 @@ async def test_update_issue_tool(mcp_client): "status": "in_progress", "priority": 0, "title": "Updated title", + "brief": False, # Get full Issue object }, ) @@ -218,14 +220,14 @@ async def test_close_issue_tool(mcp_client): # Create issue create_result = await mcp_client.call_tool( - "create", {"title": "Issue to close", "priority": 1, "issue_type": "bug"} + "create", {"title": "Issue to close", "priority": 1, "issue_type": "bug", "brief": False} ) created = json.loads(create_result.content[0].text) issue_id = created["id"] - # Close issue + # Close issue with brief=False to get full Issue object close_result = await mcp_client.call_tool( - "close", {"issue_id": issue_id, "reason": "Test complete"} + "close", {"issue_id": issue_id, "reason": "Test complete", "brief": False} ) closed_issues = json.loads(close_result.content[0].text) @@ -243,7 +245,7 @@ async def test_reopen_issue_tool(mcp_client): # Create and close issue create_result = await mcp_client.call_tool( - "create", {"title": "Issue to reopen", "priority": 1, "issue_type": "bug"} + "create", {"title": "Issue to reopen", "priority": 1, "issue_type": "bug", "brief": False} ) created = json.loads(create_result.content[0].text) issue_id = created["id"] @@ -252,9 +254,9 @@ async def test_reopen_issue_tool(mcp_client): "close", {"issue_id": issue_id, "reason": "Done"} ) - # Reopen issue + # Reopen issue with brief=False to get full Issue object reopen_result = await mcp_client.call_tool( - "reopen", {"issue_ids": [issue_id]} + "reopen", {"issue_ids": [issue_id], "brief": False} ) reopened_issues = json.loads(reopen_result.content[0].text) @@ -272,21 +274,21 @@ async def test_reopen_multiple_issues_tool(mcp_client): # Create and close two issues issue1_result = await mcp_client.call_tool( - "create", {"title": "Issue 1 to reopen", "priority": 1, "issue_type": "task"} + "create", {"title": "Issue 1 to reopen", "priority": 1, "issue_type": "task", "brief": False} ) issue1 = json.loads(issue1_result.content[0].text) issue2_result = await mcp_client.call_tool( - "create", {"title": "Issue 2 to reopen", "priority": 1, "issue_type": "task"} + "create", {"title": "Issue 2 to reopen", "priority": 1, "issue_type": "task", "brief": False} ) issue2 = json.loads(issue2_result.content[0].text) await mcp_client.call_tool("close", {"issue_id": issue1["id"], "reason": "Done"}) await mcp_client.call_tool("close", {"issue_id": issue2["id"], "reason": "Done"}) - # Reopen both issues + # Reopen both issues with brief=False reopen_result = await mcp_client.call_tool( - "reopen", {"issue_ids": [issue1["id"], issue2["id"]]} + "reopen", {"issue_ids": [issue1["id"], issue2["id"]], "brief": False} ) reopened_issues = json.loads(reopen_result.content[0].text) @@ -305,17 +307,17 @@ async def test_reopen_with_reason_tool(mcp_client): # Create and close issue create_result = await mcp_client.call_tool( - "create", {"title": "Issue to reopen with reason", "priority": 1, "issue_type": "bug"} + "create", {"title": "Issue to reopen with reason", "priority": 1, "issue_type": "bug", "brief": False} ) created = json.loads(create_result.content[0].text) issue_id = created["id"] await mcp_client.call_tool("close", {"issue_id": issue_id, "reason": "Done"}) - # Reopen with reason + # Reopen with reason and brief=False reopen_result = await mcp_client.call_tool( "reopen", - {"issue_ids": [issue_id], "reason": "Found regression"} + {"issue_ids": [issue_id], "reason": "Found regression", "brief": False} ) reopened_issues = json.loads(reopen_result.content[0].text) @@ -333,18 +335,18 @@ async def test_ready_work_tool(mcp_client): # Create a ready issue (no dependencies) ready_result = await mcp_client.call_tool( - "create", {"title": "Ready work", "priority": 1, "issue_type": "task"} + "create", {"title": "Ready work", "priority": 1, "issue_type": "task", "brief": False} ) ready_issue = json.loads(ready_result.content[0].text) # Create blocked issue blocking_result = await mcp_client.call_tool( - "create", {"title": "Blocking issue", "priority": 1, "issue_type": "task"} + "create", {"title": "Blocking issue", "priority": 1, "issue_type": "task", "brief": False} ) blocking_issue = json.loads(blocking_result.content[0].text) blocked_result = await mcp_client.call_tool( - "create", {"title": "Blocked issue", "priority": 1, "issue_type": "task"} + "create", {"title": "Blocked issue", "priority": 1, "issue_type": "task", "brief": False} ) blocked_issue = json.loads(blocked_result.content[0].text) @@ -374,12 +376,12 @@ async def test_add_dependency_tool(mcp_client): # Create two issues issue1_result = await mcp_client.call_tool( - "create", {"title": "Issue 1", "priority": 1, "issue_type": "task"} + "create", {"title": "Issue 1", "priority": 1, "issue_type": "task", "brief": False} ) issue1 = json.loads(issue1_result.content[0].text) issue2_result = await mcp_client.call_tool( - "create", {"title": "Issue 2", "priority": 1, "issue_type": "task"} + "create", {"title": "Issue 2", "priority": 1, "issue_type": "task", "brief": False} ) issue2 = json.loads(issue2_result.content[0].text) @@ -409,6 +411,7 @@ async def test_create_with_all_fields(mcp_client): "issue_type": "feature", "assignee": "testuser", "labels": ["urgent", "backend"], + "brief": False, # Get full Issue object }, ) @@ -433,6 +436,7 @@ async def test_list_with_filters(mcp_client): "priority": 0, "issue_type": "bug", "assignee": "alice", + "brief": False, }, ) await mcp_client.call_tool( @@ -442,6 +446,7 @@ async def test_list_with_filters(mcp_client): "priority": 1, "issue_type": "feature", "assignee": "bob", + "brief": False, }, ) @@ -468,10 +473,10 @@ async def test_ready_work_with_priority_filter(mcp_client): # Create issues with different priorities await mcp_client.call_tool( - "create", {"title": "P0 issue", "priority": 0, "issue_type": "bug"} + "create", {"title": "P0 issue", "priority": 0, "issue_type": "bug", "brief": False} ) await mcp_client.call_tool( - "create", {"title": "P1 issue", "priority": 1, "issue_type": "task"} + "create", {"title": "P1 issue", "priority": 1, "issue_type": "task", "brief": False} ) # Get ready work with priority filter @@ -493,14 +498,15 @@ async def test_update_partial_fields(mcp_client): "description": "Original description", "priority": 2, "issue_type": "task", + "brief": False, }, ) created = json.loads(create_result.content[0].text) issue_id = created["id"] - # Update only status + # Update only status with brief=False to get full Issue update_result = await mcp_client.call_tool( - "update", {"issue_id": issue_id, "status": "in_progress"} + "update", {"issue_id": issue_id, "status": "in_progress", "brief": False} ) updated = json.loads(update_result.content[0].text) assert updated["status"] == "in_progress" @@ -515,12 +521,12 @@ async def test_dependency_types(mcp_client): # Create issues issue1_result = await mcp_client.call_tool( - "create", {"title": "Issue 1", "priority": 1, "issue_type": "task"} + "create", {"title": "Issue 1", "priority": 1, "issue_type": "task", "brief": False} ) issue1 = json.loads(issue1_result.content[0].text) issue2_result = await mcp_client.call_tool( - "create", {"title": "Issue 2", "priority": 1, "issue_type": "task"} + "create", {"title": "Issue 2", "priority": 1, "issue_type": "task", "brief": False} ) issue2 = json.loads(issue2_result.content[0].text) @@ -542,10 +548,10 @@ async def test_stats_tool(mcp_client): # Create some issues to get stats await mcp_client.call_tool( - "create", {"title": "Stats test 1", "priority": 1, "issue_type": "bug"} + "create", {"title": "Stats test 1", "priority": 1, "issue_type": "bug", "brief": False} ) await mcp_client.call_tool( - "create", {"title": "Stats test 2", "priority": 2, "issue_type": "task"} + "create", {"title": "Stats test 2", "priority": 2, "issue_type": "task", "brief": False} ) # Get stats @@ -564,12 +570,12 @@ async def test_blocked_tool(mcp_client): # Create two issues blocking_result = await mcp_client.call_tool( - "create", {"title": "Blocking issue", "priority": 1, "issue_type": "task"} + "create", {"title": "Blocking issue", "priority": 1, "issue_type": "task", "brief": False} ) blocking_issue = json.loads(blocking_result.content[0].text) blocked_result = await mcp_client.call_tool( - "create", {"title": "Blocked issue", "priority": 1, "issue_type": "task"} + "create", {"title": "Blocked issue", "priority": 1, "issue_type": "task", "brief": False} ) blocked_issue = json.loads(blocked_result.content[0].text) @@ -664,3 +670,412 @@ async def test_context_default_show(mcp_client, temp_db): # Verify output contains workspace info (same as show action) assert "Workspace root:" in output assert "Database:" in output + + +# ============================================================================= +# OUTPUT CONTROL PARAMETER TESTS +# ============================================================================= + + +@pytest.mark.asyncio +async def test_create_brief_default(mcp_client): + """Test create returns OperationResult by default (brief=True).""" + import json + + result = await mcp_client.call_tool( + "create", + {"title": "Brief test issue", "priority": 2, "issue_type": "task"}, + ) + + data = json.loads(result.content[0].text) + # Default brief=True returns OperationResult + assert "id" in data + assert data["action"] == "created" + # Should NOT have full Issue fields + assert "title" not in data + assert "description" not in data + + +@pytest.mark.asyncio +async def test_create_brief_false(mcp_client): + """Test create returns full Issue when brief=False.""" + import json + + result = await mcp_client.call_tool( + "create", + { + "title": "Full issue test", + "description": "Full description", + "priority": 1, + "issue_type": "bug", + "brief": False, + }, + ) + + data = json.loads(result.content[0].text) + # brief=False returns full Issue + assert data["title"] == "Full issue test" + assert data["description"] == "Full description" + assert data["priority"] == 1 + assert data["issue_type"] == "bug" + assert data["status"] == "open" + + +@pytest.mark.asyncio +async def test_update_brief_default(mcp_client): + """Test update returns OperationResult by default (brief=True).""" + import json + + # Create issue first + create_result = await mcp_client.call_tool( + "create", {"title": "Update brief test", "brief": False} + ) + created = json.loads(create_result.content[0].text) + issue_id = created["id"] + + # Update with default brief=True + update_result = await mcp_client.call_tool( + "update", {"issue_id": issue_id, "status": "in_progress"} + ) + + data = json.loads(update_result.content[0].text) + assert data["id"] == issue_id + assert data["action"] == "updated" + assert "title" not in data + + +@pytest.mark.asyncio +async def test_update_brief_false(mcp_client): + """Test update returns full Issue when brief=False.""" + import json + + # Create issue first + create_result = await mcp_client.call_tool( + "create", {"title": "Update full test", "brief": False} + ) + created = json.loads(create_result.content[0].text) + issue_id = created["id"] + + # Update with brief=False + update_result = await mcp_client.call_tool( + "update", {"issue_id": issue_id, "status": "in_progress", "brief": False} + ) + + data = json.loads(update_result.content[0].text) + assert data["id"] == issue_id + assert data["status"] == "in_progress" + assert data["title"] == "Update full test" + + +@pytest.mark.asyncio +async def test_close_brief_default(mcp_client): + """Test close returns OperationResult by default (brief=True).""" + import json + + # Create issue first + create_result = await mcp_client.call_tool( + "create", {"title": "Close brief test", "brief": False} + ) + created = json.loads(create_result.content[0].text) + issue_id = created["id"] + + # Close with default brief=True + close_result = await mcp_client.call_tool( + "close", {"issue_id": issue_id, "reason": "Done"} + ) + + data = json.loads(close_result.content[0].text) + assert isinstance(data, list) + assert len(data) == 1 + assert data[0]["id"] == issue_id + assert data[0]["action"] == "closed" + + +@pytest.mark.asyncio +async def test_close_brief_false(mcp_client): + """Test close returns full Issue when brief=False.""" + import json + + # Create issue first + create_result = await mcp_client.call_tool( + "create", {"title": "Close full test", "brief": False} + ) + created = json.loads(create_result.content[0].text) + issue_id = created["id"] + + # Close with brief=False + close_result = await mcp_client.call_tool( + "close", {"issue_id": issue_id, "reason": "Done", "brief": False} + ) + + data = json.loads(close_result.content[0].text) + assert isinstance(data, list) + assert len(data) >= 1 + assert data[0]["id"] == issue_id + assert data[0]["status"] == "closed" + assert data[0]["title"] == "Close full test" + + +@pytest.mark.asyncio +async def test_reopen_brief_default(mcp_client): + """Test reopen returns OperationResult by default (brief=True).""" + import json + + # Create and close issue first + create_result = await mcp_client.call_tool( + "create", {"title": "Reopen brief test", "brief": False} + ) + created = json.loads(create_result.content[0].text) + issue_id = created["id"] + + await mcp_client.call_tool("close", {"issue_id": issue_id}) + + # Reopen with default brief=True + reopen_result = await mcp_client.call_tool( + "reopen", {"issue_ids": [issue_id]} + ) + + data = json.loads(reopen_result.content[0].text) + assert isinstance(data, list) + assert len(data) == 1 + assert data[0]["id"] == issue_id + assert data[0]["action"] == "reopened" + + +@pytest.mark.asyncio +async def test_show_brief(mcp_client): + """Test show with brief=True returns BriefIssue.""" + import json + + # Create issue first + create_result = await mcp_client.call_tool( + "create", + {"title": "Show brief test", "description": "Long description", "brief": False}, + ) + created = json.loads(create_result.content[0].text) + issue_id = created["id"] + + # Show with brief=True + show_result = await mcp_client.call_tool( + "show", {"issue_id": issue_id, "brief": True} + ) + + data = json.loads(show_result.content[0].text) + # BriefIssue has only: id, title, status, priority + assert data["id"] == issue_id + assert data["title"] == "Show brief test" + assert data["status"] == "open" + assert "priority" in data + # Should NOT have full Issue fields + assert "description" not in data + assert "dependencies" not in data + + +@pytest.mark.asyncio +async def test_show_fields_projection(mcp_client): + """Test show with fields parameter for custom projection.""" + import json + + # Create issue first + create_result = await mcp_client.call_tool( + "create", + { + "title": "Fields test", + "description": "Test description", + "priority": 1, + "brief": False, + }, + ) + created = json.loads(create_result.content[0].text) + issue_id = created["id"] + + # Show with specific fields + show_result = await mcp_client.call_tool( + "show", {"issue_id": issue_id, "fields": ["id", "title", "priority"]} + ) + + data = json.loads(show_result.content[0].text) + # Should have only requested fields + assert data["id"] == issue_id + assert data["title"] == "Fields test" + assert data["priority"] == 1 + # Should NOT have other fields + assert "description" not in data + assert "status" not in data + + +@pytest.mark.asyncio +async def test_show_fields_invalid(mcp_client): + """Test show with invalid fields raises error.""" + import json + from fastmcp.exceptions import ToolError + + # Create issue first + create_result = await mcp_client.call_tool( + "create", {"title": "Invalid fields test", "brief": False} + ) + created = json.loads(create_result.content[0].text) + issue_id = created["id"] + + # Show with invalid field should raise ToolError + with pytest.raises(ToolError) as exc_info: + await mcp_client.call_tool( + "show", {"issue_id": issue_id, "fields": ["id", "nonexistent_field"]} + ) + + # Verify error message mentions invalid field + assert "Invalid field" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_show_max_description_length(mcp_client): + """Test show with max_description_length truncates description.""" + import json + + # Create issue with long description + long_desc = "A" * 200 + create_result = await mcp_client.call_tool( + "create", + {"title": "Truncate test", "description": long_desc, "brief": False}, + ) + created = json.loads(create_result.content[0].text) + issue_id = created["id"] + + # Show with truncation + show_result = await mcp_client.call_tool( + "show", {"issue_id": issue_id, "max_description_length": 50} + ) + + data = json.loads(show_result.content[0].text) + # Description should be truncated + assert len(data["description"]) <= 53 # 50 + "..." + assert data["description"].endswith("...") + + +@pytest.mark.asyncio +async def test_list_brief(mcp_client): + """Test list with brief=True returns BriefIssue format.""" + import json + + # Create some issues + await mcp_client.call_tool( + "create", {"title": "List brief 1", "priority": 1, "brief": False} + ) + await mcp_client.call_tool( + "create", {"title": "List brief 2", "priority": 2, "brief": False} + ) + + # List with brief=True + result = await mcp_client.call_tool("list", {"brief": True}) + issues = json.loads(result.content[0].text) + + assert len(issues) >= 2 + for issue in issues: + # BriefIssue has only: id, title, status, priority + assert "id" in issue + assert "title" in issue + assert "status" in issue + assert "priority" in issue + # Should NOT have full Issue fields + assert "description" not in issue + assert "issue_type" not in issue + + +@pytest.mark.asyncio +async def test_ready_brief(mcp_client): + """Test ready with brief=True returns BriefIssue format.""" + import json + + # Create a ready issue + await mcp_client.call_tool( + "create", {"title": "Ready brief test", "priority": 1, "brief": False} + ) + + # Ready with brief=True + result = await mcp_client.call_tool("ready", {"brief": True, "limit": 100}) + issues = json.loads(result.content[0].text) + + assert len(issues) >= 1 + for issue in issues: + # BriefIssue has only: id, title, status, priority + assert "id" in issue + assert "title" in issue + assert "status" in issue + assert "priority" in issue + # Should NOT have full Issue fields + assert "description" not in issue + + +@pytest.mark.asyncio +async def test_blocked_brief(mcp_client): + """Test blocked with brief=True returns BriefIssue format.""" + import json + + # Create blocking dependency + blocking_result = await mcp_client.call_tool( + "create", {"title": "Blocker for brief test", "brief": False} + ) + blocking = json.loads(blocking_result.content[0].text) + + blocked_result = await mcp_client.call_tool( + "create", {"title": "Blocked for brief test", "brief": False} + ) + blocked = json.loads(blocked_result.content[0].text) + + await mcp_client.call_tool( + "dep", + {"issue_id": blocked["id"], "depends_on_id": blocking["id"], "dep_type": "blocks"}, + ) + + # Blocked with brief=True + result = await mcp_client.call_tool("blocked", {"brief": True}) + issues = json.loads(result.content[0].text) + + # Find our blocked issue + our_blocked = [i for i in issues if i["id"] == blocked["id"]] + assert len(our_blocked) == 1 + # BriefIssue format + assert "title" in our_blocked[0] + assert "status" in our_blocked[0] + # Should NOT have BlockedIssue-specific fields + assert "blocked_by" not in our_blocked[0] + + +@pytest.mark.asyncio +async def test_show_brief_deps(mcp_client): + """Test show with brief_deps=True returns compact dependencies.""" + import json + + # Create two issues with dependency + dep_result = await mcp_client.call_tool( + "create", {"title": "Dependency issue", "brief": False} + ) + dep_issue = json.loads(dep_result.content[0].text) + + main_result = await mcp_client.call_tool( + "create", {"title": "Main issue", "brief": False} + ) + main_issue = json.loads(main_result.content[0].text) + + await mcp_client.call_tool( + "dep", + {"issue_id": main_issue["id"], "depends_on_id": dep_issue["id"], "dep_type": "blocks"}, + ) + + # Show with brief_deps=True + show_result = await mcp_client.call_tool( + "show", {"issue_id": main_issue["id"], "brief_deps": True} + ) + + data = json.loads(show_result.content[0].text) + # Full issue data + assert data["id"] == main_issue["id"] + assert data["title"] == "Main issue" + # Dependencies should be compact (BriefDep format) + assert len(data["dependencies"]) >= 1 + dep = data["dependencies"][0] + assert "id" in dep + assert "title" in dep + assert "status" in dep + # BriefDep should NOT have full LinkedIssue fields + assert "description" not in dep