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,74 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ YouTrack MCP Server - A Model Context Protocol server for JetBrains YouTrack.
4
+ Uses FastMCP directly for clean stdio/SSE transport support.
5
+ """
6
+ import logging
7
+ import os
8
+ import sys
9
+
10
+ from mcp.server.fastmcp import FastMCP
11
+
12
+ from youtrack_mcp.version import __version__ as APP_VERSION
13
+ from youtrack_mcp.config import config
14
+ from youtrack_mcp.tools.loader import load_all_tools
15
+
16
+ # Set up logging
17
+ logging.basicConfig(
18
+ level=logging.INFO,
19
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
20
+ handlers=[logging.StreamHandler(sys.stderr)],
21
+ )
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def create_server(host: str = "0.0.0.0", port: int = 8000) -> FastMCP:
26
+ """Create and configure the FastMCP server with all tools registered."""
27
+ mcp = FastMCP(
28
+ config.MCP_SERVER_NAME,
29
+ instructions=config.MCP_SERVER_DESCRIPTION,
30
+ host=host,
31
+ port=port,
32
+ )
33
+
34
+ # Load and register all tools
35
+ tools = load_all_tools()
36
+ for name, func in tools.items():
37
+ mcp.add_tool(func, name=name)
38
+
39
+ logger.info(f"Registered {len(tools)} tools with FastMCP")
40
+ return mcp
41
+
42
+
43
+ def main():
44
+ """Run the MCP server."""
45
+ import argparse
46
+
47
+ parser = argparse.ArgumentParser(description="YouTrack MCP Server")
48
+ parser.add_argument("--transport", choices=["stdio", "sse"], default=None,
49
+ help="Transport mode (default: from TRANSPORT env var, fallback stdio)")
50
+ parser.add_argument("--host", default="0.0.0.0", help="Host for SSE transport")
51
+ parser.add_argument("--port", type=int, default=None, help="Port for SSE transport")
52
+ parser.add_argument("--version", action="store_true", help="Show version and exit")
53
+ parser.add_argument("--log-level", default="INFO",
54
+ choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"])
55
+ args = parser.parse_args()
56
+
57
+ if args.version:
58
+ print(f"YouTrack MCP Server v{APP_VERSION}")
59
+ sys.exit(0)
60
+
61
+ logging.getLogger().setLevel(getattr(logging, args.log_level))
62
+
63
+ # Determine transport: CLI arg > env var > default stdio
64
+ transport = args.transport or os.getenv("TRANSPORT", "stdio")
65
+ port = args.port or int(os.getenv("PORT", "8000"))
66
+
67
+ logger.info(f"Starting YouTrack MCP Server v{APP_VERSION} [{transport}]")
68
+
69
+ mcp = create_server(host=args.host, port=port)
70
+ mcp.run(transport=transport)
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
@@ -0,0 +1,13 @@
1
+ httpx>=0.24.0
2
+ pydantic>=2.0.0
3
+ python-dotenv>=1.0.0
4
+ mcp>=1.11.0
5
+ requests>=2.32.3
6
+
7
+ # Development dependencies
8
+ pytest>=7.0.0
9
+ black>=22.1.0
10
+ flake8>=4.0.1
11
+ mypy>=0.931
12
+ isort>=5.10.1
13
+ pytest-cov>=3.0.0
@@ -0,0 +1,5 @@
1
+ """
2
+ YouTrack MCP Server package.
3
+ """
4
+
5
+ from .version import __version__
@@ -0,0 +1,3 @@
1
+ """
2
+ YouTrack API client modules.
3
+ """
@@ -0,0 +1,317 @@
1
+
2
+ from typing import Any, Dict, List, Optional
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from youtrack_mcp.api.client import YouTrackClient
7
+
8
+
9
+ class Article(BaseModel):
10
+ """Model for a YouTrack Knowledge Base Article."""
11
+
12
+ id: str
13
+ summary: Optional[str] = None
14
+ content: Optional[str] = None
15
+ updated: Optional[int] = None
16
+ space: Optional[Dict[str, Any]] = None
17
+
18
+ model_config = {
19
+ "extra": "allow",
20
+ "populate_by_name": True,
21
+ }
22
+
23
+
24
+ class Comment(BaseModel):
25
+ """Model for a Knowledge Base Article Comment."""
26
+
27
+ id: str
28
+ text: Optional[str] = None
29
+ updated: Optional[int] = None
30
+ author: Optional[Dict[str, Any]] = None
31
+
32
+ model_config = {
33
+ "extra": "allow",
34
+ "populate_by_name": True,
35
+ }
36
+
37
+
38
+ class Attachment(BaseModel):
39
+ """Model for a Knowledge Base Article Attachment."""
40
+
41
+ id: str
42
+ name: Optional[str] = None
43
+ mimeType: Optional[str] = None
44
+ size: Optional[int] = None
45
+
46
+ model_config = {
47
+ "extra": "allow",
48
+ "populate_by_name": True,
49
+ }
50
+
51
+
52
+ class ArticlesClient:
53
+ """Client for interacting with YouTrack Articles API."""
54
+
55
+ def __init__(self, client: YouTrackClient):
56
+ self.client = client
57
+
58
+ def get_article(self, article_id: str, fields: Optional[str] = None) -> Article:
59
+ """
60
+ Get a single article by id.
61
+
62
+ Args:
63
+ article_id: The internal article id (e.g., "1-23")
64
+ fields: Optional fields selection string
65
+
66
+ Returns:
67
+ Article model
68
+ """
69
+ fields_query = fields or "id,summary,content,updated,space(id,name)"
70
+ response = self.client.get(f"articles/{article_id}?fields={fields_query}")
71
+ return Article.model_validate(response)
72
+
73
+ def list_articles(
74
+ self,
75
+ space_id: Optional[str] = None,
76
+ query: Optional[str] = None,
77
+ fields: Optional[str] = None,
78
+ top: int = 20,
79
+ skip: int = 0,
80
+ ) -> List[Article]:
81
+ """
82
+ List articles with optional filtering by space and query.
83
+
84
+ Args:
85
+ space_id: Optional Knowledge Base space id to filter on
86
+ query: Optional search query (e.g., keywords)
87
+ fields: Optional fields selection string
88
+ top: Max number of results to return
89
+ skip: Offset for pagination
90
+
91
+ Returns:
92
+ List of Article models
93
+ """
94
+ params: Dict[str, Any] = {
95
+ "$top": top,
96
+ "$skip": skip,
97
+ "fields": fields or "id,summary,updated,space(id,name)",
98
+ }
99
+
100
+ # Build query: combine space filter and user query if both provided
101
+ combined_query_parts: List[str] = []
102
+ if space_id:
103
+ combined_query_parts.append(f"space:{space_id}")
104
+ if query:
105
+ combined_query_parts.append(query)
106
+ if combined_query_parts:
107
+ params["query"] = " ".join(combined_query_parts)
108
+
109
+ response = self.client.get("articles", params=params)
110
+
111
+ # API may return list[dict]
112
+ return [Article.model_validate(item) for item in response]
113
+
114
+ def search_articles(
115
+ self,
116
+ query: str,
117
+ fields: Optional[str] = None,
118
+ top: int = 20,
119
+ skip: int = 0,
120
+ ) -> List[Article]:
121
+ """
122
+ Search articles by query.
123
+ """
124
+ return self.list_articles(
125
+ space_id=None,
126
+ query=query,
127
+ fields=fields,
128
+ top=top,
129
+ skip=skip,
130
+ )
131
+
132
+ def search_articles_filtered(
133
+ self,
134
+ space_id: Optional[str] = None,
135
+ author: Optional[str] = None,
136
+ tag: Optional[str] = None,
137
+ status: Optional[str] = None, # e.g., 'draft' or 'published'
138
+ updated_since: Optional[str] = None, # ISO8601 or epoch millis string
139
+ sort: Optional[str] = None, # e.g., 'updated desc'
140
+ query: Optional[str] = None,
141
+ fields: Optional[str] = None,
142
+ top: int = 20,
143
+ skip: int = 0,
144
+ ) -> List[Article]:
145
+ """
146
+ Search articles with rich filters.
147
+ """
148
+ params: Dict[str, Any] = {
149
+ "$top": top,
150
+ "$skip": skip,
151
+ "fields": fields or "id,summary,updated,space(id,name)",
152
+ }
153
+
154
+ parts: List[str] = []
155
+ if space_id:
156
+ parts.append(f"space:{space_id}")
157
+ if author:
158
+ parts.append(f"author:{author}")
159
+ if tag:
160
+ parts.append(f"tag:{tag}")
161
+ if status:
162
+ parts.append(f"status:{status}")
163
+ if updated_since:
164
+ parts.append(f"updated:{updated_since}..")
165
+ if query:
166
+ parts.append(query)
167
+
168
+ if parts:
169
+ params["query"] = " ".join(parts)
170
+ if sort:
171
+ params["sort"] = sort
172
+
173
+ response = self.client.get("articles", params=params)
174
+ return [Article.model_validate(item) for item in response]
175
+
176
+ # === Create / Update / Status ===
177
+ def create_article(
178
+ self,
179
+ space_id: str,
180
+ summary: str,
181
+ content: str,
182
+ parent_article_id: Optional[str] = None,
183
+ status: Optional[str] = None,
184
+ fields: Optional[str] = None,
185
+ extra: Optional[Dict[str, Any]] = None,
186
+ ) -> Article:
187
+ """
188
+ Create a new Knowledge Base article.
189
+ """
190
+ payload: Dict[str, Any] = {
191
+ "space": {"id": space_id},
192
+ "summary": summary,
193
+ "content": content,
194
+ }
195
+ if parent_article_id:
196
+ payload["parentArticle"] = {"id": parent_article_id}
197
+ if status:
198
+ payload["status"] = status
199
+ if extra:
200
+ payload.update(extra)
201
+
202
+ endpoint = "articles"
203
+ if fields:
204
+ endpoint = f"articles?fields={fields}"
205
+ response = self.client.post(endpoint, json_data=payload)
206
+ return Article.model_validate(response)
207
+
208
+ def update_article(
209
+ self,
210
+ article_id: str,
211
+ summary: Optional[str] = None,
212
+ content: Optional[str] = None,
213
+ space_id: Optional[str] = None,
214
+ parent_article_id: Optional[str] = None,
215
+ status: Optional[str] = None,
216
+ fields: Optional[str] = None,
217
+ extra: Optional[Dict[str, Any]] = None,
218
+ ) -> Article:
219
+ """
220
+ Update an existing article. Only provided fields are changed.
221
+ """
222
+ payload: Dict[str, Any] = {}
223
+ if summary is not None:
224
+ payload["summary"] = summary
225
+ if content is not None:
226
+ payload["content"] = content
227
+ if space_id is not None:
228
+ payload["space"] = {"id": space_id}
229
+ if parent_article_id is not None:
230
+ payload["parentArticle"] = {"id": parent_article_id}
231
+ if status is not None:
232
+ payload["status"] = status
233
+ if extra:
234
+ payload.update(extra)
235
+
236
+ endpoint = f"articles/{article_id}"
237
+ if fields:
238
+ endpoint = f"articles/{article_id}?fields={fields}"
239
+ response = self.client.post(endpoint, json_data=payload)
240
+ return Article.model_validate(response)
241
+
242
+ def set_article_status(self, article_id: str, status: str) -> Article:
243
+ """Convenience to set article status (e.g., 'draft', 'published')."""
244
+ return self.update_article(article_id, status=status)
245
+
246
+ # === Comments ===
247
+ def list_article_comments(
248
+ self,
249
+ article_id: str,
250
+ fields: Optional[str] = None,
251
+ top: int = 50,
252
+ skip: int = 0,
253
+ ) -> List[Comment]:
254
+ params: Dict[str, Any] = {
255
+ "$top": top,
256
+ "$skip": skip,
257
+ "fields": fields or "id,text,updated,author(login,name)",
258
+ }
259
+ response = self.client.get(f"articles/{article_id}/comments", params=params)
260
+ return [Comment.model_validate(item) for item in response]
261
+
262
+ def add_article_comment(
263
+ self, article_id: str, text: str, fields: Optional[str] = None
264
+ ) -> Comment:
265
+ endpoint = f"articles/{article_id}/comments"
266
+ if fields:
267
+ endpoint = f"articles/{article_id}/comments?fields={fields}"
268
+ response = self.client.post(endpoint, json_data={"text": text})
269
+ return Comment.model_validate(response)
270
+
271
+ def update_article_comment(
272
+ self, article_id: str, comment_id: str, text: str, fields: Optional[str] = None
273
+ ) -> Comment:
274
+ endpoint = f"articles/{article_id}/comments/{comment_id}"
275
+ if fields:
276
+ endpoint = f"articles/{article_id}/comments/{comment_id}?fields={fields}"
277
+ response = self.client.post(endpoint, json_data={"text": text})
278
+ return Comment.model_validate(response)
279
+
280
+ # === Attachments ===
281
+ def list_article_attachments(
282
+ self,
283
+ article_id: str,
284
+ fields: Optional[str] = None,
285
+ top: int = 50,
286
+ skip: int = 0,
287
+ ) -> List[Attachment]:
288
+ params: Dict[str, Any] = {
289
+ "$top": top,
290
+ "$skip": skip,
291
+ "fields": fields or "id,name,mimeType,size",
292
+ }
293
+ response = self.client.get(f"articles/{article_id}/attachments", params=params)
294
+ return [Attachment.model_validate(item) for item in response]
295
+
296
+ def upload_article_attachment(
297
+ self,
298
+ article_id: str,
299
+ filename: str,
300
+ file_bytes: bytes,
301
+ mime_type: str = "application/octet-stream",
302
+ fields: Optional[str] = None,
303
+ ) -> Attachment:
304
+ endpoint = f"articles/{article_id}/attachments"
305
+ if fields:
306
+ endpoint = f"articles/{article_id}/attachments?fields={fields}"
307
+ files = {"file": (filename, file_bytes, mime_type)}
308
+ response = self.client.post_multipart(endpoint, files=files)
309
+ return Attachment.model_validate(response)
310
+
311
+ def download_article_attachment(
312
+ self, article_id: str, attachment_id: str
313
+ ) -> bytes:
314
+ endpoint = f"articles/{article_id}/attachments/{attachment_id}/content"
315
+ return self.client.get_bytes(
316
+ endpoint, headers={"Accept": "application/octet-stream"}
317
+ )