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,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()
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
)
|