# Design: Competitive Improvements v0.4.0

**Date**: 2026-03-24
**Scope**: 1 new tool, 2 new prompts, brief mode on 2 status tools
**Motivation**: Competitive analysis of 50+ CI/CD MCP servers revealed actionable gaps

---

## 1. Health Check Tool

### Problem
No single tool answers "is Zuul healthy?" Users must call `get_components()` + `list_tenants()` + `get_config_errors()` separately and manually correlate results. Every major CI/CD MCP server (GitHub, Azure DevOps, Buildkite, Jenkins) provides a health check pattern.

### Design

**File**: `src/mcp_zuul/tools/_status.py`

```python
@mcp.tool(title="Health Check", annotations=_READ_ONLY)
@handle_errors
async def health_check(ctx: Context, tenant: str = "") -> str:
    """Quick health check for Zuul API connectivity and system components.

    Returns overall status (ok, warn, critical) with component details
    and any issues detected. Checks API reachability, system components
    (schedulers, executors, mergers), and optionally tenant config errors.
    """
```

**Behavior**:
1. `asyncio.gather(return_exceptions=True)` fetches components + tenants concurrently. If tenant is provided (explicitly or via `ZUUL_DEFAULT_TENANT`), also fetches config-errors - but a failure on config-errors must not prevent the health check from succeeding (catch independently).
2. Interprets results:
   - `critical`: API unreachable, no schedulers, no executors
   - `warn`: any component paused, config errors found, no mergers
   - `ok`: all components running, no config errors
3. Returns JSON with `status`, `components` summary (counts by state), `tenants` count, `issues` list
4. For component details (which specific scheduler is paused, hostnames, versions), suggest `get_components()` as follow-up.

**Response shape**:
```json
{
  "status": "ok",
  "zuul_url": "https://zuul.example.com",
  "components": {"schedulers": 2, "executors": 5, "mergers": 3},
  "tenants": 12,
  "issues": []
}
```

Warning example:
```json
{
  "status": "warn",
  "zuul_url": "https://zuul.example.com",
  "components": {"schedulers": 2, "executors": 5, "mergers": 3, "paused": ["scheduler-01"]},
  "tenants": 12,
  "issues": ["Scheduler 'scheduler-01' is paused", "3 config errors in tenant 'openstack'"]
}
```

**Error handling**: If API is unreachable, `@handle_errors` catches `ConnectError`/`TimeoutException` and returns a JSON error. Config-errors fetch is wrapped in try/except so a missing/invalid tenant doesn't prevent the core health check from succeeding.

**Tests** (5 cases):
1. All healthy - components running, tenants available → `status: "ok"`
2. Degraded - scheduler paused → `status: "warn"`, issues list populated
3. With tenant - config errors included in response
4. No tenant, no default - health check still succeeds (components + tenants only)
5. Config-errors API fails - health check still returns components + tenants

---

## 2. New Prompts

### 2a. `tenant_health` Prompt

**File**: `src/mcp_zuul/prompts.py`

**Problem**: Assessing overall tenant health requires calling 3 tools (`get_components`, `get_config_errors`, `list_nodes`) and correlating results. This is a common first question when investigating CI issues.

**New import**: `import asyncio` needed in `prompts.py` (not currently imported).

```python
@mcp.prompt()
async def tenant_health(tenant: str = "", ctx: Context | None = None) -> str:
    """Assess overall health of a Zuul tenant - components, config errors, and node pool status."""
```

**Note**: `tenant` is optional (falls back to `ZUUL_DEFAULT_TENANT`), but all 3 API calls require a resolved tenant. If no tenant is available, `_tenant()` raises `ValueError` which surfaces as a clear error message.

**Pre-loaded data** (3 concurrent API calls via `asyncio.gather`):
1. `/api/components` - scheduler/executor/merger states
2. `/api/tenant/{t}/config-errors` - config validation errors
3. `/api/tenant/{t}/nodes` - nodepool node states (ready/in-use/building/deleting)

**Output template**:
```markdown
Assess the health of Zuul tenant "{tenant}":

## System Components
```json
{components}
```

## Config Errors ({count})
```json
{errors}  // empty list if none
```

## Node Pool
```json
{node_summary}  // {"ready": 5, "in_use": 12, "building": 3, "total": 20}
```

## Analysis
1. Check component states - are all schedulers/executors running?
2. Review config errors - these block jobs from running
3. Assess node pool - low ready count may cause queue delays
4. For deeper investigation use `get_status(tenant="...")` for live pipeline state
```

**Tests** (3 cases):
1. Healthy tenant - all components running, no errors, nodes available
2. Degraded tenant - paused scheduler, config errors, low node count
3. Empty node pool - no nodes returned, prompt still renders correctly

### 2b. `diagnose_queue_delay` Prompt

**File**: `src/mcp_zuul/prompts.py`

**Problem**: "Why are my jobs waiting?" requires correlating pipeline status (queued items), node pool state (availability), semaphores (locks), and component health (scheduler state). Users rarely know all 4 data sources to check.

```python
@mcp.prompt()
async def diagnose_queue_delay(
    tenant: str = "", pipeline: str = "", ctx: Context | None = None
) -> str:
    """Diagnose why jobs are queued or delayed - checks nodes, semaphores, and system state."""
```

**Pre-loaded data** (4 concurrent API calls via `asyncio.gather`):
1. `/api/tenant/{t}/status` - pipeline status (**summarized** to item counts per pipeline, not raw items - avoids token explosion)
2. `/api/tenant/{t}/nodes` - node availability (summarized to counts by state)
3. `/api/tenant/{t}/semaphores` - resource locks (only non-zero shown)
4. `/api/components` - scheduler/executor state (counts + paused list)

**Output template**:
```markdown
Diagnose queue delays in Zuul tenant "{tenant}"{pipeline_filter}:

## Pipeline Status
```json
{status_summary}  // item counts per pipeline: queued, running, total
```

## Node Pool
```json
{node_summary}
```

## Semaphores
```json
{semaphores}  // only non-zero semaphores shown
```

## System Components
```json
{component_summary}
```

## Analysis
1. Check node pool: if ready=0 and building>0, nodes are being provisioned (wait)
2. Check node pool: if ready=0 and building=0, pool may be exhausted (investigate nodepool)
3. Check semaphores: if max reached, jobs wait for lock release
4. Check components: paused scheduler stops all enqueueing
5. If queue is large but nodes available, check for job dependencies or window constraints
6. Use `list_nodes(detail=true)` for per-node state details
```

**Tests** (3 cases):
1. Queue delay from node exhaustion - 0 ready nodes, items queued
2. Queue delay from semaphore - semaphore at max, items waiting
3. With pipeline filter - only matching pipeline's items counted

---

## 3. Brief Mode on Status Tools

### Problem
`get_status()` and `get_change_status()` are the most token-heavy tools. They always return full detail including `chain_summary` (8 fields), all job fields (name, uuid, status, result, elapsed, remaining, estimated, dependencies, waiting_status, voting, pre_fail, report_url, stream_url). There's no way to get a compact view.

The `brief=True/False` pattern already exists in `fmt_build()` and `fmt_buildset()` but is not available on status formatters.

### Design

**File**: `src/mcp_zuul/formatters.py`

Add `brief` parameter to `fmt_status_item()`:

```python
def fmt_status_item(item: dict, brief: bool = False) -> dict:
```

When `brief=True`:
- Jobs compressed to: `{"name": "...", "status": "...", "result": "..."}` (3 fields vs 14)
- `chain_summary` omitted entirely
- Item-level fields kept: `id`, `active`, `live`, `project`, `change`, `enqueue_time`

When `brief=False` (default, current behavior):
- No change - full output as today

**File**: `src/mcp_zuul/tools/_status.py`

Add `brief` parameter to `get_status()` and `get_change_status()`:

```python
@mcp.tool(title="Pipeline Status", annotations=_READ_ONLY)
@handle_errors
async def get_status(
    ctx: Context,
    tenant: str = "",
    pipeline: str = "",
    project: str = "",       # existing
    active_only: bool = True, # existing
    brief: bool = False,      # NEW
) -> str:
    """Live pipeline status showing all queued, running, and completed items.
    ...
    Set brief=true for a compact view with just job names and status (saves tokens).
    """
```

```python
@mcp.tool(title="Change Status", annotations=_READ_ONLY)
@handle_errors
async def get_change_status(
    ctx: Context,
    change: str = "",
    tenant: str = "",
    url: str = "",
    brief: bool = False,  # NEW
) -> str:
```

**Note**: The `check_change` prompt in `prompts.py` also calls `fmt_status_item()` but will keep full detail (prompts are designed for deep context loading).

Both pass `brief` through to `fmt_status_item(item, brief=brief)`.

**Estimated token savings**: 30-50% on status queries when `brief=True`.

**Tests** (4 cases):
1. `get_status(brief=True)` - jobs have only name/status/result, no chain_summary
2. `get_status(brief=False)` - unchanged from current behavior
3. `get_change_status(brief=True)` - compact live items
4. `get_change_status(brief=True)` when change NOT in pipeline - latest buildset still uses `fmt_buildset(brief=False)` (brief only affects live status items, not buildset fallback)

---

## 4. What's NOT in Scope

| Item | Reason |
|------|--------|
| `ZUUL_MAX_OUTPUT_LENGTH` | Truncates JSON mid-string → malformed output |
| Progress notifications | [SDK bug on streamable-http](https://github.com/modelcontextprotocol/python-sdk/issues/953) |
| Cursor-based pagination | Zuul API uses offset/limit; faking cursors adds complexity for marginal value |
| Externalize classifier | Only benefits custom deployments, low demand |
| Dry run mode | 4 write tools, most users run read-only |
| `review_pipeline` prompt | Thin wrapper around `get_status(pipeline=X)` |
| `analyze_flaky_job` prompt | Thin wrapper around `find_flaky_jobs(job_name=X)` |

---

## 5. File Change Summary

| File | Change |
|------|--------|
| `src/mcp_zuul/tools/_status.py` | Add `health_check` tool; add `brief` param to `get_status`, `get_change_status` |
| `src/mcp_zuul/tools/__init__.py` | Re-export `health_check` for backward compat |
| `src/mcp_zuul/formatters.py` | Add `brief` param to `fmt_status_item` |
| `src/mcp_zuul/prompts.py` | Add `tenant_health`, `diagnose_queue_delay` prompts; add `import asyncio` |
| `tests/test_tools_status.py` | Tests for health_check (5) + brief mode (4) |
| `tests/test_prompts.py` | Tests for new prompts (6) |
| `CLAUDE.md` | Update tool count 36→37, prompt count 3→5 |

---

## 6. Release

- Version bump: 0.3.4 → 0.4.0 (new features warrant minor version)
- Commit: `[FEATURE] Add health check, prompts, and brief status mode`
- Update CLAUDE.md tool/prompt counts
- Update MEMORY.md architecture section
