iflow-mcp_sker65-testrail-mcp 0.1.5__py3-none-any.whl

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,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: iflow-mcp_sker65-testrail-mcp
3
+ Version: 0.1.5
4
+ Summary: TestRail MCP Server
5
+ Author-email: Stefan Rinke <sker65@gmail.com>
6
+ Maintainer-email: Stefan Rinke <sker65@gmail.com>
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: fastmcp
11
+ Requires-Dist: python-dotenv>=1.0.0
12
+ Requires-Dist: python-mcp>=1.0.0
13
+ Requires-Dist: requests>=2.31.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # TestRail MCP Server
17
+
18
+ [![smithery badge](https://smithery.ai/badge/@sker65/testrail-mcp)](https://smithery.ai/server/@sker65/testrail-mcp)
19
+
20
+ A Model Context Protocol (MCP) server for TestRail that allows interaction with TestRail's core entities through a standardized protocol.
21
+
22
+ ## Features
23
+
24
+ - Authentication with TestRail API
25
+ - Access to TestRail entities:
26
+ - Projects
27
+ - Cases
28
+ - Runs
29
+ - Results
30
+ - Datasets
31
+ - Full support for the Model Context Protocol
32
+ - Compatible with any MCP client (Claude Desktop, Cursor, Windsurf, etc.)
33
+
34
+ ## See it in action together with Octomind MCP
35
+
36
+ [![Video Title](https://img.youtube.com/vi/I7lc9I0S62Y/0.jpg)](https://www.youtube.com/watch?v=I7lc9I0S62Y)
37
+
38
+ ## Installation
39
+
40
+ ### Installing via Smithery
41
+
42
+ To install testrail-mcp for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@sker65/testrail-mcp):
43
+
44
+ ```bash
45
+ npx -y @smithery/cli install @sker65/testrail-mcp --client claude
46
+ ```
47
+
48
+ ### Manual Installation
49
+ 1. Clone this repository:
50
+ ```bash
51
+ git clone https://github.com/yourusername/testrail-mcp.git
52
+ cd testrail-mcp
53
+ ```
54
+
55
+ 2. Create and activate a virtual environment:
56
+ ```bash
57
+ python -m venv .venv
58
+ source .venv/bin/activate # On Windows: .venv\Scripts\activate
59
+ ```
60
+
61
+ 3. Install dependencies:
62
+ ```bash
63
+ pip install -e .
64
+ ```
65
+
66
+ ## Configuration
67
+
68
+ The TestRail MCP server requires specific environment variables to authenticate with your TestRail instance. These must be set before running the server.
69
+
70
+ 1. Create a `.env` file in the root directory of the project:
71
+ ```
72
+ TESTRAIL_URL=https://your-instance.testrail.io
73
+ TESTRAIL_USERNAME=your-email@example.com
74
+ TESTRAIL_API_KEY=your-api-key
75
+ ```
76
+
77
+ **Important Notes:**
78
+ - `TESTRAIL_URL` should be the full URL to your TestRail instance (e.g., `https://example.testrail.io`)
79
+ - `TESTRAIL_USERNAME` is your TestRail email address used for login
80
+ - `TESTRAIL_API_KEY` is your TestRail API key (not your password)
81
+ - To generate an API key, log in to TestRail, go to "My Settings" > "API Keys" and create a new key
82
+
83
+ 2. Verify that the configuration is loaded correctly:
84
+ ```bash
85
+ uvx testrail-mcp --config
86
+ ```
87
+
88
+ This will display your TestRail configuration information, including your URL, username, and the first few characters of your API key for verification.
89
+
90
+ If you're using this server with a client like Claude Desktop or Cursor, make sure the environment variables are accessible to the process running the server. You may need to set these variables in your system environment or ensure they're loaded from the `.env` file.
91
+
92
+ ## Usage
93
+
94
+ ### Running the Server
95
+
96
+ The server can be run directly using the installed script:
97
+
98
+ ```bash
99
+ uvx testrail-mcp
100
+ ```
101
+
102
+ This will start the MCP server in stdio mode, which can be used with MCP clients that support stdio communication.
103
+
104
+ ### Using with MCP Clients
105
+
106
+ #### Claude Desktop
107
+
108
+ In Claude Desktop, add a new server with the following configuration:
109
+
110
+ ```json
111
+ {
112
+ "mcpServers": {
113
+ "testrail": {
114
+ "command": "uvx",
115
+ "args": [
116
+ "testrail-mcp"
117
+ ],
118
+ "env": {
119
+ "TESTRAIL_URL": "https://your-instance.testrail.io",
120
+ "TESTRAIL_USERNAME": "your-email@example.com",
121
+ "TESTRAIL_API_KEY": "your-api-key"
122
+ }
123
+ }
124
+ }
125
+ }
126
+ ```
127
+
128
+ #### Cursor
129
+
130
+ In Cursor, add a new custom tool with the following configuration:
131
+
132
+ ```json
133
+ {
134
+ "name": "TestRail MCP",
135
+ "command": "uvx",
136
+ "args": [
137
+ "testrail-mcp"
138
+ ],
139
+ "env": {
140
+ "TESTRAIL_URL": "https://your-instance.testrail.io",
141
+ "TESTRAIL_USERNAME": "your-email@example.com",
142
+ "TESTRAIL_API_KEY": "your-api-key"
143
+ }
144
+ }
145
+ ```
146
+
147
+ #### Windsurf
148
+
149
+ In Windsurf, add a new tool with the following configuration:
150
+
151
+ ```json
152
+ {
153
+ "name": "TestRail MCP",
154
+ "command": "uvx",
155
+ "args": [
156
+ "testrail-mcp"
157
+ ],
158
+ "env": {
159
+ "TESTRAIL_URL": "https://your-instance.testrail.io",
160
+ "TESTRAIL_USERNAME": "your-email@example.com",
161
+ "TESTRAIL_API_KEY": "your-api-key"
162
+ }
163
+ }
164
+ ```
165
+
166
+ #### Testing with MCP Inspector
167
+
168
+ For testing and debugging, you can use the MCP Inspector:
169
+
170
+ ```bash
171
+ npx @modelcontextprotocol/inspector \
172
+ -e TESTRAIL_URL=<your-url> \
173
+ -e TESTRAIL_USERNAME=<your-username> \
174
+ -e TESTRAIL_API_KEY=<your-api-key> \
175
+ uvx testrail-mcp
176
+ ```
177
+
178
+ This will open a web interface where you can explore and test all the available tools and resources.
179
+
180
+ ## Development
181
+
182
+ This server is built using:
183
+
184
+ - [FastMCP](https://github.com/jlowin/fastmcp) - A Python framework for building MCP servers
185
+ - [Requests](https://requests.readthedocs.io/) - For HTTP communication with TestRail API
186
+ - [python-dotenv](https://github.com/theskumar/python-dotenv) - For environment variable management
187
+
188
+ ## License
189
+
190
+ MIT
@@ -0,0 +1,10 @@
1
+ testrail_mcp/__init__.py,sha256=qD-ToZHGgYbGVwzdH4IK2-mrL9DOnGL4VhagARVnhYg,34
2
+ testrail_mcp/__main__.py,sha256=BlQY356TPrUV7W-LH6CfA_vNv6jKlY1r-FU-P8gABaw,388
3
+ testrail_mcp/config.py,sha256=iZ-pp7Ihy3-nIhawusVW675NtYtmpuMFwBGKV62-SV0,578
4
+ testrail_mcp/mcp_server.py,sha256=k2Dl9muaVsjIsu9wmEjfQD9CbEgMGiJ_7iOwVzfqlW4,25959
5
+ testrail_mcp/testrail_client.py,sha256=0MoRDVt6UQoFsUURlmeDr2TWOViNqnhDZyqRDWNTLNY,8613
6
+ iflow_mcp_sker65_testrail_mcp-0.1.5.dist-info/METADATA,sha256=HOzp6FsHyiZ_CSy1pbS3xj8UD3oHPPEkc29Lwsheo4M,5097
7
+ iflow_mcp_sker65_testrail_mcp-0.1.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
+ iflow_mcp_sker65_testrail_mcp-0.1.5.dist-info/entry_points.txt,sha256=0ApB6a60n2oBb9s6d5iP5EbRYinxec0BXSQQ1xz5rEI,60
9
+ iflow_mcp_sker65_testrail_mcp-0.1.5.dist-info/licenses/LICENSE,sha256=RLjrd9J2YCGMaJtvLLWONAd_mVmcZscQKmIM2bQMBRw,1069
10
+ iflow_mcp_sker65_testrail_mcp-0.1.5.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ testrail-mcp = testrail_mcp.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Stefan Rinke
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 @@
1
+ """TestRail MCP server package."""
@@ -0,0 +1,14 @@
1
+ """Entry point for the TestRail MCP server when run as a module."""
2
+ import sys
3
+ import asyncio
4
+
5
+ from testrail_mcp.mcp_server import TestRailMCPServer
6
+
7
+ def main():
8
+ """Run the TestRail MCP server."""
9
+ print("Starting TestRail MCP server in stdio mode", file=sys.stderr)
10
+ server = TestRailMCPServer()
11
+ asyncio.run(server.run_stdio_async())
12
+
13
+ if __name__ == "__main__":
14
+ main()
testrail_mcp/config.py ADDED
@@ -0,0 +1,18 @@
1
+ """Configuration module for TestRail MCP server."""
2
+ import os
3
+ from dotenv import load_dotenv
4
+
5
+ # Load environment variables from .env file
6
+ load_dotenv()
7
+
8
+ # TestRail configuration
9
+ TESTRAIL_URL = os.getenv('TESTRAIL_URL')
10
+ TESTRAIL_USERNAME = os.getenv('TESTRAIL_USERNAME')
11
+ TESTRAIL_API_KEY = os.getenv('TESTRAIL_API_KEY')
12
+
13
+ # Validate configuration
14
+ if not all([TESTRAIL_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY]):
15
+ raise ValueError(
16
+ "Missing TestRail configuration. Please set TESTRAIL_URL, "
17
+ "TESTRAIL_USERNAME, and TESTRAIL_API_KEY environment variables."
18
+ )
@@ -0,0 +1,645 @@
1
+ """MCP server implementation for TestRail."""
2
+ from typing import Dict, List, Any, Optional, Union
3
+ from fastmcp import FastMCP
4
+
5
+ from testrail_mcp.testrail_client import TestRailClient
6
+ from testrail_mcp.config import TESTRAIL_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY
7
+
8
+
9
+ class TestRailMCPServer(FastMCP):
10
+ """MCP server for TestRail integration using FastMCP."""
11
+
12
+ def __init__(self):
13
+ """Initialize the TestRail MCP server."""
14
+ super().__init__(name="TestRail MCP Server", version="0.1.3")
15
+ self.client = TestRailClient(TESTRAIL_URL, TESTRAIL_USERNAME, TESTRAIL_API_KEY)
16
+ self._register_tools()
17
+ self._register_resources()
18
+
19
+ def _register_tools(self):
20
+ """Register all TestRail tools with the MCP server."""
21
+ # Project tools
22
+ @self.tool("get_project", description="Get a project by ID")
23
+ def get_project(project_id: int) -> Dict:
24
+ """Get a project by ID."""
25
+ return self.client.get_project(project_id)
26
+
27
+ @self.tool("get_projects", description="Get all projects")
28
+ def get_projects() -> List[Dict]:
29
+ """Get all projects."""
30
+ return self.client.get_projects()
31
+
32
+ @self.tool("add_project", description="Add a new project")
33
+ def add_project(
34
+ name: str,
35
+ announcement: Optional[str] = None,
36
+ show_announcement: Optional[bool] = None,
37
+ suite_mode: Optional[int] = None
38
+ ) -> Dict:
39
+ """
40
+ Add a new project.
41
+
42
+ Args:
43
+ name: The name of the project
44
+ announcement: The announcement of the project (optional)
45
+ show_announcement: Whether to show the announcement (optional)
46
+ suite_mode: The suite mode: 1 for single suite mode, 2 for single suite + baselines, 3 for multiple suites (optional)
47
+ """
48
+ data = {'name': name}
49
+ if announcement is not None:
50
+ data['announcement'] = announcement
51
+ if show_announcement is not None:
52
+ data['show_announcement'] = show_announcement
53
+ if suite_mode is not None:
54
+ data['suite_mode'] = suite_mode
55
+ return self.client.add_project(data)
56
+
57
+ @self.tool("update_project", description="Update an existing project")
58
+ def update_project(
59
+ project_id: int,
60
+ name: Optional[str] = None,
61
+ announcement: Optional[str] = None,
62
+ show_announcement: Optional[bool] = None,
63
+ is_completed: Optional[bool] = None
64
+ ) -> Dict:
65
+ """
66
+ Update an existing project.
67
+
68
+ Args:
69
+ project_id: The ID of the project
70
+ name: The name of the project (optional)
71
+ announcement: The announcement of the project (optional)
72
+ show_announcement: Whether to show the announcement (optional)
73
+ is_completed: Whether the project is completed (optional)
74
+ """
75
+ data = {}
76
+ if name is not None:
77
+ data['name'] = name
78
+ if announcement is not None:
79
+ data['announcement'] = announcement
80
+ if show_announcement is not None:
81
+ data['show_announcement'] = show_announcement
82
+ if is_completed is not None:
83
+ data['is_completed'] = is_completed
84
+ return self.client.update_project(project_id, data)
85
+
86
+ @self.tool("delete_project", description="Delete a project")
87
+ def delete_project(project_id: int) -> Dict:
88
+ """
89
+ Delete a project.
90
+
91
+ Args:
92
+ project_id: The ID of the project
93
+ """
94
+ return self.client.delete_project(project_id)
95
+
96
+ # Case tools
97
+ @self.tool("get_case", description="Get a test case by ID")
98
+ def get_case(case_id: int) -> Dict:
99
+ """
100
+ Get a test case by ID.
101
+
102
+ Args:
103
+ case_id: The ID of the test case
104
+ """
105
+ return self.client.get_case(case_id)
106
+
107
+ @self.tool("get_cases", description="Get all test cases for a project/suite")
108
+ def get_cases(project_id: int, suite_id: Optional[int] = None) -> List[Dict]:
109
+ """
110
+ Get all test cases for a project/suite.
111
+
112
+ Args:
113
+ project_id: The ID of the project
114
+ suite_id: The ID of the test suite (optional)
115
+ """
116
+ return self.client.get_cases(project_id, suite_id)
117
+
118
+ @self.tool("add_case", description="Add a new test case")
119
+ def add_case(
120
+ section_id: int,
121
+ title: str,
122
+ type_id: Optional[int] = None,
123
+ priority_id: Optional[int] = None,
124
+ estimate: Optional[str] = None,
125
+ milestone_id: Optional[int] = None,
126
+ refs: Optional[str] = None,
127
+ custom_steps: Optional[str] = None,
128
+ custom_expected: Optional[str] = None,
129
+ custom_steps_separated: Optional[List[Dict[str, str]]] = None,
130
+ steps_separated: Optional[List[Dict[str, str]]] = None
131
+ ) -> Dict:
132
+ """
133
+ Add a new test case.
134
+
135
+ Args:
136
+ section_id: The ID of the section
137
+ title: The title of the test case
138
+ type_id: The ID of the case type (optional)
139
+ priority_id: The ID of the priority (optional)
140
+ estimate: The estimate, e.g. '30s' or '1m 45s' (optional)
141
+ milestone_id: The ID of the milestone (optional)
142
+ refs: A comma-separated list of references (optional)
143
+ custom_steps: Steps as string
144
+ custom_expected: case expected result
145
+ custom_steps_separated: A list of test steps (optional), each with fields:
146
+ - content: The text contents of the "Step" field
147
+ - expected: The text contents of the "Expected Result" field
148
+ - additional_info: The text contents of the "Additional Info" field
149
+ - refs: Reference information for the "References" field
150
+ steps_separated: A list of test steps (optional), each with fields:
151
+ - content: The text contents of the "Step" field
152
+ - expected: The text contents of the "Expected Result" field
153
+ - additional_info: The text contents of the "Additional Info" field
154
+ - refs: Reference information for the "References" field
155
+ """
156
+ data = {'title': title}
157
+ if type_id is not None:
158
+ data['type_id'] = type_id
159
+ if priority_id is not None:
160
+ data['priority_id'] = priority_id
161
+ if estimate is not None:
162
+ data['estimate'] = estimate
163
+ if milestone_id is not None:
164
+ data['milestone_id'] = milestone_id
165
+ if refs is not None:
166
+ data['refs'] = refs
167
+ if custom_steps_separated is not None:
168
+ data['custom_steps_separated'] = custom_steps_separated
169
+ if steps_separated is not None:
170
+ data['steps_separated'] = steps_separated
171
+ if custom_steps is not None:
172
+ data['custom_steps'] = custom_steps
173
+ if custom_expected is not None:
174
+ data['custom_expected'] = custom_expected
175
+ return self.client.add_case(section_id, data)
176
+
177
+ @self.tool("update_case", description="Update an existing test case")
178
+ def update_case(
179
+ case_id: int,
180
+ title: Optional[str] = None,
181
+ type_id: Optional[int] = None,
182
+ priority_id: Optional[int] = None,
183
+ estimate: Optional[str] = None,
184
+ milestone_id: Optional[int] = None,
185
+ refs: Optional[str] = None,
186
+ custom_steps: Optional[str] = None,
187
+ custom_expected: Optional[str] = None,
188
+ custom_steps_separated: Optional[List[Dict[str, str]]] = None,
189
+ steps_separated: Optional[List[Dict[str, str]]] = None
190
+ ) -> Dict:
191
+ """
192
+ Update an existing test case.
193
+
194
+ Args:
195
+ case_id: The ID of the test case
196
+ title: The title of the test case (optional)
197
+ type_id: The ID of the case type (optional)
198
+ priority_id: The ID of the priority (optional)
199
+ estimate: The estimate, e.g. '30s' or '1m 45s' (optional)
200
+ milestone_id: The ID of the milestone (optional)
201
+ refs: A comma-separated list of references (optional)
202
+ custom_expected: case expected result
203
+ custom_steps_separated: A list of test steps (optional), each with fields:
204
+ - content: The text contents of the "Step" field
205
+ - expected: The text contents of the "Expected Result" field
206
+ - additional_info: The text contents of the "Additional Info" field
207
+ - refs: Reference information for the "References" field
208
+ steps_separated: A list of test steps (optional), each with fields:
209
+ - content: The text contents of the "Step" field
210
+ - expected: The text contents of the "Expected Result" field
211
+ - additional_info: The text contents of the "Additional Info" field
212
+ - refs: Reference information for the "References" field
213
+ """
214
+ data = {}
215
+ if title is not None:
216
+ data['title'] = title
217
+ if type_id is not None:
218
+ data['type_id'] = type_id
219
+ if priority_id is not None:
220
+ data['priority_id'] = priority_id
221
+ if estimate is not None:
222
+ data['estimate'] = estimate
223
+ if milestone_id is not None:
224
+ data['milestone_id'] = milestone_id
225
+ if refs is not None:
226
+ data['refs'] = refs
227
+ if custom_steps_separated is not None:
228
+ data['custom_steps_separated'] = custom_steps_separated
229
+ if steps_separated is not None:
230
+ data['steps_separated'] = steps_separated
231
+ if custom_steps is not None:
232
+ data['custom_steps'] = custom_steps
233
+ if custom_expected is not None:
234
+ data['custom_expected'] = custom_expected
235
+ return self.client.update_case(case_id, data)
236
+
237
+ @self.tool("delete_case", description="Delete a test case")
238
+ def delete_case(case_id: int) -> Dict:
239
+ """
240
+ Delete a test case.
241
+
242
+ Args:
243
+ case_id: The ID of the test case
244
+ """
245
+ return self.client.delete_case(case_id)
246
+ # Section tools
247
+ @self.tool("get_section", description="Retrieves details of a specific section by ID")
248
+ def get_section(section_id: int) -> Dict:
249
+ """
250
+ Get a section by ID.
251
+
252
+ Args:
253
+ section_id: The ID of the section
254
+ """
255
+ return self.client.get_section(section_id)
256
+
257
+ @self.tool("get_sections", description="Retrieves all sections for a specified project and or suite")
258
+ def get_sections(
259
+ project_id : int,
260
+ suite_id: Optional[int] = None ) -> Dict:
261
+ """
262
+ Retrieves all sections for a specified project and suite
263
+
264
+ Args:
265
+ project_id: The ID of the project
266
+ suite_id: The ID of the test suite (Optional)
267
+
268
+ """
269
+ return self.client.get_sections(project_id,suite_id)
270
+
271
+ @self.tool("add_section", description="Creates a new section in a TestRail project")
272
+ def add_section(
273
+ project_id : int,
274
+ name: str,
275
+ description: str,
276
+ suite_id: Optional[int] = None,
277
+ parent_id: Optional[int] = None) -> Dict:
278
+ """
279
+ Retrieves all sections for a specified project and suite
280
+
281
+ Args:
282
+ project_id: The ID of the project
283
+ name: Name of the section
284
+ description: Description of the section
285
+ suite_id: The ID of the test suite (Optional)
286
+ parent_id: The ID of the parent
287
+
288
+ """
289
+ data = {}
290
+ data["name"] = name
291
+ data["description"] = description
292
+ if suite_id is not None:
293
+ data["suite_id"] = suite_id
294
+ if parent_id is not None:
295
+ data["parent_id"] = parent_id
296
+
297
+ return self.client.add_section(project_id,data)
298
+
299
+ @self.tool("update_section", description="Updates an existing section")
300
+ def update_section(
301
+ section_id : int,
302
+ name: Optional[str] = None,
303
+ description: Optional[str] = None) -> Dict:
304
+ """
305
+ Updates an existing section
306
+
307
+ Args:
308
+ section_id: The ID of the section
309
+ name: Name of the section
310
+ description: Description of the section
311
+ """
312
+ data = {}
313
+ if name is not None:
314
+ data["name"] = name
315
+ if description is not None:
316
+ data["description"] = description
317
+
318
+ return self.client.update_section(section_id, data)
319
+
320
+ @self.tool("delete_section", description="Deletes a section")
321
+ def delete_section(
322
+ section_id : int,
323
+ soft: bool) -> Dict:
324
+ """
325
+ Deletes an existing section
326
+
327
+ Args:
328
+ section_id: The ID of the section
329
+ soft: Omitting the soft parameter, or submitting soft=0 will delete the section and its test cases If soft=1, this will return data on the number of affected tests, cases, etc.
330
+ """
331
+
332
+ return self.client.delete_section(section_id, soft)
333
+
334
+ @self.tool("move_section", description="Moves a section to a new position in the test hierarchy")
335
+ def move_section(
336
+ section_id : int,
337
+ parent_id : Optional[int],
338
+ after_id : Optional[int]) -> Dict:
339
+ """
340
+ Moves a section to a new position in the test hierarchy
341
+
342
+ Args:
343
+ section_id: The ID of the section
344
+ parent_id: ID of the new parent
345
+ after_id: ID of the section to be moved after
346
+ """
347
+ data = {}
348
+ if parent_id is not None:
349
+ data["parent_id"] = parent_id
350
+ if after_id is not None:
351
+ data["after_id"] = after_id
352
+
353
+ return self.client.move_section(section_id, data)
354
+
355
+ # Run tools
356
+ @self.tool("get_run", description="Get a test run by ID")
357
+ def get_run(run_id: int) -> Dict:
358
+ """
359
+ Get a test run by ID.
360
+
361
+ Args:
362
+ run_id: The ID of the test run
363
+ """
364
+ return self.client.get_run(run_id)
365
+
366
+ @self.tool("get_runs", description="Get all test runs for a project")
367
+ def get_runs(project_id: int) -> List[Dict]:
368
+ """
369
+ Get all test runs for a project.
370
+
371
+ Args:
372
+ project_id: The ID of the project
373
+ """
374
+ return self.client.get_runs(project_id)
375
+
376
+ @self.tool("add_run", description="Add a new test run")
377
+ def add_run(
378
+ project_id: int,
379
+ suite_id: int,
380
+ name: str,
381
+ description: Optional[str] = None,
382
+ milestone_id: Optional[int] = None,
383
+ assignedto_id: Optional[int] = None,
384
+ include_all: Optional[bool] = None,
385
+ case_ids: Optional[List[int]] = None
386
+ ) -> Dict:
387
+ """
388
+ Add a new test run.
389
+
390
+ Args:
391
+ project_id: The ID of the project
392
+ suite_id: The ID of the test suite
393
+ name: The name of the test run
394
+ description: The description of the test run (optional)
395
+ milestone_id: The ID of the milestone (optional)
396
+ assignedto_id: The ID of the user the test run should be assigned to (optional)
397
+ include_all: True for including all test cases of the test suite and false for a custom case selection (default: true) (optional)
398
+ case_ids: An array of case IDs for the custom case selection (optional)
399
+ """
400
+ data = {
401
+ 'suite_id': suite_id,
402
+ 'name': name
403
+ }
404
+ if description is not None:
405
+ data['description'] = description
406
+ if milestone_id is not None:
407
+ data['milestone_id'] = milestone_id
408
+ if assignedto_id is not None:
409
+ data['assignedto_id'] = assignedto_id
410
+ if include_all is not None:
411
+ data['include_all'] = include_all
412
+ if case_ids is not None:
413
+ data['case_ids'] = case_ids
414
+ return self.client.add_run(project_id, data)
415
+
416
+ @self.tool("update_run", description="Update an existing test run")
417
+ def update_run(
418
+ run_id: int,
419
+ name: Optional[str] = None,
420
+ description: Optional[str] = None,
421
+ milestone_id: Optional[int] = None,
422
+ assignedto_id: Optional[int] = None,
423
+ include_all: Optional[bool] = None,
424
+ case_ids: Optional[List[int]] = None
425
+ ) -> Dict:
426
+ """
427
+ Update an existing test run.
428
+
429
+ Args:
430
+ run_id: The ID of the test run
431
+ name: The name of the test run (optional)
432
+ description: The description of the test run (optional)
433
+ milestone_id: The ID of the milestone (optional)
434
+ assignedto_id: The ID of the user the test run should be assigned to (optional)
435
+ include_all: True for including all test cases of the test suite and false for a custom case selection (default: true) (optional)
436
+ case_ids: An array of case IDs for the custom case selection (optional)
437
+ """
438
+ data = {}
439
+ if name is not None:
440
+ data['name'] = name
441
+ if description is not None:
442
+ data['description'] = description
443
+ if milestone_id is not None:
444
+ data['milestone_id'] = milestone_id
445
+ if assignedto_id is not None:
446
+ data['assignedto_id'] = assignedto_id
447
+ if include_all is not None:
448
+ data['include_all'] = include_all
449
+ if case_ids is not None:
450
+ data['case_ids'] = case_ids
451
+ return self.client.update_run(run_id, data)
452
+
453
+ @self.tool("close_run", description="Close an existing test run")
454
+ def close_run(run_id: int) -> Dict:
455
+ """
456
+ Close an existing test run.
457
+
458
+ Args:
459
+ run_id: The ID of the test run
460
+ """
461
+ return self.client.close_run(run_id)
462
+
463
+ @self.tool("delete_run", description="Delete a test run")
464
+ def delete_run(run_id: int) -> Dict:
465
+ """
466
+ Delete a test run.
467
+
468
+ Args:
469
+ run_id: The ID of the test run
470
+ """
471
+ return self.client.delete_run(run_id)
472
+
473
+ # Results tools
474
+ @self.tool("get_results", description="Get all test results for a test")
475
+ def get_results(test_id: int) -> List[Dict]:
476
+ """
477
+ Get all test results for a test.
478
+
479
+ Args:
480
+ test_id: The ID of the test
481
+ """
482
+ return self.client.get_results(test_id)
483
+
484
+ @self.tool("add_result", description="Add a new test result")
485
+ def add_result(
486
+ test_id: int,
487
+ status_id: int,
488
+ comment: Optional[str] = None,
489
+ version: Optional[str] = None,
490
+ elapsed: Optional[str] = None,
491
+ defects: Optional[str] = None,
492
+ assignedto_id: Optional[int] = None
493
+ ) -> Dict:
494
+ """
495
+ Add a new test result.
496
+
497
+ Args:
498
+ test_id: The ID of the test
499
+ status_id: The ID of the test status
500
+ comment: The comment / description for the test result (optional)
501
+ version: The version or build you tested against (optional)
502
+ elapsed: The time it took to execute the test, e.g. '30s' or '1m 45s' (optional)
503
+ defects: A comma-separated list of defects to link to the test result (optional)
504
+ assignedto_id: The ID of a user the test should be assigned to (optional)
505
+ """
506
+ data = {
507
+ 'status_id': status_id
508
+ }
509
+ if comment is not None:
510
+ data['comment'] = comment
511
+ if version is not None:
512
+ data['version'] = version
513
+ if elapsed is not None:
514
+ data['elapsed'] = elapsed
515
+ if defects is not None:
516
+ data['defects'] = defects
517
+ if assignedto_id is not None:
518
+ data['assignedto_id'] = assignedto_id
519
+ return self.client.add_result(test_id, data)
520
+
521
+ # Dataset tools
522
+ @self.tool("get_dataset", description="Get a dataset by ID")
523
+ def get_dataset(dataset_id: int) -> Dict:
524
+ """
525
+ Get a dataset by ID.
526
+
527
+ Args:
528
+ dataset_id: The ID of the dataset
529
+ """
530
+ return self.client.get_dataset(dataset_id)
531
+
532
+ @self.tool("get_datasets", description="Get all datasets for a project")
533
+ def get_datasets(project_id: int) -> List[Dict]:
534
+ """
535
+ Get all datasets for a project.
536
+
537
+ Args:
538
+ project_id: The ID of the project
539
+ """
540
+ return self.client.get_datasets(project_id)
541
+
542
+ @self.tool("add_dataset", description="Add a new dataset")
543
+ def add_dataset(
544
+ project_id: int,
545
+ name: str,
546
+ description: Optional[str] = None
547
+ ) -> Dict:
548
+ """
549
+ Add a new dataset.
550
+
551
+ Args:
552
+ project_id: The ID of the project
553
+ name: The name of the dataset
554
+ description: The description of the dataset (optional)
555
+ """
556
+ data = {
557
+ 'name': name
558
+ }
559
+ if description is not None:
560
+ data['description'] = description
561
+ return self.client.add_dataset(project_id, data)
562
+
563
+ @self.tool("update_dataset", description="Update an existing dataset")
564
+ def update_dataset(
565
+ dataset_id: int,
566
+ name: Optional[str] = None,
567
+ description: Optional[str] = None
568
+ ) -> Dict:
569
+ """
570
+ Update an existing dataset.
571
+
572
+ Args:
573
+ dataset_id: The ID of the dataset
574
+ name: The name of the dataset (optional)
575
+ description: The description of the dataset (optional)
576
+ """
577
+ data = {}
578
+ if name is not None:
579
+ data['name'] = name
580
+ if description is not None:
581
+ data['description'] = description
582
+ return self.client.update_dataset(dataset_id, data)
583
+
584
+ @self.tool("delete_dataset", description="Delete a dataset")
585
+ def delete_dataset(dataset_id: int) -> Dict:
586
+ """
587
+ Delete a dataset.
588
+
589
+ Args:
590
+ dataset_id: The ID of the dataset
591
+ """
592
+ return self.client.delete_dataset(dataset_id)
593
+
594
+ def _register_resources(self):
595
+ """Register all TestRail resources with the MCP server."""
596
+ @self.resource("testrail://project/{project_id}")
597
+ def get_project_resource(project_id: int) -> Dict:
598
+ """
599
+ Get a project by ID.
600
+
601
+ Args:
602
+ project_id: The ID of the project
603
+ """
604
+ return self.client.get_project(project_id)
605
+
606
+ @self.resource("testrail://case/{case_id}")
607
+ def get_case_resource(case_id: int) -> Dict:
608
+ """
609
+ Get a test case by ID.
610
+
611
+ Args:
612
+ case_id: The ID of the test case
613
+ """
614
+ return self.client.get_case(case_id)
615
+
616
+ @self.resource("testrail://run/{run_id}")
617
+ def get_run_resource(run_id: int) -> Dict:
618
+ """
619
+ Get a test run by ID.
620
+
621
+ Args:
622
+ run_id: The ID of the test run
623
+ """
624
+ return self.client.get_run(run_id)
625
+
626
+ @self.resource("testrail://results/{test_id}")
627
+ def get_results_resource(test_id: int) -> List[Dict]:
628
+ """
629
+ Get all test results for a test.
630
+
631
+ Args:
632
+ test_id: The ID of the test
633
+ """
634
+ return self.client.get_results(test_id)
635
+
636
+ @self.resource("testrail://dataset/{dataset_id}")
637
+ def get_dataset_resource(dataset_id: int) -> Dict:
638
+ """
639
+ Get a dataset by ID.
640
+
641
+ Args:
642
+ dataset_id: The ID of the dataset
643
+ """
644
+ return self.client.get_dataset(dataset_id)
645
+
@@ -0,0 +1,216 @@
1
+ """TestRail API client module."""
2
+ import base64
3
+ import json
4
+ from typing import Dict, List, Any, Optional, Union
5
+ import requests
6
+
7
+ class TestRailClient:
8
+ """TestRail API client for interacting with TestRail."""
9
+
10
+ def __init__(self, base_url: str, username: str, api_key: str):
11
+ """
12
+ Initialize the TestRail API client.
13
+
14
+ Args:
15
+ base_url: The URL of your TestRail instance (e.g., [https://example.testrail.io/)](https://example.testrail.io/))
16
+ username: Your TestRail username/email
17
+ api_key: Your TestRail API key
18
+ """
19
+ self.username = username
20
+ self.api_key = api_key
21
+
22
+ # Ensure the base URL ends with a slash
23
+ if not base_url.endswith('/'):
24
+ base_url += '/'
25
+ self.base_url = base_url + 'index.php?/api/v2/'
26
+
27
+ # Set up the session with authentication
28
+ self.session = requests.Session()
29
+ auth = str(
30
+ base64.b64encode(
31
+ bytes(f'{username}:{api_key}', 'utf-8')
32
+ ),
33
+ 'ascii'
34
+ ).strip()
35
+ self.session.headers.update({
36
+ 'Authorization': f'Basic {auth}',
37
+ 'Content-Type': 'application/json',
38
+ })
39
+
40
+ def _send_request(self, method: str, uri: str, data: Optional[Dict] = None) -> Any:
41
+ """
42
+ Send a request to the TestRail API.
43
+
44
+ Args:
45
+ method: HTTP method (GET, POST, etc.)
46
+ uri: API endpoint URI
47
+ data: Request data for POST/PUT requests
48
+
49
+ Returns:
50
+ Response data from TestRail
51
+
52
+ Raises:
53
+ Exception: If the request fails
54
+ """
55
+ url = self.base_url + uri
56
+
57
+ if method.upper() == 'GET':
58
+ response = self.session.get(url)
59
+ elif method.upper() == 'POST':
60
+ response = self.session.post(url, data=json.dumps(data) if data else None)
61
+ elif method.upper() == 'PUT':
62
+ response = self.session.put(url, data=json.dumps(data) if data else None)
63
+ elif method.upper() == 'DELETE':
64
+ response = self.session.delete(url)
65
+ else:
66
+ raise ValueError(f"Unsupported HTTP method: {method}")
67
+
68
+ if response.status_code >= 300:
69
+ try:
70
+ error = response.json()
71
+ except:
72
+ error = response.text
73
+ raise Exception(f"TestRail API returned HTTP {response.status_code}: {error}")
74
+
75
+ return response.json() if response.content else {}
76
+
77
+ # Cases API
78
+ def get_case(self, case_id: int) -> Dict:
79
+ """Get a test case by ID."""
80
+ return self._send_request('GET', f'get_case/{case_id}')
81
+
82
+ def get_cases(self, project_id: int, suite_id: Optional[int] = None) -> List[Dict]:
83
+ """Get all test cases for a project/suite."""
84
+ uri = f'get_cases/{project_id}'
85
+ if suite_id:
86
+ uri += f'&suite_id={suite_id}'
87
+ return self._send_request('GET', uri)
88
+
89
+ def add_case(self, section_id: int, data: Dict) -> Dict:
90
+ """Add a new test case."""
91
+ return self._send_request('POST', f'add_case/{section_id}', data)
92
+
93
+ def update_case(self, case_id: int, data: Dict) -> Dict:
94
+ """Update an existing test case."""
95
+ return self._send_request('POST', f'update_case/{case_id}', data)
96
+
97
+ def delete_case(self, case_id: int) -> Dict:
98
+ """Delete a test case."""
99
+ return self._send_request('POST', f'delete_case/{case_id}')
100
+
101
+ # Projects API
102
+ def get_project(self, project_id: int) -> Dict:
103
+ """Get a project by ID."""
104
+ return self._send_request('GET', f'get_project/{project_id}')
105
+
106
+ def get_projects(self) -> List[Dict]:
107
+ """Get all projects."""
108
+ return self._send_request('GET', 'get_projects')
109
+
110
+ def add_project(self, data: Dict) -> Dict:
111
+ """Add a new project."""
112
+ return self._send_request('POST', 'add_project', data)
113
+
114
+ def update_project(self, project_id: int, data: Dict) -> Dict:
115
+ """Update an existing project."""
116
+ return self._send_request('POST', f'update_project/{project_id}', data)
117
+
118
+ def delete_project(self, project_id: int) -> Dict:
119
+ """Delete a project."""
120
+ return self._send_request('POST', f'delete_project/{project_id}')
121
+
122
+ # Runs API
123
+ def get_run(self, run_id: int) -> Dict:
124
+ """Get a test run by ID."""
125
+ return self._send_request('GET', f'get_run/{run_id}')
126
+
127
+ def get_runs(self, project_id: int) -> List[Dict]:
128
+ """Get all test runs for a project."""
129
+ return self._send_request('GET', f'get_runs/{project_id}')
130
+
131
+ def add_run(self, project_id: int, data: Dict) -> Dict:
132
+ """Add a new test run."""
133
+ return self._send_request('POST', f'add_run/{project_id}', data)
134
+
135
+ def update_run(self, run_id: int, data: Dict) -> Dict:
136
+ """Update an existing test run."""
137
+ return self._send_request('POST', f'update_run/{run_id}', data)
138
+
139
+ def close_run(self, run_id: int) -> Dict:
140
+ """Close a test run."""
141
+ return self._send_request('POST', f'close_run/{run_id}')
142
+
143
+ def delete_run(self, run_id: int) -> Dict:
144
+ """Delete a test run."""
145
+ return self._send_request('POST', f'delete_run/{run_id}')
146
+
147
+ # Results API
148
+ def get_results(self, test_id: int) -> List[Dict]:
149
+ """Get all results for a test."""
150
+ return self._send_request('GET', f'get_results/{test_id}')
151
+
152
+ def get_results_for_run(self, run_id: int) -> List[Dict]:
153
+ """Get all results for a run."""
154
+ return self._send_request('GET', f'get_results_for_run/{run_id}')
155
+
156
+ def add_result(self, test_id: int, data: Dict) -> Dict:
157
+ """Add a new result for a test."""
158
+ return self._send_request('POST', f'add_result/{test_id}', data)
159
+
160
+ def add_results(self, run_id: int, data: Dict) -> List[Dict]:
161
+ """Add multiple results for a run."""
162
+ return self._send_request('POST', f'add_results/{run_id}', data)
163
+
164
+ def add_results_for_cases(self, run_id: int, data: Dict) -> List[Dict]:
165
+ """Add results for specific cases in a run."""
166
+ return self._send_request('POST', f'add_results_for_cases/{run_id}', data)
167
+
168
+ # Datasets API (assuming TestRail has dataset endpoints)
169
+ def get_datasets(self, project_id: int) -> List[Dict]:
170
+ """Get all datasets for a project."""
171
+ return self._send_request('GET', f'get_datasets/{project_id}')
172
+
173
+ def get_dataset(self, dataset_id: int) -> Dict:
174
+ """Get a dataset by ID."""
175
+ return self._send_request('GET', f'get_dataset/{dataset_id}')
176
+
177
+ def add_dataset(self, project_id: int, data: Dict) -> Dict:
178
+ """Add a new dataset."""
179
+ return self._send_request('POST', f'add_dataset/{project_id}', data)
180
+
181
+ def update_dataset(self, dataset_id: int, data: Dict) -> Dict:
182
+ """Update an existing dataset."""
183
+ return self._send_request('POST', f'update_dataset/{dataset_id}', data)
184
+
185
+ def delete_dataset(self, dataset_id: int) -> Dict:
186
+ """Delete a dataset."""
187
+ return self._send_request('POST', f'delete_dataset/{dataset_id}')
188
+
189
+ # Sections API
190
+ def get_section(self, section_id:int) -> Dict:
191
+ """Get a specific section"""
192
+ return self._send_request('GET', f'get_section/{section_id}')
193
+
194
+ def get_sections(self, project_id: int, suite_id:Optional[int] = None, params:Optional[Dict] = None) -> Dict:
195
+ """Get all sections for a project"""
196
+ query_params = {**params, "suite_id": suite_id} if suite_id else params
197
+ return self._send_request('GET', f'get_sections/{project_id}',{params : query_params})
198
+
199
+ def add_section(self, project_id:int, data:Dict) -> Dict:
200
+ """Add a new section"""
201
+ return self._send_request('POST', f'add_section/{project_id}', data)
202
+
203
+ def update_section(self, section_id:int, data:Dict) -> Dict:
204
+ """Update an existing section"""
205
+ return self._send_request('POST', f'update_section/{section_id}', data)
206
+
207
+ def delete_section(self, section_id:int, soft:bool) -> Dict:
208
+ """Delete an existing section"""
209
+ url = f'delete_section/{section_id}'
210
+ if (soft):
211
+ url = f'delete_section/{section_id}?soft=1'
212
+ return self._send_request('POST', url)
213
+
214
+ def move_section(self, section_id:int, data: Dict) -> Dict:
215
+ """Move a section to a different parent or position"""
216
+ return self._send_request('POST', f'move_section/{section_id}', data)