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.
Files changed (44) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +332 -0
  3. package/README.npm.md +436 -0
  4. package/dist/bin/youtrack-mcp.js +158 -0
  5. package/dist/index.js +129 -0
  6. package/dist/python/main.py +74 -0
  7. package/dist/python/requirements.txt +13 -0
  8. package/dist/python/youtrack_mcp/__init__.py +5 -0
  9. package/dist/python/youtrack_mcp/__pycache__/__init__.cpython-311.pyc +0 -0
  10. package/dist/python/youtrack_mcp/__pycache__/version.cpython-311.pyc +0 -0
  11. package/dist/python/youtrack_mcp/api/__init__.py +3 -0
  12. package/dist/python/youtrack_mcp/api/articles.py +317 -0
  13. package/dist/python/youtrack_mcp/api/client.py +466 -0
  14. package/dist/python/youtrack_mcp/api/issues.py +3008 -0
  15. package/dist/python/youtrack_mcp/api/mcp_wrappers.py +304 -0
  16. package/dist/python/youtrack_mcp/api/projects.py +1009 -0
  17. package/dist/python/youtrack_mcp/api/search.py +244 -0
  18. package/dist/python/youtrack_mcp/api/spaces.py +46 -0
  19. package/dist/python/youtrack_mcp/api/users.py +149 -0
  20. package/dist/python/youtrack_mcp/config.py +273 -0
  21. package/dist/python/youtrack_mcp/mcp_server.py +158 -0
  22. package/dist/python/youtrack_mcp/mcp_wrappers.py +324 -0
  23. package/dist/python/youtrack_mcp/tools/__init__.py +8 -0
  24. package/dist/python/youtrack_mcp/tools/articles.py +487 -0
  25. package/dist/python/youtrack_mcp/tools/create_project_tool.py +51 -0
  26. package/dist/python/youtrack_mcp/tools/issues/__init__.py +208 -0
  27. package/dist/python/youtrack_mcp/tools/issues/attachments.py +161 -0
  28. package/dist/python/youtrack_mcp/tools/issues/basic_operations.py +322 -0
  29. package/dist/python/youtrack_mcp/tools/issues/custom_fields.py +365 -0
  30. package/dist/python/youtrack_mcp/tools/issues/dedicated_updates.py +601 -0
  31. package/dist/python/youtrack_mcp/tools/issues/diagnostics.py +363 -0
  32. package/dist/python/youtrack_mcp/tools/issues/linking.py +283 -0
  33. package/dist/python/youtrack_mcp/tools/issues/utilities.py +291 -0
  34. package/dist/python/youtrack_mcp/tools/loader.py +383 -0
  35. package/dist/python/youtrack_mcp/tools/projects.py +826 -0
  36. package/dist/python/youtrack_mcp/tools/projects.py-e +411 -0
  37. package/dist/python/youtrack_mcp/tools/resources.py +744 -0
  38. package/dist/python/youtrack_mcp/tools/search.py +297 -0
  39. package/dist/python/youtrack_mcp/tools/spaces.py +69 -0
  40. package/dist/python/youtrack_mcp/tools/users.py +185 -0
  41. package/dist/python/youtrack_mcp/utils.py +87 -0
  42. package/dist/python/youtrack_mcp/version.py +5 -0
  43. package/package.json +61 -0
  44. package/requirements.txt +13 -0
@@ -0,0 +1,466 @@
1
+ """
2
+ Base client for YouTrack REST API.
3
+ """
4
+
5
+ import logging
6
+ import time
7
+ from typing import Any, Dict, Optional
8
+ import json
9
+ import random
10
+
11
+ import requests
12
+ from pydantic import BaseModel, ConfigDict
13
+
14
+ from youtrack_mcp.config import config
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class YouTrackAPIError(Exception):
20
+ """Base exception for YouTrack API errors."""
21
+
22
+ def __init__(
23
+ self,
24
+ message: str,
25
+ status_code: Optional[int] = None,
26
+ response: Optional[requests.Response] = None,
27
+ ):
28
+ self.status_code = status_code
29
+ self.response = response
30
+ super().__init__(message)
31
+
32
+
33
+ class RateLimitError(YouTrackAPIError):
34
+ """Exception for API rate limiting errors."""
35
+
36
+ pass
37
+
38
+
39
+ class ResourceNotFoundError(YouTrackAPIError):
40
+ """Exception for 404 Not Found errors."""
41
+
42
+ pass
43
+
44
+
45
+ class AuthenticationError(YouTrackAPIError):
46
+ """Exception for authentication errors."""
47
+
48
+ pass
49
+
50
+
51
+ class PermissionDeniedError(YouTrackAPIError):
52
+ """Exception for permission-related errors."""
53
+
54
+ pass
55
+
56
+
57
+ class ValidationError(YouTrackAPIError):
58
+ """Exception for validation errors in API requests."""
59
+
60
+ pass
61
+
62
+
63
+ class ServerError(YouTrackAPIError):
64
+ """Exception for server-side errors."""
65
+
66
+ pass
67
+
68
+
69
+ class YouTrackModel(BaseModel):
70
+ """Base model for YouTrack API resources."""
71
+
72
+ model_config = ConfigDict(
73
+ extra="allow", # Allow extra fields in the model
74
+ populate_by_name=True, # Allow population by field name
75
+ )
76
+
77
+ id: str
78
+
79
+
80
+ class YouTrackClient:
81
+ """Base client for YouTrack REST API."""
82
+
83
+ def __init__(
84
+ self,
85
+ base_url: Optional[str] = None,
86
+ api_token: Optional[str] = None,
87
+ verify_ssl: Optional[bool] = None,
88
+ max_retries: int = 3,
89
+ retry_delay: float = 1.0,
90
+ ):
91
+ """
92
+ Initialize YouTrack API client.
93
+
94
+ Args:
95
+ base_url: YouTrack instance URL, defaults to config.get_base_url()
96
+ api_token: API token for authentication, defaults to config.YOUTRACK_API_TOKEN
97
+ verify_ssl: Whether to verify SSL certificates, defaults to config.VERIFY_SSL
98
+ max_retries: Maximum number of retries for transient errors
99
+ retry_delay: Initial delay between retries in seconds (increases exponentially)
100
+ """
101
+ self.base_url = base_url or config.get_base_url()
102
+ self.api_token = api_token if api_token else config.get_api_token()
103
+ self.verify_ssl = (
104
+ verify_ssl if verify_ssl is not None else config.VERIFY_SSL
105
+ )
106
+ self.max_retries = max_retries
107
+ self.retry_delay = retry_delay
108
+
109
+ # Validate required configuration
110
+ if not self.api_token:
111
+ raise ValueError("API token is required")
112
+
113
+ # Session for connection pooling and header reuse
114
+ self.session = requests.Session()
115
+ self.session.headers.update(
116
+ {
117
+ "Authorization": f"Bearer {self.api_token}",
118
+ "Accept": "application/json",
119
+ "Content-Type": "application/json",
120
+ }
121
+ )
122
+
123
+ # Set SSL verification options
124
+ self.session.verify = self.verify_ssl
125
+ if not self.verify_ssl:
126
+ # Use the custom SSL context
127
+ self.session.verify = False
128
+ # Suppress insecure request warnings
129
+ from requests.packages.urllib3.exceptions import (
130
+ InsecureRequestWarning,
131
+ )
132
+
133
+ requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
134
+
135
+ logger.debug(
136
+ f"YouTrack client initialized for {'YouTrack Cloud' if config.is_cloud_instance() else self.base_url}"
137
+ )
138
+
139
+ def _get_api_url(self, endpoint: str) -> str:
140
+ """
141
+ Construct full API URL from endpoint.
142
+
143
+ Args:
144
+ endpoint: API endpoint (without leading slash)
145
+
146
+ Returns:
147
+ Full API URL
148
+ """
149
+ # Remove trailing slash from base_url for consistent URL construction
150
+ base = self.base_url.rstrip('/')
151
+
152
+ if base.endswith("/api"):
153
+ return f"{base}/{endpoint}"
154
+ else:
155
+ return f"{base}/api/{endpoint}"
156
+
157
+ def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
158
+ """
159
+ Handle API response, raising appropriate exceptions for errors.
160
+
161
+ Args:
162
+ response: Response from API
163
+
164
+ Returns:
165
+ Parsed JSON response
166
+
167
+ Raises:
168
+ Various exceptions based on response status
169
+ """
170
+ status_code = response.status_code
171
+
172
+ # Handle success
173
+ if 200 <= status_code < 300:
174
+ # Some endpoints return empty responses
175
+ if not response.content or response.content.strip() == b"":
176
+ return {}
177
+
178
+ try:
179
+ return response.json()
180
+ except json.JSONDecodeError:
181
+ # Handle non-JSON responses
182
+ logger.warning(
183
+ f"Non-JSON response received from API: {response.content[:100]}"
184
+ )
185
+ return {
186
+ "raw_content": response.content.decode(
187
+ "utf-8", errors="replace"
188
+ )
189
+ }
190
+
191
+ # Handle error responses
192
+ error_message = f"API request failed with status {status_code}"
193
+
194
+ # Try to extract error details from response
195
+ try:
196
+ error_data = response.json()
197
+ if isinstance(error_data, dict) and "error" in error_data:
198
+ error_message = f"{error_message}: {error_data['error']}"
199
+ except (json.JSONDecodeError, KeyError):
200
+ if response.content:
201
+ error_message = f"{error_message}: {response.content.decode('utf-8', errors='replace')}"
202
+
203
+ # Raise appropriate exception based on status code
204
+ if status_code == 400:
205
+ raise ValidationError(error_message, status_code, response)
206
+ elif status_code == 401:
207
+ raise AuthenticationError(error_message, status_code, response)
208
+ elif status_code == 403:
209
+ raise PermissionDeniedError(error_message, status_code, response)
210
+ elif status_code == 404:
211
+ raise ResourceNotFoundError(error_message, status_code, response)
212
+ elif status_code == 429:
213
+ raise RateLimitError(error_message, status_code, response)
214
+ elif 500 <= status_code < 600:
215
+ raise ServerError(error_message, status_code, response)
216
+ else:
217
+ raise YouTrackAPIError(error_message, status_code, response)
218
+
219
+ def _make_request(
220
+ self, method: str, endpoint: str, **kwargs
221
+ ) -> Dict[str, Any]:
222
+ """
223
+ Make API request with retry logic for transient errors.
224
+
225
+ Args:
226
+ method: HTTP method (GET, POST, PUT, DELETE)
227
+ endpoint: API endpoint
228
+ **kwargs: Additional arguments to pass to requests
229
+
230
+ Returns:
231
+ Parsed JSON response
232
+
233
+ Raises:
234
+ YouTrackAPIError: For non-transient errors or if all retries fail
235
+ """
236
+ url = self._get_api_url(endpoint)
237
+ retries = 0
238
+ delay = self.retry_delay
239
+ last_error = None
240
+
241
+ # For debugging purposes, log essential request details
242
+ if "json" in kwargs:
243
+ logger.debug(
244
+ f"{method} {url} with JSON: {json.dumps(kwargs['json'])}"
245
+ )
246
+ elif "data" in kwargs:
247
+ logger.debug(f"{method} {url} with data: {kwargs['data']}")
248
+ else:
249
+ logger.debug(f"{method} {url}")
250
+
251
+ while retries <= self.max_retries:
252
+ try:
253
+ response = self.session.request(method, url, **kwargs)
254
+ return self._handle_response(response)
255
+ except (ServerError, RateLimitError) as e:
256
+ # These are potentially transient, so we retry
257
+ last_error = e
258
+ retries += 1
259
+
260
+ if retries > self.max_retries:
261
+ logger.error(f"Maximum retries reached for {method} {url}")
262
+ break
263
+
264
+ # Calculate backoff delay (exponential with jitter)
265
+ backoff = delay * (2**retries) * (0.5 + 0.5 * random.random())
266
+ logger.warning(
267
+ f"Transient error, retrying in {backoff:.2f}s: {str(e)}"
268
+ )
269
+ time.sleep(backoff)
270
+ except YouTrackAPIError as e:
271
+ # Non-transient errors
272
+ logger.error(f"API error for {method} {url}: {str(e)}")
273
+ if hasattr(e, "response") and e.response is not None:
274
+ try:
275
+ error_content = e.response.content.decode(
276
+ "utf-8", errors="replace"
277
+ )
278
+ logger.error(f"Response content: {error_content}")
279
+ except Exception:
280
+ pass
281
+ raise
282
+ except Exception as e:
283
+ # Unexpected errors
284
+ logger.exception(
285
+ f"Unexpected error for {method} {url}: {str(e)}"
286
+ )
287
+ raise YouTrackAPIError(f"Unexpected error: {str(e)}")
288
+
289
+ # If we got here, we've exceeded retries
290
+ if last_error:
291
+ raise last_error
292
+
293
+ # This should never happen, but just in case
294
+ raise YouTrackAPIError(f"Maximum retries exceeded for {method} {url}")
295
+
296
+ def get(
297
+ self, endpoint: str, params: Optional[Dict[str, Any]] = None, **kwargs
298
+ ) -> Dict[str, Any]:
299
+ """
300
+ Make GET request to API.
301
+
302
+ Args:
303
+ endpoint: API endpoint
304
+ params: Query parameters
305
+ **kwargs: Additional arguments to pass to requests
306
+
307
+ Returns:
308
+ Parsed JSON response
309
+ """
310
+ return self._make_request("GET", endpoint, params=params, **kwargs)
311
+
312
+ def post(
313
+ self,
314
+ endpoint: str,
315
+ data: Optional[Dict[str, Any]] = None,
316
+ json_data: Optional[Dict[str, Any]] = None,
317
+ **kwargs,
318
+ ) -> Dict[str, Any]:
319
+ """
320
+ Make POST request to API.
321
+
322
+ Args:
323
+ endpoint: API endpoint
324
+ data: Form data
325
+ json_data: JSON data
326
+ **kwargs: Additional arguments to pass to requests
327
+
328
+ Returns:
329
+ Parsed JSON response
330
+ """
331
+ # If data is provided but json_data is not, use data as json
332
+ if data is not None and json_data is None:
333
+ # Log the data being sent for debugging
334
+ logger.debug(f"POST {endpoint} with data: {json.dumps(data)}")
335
+
336
+ # Some endpoints expect parameters in different formats
337
+ # YouTrack API usually expects data as JSON
338
+ return self._make_request("POST", endpoint, json=data, **kwargs)
339
+
340
+ return self._make_request(
341
+ "POST", endpoint, data=data, json=json_data, **kwargs
342
+ )
343
+
344
+ def put(
345
+ self,
346
+ endpoint: str,
347
+ data: Optional[Dict[str, Any]] = None,
348
+ json_data: Optional[Dict[str, Any]] = None,
349
+ **kwargs,
350
+ ) -> Dict[str, Any]:
351
+ """
352
+ Make PUT request to API.
353
+
354
+ Args:
355
+ endpoint: API endpoint
356
+ data: Form data
357
+ json_data: JSON data
358
+ **kwargs: Additional arguments to pass to requests
359
+
360
+ Returns:
361
+ Parsed JSON response
362
+ """
363
+ return self._make_request(
364
+ "PUT", endpoint, data=data, json=json_data, **kwargs
365
+ )
366
+
367
+ def delete(self, endpoint: str, **kwargs) -> Dict[str, Any]:
368
+ """
369
+ Make DELETE request to API.
370
+
371
+ Args:
372
+ endpoint: API endpoint
373
+ **kwargs: Additional arguments to pass to requests
374
+
375
+ Returns:
376
+ Parsed JSON response
377
+ """
378
+ return self._make_request("DELETE", endpoint, **kwargs)
379
+
380
+ def post_multipart(
381
+ self,
382
+ endpoint: str,
383
+ files: Dict[str, Any],
384
+ data: Optional[Dict[str, Any]] = None,
385
+ headers: Optional[Dict[str, str]] = None,
386
+ ) -> Dict[str, Any]:
387
+ """
388
+ Make POST request with multipart form-data (for file uploads).
389
+
390
+ Args:
391
+ endpoint: API endpoint
392
+ files: Mapping for requests 'files' parameter, e.g. {'file': (filename, bytes, mimeType)}
393
+ data: Optional additional form fields
394
+ headers: Optional headers to merge for this request
395
+
396
+ Returns:
397
+ Parsed JSON response
398
+ """
399
+ url = self._get_api_url(endpoint)
400
+ request_headers = dict(self.session.headers)
401
+ # Remove JSON content-type so requests can set multipart boundary
402
+ if "Content-Type" in request_headers:
403
+ request_headers.pop("Content-Type", None)
404
+ if headers:
405
+ request_headers.update(headers)
406
+
407
+ response = self.session.request(
408
+ "POST",
409
+ url,
410
+ files=files,
411
+ data=data,
412
+ headers=request_headers,
413
+ verify=self.session.verify,
414
+ )
415
+ return self._handle_response(response)
416
+
417
+ def get_bytes(
418
+ self,
419
+ endpoint: str,
420
+ params: Optional[Dict[str, Any]] = None,
421
+ headers: Optional[Dict[str, str]] = None,
422
+ ) -> bytes:
423
+ """
424
+ Make GET request and return raw bytes (for binary downloads).
425
+
426
+ Args:
427
+ endpoint: API endpoint
428
+ params: Query parameters
429
+ headers: Optional headers (e.g., Accept: application/octet-stream)
430
+
431
+ Returns:
432
+ Raw response bytes
433
+ """
434
+ url = self._get_api_url(endpoint)
435
+ request_headers = dict(self.session.headers)
436
+ if headers:
437
+ request_headers.update(headers)
438
+ response = self.session.request(
439
+ "GET",
440
+ url,
441
+ params=params,
442
+ headers=request_headers,
443
+ verify=self.session.verify,
444
+ stream=True,
445
+ )
446
+ status_code = response.status_code
447
+ if 200 <= status_code < 300:
448
+ return response.content
449
+ # Delegate error handling to common handler (will raise)
450
+ self._handle_response(response)
451
+ # Should not reach here
452
+ raise YouTrackAPIError(
453
+ f"Unexpected error downloading from {endpoint}", status_code, response
454
+ )
455
+
456
+ def close(self) -> None:
457
+ """Close the API client session."""
458
+ self.session.close()
459
+
460
+ def __enter__(self):
461
+ """Enter context manager."""
462
+ return self
463
+
464
+ def __exit__(self, exc_type, exc_val, exc_tb):
465
+ """Exit context manager, closing session."""
466
+ self.close()