agentek-youtrack-mcp 1.0.0
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.
- package/LICENSE +22 -0
- package/README.md +332 -0
- package/README.npm.md +436 -0
- package/dist/bin/youtrack-mcp.js +158 -0
- package/dist/index.js +129 -0
- package/dist/python/main.py +74 -0
- package/dist/python/requirements.txt +13 -0
- package/dist/python/youtrack_mcp/__init__.py +5 -0
- package/dist/python/youtrack_mcp/__pycache__/__init__.cpython-311.pyc +0 -0
- package/dist/python/youtrack_mcp/__pycache__/version.cpython-311.pyc +0 -0
- package/dist/python/youtrack_mcp/api/__init__.py +3 -0
- package/dist/python/youtrack_mcp/api/articles.py +317 -0
- package/dist/python/youtrack_mcp/api/client.py +466 -0
- package/dist/python/youtrack_mcp/api/issues.py +3008 -0
- package/dist/python/youtrack_mcp/api/mcp_wrappers.py +304 -0
- package/dist/python/youtrack_mcp/api/projects.py +1009 -0
- package/dist/python/youtrack_mcp/api/search.py +244 -0
- package/dist/python/youtrack_mcp/api/spaces.py +46 -0
- package/dist/python/youtrack_mcp/api/users.py +149 -0
- package/dist/python/youtrack_mcp/config.py +273 -0
- package/dist/python/youtrack_mcp/mcp_server.py +158 -0
- package/dist/python/youtrack_mcp/mcp_wrappers.py +324 -0
- package/dist/python/youtrack_mcp/tools/__init__.py +8 -0
- package/dist/python/youtrack_mcp/tools/articles.py +487 -0
- package/dist/python/youtrack_mcp/tools/create_project_tool.py +51 -0
- package/dist/python/youtrack_mcp/tools/issues/__init__.py +208 -0
- package/dist/python/youtrack_mcp/tools/issues/attachments.py +161 -0
- package/dist/python/youtrack_mcp/tools/issues/basic_operations.py +322 -0
- package/dist/python/youtrack_mcp/tools/issues/custom_fields.py +365 -0
- package/dist/python/youtrack_mcp/tools/issues/dedicated_updates.py +601 -0
- package/dist/python/youtrack_mcp/tools/issues/diagnostics.py +363 -0
- package/dist/python/youtrack_mcp/tools/issues/linking.py +283 -0
- package/dist/python/youtrack_mcp/tools/issues/utilities.py +291 -0
- package/dist/python/youtrack_mcp/tools/loader.py +383 -0
- package/dist/python/youtrack_mcp/tools/projects.py +826 -0
- package/dist/python/youtrack_mcp/tools/projects.py-e +411 -0
- package/dist/python/youtrack_mcp/tools/resources.py +744 -0
- package/dist/python/youtrack_mcp/tools/search.py +297 -0
- package/dist/python/youtrack_mcp/tools/spaces.py +69 -0
- package/dist/python/youtrack_mcp/tools/users.py +185 -0
- package/dist/python/youtrack_mcp/utils.py +87 -0
- package/dist/python/youtrack_mcp/version.py +5 -0
- package/package.json +61 -0
- package/requirements.txt +13 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Search functionality for YouTrack API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from youtrack_mcp.api.client import YouTrackClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SearchClient:
|
|
12
|
+
"""Client for advanced search operations in YouTrack."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, client: YouTrackClient):
|
|
15
|
+
"""
|
|
16
|
+
Initialize the Search API client.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
client: The YouTrack API client
|
|
20
|
+
"""
|
|
21
|
+
self.client = client
|
|
22
|
+
|
|
23
|
+
def search_issues(
|
|
24
|
+
self,
|
|
25
|
+
query: str,
|
|
26
|
+
fields: Optional[List[str]] = None,
|
|
27
|
+
limit: int = 10,
|
|
28
|
+
offset: int = 0,
|
|
29
|
+
sort_by: Optional[str] = None,
|
|
30
|
+
sort_order: Optional[str] = None,
|
|
31
|
+
custom_fields: Optional[List[str]] = None,
|
|
32
|
+
) -> List[Dict[str, Any]]:
|
|
33
|
+
"""
|
|
34
|
+
Search for issues with advanced query capabilities.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
query: The YouTrack query string
|
|
38
|
+
fields: List of fields to include in the response (default: all fields)
|
|
39
|
+
limit: Maximum number of issues to return
|
|
40
|
+
offset: Offset for pagination
|
|
41
|
+
sort_by: Field to sort by (e.g., 'created', 'updated', 'priority')
|
|
42
|
+
sort_order: Sort order ('asc' or 'desc')
|
|
43
|
+
custom_fields: List of custom field names to include
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
List of matching issues with requested fields
|
|
47
|
+
"""
|
|
48
|
+
# Base set of fields to retrieve
|
|
49
|
+
default_fields = [
|
|
50
|
+
"id",
|
|
51
|
+
"idReadable",
|
|
52
|
+
"summary",
|
|
53
|
+
"description",
|
|
54
|
+
"created",
|
|
55
|
+
"updated",
|
|
56
|
+
"resolved",
|
|
57
|
+
"priority",
|
|
58
|
+
"project(id,name,shortName)",
|
|
59
|
+
"reporter(id,login,name)",
|
|
60
|
+
"assignee(id,login,name)",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
# Combine default fields with requested fields
|
|
64
|
+
if fields:
|
|
65
|
+
requested_fields = default_fields + fields
|
|
66
|
+
else:
|
|
67
|
+
requested_fields = default_fields
|
|
68
|
+
|
|
69
|
+
# Add custom fields if requested
|
|
70
|
+
if custom_fields:
|
|
71
|
+
# Build the customFields part with specific field names
|
|
72
|
+
cf_string = ",".join(
|
|
73
|
+
[f"customFields(id,name,value)" for _ in custom_fields]
|
|
74
|
+
)
|
|
75
|
+
requested_fields.append(cf_string)
|
|
76
|
+
else:
|
|
77
|
+
# By default, include all custom fields
|
|
78
|
+
requested_fields.append("customFields(id,name,value)")
|
|
79
|
+
|
|
80
|
+
# Build the fields parameter
|
|
81
|
+
fields_param = ",".join(requested_fields)
|
|
82
|
+
|
|
83
|
+
# Build params dictionary
|
|
84
|
+
params = {
|
|
85
|
+
"query": query,
|
|
86
|
+
"$top": limit,
|
|
87
|
+
"$skip": offset,
|
|
88
|
+
"fields": fields_param,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# Add sort parameters if provided
|
|
92
|
+
if sort_by:
|
|
93
|
+
params["$sort"] = sort_by
|
|
94
|
+
if sort_order and sort_order.lower() in ("asc", "desc"):
|
|
95
|
+
params["$sortOrder"] = sort_order.lower()
|
|
96
|
+
|
|
97
|
+
# Make the API request
|
|
98
|
+
return self.client.get("issues", params=params)
|
|
99
|
+
|
|
100
|
+
def search_with_custom_field_values(
|
|
101
|
+
self, query: str, custom_field_values: Dict[str, Any], limit: int = 10
|
|
102
|
+
) -> List[Dict[str, Any]]:
|
|
103
|
+
"""
|
|
104
|
+
Search for issues with specific custom field values.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
query: The base YouTrack query string
|
|
108
|
+
custom_field_values: Dictionary mapping custom field names to values
|
|
109
|
+
limit: Maximum number of issues to return
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
List of matching issues
|
|
113
|
+
"""
|
|
114
|
+
# Build query with custom field conditions
|
|
115
|
+
enhanced_query = query
|
|
116
|
+
|
|
117
|
+
for field_name, field_value in custom_field_values.items():
|
|
118
|
+
if field_value is not None:
|
|
119
|
+
# Handle different types of values
|
|
120
|
+
if isinstance(field_value, bool):
|
|
121
|
+
value_str = "true" if field_value else "false"
|
|
122
|
+
elif isinstance(field_value, (int, float)):
|
|
123
|
+
value_str = str(field_value)
|
|
124
|
+
elif isinstance(field_value, list):
|
|
125
|
+
# For multi-value fields, we use 'in' operator
|
|
126
|
+
values = ", ".join([f'"{val}"' for val in field_value])
|
|
127
|
+
enhanced_query += f" {field_name} in ({values})"
|
|
128
|
+
continue
|
|
129
|
+
else:
|
|
130
|
+
# String values need to be quoted
|
|
131
|
+
value_str = f'"{field_value}"'
|
|
132
|
+
|
|
133
|
+
# Add the condition to the query
|
|
134
|
+
enhanced_query += f" {field_name}: {value_str}"
|
|
135
|
+
|
|
136
|
+
# Call the search_issues method with the enhanced query
|
|
137
|
+
return self.search_issues(enhanced_query, limit=limit)
|
|
138
|
+
|
|
139
|
+
def search_with_filter(
|
|
140
|
+
self,
|
|
141
|
+
project: Optional[str] = None,
|
|
142
|
+
author: Optional[str] = None,
|
|
143
|
+
assignee: Optional[str] = None,
|
|
144
|
+
state: Optional[str] = None,
|
|
145
|
+
priority: Optional[str] = None,
|
|
146
|
+
text: Optional[str] = None,
|
|
147
|
+
created_after: Optional[str] = None,
|
|
148
|
+
created_before: Optional[str] = None,
|
|
149
|
+
updated_after: Optional[str] = None,
|
|
150
|
+
updated_before: Optional[str] = None,
|
|
151
|
+
custom_fields: Optional[Dict[str, Any]] = None,
|
|
152
|
+
limit: int = 10,
|
|
153
|
+
) -> List[Dict[str, Any]]:
|
|
154
|
+
"""
|
|
155
|
+
Search for issues using a structured filter approach.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
project: Project ID or name
|
|
159
|
+
author: Issue author (reporter) login or name
|
|
160
|
+
assignee: Issue assignee login or name
|
|
161
|
+
state: Issue state (e.g., "Open", "Resolved")
|
|
162
|
+
priority: Issue priority
|
|
163
|
+
text: Text to search in summary and description
|
|
164
|
+
created_after: Issues created after this date (YYYY-MM-DD)
|
|
165
|
+
created_before: Issues created before this date (YYYY-MM-DD)
|
|
166
|
+
updated_after: Issues updated after this date (YYYY-MM-DD)
|
|
167
|
+
updated_before: Issues updated before this date (YYYY-MM-DD)
|
|
168
|
+
custom_fields: Dictionary mapping custom field names to values
|
|
169
|
+
limit: Maximum number of issues to return
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
List of matching issues
|
|
173
|
+
"""
|
|
174
|
+
# Build query based on provided filters
|
|
175
|
+
query_parts = []
|
|
176
|
+
|
|
177
|
+
if project:
|
|
178
|
+
query_parts.append(f'project: "{project}"')
|
|
179
|
+
|
|
180
|
+
if author:
|
|
181
|
+
query_parts.append(f'reporter: "{author}"')
|
|
182
|
+
|
|
183
|
+
if assignee:
|
|
184
|
+
if assignee.lower() == "unassigned":
|
|
185
|
+
query_parts.append("assignee: Unassigned")
|
|
186
|
+
else:
|
|
187
|
+
query_parts.append(f'assignee: "{assignee}"')
|
|
188
|
+
|
|
189
|
+
if state:
|
|
190
|
+
query_parts.append(f'State: "{state}"')
|
|
191
|
+
|
|
192
|
+
if priority:
|
|
193
|
+
query_parts.append(f'Priority: "{priority}"')
|
|
194
|
+
|
|
195
|
+
if text:
|
|
196
|
+
# Search in summary and description
|
|
197
|
+
query_parts.append(f'summary: "{text}" description: "{text}"')
|
|
198
|
+
|
|
199
|
+
if created_after:
|
|
200
|
+
query_parts.append(f"created: {created_after} ..")
|
|
201
|
+
|
|
202
|
+
if created_before:
|
|
203
|
+
query_parts.append(f"created: .. {created_before}")
|
|
204
|
+
|
|
205
|
+
if updated_after:
|
|
206
|
+
query_parts.append(f"updated: {updated_after} ..")
|
|
207
|
+
|
|
208
|
+
if updated_before:
|
|
209
|
+
query_parts.append(f"updated: .. {updated_before}")
|
|
210
|
+
|
|
211
|
+
# Combine all parts into a single query
|
|
212
|
+
base_query = " ".join(query_parts)
|
|
213
|
+
|
|
214
|
+
# If custom fields are provided, use the specialized method
|
|
215
|
+
if custom_fields:
|
|
216
|
+
return self.search_with_custom_field_values(
|
|
217
|
+
base_query, custom_fields, limit
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
return self.search_issues(base_query, limit=limit)
|
|
221
|
+
|
|
222
|
+
def get_available_custom_fields(
|
|
223
|
+
self, project_id: Optional[str] = None
|
|
224
|
+
) -> List[Dict[str, Any]]:
|
|
225
|
+
"""
|
|
226
|
+
Get all available custom fields, optionally for a specific project.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
project_id: Optional project ID to get fields for a specific project
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
List of custom fields with their properties
|
|
233
|
+
"""
|
|
234
|
+
if project_id:
|
|
235
|
+
# Get custom fields for a specific project
|
|
236
|
+
endpoint = f"admin/projects/{project_id}/customFields"
|
|
237
|
+
else:
|
|
238
|
+
# Get all custom fields in the system
|
|
239
|
+
endpoint = "admin/customFieldSettings/customFields"
|
|
240
|
+
|
|
241
|
+
fields = "id,name,localizedName,fieldType(id,name),isPrivate,isPublic,aliases"
|
|
242
|
+
params = {"fields": fields}
|
|
243
|
+
|
|
244
|
+
return self.client.get(endpoint, params=params)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
YouTrack Knowledge Base Spaces API client.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from youtrack_mcp.api.client import YouTrackClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Space(BaseModel):
|
|
13
|
+
"""Model for a YouTrack Knowledge Base Space."""
|
|
14
|
+
|
|
15
|
+
id: str
|
|
16
|
+
name: Optional[str] = None
|
|
17
|
+
|
|
18
|
+
model_config = {
|
|
19
|
+
"extra": "allow",
|
|
20
|
+
"populate_by_name": True,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SpacesClient:
|
|
25
|
+
"""Client for interacting with YouTrack KB Spaces API."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, client: YouTrackClient):
|
|
28
|
+
self.client = client
|
|
29
|
+
|
|
30
|
+
def get_space(self, space_id: str, fields: Optional[str] = None) -> Space:
|
|
31
|
+
"""Get a single space by id."""
|
|
32
|
+
fields_query = fields or "id,name"
|
|
33
|
+
response = self.client.get(f"spaces/{space_id}?fields={fields_query}")
|
|
34
|
+
return Space.model_validate(response)
|
|
35
|
+
|
|
36
|
+
def list_spaces(
|
|
37
|
+
self, fields: Optional[str] = None, top: int = 50, skip: int = 0
|
|
38
|
+
) -> List[Space]:
|
|
39
|
+
"""List spaces with pagination."""
|
|
40
|
+
params: Dict[str, Any] = {
|
|
41
|
+
"$top": top,
|
|
42
|
+
"$skip": skip,
|
|
43
|
+
"fields": fields or "id,name",
|
|
44
|
+
}
|
|
45
|
+
response = self.client.get("spaces", params=params)
|
|
46
|
+
return [Space.model_validate(item) for item in response]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
YouTrack Users API client.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from youtrack_mcp.api.client import YouTrackClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class User(BaseModel):
|
|
14
|
+
"""Model for a YouTrack user."""
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
login: Optional[str] = None
|
|
18
|
+
name: Optional[str] = None
|
|
19
|
+
email: Optional[str] = None
|
|
20
|
+
jabber: Optional[str] = None
|
|
21
|
+
ring_id: Optional[str] = None
|
|
22
|
+
guest: Optional[bool] = None
|
|
23
|
+
online: Optional[bool] = None
|
|
24
|
+
banned: Optional[bool] = None
|
|
25
|
+
|
|
26
|
+
model_config = {
|
|
27
|
+
"extra": "allow", # Allow extra fields from the API
|
|
28
|
+
"populate_by_name": True, # Allow population by field name (helps with aliases)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UsersClient:
|
|
33
|
+
"""Client for interacting with YouTrack Users API."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, client: YouTrackClient):
|
|
36
|
+
"""
|
|
37
|
+
Initialize the Users API client.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
client: The YouTrack API client
|
|
41
|
+
"""
|
|
42
|
+
self.client = client
|
|
43
|
+
|
|
44
|
+
def get_current_user(self) -> User:
|
|
45
|
+
"""
|
|
46
|
+
Get the current authenticated user.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The user data
|
|
50
|
+
"""
|
|
51
|
+
fields = "id,login,name,email,jabber,ringId,guest,online,banned"
|
|
52
|
+
response = self.client.get(f"users/me?fields={fields}")
|
|
53
|
+
return User.model_validate(response)
|
|
54
|
+
|
|
55
|
+
def get_user(self, user_id: str) -> User:
|
|
56
|
+
"""
|
|
57
|
+
Get a user by ID.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
user_id: The user ID
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The user data
|
|
64
|
+
"""
|
|
65
|
+
fields = "id,login,name,email,jabber,ringId,guest,online,banned"
|
|
66
|
+
response = self.client.get(f"users/{user_id}?fields={fields}")
|
|
67
|
+
return User.model_validate(response)
|
|
68
|
+
|
|
69
|
+
def search_users(self, query: str, limit: int = 10) -> List[User]:
|
|
70
|
+
"""
|
|
71
|
+
Search for users.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
query: The search query (name, login, or email)
|
|
75
|
+
limit: Maximum number of users to return
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
List of matching users
|
|
79
|
+
"""
|
|
80
|
+
# Request additional fields to ensure we get complete user data
|
|
81
|
+
fields = "id,login,name,email,jabber,ringId,guest,online,banned"
|
|
82
|
+
params = {"query": query, "$top": limit, "fields": fields}
|
|
83
|
+
response = self.client.get("users", params=params)
|
|
84
|
+
|
|
85
|
+
users = []
|
|
86
|
+
for item in response:
|
|
87
|
+
try:
|
|
88
|
+
users.append(User.model_validate(item))
|
|
89
|
+
except Exception as e:
|
|
90
|
+
# Log the error but continue processing other users
|
|
91
|
+
logging.getLogger(__name__).warning(
|
|
92
|
+
f"Failed to validate user: {str(e)}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return users
|
|
96
|
+
|
|
97
|
+
def get_user_by_login(self, login: str) -> Optional[User]:
|
|
98
|
+
"""
|
|
99
|
+
Get a user by login name.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
login: The user login name
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
The user data or None if not found
|
|
106
|
+
"""
|
|
107
|
+
# Search for the exact login
|
|
108
|
+
users = self.search_users(f"login: {login}", limit=1)
|
|
109
|
+
return users[0] if users else None
|
|
110
|
+
|
|
111
|
+
def get_user_groups(self, user_id: str) -> List[Dict[str, Any]]:
|
|
112
|
+
"""
|
|
113
|
+
Get groups for a user.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
user_id: The user ID
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
List of group data
|
|
120
|
+
"""
|
|
121
|
+
response = self.client.get(f"users/{user_id}/groups")
|
|
122
|
+
return response
|
|
123
|
+
|
|
124
|
+
def check_user_permissions(self, user_id: str, permission: str) -> bool:
|
|
125
|
+
"""
|
|
126
|
+
Check if a user has a specific permission.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
user_id: The user ID
|
|
130
|
+
permission: The permission to check
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
True if the user has the permission, False otherwise
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
# YouTrack doesn't have a direct API for checking permissions,
|
|
137
|
+
# but we can check user's groups and infer permissions
|
|
138
|
+
groups = self.get_user_groups(user_id)
|
|
139
|
+
|
|
140
|
+
# Different permissions might require different group checks
|
|
141
|
+
# This is a simplified example
|
|
142
|
+
for group in groups:
|
|
143
|
+
if permission.lower() in (group.get("name", "").lower() or ""):
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
return False
|
|
147
|
+
except Exception:
|
|
148
|
+
# If we can't determine, assume no permission
|
|
149
|
+
return False
|