Files
beads/integrations/beads-mcp/tests/test_mcp_server_integration.py
Aarya Reddy e58d60b802 feat(mcp): add output control parameters for token efficiency (#667)
Add granular control over MCP tool response sizes to minimize context
window usage while maintaining full functionality when needed.

## Output Control Parameters

### Read Operations (ready, list, show, blocked)
- `brief`: Return BriefIssue {id, title, status, priority} (~97% smaller)
- `fields`: Custom field projection with validation - invalid fields raise ValueError
- `max_description_length`: Truncate descriptions to N chars with "..."
- `brief_deps`: Full issue but dependencies as BriefDep (~48% smaller)

### Write Operations (create, update, close, reopen)
- `brief`: Return OperationResult {id, action, message} (~97% smaller)
- Default is brief=True for minimal confirmations

## Token Savings

| Operation | Before | After (brief) | Reduction |
|-----------|--------|---------------|-----------|
| ready(limit=10) | ~20KB | ~600B | 97% |
| list(limit=20) | ~40KB | ~800B | 98% |
| show() | ~2KB | ~60B | 97% |
| show() with 5 deps | ~4.5KB | ~2.3KB | 48% |
| blocked() | ~10KB | ~240B | 98% |
| create() response | ~2KB | ~50B | 97% |

## New Models

- BriefIssue: Ultra-minimal issue (4 fields, ~60B)
- BriefDep: Compact dependency (5 fields, ~70B)
- OperationResult: Write confirmation (3 fields, ~50B)
- OperationAction: Literal["created", "updated", "closed", "reopened"]

## Best Practices

- Unified `brief` parameter naming across all operations
- `brief=True` always means "give me less data"
- Field validation with clear error messages listing valid fields
- All parameters optional with backwards-compatible defaults
- Progressive disclosure: scan cheap, detail on demand

---

## Filtering Parameters (aligns MCP with CLI)

Add missing filter parameters to `list` and `ready` tools that were
documented in MCP instructions but not implemented.

### ReadyWorkParams - New Fields
- `labels: list[str]` - AND filter: must have ALL specified labels
- `labels_any: list[str]` - OR filter: must have at least one
- `unassigned: bool` - Filter to only unassigned issues
- `sort_policy: str` - Sort by: hybrid (default), priority, oldest

### ListIssuesParams - New Fields
- `labels: list[str]` - AND filter: must have ALL specified labels
- `labels_any: list[str]` - OR filter: must have at least one
- `query: str` - Search in title (case-insensitive substring)
- `unassigned: bool` - Filter to only unassigned issues

### CLI Flag Mappings

| MCP Parameter | ready CLI Flag | list CLI Flag |
|---------------|----------------|---------------|
| labels | --label (repeated) | --label (repeated) |
| labels_any | --label-any (repeated) | --label-any (repeated) |
| query | N/A | --title |
| unassigned | --unassigned | --no-assignee |
| sort_policy | --sort | N/A |

---

## Documentation & Testing

### get_tool_info() Updates
- Added `brief`, `brief_deps`, `fields`, `max_description_length` to show tool
- Added `brief` parameter docs for create, update, close, reopen
- Added `brief`, `brief_deps` parameter docs for blocked

### Test Coverage (16 new tests)
- `test_create_brief_default` / `test_create_brief_false`
- `test_update_brief_default` / `test_update_brief_false`
- `test_close_brief_default` / `test_close_brief_false`
- `test_reopen_brief_default`
- `test_show_brief` / `test_show_fields_projection` / `test_show_fields_invalid`
- `test_show_max_description_length` / `test_show_brief_deps`
- `test_list_brief` / `test_ready_brief` / `test_blocked_brief`

---

## Backward Compatibility

All new parameters are optional with sensible defaults:
- `brief`: False for reads, True for writes
- `fields`, `max_description_length`: None (no filtering/truncation)
- `labels`, `labels_any`, `query`: None (no filtering)
- `unassigned`: False (include all)
- `sort_policy`: None (use default hybrid sort)

Existing MCP tool calls continue to work unchanged.
2025-12-20 21:33:38 -08:00

1082 lines
34 KiB
Python

"""Real integration tests for MCP server using fastmcp.Client."""
import os
import shutil
import tempfile
import pytest
from fastmcp.client import Client
from beads_mcp.server import mcp
@pytest.fixture(scope="session")
def bd_executable():
"""Verify bd is available in PATH."""
bd_path = shutil.which("bd")
if not bd_path:
pytest.fail(
"bd executable not found in PATH. "
"Please install bd or add it to your PATH before running integration tests."
)
return bd_path
@pytest.fixture
async def temp_db(bd_executable):
"""Create a temporary database file and initialize it - fully hermetic."""
# Create temp directory that will serve as the workspace root
temp_dir = tempfile.mkdtemp(prefix="beads_mcp_test_", dir="/tmp")
# Initialize database in this directory (creates .beads/ subdirectory)
import asyncio
env = os.environ.copy()
# Clear any existing BEADS_DIR/BEADS_DB to ensure clean state
env.pop("BEADS_DB", None)
env.pop("BEADS_DIR", None)
# Run bd init in the temp directory - it will create .beads/ subdirectory
process = await asyncio.create_subprocess_exec(
bd_executable,
"init",
"--prefix",
"test",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
cwd=temp_dir, # Run in temp dir - bd init creates .beads/ here
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
pytest.fail(f"Failed to initialize test database: {stderr.decode()}")
# Return the .beads directory path (not the db file)
beads_dir = os.path.join(temp_dir, ".beads")
yield beads_dir
# Cleanup
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.fixture
async def mcp_client(bd_executable, temp_db, monkeypatch):
"""Create MCP client with temporary database."""
from beads_mcp import tools
# Reset connection pool before test
tools._connection_pool.clear()
# Reset context environment variables
os.environ.pop("BEADS_CONTEXT_SET", None)
os.environ.pop("BEADS_WORKING_DIR", None)
os.environ.pop("BEADS_DB", None)
os.environ.pop("BEADS_DIR", None)
# temp_db is now the .beads directory path
# The workspace root is the parent directory
workspace_root = os.path.dirname(temp_db)
# Disable daemon mode for tests (prevents daemon accumulation and timeouts)
os.environ["BEADS_NO_DAEMON"] = "1"
# Create test client
async with Client(mcp) as client:
# Automatically set context for the tests
await client.call_tool("context", {"workspace_root": workspace_root})
yield client
# Reset connection pool and context after test
tools._connection_pool.clear()
os.environ.pop("BEADS_CONTEXT_SET", None)
os.environ.pop("BEADS_WORKING_DIR", None)
os.environ.pop("BEADS_DB", None)
os.environ.pop("BEADS_DIR", None)
os.environ.pop("BEADS_NO_DAEMON", None)
@pytest.mark.asyncio
async def test_quickstart_resource(mcp_client):
"""Test beads://quickstart resource."""
result = await mcp_client.read_resource("beads://quickstart")
assert result is not None
content = result[0].text
assert len(content) > 0
assert "beads" in content.lower() or "bd" in content.lower()
@pytest.mark.asyncio
async def test_create_issue_tool(mcp_client):
"""Test create_issue tool."""
result = await mcp_client.call_tool(
"create",
{
"title": "Test MCP issue",
"description": "Created via MCP server",
"priority": 1,
"issue_type": "bug",
"brief": False, # Get full Issue object
},
)
# Parse the JSON response from CallToolResult
import json
issue_data = json.loads(result.content[0].text)
assert issue_data["title"] == "Test MCP issue"
assert issue_data["description"] == "Created via MCP server"
assert issue_data["priority"] == 1
assert issue_data["issue_type"] == "bug"
assert issue_data["status"] == "open"
assert "id" in issue_data
return issue_data["id"]
@pytest.mark.asyncio
async def test_show_issue_tool(mcp_client):
"""Test show_issue tool."""
# First create an issue
create_result = await mcp_client.call_tool(
"create",
{"title": "Issue to show", "priority": 2, "issue_type": "task", "brief": False},
)
import json
created = json.loads(create_result.content[0].text)
issue_id = created["id"]
# Show the issue
show_result = await mcp_client.call_tool("show", {"issue_id": issue_id})
issue = json.loads(show_result.content[0].text)
assert issue["id"] == issue_id
assert issue["title"] == "Issue to show"
@pytest.mark.asyncio
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", "brief": False}
)
await mcp_client.call_tool(
"create", {"title": "Issue 2", "priority": 1, "issue_type": "feature", "brief": False}
)
# List all issues
result = await mcp_client.call_tool("list", {})
import json
issues = json.loads(result.content[0].text)
assert len(issues) >= 2
# List with status filter
result = await mcp_client.call_tool("list", {"status": "open"})
issues = json.loads(result.content[0].text)
assert all(issue["status"] == "open" for issue in issues)
@pytest.mark.asyncio
async def test_update_issue_tool(mcp_client):
"""Test update_issue tool."""
import json
# Create issue
create_result = await mcp_client.call_tool(
"create", {"title": "Issue to update", "priority": 2, "issue_type": "task", "brief": False}
)
created = json.loads(create_result.content[0].text)
issue_id = created["id"]
# Update issue
update_result = await mcp_client.call_tool(
"update",
{
"issue_id": issue_id,
"status": "in_progress",
"priority": 0,
"title": "Updated title",
"brief": False, # Get full Issue object
},
)
updated = json.loads(update_result.content[0].text)
assert updated["id"] == issue_id
assert updated["status"] == "in_progress"
assert updated["priority"] == 0
assert updated["title"] == "Updated title"
@pytest.mark.asyncio
async def test_close_issue_tool(mcp_client):
"""Test close_issue tool."""
import json
# Create issue
create_result = await mcp_client.call_tool(
"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 with brief=False to get full Issue object
close_result = await mcp_client.call_tool(
"close", {"issue_id": issue_id, "reason": "Test complete", "brief": False}
)
closed_issues = json.loads(close_result.content[0].text)
assert len(closed_issues) >= 1
closed = closed_issues[0]
assert closed["id"] == issue_id
assert closed["status"] == "closed"
assert closed["closed_at"] is not None
@pytest.mark.asyncio
async def test_reopen_issue_tool(mcp_client):
"""Test reopen_issue tool."""
import json
# Create and close issue
create_result = await mcp_client.call_tool(
"create", {"title": "Issue to reopen", "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 issue with brief=False to get full Issue object
reopen_result = await mcp_client.call_tool(
"reopen", {"issue_ids": [issue_id], "brief": False}
)
reopened_issues = json.loads(reopen_result.content[0].text)
assert len(reopened_issues) >= 1
reopened = reopened_issues[0]
assert reopened["id"] == issue_id
assert reopened["status"] == "open"
assert reopened["closed_at"] is None
@pytest.mark.asyncio
async def test_reopen_multiple_issues_tool(mcp_client):
"""Test reopening multiple issues via MCP tool."""
import json
# Create and close two issues
issue1_result = await mcp_client.call_tool(
"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", "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 with brief=False
reopen_result = await mcp_client.call_tool(
"reopen", {"issue_ids": [issue1["id"], issue2["id"]], "brief": False}
)
reopened_issues = json.loads(reopen_result.content[0].text)
assert len(reopened_issues) == 2
reopened_ids = {issue["id"] for issue in reopened_issues}
assert issue1["id"] in reopened_ids
assert issue2["id"] in reopened_ids
assert all(issue["status"] == "open" for issue in reopened_issues)
assert all(issue["closed_at"] is None for issue in reopened_issues)
@pytest.mark.asyncio
async def test_reopen_with_reason_tool(mcp_client):
"""Test reopening issue with reason parameter via MCP tool."""
import json
# Create and close issue
create_result = await mcp_client.call_tool(
"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 and brief=False
reopen_result = await mcp_client.call_tool(
"reopen",
{"issue_ids": [issue_id], "reason": "Found regression", "brief": False}
)
reopened_issues = json.loads(reopen_result.content[0].text)
assert len(reopened_issues) >= 1
reopened = reopened_issues[0]
assert reopened["id"] == issue_id
assert reopened["status"] == "open"
assert reopened["closed_at"] is None
@pytest.mark.asyncio
async def test_ready_work_tool(mcp_client):
"""Test ready_work tool."""
import json
# Create a ready issue (no dependencies)
ready_result = await mcp_client.call_tool(
"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", "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", "brief": False}
)
blocked_issue = json.loads(blocked_result.content[0].text)
# Add blocking dependency
await mcp_client.call_tool(
"dep",
{
"issue_id": blocked_issue["id"],
"depends_on_id": blocking_issue["id"],
"dep_type": "blocks",
},
)
# Get ready work
result = await mcp_client.call_tool("ready", {"limit": 100})
ready_issues = json.loads(result.content[0].text)
ready_ids = [issue["id"] for issue in ready_issues]
assert ready_issue["id"] in ready_ids
assert blocked_issue["id"] not in ready_ids
@pytest.mark.asyncio
async def test_add_dependency_tool(mcp_client):
"""Test add_dependency tool."""
import json
# Create two issues
issue1_result = await mcp_client.call_tool(
"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", "brief": False}
)
issue2 = json.loads(issue2_result.content[0].text)
# Add dependency
result = await mcp_client.call_tool(
"dep",
{"issue_id": issue1["id"], "depends_on_id": issue2["id"], "dep_type": "blocks"},
)
message = result.content[0].text
assert "Added dependency" in message
assert issue1["id"] in message
assert issue2["id"] in message
@pytest.mark.asyncio
async def test_create_with_all_fields(mcp_client):
"""Test create_issue with all optional fields."""
import json
result = await mcp_client.call_tool(
"create",
{
"title": "Full issue",
"description": "Complete description",
"priority": 0,
"issue_type": "feature",
"assignee": "testuser",
"labels": ["urgent", "backend"],
"brief": False, # Get full Issue object
},
)
issue = json.loads(result.content[0].text)
assert issue["title"] == "Full issue"
assert issue["description"] == "Complete description"
assert issue["priority"] == 0
assert issue["issue_type"] == "feature"
assert issue["assignee"] == "testuser"
@pytest.mark.asyncio
async def test_list_with_filters(mcp_client):
"""Test list_issues with various filters."""
import json
# Create issues with different attributes
await mcp_client.call_tool(
"create",
{
"title": "Bug P0",
"priority": 0,
"issue_type": "bug",
"assignee": "alice",
"brief": False,
},
)
await mcp_client.call_tool(
"create",
{
"title": "Feature P1",
"priority": 1,
"issue_type": "feature",
"assignee": "bob",
"brief": False,
},
)
# Filter by priority
result = await mcp_client.call_tool("list", {"priority": 0})
issues = json.loads(result.content[0].text)
assert all(issue["priority"] == 0 for issue in issues)
# Filter by type
result = await mcp_client.call_tool("list", {"issue_type": "bug"})
issues = json.loads(result.content[0].text)
assert all(issue["issue_type"] == "bug" for issue in issues)
# Filter by assignee
result = await mcp_client.call_tool("list", {"assignee": "alice"})
issues = json.loads(result.content[0].text)
assert all(issue["assignee"] == "alice" for issue in issues)
@pytest.mark.asyncio
async def test_ready_work_with_priority_filter(mcp_client):
"""Test ready_work with priority filter."""
import json
# Create issues with different priorities
await mcp_client.call_tool(
"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", "brief": False}
)
# Get ready work with priority filter
result = await mcp_client.call_tool("ready", {"priority": 0, "limit": 100})
issues = json.loads(result.content[0].text)
assert all(issue["priority"] == 0 for issue in issues)
@pytest.mark.asyncio
async def test_update_partial_fields(mcp_client):
"""Test update_issue with partial field updates."""
import json
# Create issue
create_result = await mcp_client.call_tool(
"create",
{
"title": "Original title",
"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 with brief=False to get full Issue
update_result = await mcp_client.call_tool(
"update", {"issue_id": issue_id, "status": "in_progress", "brief": False}
)
updated = json.loads(update_result.content[0].text)
assert updated["status"] == "in_progress"
assert updated["title"] == "Original title" # Unchanged
assert updated["priority"] == 2 # Unchanged
@pytest.mark.asyncio
async def test_dependency_types(mcp_client):
"""Test different dependency types."""
import json
# Create issues
issue1_result = await mcp_client.call_tool(
"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", "brief": False}
)
issue2 = json.loads(issue2_result.content[0].text)
# Test related dependency
result = await mcp_client.call_tool(
"dep",
{"issue_id": issue1["id"], "depends_on_id": issue2["id"], "dep_type": "related"},
)
message = result.content[0].text
assert "Added dependency" in message
assert "related" in message
@pytest.mark.asyncio
async def test_stats_tool(mcp_client):
"""Test stats tool."""
import json
# Create some issues to get stats
await mcp_client.call_tool(
"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", "brief": False}
)
# Get stats
result = await mcp_client.call_tool("stats", {})
stats = json.loads(result.content[0].text)
assert "total_issues" in stats
assert "open_issues" in stats
assert stats["total_issues"] >= 2
@pytest.mark.asyncio
async def test_blocked_tool(mcp_client):
"""Test blocked tool."""
import json
# Create two issues
blocking_result = await mcp_client.call_tool(
"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", "brief": False}
)
blocked_issue = json.loads(blocked_result.content[0].text)
# Add blocking dependency
await mcp_client.call_tool(
"dep",
{
"issue_id": blocked_issue["id"],
"depends_on_id": blocking_issue["id"],
"dep_type": "blocks",
},
)
# Get blocked issues
result = await mcp_client.call_tool("blocked", {})
blocked_issues = json.loads(result.content[0].text)
# Should have at least the one we created
blocked_ids = [issue["id"] for issue in blocked_issues]
assert blocked_issue["id"] in blocked_ids
# Find our blocked issue and verify it has blocking info
our_blocked = next(issue for issue in blocked_issues if issue["id"] == blocked_issue["id"])
assert our_blocked["blocked_by_count"] >= 1
assert blocking_issue["id"] in our_blocked["blocked_by"]
@pytest.mark.asyncio
async def test_context_init_action(bd_executable):
"""Test context tool with init action.
Note: This test validates that context(action='init') can be called successfully via MCP.
Uses a fresh temp directory without an existing database.
"""
import os
import tempfile
import shutil
from beads_mcp import tools
from beads_mcp.server import mcp
# Reset connection pool and context
tools._connection_pool.clear()
os.environ.pop("BEADS_CONTEXT_SET", None)
os.environ.pop("BEADS_WORKING_DIR", None)
os.environ.pop("BEADS_DB", None)
os.environ.pop("BEADS_DIR", None)
os.environ["BEADS_NO_DAEMON"] = "1"
# Create a fresh temp directory without any beads database
temp_dir = tempfile.mkdtemp(prefix="beads_init_test_")
try:
async with Client(mcp) as client:
# First set context to the fresh directory
await client.call_tool("context", {"workspace_root": temp_dir})
# Call context tool with init action
result = await client.call_tool("context", {"action": "init", "prefix": "test-init"})
output = result.content[0].text
# Verify output contains success message
assert "bd initialized successfully!" in output
assert "test-init" in output
finally:
tools._connection_pool.clear()
shutil.rmtree(temp_dir, ignore_errors=True)
os.environ.pop("BEADS_CONTEXT_SET", None)
os.environ.pop("BEADS_WORKING_DIR", None)
@pytest.mark.asyncio
async def test_context_show_action(mcp_client, temp_db):
"""Test context tool with show action.
Verifies that context(action='show') returns workspace information.
"""
# Call context tool with show action (default when no args)
result = await mcp_client.call_tool("context", {"action": "show"})
output = result.content[0].text
# Verify output contains workspace info
assert "Workspace root:" in output
assert "Database:" in output
@pytest.mark.asyncio
async def test_context_default_show(mcp_client, temp_db):
"""Test context tool defaults to show when no args provided."""
# Call context tool with no args - should default to show
result = await mcp_client.call_tool("context", {})
output = result.content[0].text
# 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