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.
- allure_testops_mcp-0.1.0/.env.example +9 -0
- allure_testops_mcp-0.1.0/.github/workflows/publish.yml +67 -0
- allure_testops_mcp-0.1.0/.gitignore +15 -0
- allure_testops_mcp-0.1.0/CHANGELOG.md +20 -0
- allure_testops_mcp-0.1.0/Dockerfile +5 -0
- allure_testops_mcp-0.1.0/LICENSE +21 -0
- allure_testops_mcp-0.1.0/PKG-INFO +156 -0
- allure_testops_mcp-0.1.0/README.md +124 -0
- allure_testops_mcp-0.1.0/pyproject.toml +58 -0
- allure_testops_mcp-0.1.0/server.json +42 -0
- allure_testops_mcp-0.1.0/src/allure_testops_mcp/__init__.py +3 -0
- allure_testops_mcp-0.1.0/src/allure_testops_mcp/_mcp.py +59 -0
- allure_testops_mcp-0.1.0/src/allure_testops_mcp/client.py +72 -0
- allure_testops_mcp-0.1.0/src/allure_testops_mcp/errors.py +59 -0
- allure_testops_mcp-0.1.0/src/allure_testops_mcp/models.py +106 -0
- allure_testops_mcp-0.1.0/src/allure_testops_mcp/output.py +24 -0
- allure_testops_mcp-0.1.0/src/allure_testops_mcp/server.py +20 -0
- allure_testops_mcp-0.1.0/src/allure_testops_mcp/tools.py +394 -0
|
@@ -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,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,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
|
+
[](https://pypi.org/project/allure-testops-mcp/)
|
|
38
|
+
[](https://pypi.org/project/allure-testops-mcp/)
|
|
39
|
+
[](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
|
+
[](https://pypi.org/project/allure-testops-mcp/)
|
|
6
|
+
[](https://pypi.org/project/allure-testops-mcp/)
|
|
7
|
+
[](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,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}")
|