allure-testops-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ # Allure TestOps instance URL (no trailing slash)
2
+ ALLURE_URL=https://allure.example.com
3
+
4
+ # API token from Allure TestOps (Profile → API tokens)
5
+ ALLURE_TOKEN=your-api-token-here
6
+
7
+ # Verify SSL certificates. Set to "false" for self-signed corp certs.
8
+ # Default: "true"
9
+ ALLURE_SSL_VERIFY=true
@@ -0,0 +1,67 @@
1
+ name: Publish to PyPI
2
+
3
+ # Fires when a tag like v0.1.0, v1.0.0rc1 is pushed.
4
+ # Builds the sdist+wheel once, then publishes to PyPI using a
5
+ # Trusted Publisher (OIDC — no API token stored anywhere).
6
+ on:
7
+ push:
8
+ tags:
9
+ - "v*"
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ build:
16
+ name: Build sdist + wheel
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - name: Check out code
20
+ uses: actions/checkout@v4
21
+
22
+ - name: Set up Python
23
+ uses: actions/setup-python@v5
24
+ with:
25
+ python-version: "3.12"
26
+
27
+ - name: Install build tooling
28
+ run: python -m pip install --upgrade build
29
+
30
+ - name: Verify tag matches package version
31
+ run: |
32
+ TAG="${GITHUB_REF_NAME#v}"
33
+ PKG=$(python -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
34
+ echo "tag=$TAG pyproject.version=$PKG"
35
+ if [ "$TAG" != "$PKG" ]; then
36
+ echo "::error::Tag v$TAG does not match pyproject version $PKG"
37
+ exit 1
38
+ fi
39
+
40
+ - name: Build
41
+ run: python -m build
42
+
43
+ - name: Upload dist artifact
44
+ uses: actions/upload-artifact@v4
45
+ with:
46
+ name: dist
47
+ path: dist/
48
+ if-no-files-found: error
49
+
50
+ publish:
51
+ name: Publish to PyPI
52
+ needs: build
53
+ runs-on: ubuntu-latest
54
+ environment:
55
+ name: pypi
56
+ url: https://pypi.org/project/allure-testops-mcp/
57
+ permissions:
58
+ id-token: write
59
+ steps:
60
+ - name: Download dist artifact
61
+ uses: actions/download-artifact@v4
62
+ with:
63
+ name: dist
64
+ path: dist
65
+
66
+ - name: Publish to PyPI
67
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ venv/
6
+ dist/
7
+ build/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .mypy_cache/
11
+ .env
12
+ .env.*
13
+ !.env.example
14
+ .DS_Store
15
+ *.log
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to `allure-testops-mcp` are documented here. Format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions use [SemVer](https://semver.org/).
5
+
6
+ ## [0.1.0] — 2026-04-18
7
+
8
+ ### Added
9
+ - Initial release with 6 read-only tools covering Allure TestOps REST API:
10
+ - `allure_list_projects` — list all projects
11
+ - `allure_list_launches` — recent launches with pass/fail stats
12
+ - `allure_get_test_results` — test results per launch (filter by status)
13
+ - `allure_list_test_cases` — TC listing with automation/layer filters
14
+ - `allure_get_project_statistics` — TC count, automation rate, last launch summary
15
+ - `allure_search_failed_tests` — FAILED/BROKEN tests in last or specified launch
16
+ - FastMCP + Pydantic input validation + TypedDict output schemas.
17
+ - Structured error mapping for 401/403/404/429/5xx with actionable next steps.
18
+ - `ALLURE_SSL_VERIFY` toggle for self-signed corp certificates.
19
+ - MIT license.
20
+ - Published on PyPI and in the MCP Registry as `io.github.mshegolev/allure-testops-mcp`.
@@ -0,0 +1,5 @@
1
+ FROM python:3.12-slim
2
+
3
+ RUN pip install --no-cache-dir allure-testops-mcp
4
+
5
+ ENTRYPOINT ["allure-testops-mcp"]
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mikhail Shchegolev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: allure-testops-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for Allure TestOps — projects, launches, test cases, test results
5
+ Project-URL: Homepage, https://github.com/mshegolev/allure-testops-mcp
6
+ Project-URL: Issues, https://github.com/mshegolev/allure-testops-mcp/issues
7
+ Author: Mikhail Shchegolev
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: allure,allure-testops,anthropic,claude,mcp,qa,testing
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Classifier: Topic :: Software Development :: Testing
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: mcp>=1.2
23
+ Requires-Dist: pydantic>=2.0
24
+ Requires-Dist: requests>=2.31
25
+ Requires-Dist: typing-extensions>=4.7
26
+ Requires-Dist: urllib3>=2.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7; extra == 'dev'
29
+ Requires-Dist: responses>=0.25; extra == 'dev'
30
+ Requires-Dist: ruff>=0.5; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # allure-testops-mcp
34
+
35
+ <!-- mcp-name: io.github.mshegolev/allure-testops-mcp -->
36
+
37
+ [![PyPI](https://img.shields.io/pypi/v/allure-testops-mcp.svg?logo=pypi&logoColor=white)](https://pypi.org/project/allure-testops-mcp/)
38
+ [![Python](https://img.shields.io/pypi/pyversions/allure-testops-mcp.svg?logo=python&logoColor=white)](https://pypi.org/project/allure-testops-mcp/)
39
+ [![License: MIT](https://img.shields.io/pypi/l/allure-testops-mcp.svg)](LICENSE)
40
+
41
+ MCP server for [Allure TestOps](https://qameta.io/). Lets an LLM agent (Claude Code, Cursor, OpenCode, etc.) query projects, launches, test cases and test results through the Allure REST API.
42
+
43
+ Python, [FastMCP](https://github.com/modelcontextprotocol/python-sdk), stdio transport.
44
+
45
+ Works with any Allure TestOps instance — SaaS `qameta.io` or self-hosted / on-prem. Designed with corporate networks in mind: configurable proxy bypass, optional SSL-verify toggle, API-token auth.
46
+
47
+ ## Design highlights
48
+
49
+ - **Tool annotations** — every tool is marked `readOnlyHint: True` / `openWorldHint: True`. All 6 tools are read-only; MCP clients won't ask for confirmation.
50
+ - **Structured output on every tool** — each tool declares a `TypedDict` return type, so FastMCP auto-generates an `outputSchema` and every result carries both `structuredContent` (typed payload) and a pre-rendered markdown text block.
51
+ - **Structured errors** — auth, 404, 403, 429, 5xx, missing-env errors converted to actionable messages (e.g. _"Authentication failed — verify ALLURE_TOKEN has API scope"_).
52
+ - **Pydantic input validation** — every argument has typed constraints (ranges, lengths, literals) auto-exposed as JSON Schema.
53
+ - **Pagination** — list tools return a `pagination` block with `page`, `total`, `has_more`, `next_page`.
54
+
55
+ ## Features
56
+
57
+ 6 tools covering everyday Allure TestOps workflows:
58
+
59
+ **Discovery**
60
+ - `allure_list_projects` — all projects with ID, name, abbreviation
61
+ - `allure_get_project_statistics` — TC count, automation rate, last launch summary
62
+
63
+ **Launches & results**
64
+ - `allure_list_launches` — recent launches with pass/fail stats
65
+ - `allure_get_test_results` — test results in a launch (filter by status)
66
+ - `allure_search_failed_tests` — FAILED/BROKEN tests in last or specified launch
67
+
68
+ **Test cases**
69
+ - `allure_list_test_cases` — test cases with manual/auto/layer filters
70
+
71
+ ## Installation
72
+
73
+ Requires Python 3.10+.
74
+
75
+ ```bash
76
+ # via uvx (recommended)
77
+ uvx --from allure-testops-mcp allure-testops-mcp
78
+
79
+ # or via pipx
80
+ pipx install allure-testops-mcp
81
+ ```
82
+
83
+ ## Configuration
84
+
85
+ Short version — `claude mcp add`:
86
+
87
+ ```bash
88
+ claude mcp add allure -s project \
89
+ --env ALLURE_URL=https://allure.example.com \
90
+ --env ALLURE_TOKEN=your-api-token \
91
+ --env ALLURE_SSL_VERIFY=true \
92
+ -- uvx --from allure-testops-mcp allure-testops-mcp
93
+ ```
94
+
95
+ Or in `~/.claude.json` / project `.mcp.json`:
96
+
97
+ ```json
98
+ {
99
+ "mcpServers": {
100
+ "allure": {
101
+ "type": "stdio",
102
+ "command": "uvx",
103
+ "args": ["--from", "allure-testops-mcp", "allure-testops-mcp"],
104
+ "env": {
105
+ "ALLURE_URL": "https://allure.example.com",
106
+ "ALLURE_TOKEN": "${ALLURE_TOKEN}",
107
+ "ALLURE_SSL_VERIFY": "true"
108
+ }
109
+ }
110
+ }
111
+ }
112
+ ```
113
+
114
+ Check:
115
+
116
+ ```bash
117
+ claude mcp list
118
+ # allure: uvx --from allure-testops-mcp allure-testops-mcp - ✓ Connected
119
+ ```
120
+
121
+ ## Environment variables
122
+
123
+ | Variable | Required | Description |
124
+ |---|---|---|
125
+ | `ALLURE_URL` | yes | Allure TestOps URL (e.g. `https://allure.example.com`) |
126
+ | `ALLURE_TOKEN` | yes | API token from Allure TestOps (Profile → API tokens) |
127
+ | `ALLURE_SSL_VERIFY` | no | `true`/`false`. Set to `false` for self-signed corp certs. Default: `true`. |
128
+
129
+ ## Example usage
130
+
131
+ In Claude Code:
132
+
133
+ - "List all Allure projects"
134
+ - "Show last 10 launches for project 63"
135
+ - "Failed tests in the last launch for project 175"
136
+ - "Automation rate for project 842"
137
+ - "Test results in launch 12345 with status FAILED"
138
+
139
+ ## Development
140
+
141
+ ```bash
142
+ git clone https://github.com/mshegolev/allure-testops-mcp.git
143
+ cd allure-testops-mcp
144
+ pip install -e '.[dev]'
145
+ pytest
146
+ ```
147
+
148
+ Run the server directly (stdio transport, waits on stdin for MCP messages):
149
+
150
+ ```bash
151
+ ALLURE_URL=... ALLURE_TOKEN=... allure-testops-mcp
152
+ ```
153
+
154
+ ## License
155
+
156
+ MIT © Mikhail Shchegolev
@@ -0,0 +1,124 @@
1
+ # allure-testops-mcp
2
+
3
+ <!-- mcp-name: io.github.mshegolev/allure-testops-mcp -->
4
+
5
+ [![PyPI](https://img.shields.io/pypi/v/allure-testops-mcp.svg?logo=pypi&logoColor=white)](https://pypi.org/project/allure-testops-mcp/)
6
+ [![Python](https://img.shields.io/pypi/pyversions/allure-testops-mcp.svg?logo=python&logoColor=white)](https://pypi.org/project/allure-testops-mcp/)
7
+ [![License: MIT](https://img.shields.io/pypi/l/allure-testops-mcp.svg)](LICENSE)
8
+
9
+ MCP server for [Allure TestOps](https://qameta.io/). Lets an LLM agent (Claude Code, Cursor, OpenCode, etc.) query projects, launches, test cases and test results through the Allure REST API.
10
+
11
+ Python, [FastMCP](https://github.com/modelcontextprotocol/python-sdk), stdio transport.
12
+
13
+ Works with any Allure TestOps instance — SaaS `qameta.io` or self-hosted / on-prem. Designed with corporate networks in mind: configurable proxy bypass, optional SSL-verify toggle, API-token auth.
14
+
15
+ ## Design highlights
16
+
17
+ - **Tool annotations** — every tool is marked `readOnlyHint: True` / `openWorldHint: True`. All 6 tools are read-only; MCP clients won't ask for confirmation.
18
+ - **Structured output on every tool** — each tool declares a `TypedDict` return type, so FastMCP auto-generates an `outputSchema` and every result carries both `structuredContent` (typed payload) and a pre-rendered markdown text block.
19
+ - **Structured errors** — auth, 404, 403, 429, 5xx, missing-env errors converted to actionable messages (e.g. _"Authentication failed — verify ALLURE_TOKEN has API scope"_).
20
+ - **Pydantic input validation** — every argument has typed constraints (ranges, lengths, literals) auto-exposed as JSON Schema.
21
+ - **Pagination** — list tools return a `pagination` block with `page`, `total`, `has_more`, `next_page`.
22
+
23
+ ## Features
24
+
25
+ 6 tools covering everyday Allure TestOps workflows:
26
+
27
+ **Discovery**
28
+ - `allure_list_projects` — all projects with ID, name, abbreviation
29
+ - `allure_get_project_statistics` — TC count, automation rate, last launch summary
30
+
31
+ **Launches & results**
32
+ - `allure_list_launches` — recent launches with pass/fail stats
33
+ - `allure_get_test_results` — test results in a launch (filter by status)
34
+ - `allure_search_failed_tests` — FAILED/BROKEN tests in last or specified launch
35
+
36
+ **Test cases**
37
+ - `allure_list_test_cases` — test cases with manual/auto/layer filters
38
+
39
+ ## Installation
40
+
41
+ Requires Python 3.10+.
42
+
43
+ ```bash
44
+ # via uvx (recommended)
45
+ uvx --from allure-testops-mcp allure-testops-mcp
46
+
47
+ # or via pipx
48
+ pipx install allure-testops-mcp
49
+ ```
50
+
51
+ ## Configuration
52
+
53
+ Short version — `claude mcp add`:
54
+
55
+ ```bash
56
+ claude mcp add allure -s project \
57
+ --env ALLURE_URL=https://allure.example.com \
58
+ --env ALLURE_TOKEN=your-api-token \
59
+ --env ALLURE_SSL_VERIFY=true \
60
+ -- uvx --from allure-testops-mcp allure-testops-mcp
61
+ ```
62
+
63
+ Or in `~/.claude.json` / project `.mcp.json`:
64
+
65
+ ```json
66
+ {
67
+ "mcpServers": {
68
+ "allure": {
69
+ "type": "stdio",
70
+ "command": "uvx",
71
+ "args": ["--from", "allure-testops-mcp", "allure-testops-mcp"],
72
+ "env": {
73
+ "ALLURE_URL": "https://allure.example.com",
74
+ "ALLURE_TOKEN": "${ALLURE_TOKEN}",
75
+ "ALLURE_SSL_VERIFY": "true"
76
+ }
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ Check:
83
+
84
+ ```bash
85
+ claude mcp list
86
+ # allure: uvx --from allure-testops-mcp allure-testops-mcp - ✓ Connected
87
+ ```
88
+
89
+ ## Environment variables
90
+
91
+ | Variable | Required | Description |
92
+ |---|---|---|
93
+ | `ALLURE_URL` | yes | Allure TestOps URL (e.g. `https://allure.example.com`) |
94
+ | `ALLURE_TOKEN` | yes | API token from Allure TestOps (Profile → API tokens) |
95
+ | `ALLURE_SSL_VERIFY` | no | `true`/`false`. Set to `false` for self-signed corp certs. Default: `true`. |
96
+
97
+ ## Example usage
98
+
99
+ In Claude Code:
100
+
101
+ - "List all Allure projects"
102
+ - "Show last 10 launches for project 63"
103
+ - "Failed tests in the last launch for project 175"
104
+ - "Automation rate for project 842"
105
+ - "Test results in launch 12345 with status FAILED"
106
+
107
+ ## Development
108
+
109
+ ```bash
110
+ git clone https://github.com/mshegolev/allure-testops-mcp.git
111
+ cd allure-testops-mcp
112
+ pip install -e '.[dev]'
113
+ pytest
114
+ ```
115
+
116
+ Run the server directly (stdio transport, waits on stdin for MCP messages):
117
+
118
+ ```bash
119
+ ALLURE_URL=... ALLURE_TOKEN=... allure-testops-mcp
120
+ ```
121
+
122
+ ## License
123
+
124
+ MIT © Mikhail Shchegolev
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "allure-testops-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server for Allure TestOps — projects, launches, test cases, test results"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Mikhail Shchegolev" },
14
+ ]
15
+ keywords = ["mcp", "allure", "allure-testops", "testing", "qa", "claude", "anthropic"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Topic :: Software Development :: Testing",
26
+ "Topic :: Software Development :: Quality Assurance",
27
+ ]
28
+ dependencies = [
29
+ "mcp>=1.2",
30
+ "requests>=2.31",
31
+ "urllib3>=2.0",
32
+ "pydantic>=2.0",
33
+ "typing-extensions>=4.7",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ dev = [
38
+ "pytest>=7",
39
+ "ruff>=0.5",
40
+ "responses>=0.25",
41
+ ]
42
+
43
+ [project.scripts]
44
+ allure-testops-mcp = "allure_testops_mcp.server:main"
45
+
46
+ [project.urls]
47
+ Homepage = "https://github.com/mshegolev/allure-testops-mcp"
48
+ Issues = "https://github.com/mshegolev/allure-testops-mcp/issues"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/allure_testops_mcp"]
52
+
53
+ [tool.ruff]
54
+ line-length = 120
55
+ target-version = "py310"
56
+
57
+ [tool.ruff.lint]
58
+ select = ["E", "F", "W", "I", "B", "UP"]
@@ -0,0 +1,42 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.mshegolev/allure-testops-mcp",
4
+ "description": "Allure TestOps MCP — projects, launches, test cases, test results via REST API.",
5
+ "repository": {
6
+ "url": "https://github.com/mshegolev/allure-testops-mcp",
7
+ "source": "github"
8
+ },
9
+ "version": "0.1.0",
10
+ "packages": [
11
+ {
12
+ "registryType": "pypi",
13
+ "identifier": "allure-testops-mcp",
14
+ "version": "0.1.0",
15
+ "transport": {
16
+ "type": "stdio"
17
+ },
18
+ "environmentVariables": [
19
+ {
20
+ "name": "ALLURE_URL",
21
+ "description": "Allure TestOps URL (e.g. https://allure.example.com)",
22
+ "isRequired": true,
23
+ "format": "string"
24
+ },
25
+ {
26
+ "name": "ALLURE_TOKEN",
27
+ "description": "API token from Allure TestOps (Profile -> API tokens)",
28
+ "isRequired": true,
29
+ "isSecret": true,
30
+ "format": "string"
31
+ },
32
+ {
33
+ "name": "ALLURE_SSL_VERIFY",
34
+ "description": "Verify SSL certificates (true/false). Set to 'false' for self-signed corp certs.",
35
+ "isRequired": false,
36
+ "format": "string",
37
+ "default": "true"
38
+ }
39
+ ]
40
+ }
41
+ ]
42
+ }
@@ -0,0 +1,3 @@
1
+ """MCP server for Allure TestOps."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,59 @@
1
+ """Shared FastMCP instance and client cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import AsyncIterator
7
+ from contextlib import asynccontextmanager
8
+ from typing import Any
9
+
10
+ from mcp.server.fastmcp import FastMCP
11
+
12
+ from allure_testops_mcp.client import AllureClient
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ _client: AllureClient | None = None
17
+
18
+
19
+ @asynccontextmanager
20
+ async def app_lifespan(_app: FastMCP) -> AsyncIterator[dict[str, Any]]:
21
+ """Server lifespan: close HTTP session on shutdown."""
22
+ logger.debug("allure_testops_mcp: startup")
23
+ try:
24
+ yield {}
25
+ finally:
26
+ global _client
27
+ if _client is not None:
28
+ try:
29
+ _client.close()
30
+ except Exception:
31
+ pass
32
+ _client = None
33
+ logger.debug("allure_testops_mcp: shutdown — HTTP session closed")
34
+
35
+
36
+ mcp = FastMCP("allure_testops_mcp", lifespan=app_lifespan)
37
+
38
+
39
+ def get_client() -> AllureClient:
40
+ """Return a cached :class:`AllureClient` (lazy-init on first call)."""
41
+ global _client
42
+ if _client is None:
43
+ _client = AllureClient()
44
+ return _client
45
+
46
+
47
+ def pagination_from(data: dict[str, Any]) -> dict[str, Any]:
48
+ """Extract a pagination summary from an Allure list response."""
49
+ total = data.get("totalElements", 0)
50
+ size = data.get("size", 0) or 1
51
+ page = data.get("number", 0)
52
+ total_pages = data.get("totalPages", 1)
53
+ return {
54
+ "page": page,
55
+ "size": size,
56
+ "total": total,
57
+ "total_pages": total_pages,
58
+ "has_more": page < max(total_pages - 1, 0),
59
+ }
@@ -0,0 +1,72 @@
1
+ """HTTP client for Allure TestOps REST API.
2
+
3
+ Thin wrapper around :mod:`requests` — reads configuration from environment
4
+ variables, adds auth header, handles SSL verify toggling, and propagates
5
+ HTTPError exceptions (mapped later to actionable messages by
6
+ :mod:`allure_testops_mcp.errors`).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from typing import Any
13
+
14
+ import requests
15
+ import urllib3
16
+
17
+
18
+ class AllureClient:
19
+ """Minimal Allure TestOps REST client.
20
+
21
+ The client reads ``ALLURE_URL``, ``ALLURE_TOKEN`` and ``ALLURE_SSL_VERIFY``
22
+ from the process environment on first access. Instances are safe to reuse
23
+ — a single :class:`requests.Session` is kept for connection pooling.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ url: str | None = None,
29
+ token: str | None = None,
30
+ ssl_verify: bool | None = None,
31
+ ) -> None:
32
+ self.url = (url or os.environ.get("ALLURE_URL", "")).rstrip("/")
33
+ self.token = token or os.environ.get("ALLURE_TOKEN", "")
34
+ if ssl_verify is None:
35
+ env_val = os.environ.get("ALLURE_SSL_VERIFY", "true").lower()
36
+ ssl_verify = env_val not in ("false", "0", "no")
37
+ self.ssl_verify = ssl_verify
38
+
39
+ if not self.url:
40
+ raise ValueError("ALLURE_URL is not set — configure the env var")
41
+ if not self.token:
42
+ raise ValueError("ALLURE_TOKEN is not set — configure the env var")
43
+
44
+ self.base = f"{self.url}/api/rs"
45
+ self.session = requests.Session()
46
+ self.session.headers.update(
47
+ {
48
+ "Authorization": f"Api-Token {self.token}",
49
+ "Content-Type": "application/json",
50
+ }
51
+ )
52
+ self.session.verify = self.ssl_verify
53
+ # Ignore HTTP(S)_PROXY from env — Allure is often a corp service only
54
+ # reachable directly.
55
+ self.session.trust_env = False
56
+
57
+ if not self.ssl_verify:
58
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
59
+
60
+ def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
61
+ """Perform ``GET {base}/{path}`` and return parsed JSON.
62
+
63
+ Raises :class:`requests.HTTPError` on 4xx/5xx — caller maps it to a
64
+ user-facing message via :mod:`allure_testops_mcp.errors`.
65
+ """
66
+ r = self.session.get(f"{self.base}/{path.lstrip('/')}", params=params, timeout=30)
67
+ r.raise_for_status()
68
+ return r.json()
69
+
70
+ def close(self) -> None:
71
+ """Close the underlying HTTP session."""
72
+ self.session.close()
@@ -0,0 +1,59 @@
1
+ """Actionable error messages for Allure TestOps HTTP errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import requests
6
+
7
+
8
+ def handle(exc: Exception, action: str) -> str:
9
+ """Convert an exception raised while performing ``action`` into an
10
+ LLM-readable string with a suggested next step.
11
+ """
12
+ if isinstance(exc, ValueError):
13
+ return f"Error: configuration problem — {exc}"
14
+
15
+ if isinstance(exc, requests.HTTPError):
16
+ code = exc.response.status_code if exc.response is not None else None
17
+ if code == 401:
18
+ return (
19
+ f"Error: authentication failed (HTTP 401) while {action}. "
20
+ "Verify that ALLURE_TOKEN is set, not expired, and has API scope."
21
+ )
22
+ if code == 403:
23
+ return (
24
+ f"Error: forbidden (HTTP 403) while {action}. "
25
+ "Your token does not have permission for this resource."
26
+ )
27
+ if code == 404:
28
+ return (
29
+ f"Error: resource not found (HTTP 404) while {action}. "
30
+ "Check project_id / launch_id / IDs and spelling."
31
+ )
32
+ if code == 429:
33
+ return (
34
+ f"Error: rate-limited (HTTP 429) while {action}. "
35
+ "Wait 30-60s before retrying, reduce page size, or make fewer calls."
36
+ )
37
+ if code is not None and 500 <= code < 600:
38
+ return (
39
+ f"Error: Allure TestOps server error (HTTP {code}) while {action}. "
40
+ "This is usually transient — retry in a few seconds."
41
+ )
42
+ body = ""
43
+ if exc.response is not None:
44
+ try:
45
+ body = exc.response.text[:200]
46
+ except Exception:
47
+ pass
48
+ return f"Error: HTTP {code} while {action}. Response: {body}"
49
+
50
+ if isinstance(exc, requests.ConnectionError):
51
+ return (
52
+ f"Error: could not connect to Allure TestOps while {action}. "
53
+ "Check ALLURE_URL, network access, proxy settings."
54
+ )
55
+
56
+ if isinstance(exc, requests.Timeout):
57
+ return f"Error: request timed out while {action}. Check network and retry."
58
+
59
+ return f"Error: unexpected {type(exc).__name__} while {action}: {exc}"
@@ -0,0 +1,106 @@
1
+ """TypedDict output schemas for every MCP tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing_extensions import TypedDict
6
+
7
+
8
+ class PaginationMeta(TypedDict, total=False):
9
+ page: int | None
10
+ size: int | None
11
+ total: int | None
12
+ total_pages: int | None
13
+ has_more: bool
14
+
15
+
16
+ # ── Projects ────────────────────────────────────────────────────────────────
17
+
18
+
19
+ class ProjectSummary(TypedDict):
20
+ id: int
21
+ name: str
22
+ abbreviation: str | None
23
+
24
+
25
+ class ProjectsListOutput(TypedDict):
26
+ count: int
27
+ projects: list[ProjectSummary]
28
+
29
+
30
+ class ProjectStatistics(TypedDict, total=False):
31
+ project_id: int
32
+ total_test_cases: int
33
+ automated_test_cases: int
34
+ manual_test_cases: int
35
+ automation_rate_pct: float
36
+ last_launch_id: int | None
37
+ last_launch_name: str | None
38
+ last_launch_passed: int
39
+ last_launch_failed: int
40
+ last_launch_broken: int
41
+ last_launch_total: int
42
+ recent_launches_count: int
43
+
44
+
45
+ # ── Launches ────────────────────────────────────────────────────────────────
46
+
47
+
48
+ class LaunchSummary(TypedDict):
49
+ id: int
50
+ name: str
51
+ status: str
52
+ created_date: str | None
53
+ passed: int
54
+ failed: int
55
+ broken: int
56
+ skipped: int
57
+ total: int
58
+
59
+
60
+ class LaunchesListOutput(TypedDict):
61
+ project_id: int
62
+ count: int
63
+ pagination: PaginationMeta
64
+ launches: list[LaunchSummary]
65
+
66
+
67
+ # ── Test results ────────────────────────────────────────────────────────────
68
+
69
+
70
+ class TestResultSummary(TypedDict):
71
+ id: int
72
+ name: str
73
+ status: str
74
+ duration_ms: int
75
+ error: str
76
+
77
+
78
+ class TestResultsOutput(TypedDict):
79
+ launch_id: int
80
+ count: int
81
+ pagination: PaginationMeta
82
+ results: list[TestResultSummary]
83
+
84
+
85
+ class FailedTestsOutput(TypedDict):
86
+ launch_id: int
87
+ failed_count: int
88
+ results: list[TestResultSummary]
89
+
90
+
91
+ # ── Test cases ──────────────────────────────────────────────────────────────
92
+
93
+
94
+ class TestCaseSummary(TypedDict):
95
+ id: int
96
+ name: str
97
+ automated: bool
98
+ status: str
99
+ layer: str
100
+
101
+
102
+ class TestCasesListOutput(TypedDict):
103
+ project_id: int
104
+ count: int
105
+ pagination: PaginationMeta
106
+ test_cases: list[TestCaseSummary]
@@ -0,0 +1,24 @@
1
+ """Helpers that produce the dual-channel tool result."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from mcp.server.fastmcp.exceptions import ToolError
9
+ from mcp.types import CallToolResult, TextContent
10
+
11
+ from allure_testops_mcp import errors
12
+
13
+
14
+ def ok(data: Mapping[str, Any], markdown: str) -> CallToolResult:
15
+ """Wrap ``data`` + a markdown rendering into a non-error tool result."""
16
+ return CallToolResult(
17
+ content=[TextContent(type="text", text=markdown)],
18
+ structuredContent=dict(data),
19
+ )
20
+
21
+
22
+ def fail(exc: Exception, action: str) -> None:
23
+ """Raise a ``ToolError`` carrying the actionable error message."""
24
+ raise ToolError(errors.handle(exc, action)) from exc
@@ -0,0 +1,20 @@
1
+ """FastMCP server entry point for Allure TestOps MCP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # Importing the tools module attaches @mcp.tool decorators to the shared
6
+ # FastMCP instance. The re-exports below are for external consumers.
7
+ from allure_testops_mcp import tools as _tools # noqa: F401
8
+ from allure_testops_mcp._mcp import app_lifespan, mcp
9
+
10
+
11
+ def main() -> None:
12
+ """Entry point for the ``allure-testops-mcp`` console script (stdio)."""
13
+ mcp.run()
14
+
15
+
16
+ __all__ = ["mcp", "app_lifespan", "main"]
17
+
18
+
19
+ if __name__ == "__main__":
20
+ main()
@@ -0,0 +1,394 @@
1
+ """MCP tools for Allure TestOps.
2
+
3
+ 6 read-only tools covering the main REST API surface — projects, launches,
4
+ test cases, test results. All tools declare ``readOnlyHint: True`` so MCP
5
+ clients do not ask for per-call confirmation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Annotated, Literal
11
+
12
+ from pydantic import Field
13
+
14
+ from allure_testops_mcp import output
15
+ from allure_testops_mcp._mcp import get_client, mcp, pagination_from
16
+ from allure_testops_mcp.models import (
17
+ FailedTestsOutput,
18
+ LaunchesListOutput,
19
+ LaunchSummary,
20
+ ProjectStatistics,
21
+ ProjectsListOutput,
22
+ ProjectSummary,
23
+ TestCaseSummary,
24
+ TestCasesListOutput,
25
+ TestResultSummary,
26
+ TestResultsOutput,
27
+ )
28
+
29
+ # ── Projects ────────────────────────────────────────────────────────────────
30
+
31
+
32
+ @mcp.tool(
33
+ name="allure_list_projects",
34
+ annotations={
35
+ "title": "List Projects",
36
+ "readOnlyHint": True,
37
+ "destructiveHint": False,
38
+ "idempotentHint": True,
39
+ "openWorldHint": True,
40
+ },
41
+ structured_output=True,
42
+ )
43
+ def allure_list_projects(
44
+ page: Annotated[int, Field(default=0, ge=0, description="0-based page number.")] = 0,
45
+ size: Annotated[int, Field(default=200, ge=1, le=500, description="Items per page (1-500).")] = 200,
46
+ ) -> ProjectsListOutput:
47
+ """List all projects in the Allure TestOps instance.
48
+
49
+ Use this first to discover which project IDs exist — all other tools
50
+ take a ``project_id`` that you can look up here.
51
+ """
52
+ try:
53
+ client = get_client()
54
+ data = client.get("/project", {"page": page, "size": size})
55
+ content = data.get("content", [])
56
+ projects: list[ProjectSummary] = [
57
+ {
58
+ "id": int(p["id"]),
59
+ "name": p.get("name", ""),
60
+ "abbreviation": p.get("abbreviation"),
61
+ }
62
+ for p in content
63
+ ]
64
+ result: ProjectsListOutput = {"count": len(projects), "projects": projects}
65
+ md = "\n".join([f"- **{p['id']}** — {p['name']}" for p in projects]) or "(no projects)"
66
+ return output.ok(result, f"## Projects ({len(projects)})\n\n{md}") # type: ignore[return-value]
67
+ except Exception as exc:
68
+ output.fail(exc, "listing projects")
69
+
70
+
71
+ @mcp.tool(
72
+ name="allure_get_project_statistics",
73
+ annotations={
74
+ "title": "Get Project Statistics",
75
+ "readOnlyHint": True,
76
+ "destructiveHint": False,
77
+ "idempotentHint": True,
78
+ "openWorldHint": True,
79
+ },
80
+ structured_output=True,
81
+ )
82
+ def allure_get_project_statistics(
83
+ project_id: Annotated[int, Field(ge=1, description="Allure project ID.")],
84
+ ) -> ProjectStatistics:
85
+ """Get summary statistics for an Allure project.
86
+
87
+ Returns TC count, automation rate, and the last closed launch's pass/fail
88
+ breakdown.
89
+ """
90
+ try:
91
+ client = get_client()
92
+ total_tc = int(
93
+ client.get("/testcase", {"projectId": project_id, "page": 0, "size": 1}).get("totalElements", 0)
94
+ )
95
+ auto_tc = int(
96
+ client.get(
97
+ "/testcase",
98
+ {"projectId": project_id, "page": 0, "size": 1, "automated": "true"},
99
+ ).get("totalElements", 0)
100
+ )
101
+ launches = client.get(
102
+ "/launch",
103
+ {"projectId": project_id, "page": 0, "size": 20, "sort": "createdDate,desc"},
104
+ ).get("content", [])
105
+ last = next((launch for launch in launches if launch.get("closed")), None)
106
+
107
+ stat_map: dict[str, int] = {}
108
+ if last:
109
+ stat_list = client.get(f"/launch/{last['id']}/statistic")
110
+ stat_map = {s["status"]: int(s.get("count", 0)) for s in stat_list}
111
+
112
+ result: ProjectStatistics = {
113
+ "project_id": project_id,
114
+ "total_test_cases": total_tc,
115
+ "automated_test_cases": auto_tc,
116
+ "manual_test_cases": total_tc - auto_tc,
117
+ "automation_rate_pct": round(auto_tc / total_tc * 100, 1) if total_tc else 0.0,
118
+ "last_launch_id": int(last["id"]) if last else None,
119
+ "last_launch_name": last.get("name", "") if last else None,
120
+ "last_launch_passed": stat_map.get("passed", 0),
121
+ "last_launch_failed": stat_map.get("failed", 0),
122
+ "last_launch_broken": stat_map.get("broken", 0),
123
+ "last_launch_total": sum(stat_map.values()),
124
+ "recent_launches_count": len(launches),
125
+ }
126
+ md = (
127
+ f"## Project {project_id}\n\n"
128
+ f"- **Test cases:** {total_tc} ({auto_tc} automated, "
129
+ f"{result['automation_rate_pct']}%)\n"
130
+ )
131
+ if last:
132
+ md += (
133
+ f"- **Last launch #{result['last_launch_id']}** — {result['last_launch_name']}\n"
134
+ f" passed={result['last_launch_passed']} / failed={result['last_launch_failed']} / "
135
+ f"broken={result['last_launch_broken']} / total={result['last_launch_total']}\n"
136
+ )
137
+ return output.ok(result, md) # type: ignore[return-value]
138
+ except Exception as exc:
139
+ output.fail(exc, f"getting statistics for project {project_id}")
140
+
141
+
142
+ # ── Launches ────────────────────────────────────────────────────────────────
143
+
144
+
145
+ @mcp.tool(
146
+ name="allure_list_launches",
147
+ annotations={
148
+ "title": "List Launches",
149
+ "readOnlyHint": True,
150
+ "destructiveHint": False,
151
+ "idempotentHint": True,
152
+ "openWorldHint": True,
153
+ },
154
+ structured_output=True,
155
+ )
156
+ def allure_list_launches(
157
+ project_id: Annotated[int, Field(ge=1, description="Allure project ID.")],
158
+ page: Annotated[int, Field(default=0, ge=0, description="0-based page.")] = 0,
159
+ size: Annotated[int, Field(default=20, ge=1, le=100, description="Items per page (1-100).")] = 20,
160
+ ) -> LaunchesListOutput:
161
+ """List recent launches for a project, newest first.
162
+
163
+ Each launch carries a pass/fail/broken/skipped breakdown from Allure's
164
+ statistic field.
165
+ """
166
+ try:
167
+ client = get_client()
168
+ data = client.get(
169
+ "/launch",
170
+ {
171
+ "projectId": project_id,
172
+ "page": page,
173
+ "size": size,
174
+ "sort": "createdDate,desc",
175
+ },
176
+ )
177
+ launches: list[LaunchSummary] = [
178
+ {
179
+ "id": int(launch["id"]),
180
+ "name": launch.get("name", ""),
181
+ "status": launch.get("status", ""),
182
+ "created_date": launch.get("createdDate"),
183
+ "passed": int(launch.get("statistic", {}).get("passed", 0)),
184
+ "failed": int(launch.get("statistic", {}).get("failed", 0)),
185
+ "broken": int(launch.get("statistic", {}).get("broken", 0)),
186
+ "skipped": int(launch.get("statistic", {}).get("skipped", 0)),
187
+ "total": int(launch.get("statistic", {}).get("total", 0)),
188
+ }
189
+ for launch in data.get("content", [])
190
+ ]
191
+ result: LaunchesListOutput = {
192
+ "project_id": project_id,
193
+ "count": len(launches),
194
+ "pagination": pagination_from(data), # type: ignore[typeddict-item]
195
+ "launches": launches,
196
+ }
197
+ md = f"## Launches for project {project_id} ({len(launches)} shown)\n\n" + "\n".join(
198
+ [
199
+ f"- **#{lnch['id']}** {lnch['name']} — {lnch['status']} "
200
+ f"(P{lnch['passed']} / F{lnch['failed']} / B{lnch['broken']} / S{lnch['skipped']})"
201
+ for lnch in launches
202
+ ]
203
+ )
204
+ return output.ok(result, md) # type: ignore[return-value]
205
+ except Exception as exc:
206
+ output.fail(exc, f"listing launches for project {project_id}")
207
+
208
+
209
+ # ── Test results ────────────────────────────────────────────────────────────
210
+
211
+
212
+ StatusFilter = Literal["PASSED", "FAILED", "BROKEN", "SKIPPED"]
213
+
214
+
215
+ @mcp.tool(
216
+ name="allure_get_test_results",
217
+ annotations={
218
+ "title": "Get Test Results",
219
+ "readOnlyHint": True,
220
+ "destructiveHint": False,
221
+ "idempotentHint": True,
222
+ "openWorldHint": True,
223
+ },
224
+ structured_output=True,
225
+ )
226
+ def allure_get_test_results(
227
+ launch_id: Annotated[int, Field(ge=1, description="Allure launch ID.")],
228
+ status: Annotated[
229
+ StatusFilter | None,
230
+ Field(default=None, description="Filter by status. None returns all statuses."),
231
+ ] = None,
232
+ page: Annotated[int, Field(default=0, ge=0, description="0-based page.")] = 0,
233
+ size: Annotated[int, Field(default=50, ge=1, le=200, description="Items per page (1-200).")] = 50,
234
+ ) -> TestResultsOutput:
235
+ """Get individual test results inside a launch, optionally filtered by status.
236
+
237
+ Use ``allure_search_failed_tests`` for a quick view of only failures.
238
+ """
239
+ try:
240
+ client = get_client()
241
+ params: dict[str, object] = {"launchId": launch_id, "page": page, "size": size}
242
+ if status:
243
+ params["status"] = status
244
+ data = client.get("/testresult", params)
245
+ results: list[TestResultSummary] = [
246
+ {
247
+ "id": int(r["id"]),
248
+ "name": r.get("name", ""),
249
+ "status": r.get("status", ""),
250
+ "duration_ms": int(r.get("duration", 0) or 0),
251
+ "error": (r.get("statusMessage", "") or "")[:300],
252
+ }
253
+ for r in data.get("content", [])
254
+ ]
255
+ result: TestResultsOutput = {
256
+ "launch_id": launch_id,
257
+ "count": len(results),
258
+ "pagination": pagination_from(data), # type: ignore[typeddict-item]
259
+ "results": results,
260
+ }
261
+ md = f"## Test results in launch {launch_id} ({len(results)} shown)\n\n" + "\n".join(
262
+ [f"- **{r['status']}** {r['name']} ({r['duration_ms']} ms)" for r in results]
263
+ )
264
+ return output.ok(result, md) # type: ignore[return-value]
265
+ except Exception as exc:
266
+ output.fail(exc, f"getting test results for launch {launch_id}")
267
+
268
+
269
+ @mcp.tool(
270
+ name="allure_search_failed_tests",
271
+ annotations={
272
+ "title": "Search Failed Tests",
273
+ "readOnlyHint": True,
274
+ "destructiveHint": False,
275
+ "idempotentHint": True,
276
+ "openWorldHint": True,
277
+ },
278
+ structured_output=True,
279
+ )
280
+ def allure_search_failed_tests(
281
+ project_id: Annotated[int, Field(ge=1, description="Allure project ID.")],
282
+ launch_id: Annotated[
283
+ int | None,
284
+ Field(default=None, description="Specific launch ID. If omitted, uses the most recent launch."),
285
+ ] = None,
286
+ limit: Annotated[int, Field(default=20, ge=1, le=200, description="Max failures to return per status.")] = 20,
287
+ ) -> FailedTestsOutput:
288
+ """Find FAILED and BROKEN tests in the most recent (or given) launch.
289
+
290
+ Useful for triage: _"what's broken in the latest run"_ without listing
291
+ everything.
292
+ """
293
+ try:
294
+ client = get_client()
295
+ if not launch_id:
296
+ content = client.get(
297
+ "/launch",
298
+ {"projectId": project_id, "page": 0, "size": 1, "sort": "createdDate,desc"},
299
+ ).get("content", [])
300
+ if not content:
301
+ result: FailedTestsOutput = {"launch_id": 0, "failed_count": 0, "results": []}
302
+ return output.ok(result, "(no launches found for project)") # type: ignore[return-value]
303
+ launch_id = int(content[0]["id"])
304
+
305
+ failed: list[TestResultSummary] = []
306
+ for status in ("FAILED", "BROKEN"):
307
+ items = client.get(
308
+ "/testresult",
309
+ {"launchId": launch_id, "status": status, "page": 0, "size": limit},
310
+ ).get("content", [])
311
+ for r in items:
312
+ failed.append(
313
+ {
314
+ "id": int(r["id"]),
315
+ "name": r.get("name", ""),
316
+ "status": r.get("status", ""),
317
+ "duration_ms": int(r.get("duration", 0) or 0),
318
+ "error": (r.get("statusMessage", "") or "")[:300],
319
+ }
320
+ )
321
+
322
+ result: FailedTestsOutput = {
323
+ "launch_id": int(launch_id),
324
+ "failed_count": len(failed),
325
+ "results": failed[:limit],
326
+ }
327
+ md = f"## Failed tests in launch {launch_id} ({len(failed)} total)\n\n" + "\n".join(
328
+ [f"- **{r['status']}** {r['name']} — {r['error'][:120]}" for r in failed[:limit]]
329
+ )
330
+ return output.ok(result, md) # type: ignore[return-value]
331
+ except Exception as exc:
332
+ output.fail(exc, f"searching failed tests for project {project_id}")
333
+
334
+
335
+ # ── Test cases ──────────────────────────────────────────────────────────────
336
+
337
+
338
+ @mcp.tool(
339
+ name="allure_list_test_cases",
340
+ annotations={
341
+ "title": "List Test Cases",
342
+ "readOnlyHint": True,
343
+ "destructiveHint": False,
344
+ "idempotentHint": True,
345
+ "openWorldHint": True,
346
+ },
347
+ structured_output=True,
348
+ )
349
+ def allure_list_test_cases(
350
+ project_id: Annotated[int, Field(ge=1, description="Allure project ID.")],
351
+ automated: Annotated[
352
+ bool | None,
353
+ Field(default=None, description="True: only automated. False: only manual. None: both."),
354
+ ] = None,
355
+ page: Annotated[int, Field(default=0, ge=0, description="0-based page.")] = 0,
356
+ size: Annotated[int, Field(default=50, ge=1, le=200, description="Items per page (1-200).")] = 50,
357
+ ) -> TestCasesListOutput:
358
+ """List test cases for a project with optional manual/automated filter.
359
+
360
+ Each TC returns id, name, automation flag, status and layer (e.g. ``UNIT``,
361
+ ``API``, ``E2E``).
362
+ """
363
+ try:
364
+ client = get_client()
365
+ params: dict[str, object] = {"projectId": project_id, "page": page, "size": size}
366
+ if automated is not None:
367
+ params["automated"] = "true" if automated else "false"
368
+ data = client.get("/testcase", params)
369
+ test_cases: list[TestCaseSummary] = [
370
+ {
371
+ "id": int(tc["id"]),
372
+ "name": tc.get("name", ""),
373
+ "automated": bool(tc.get("automated", False)),
374
+ "status": tc.get("status", ""),
375
+ "layer": (tc.get("layer") or {}).get("name", ""),
376
+ }
377
+ for tc in data.get("content", [])
378
+ ]
379
+ result: TestCasesListOutput = {
380
+ "project_id": project_id,
381
+ "count": len(test_cases),
382
+ "pagination": pagination_from(data), # type: ignore[typeddict-item]
383
+ "test_cases": test_cases,
384
+ }
385
+ md = f"## Test cases for project {project_id} ({len(test_cases)} shown)\n\n" + "\n".join(
386
+ [
387
+ f"- **#{tc['id']}** {tc['name']} "
388
+ f"({'auto' if tc['automated'] else 'manual'}, {tc['layer'] or 'no-layer'})"
389
+ for tc in test_cases
390
+ ]
391
+ )
392
+ return output.ok(result, md) # type: ignore[return-value]
393
+ except Exception as exc:
394
+ output.fail(exc, f"listing test cases for project {project_id}")