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,1009 @@
1
+ """
2
+ YouTrack Projects API client.
3
+ """
4
+
5
+ from typing import Any, Dict, List, Optional
6
+ import json
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from youtrack_mcp.api.client import YouTrackClient
11
+ import logging
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class Project(BaseModel):
17
+ """Model for a YouTrack project."""
18
+
19
+ id: str
20
+ name: str
21
+ shortName: str
22
+ description: Optional[str] = None
23
+ archived: bool = False
24
+ created: Optional[int] = None
25
+ updated: Optional[int] = None
26
+ lead: Optional[Dict[str, Any]] = None
27
+ custom_fields: List[Dict[str, Any]] = Field(default_factory=list)
28
+
29
+
30
+ class ProjectsClient:
31
+ """Client for interacting with YouTrack Projects API."""
32
+
33
+ def __init__(self, client: YouTrackClient):
34
+ """
35
+ Initialize the Projects API client.
36
+
37
+ Args:
38
+ client: The YouTrack API client
39
+ """
40
+ self.client = client
41
+
42
+ def get_projects(
43
+ self, include_archived: bool = False, page_size: int = 100
44
+ ) -> List[Project]:
45
+ """
46
+ Get all projects with pagination support.
47
+
48
+ This method fetches all projects by iterating through pages of results.
49
+ YouTrack API limits results per request, so pagination is required
50
+ for instances with many projects (>50).
51
+
52
+ Args:
53
+ include_archived: Whether to include archived projects
54
+ page_size: Number of projects to fetch per page (default: 100)
55
+
56
+ Returns:
57
+ List of all projects
58
+ """
59
+ all_projects: List[Project] = []
60
+ skip = 0
61
+ fields = "id,name,shortName,description,archived,created,updated,lead(id,name,login)"
62
+
63
+ while True:
64
+ params = {
65
+ "fields": fields,
66
+ "$top": page_size,
67
+ "$skip": skip,
68
+ }
69
+ if not include_archived:
70
+ params["$filter"] = "archived eq false"
71
+
72
+ logger.debug(f"Fetching projects page: skip={skip}, top={page_size}")
73
+ response = self.client.get("admin/projects", params=params)
74
+
75
+ if not response:
76
+ # No more projects
77
+ break
78
+
79
+ projects = [Project.model_validate(project) for project in response]
80
+ all_projects.extend(projects)
81
+
82
+ logger.debug(f"Fetched {len(projects)} projects (total: {len(all_projects)})")
83
+
84
+ if len(projects) < page_size:
85
+ # Last page - fewer results than requested
86
+ break
87
+
88
+ skip += page_size
89
+
90
+ logger.info(f"Retrieved {len(all_projects)} total projects")
91
+ return all_projects
92
+
93
+ def get_project(self, project_id: str) -> Project:
94
+ """
95
+ Get a project by ID.
96
+
97
+ Args:
98
+ project_id: The project ID
99
+
100
+ Returns:
101
+ The project data
102
+ """
103
+ response = self.client.get(
104
+ f"admin/projects/{project_id}",
105
+ params={
106
+ "fields": "id,name,shortName,description,archived,created,updated,lead(id,name,login)"
107
+ },
108
+ )
109
+ return Project.model_validate(response)
110
+
111
+ def get_project_by_name(self, project_name: str) -> Optional[Project]:
112
+ """
113
+ Get a project by its name or short name.
114
+
115
+ This method uses an efficient lookup strategy:
116
+ 1. First, try direct API lookup by shortName (most efficient)
117
+ 2. If that fails, fetch all projects with pagination and match client-side
118
+
119
+ Args:
120
+ project_name: The project name or short name
121
+
122
+ Returns:
123
+ The project data or None if not found
124
+ """
125
+ # Strategy 1: Try direct API lookup by shortName (most efficient)
126
+ # YouTrack allows fetching a project directly by its shortName
127
+ try:
128
+ logger.debug(f"Attempting direct project lookup: {project_name}")
129
+ response = self.client.get(
130
+ f"admin/projects/{project_name}",
131
+ params={
132
+ "fields": "id,name,shortName,description,archived,created,updated,lead(id,name,login)"
133
+ },
134
+ )
135
+ if response:
136
+ project = Project.model_validate(response)
137
+ logger.info(f"Found project by direct lookup: {project.name} ({project.shortName})")
138
+ return project
139
+ except Exception as e:
140
+ # Direct lookup failed, this is expected for full names
141
+ logger.debug(f"Direct project lookup failed for '{project_name}': {e}")
142
+
143
+ # Strategy 2: Fetch all projects with pagination and match client-side
144
+ logger.debug(f"Falling back to full project search for: {project_name}")
145
+ projects = self.get_projects(include_archived=True)
146
+
147
+ # First try to match by short name (exact match, case-insensitive)
148
+ for project in projects:
149
+ if project.shortName.lower() == project_name.lower():
150
+ logger.info(f"Found project by shortName match: {project.name}")
151
+ return project
152
+
153
+ # Then try to match by full name (case-insensitive)
154
+ for project in projects:
155
+ if project.name.lower() == project_name.lower():
156
+ logger.info(f"Found project by name match: {project.name}")
157
+ return project
158
+
159
+ # Finally try to match if project_name is contained in the name
160
+ for project in projects:
161
+ if project_name.lower() in project.name.lower():
162
+ logger.info(f"Found project by partial name match: {project.name}")
163
+ return project
164
+
165
+ logger.warning(f"Project not found: {project_name}")
166
+ return None
167
+
168
+ def get_project_issues(
169
+ self, project_id: str, limit: int = 10
170
+ ) -> List[Dict[str, Any]]:
171
+ """
172
+ Get issues for a specific project.
173
+
174
+ Args:
175
+ project_id: The project ID
176
+ limit: Maximum number of issues to return
177
+
178
+ Returns:
179
+ List of issues in the project
180
+ """
181
+ logger.info(f"Getting issues for project {project_id}, limit {limit}")
182
+
183
+ # Request more fields to get complete issue information
184
+ fields = "id,summary,description,created,updated,reporter(id,login,name),assignee(id,login,name),project(id,name,shortName),customFields(id,name,value($type,name,text,id),projectCustomField(field(name)))"
185
+
186
+ params = {
187
+ "$filter": f"project/id eq {project_id}",
188
+ "$top": limit,
189
+ "fields": fields,
190
+ }
191
+
192
+ try:
193
+ issues = self.client.get("issues", params=params)
194
+ logger.info(
195
+ f"Retrieved {len(issues) if isinstance(issues, list) else 0} issues"
196
+ )
197
+ return issues
198
+ except Exception as e:
199
+ logger.error(
200
+ f"Error getting issues for project {project_id}: {str(e)}"
201
+ )
202
+ # Return empty list on error
203
+ return []
204
+
205
+ def create_project(
206
+ self,
207
+ name: str,
208
+ short_name: str,
209
+ description: Optional[str] = None,
210
+ lead_id: Optional[str] = None,
211
+ ) -> Project:
212
+ """
213
+ Create a new project.
214
+
215
+ Args:
216
+ name: The project name
217
+ short_name: The project short name (used in issue IDs)
218
+ description: Optional project description
219
+ lead_id: Optional project lead user ID
220
+
221
+ Returns:
222
+ The created project data
223
+ """
224
+ if not name:
225
+ raise ValueError("Project name is required")
226
+ if not short_name:
227
+ raise ValueError("Project short name is required")
228
+
229
+ data = {"name": name, "shortName": short_name}
230
+
231
+ if description:
232
+ data["description"] = description
233
+
234
+ if lead_id:
235
+ # The YouTrack API expects "leader", not "lead_id"
236
+ data["leader"] = {"id": lead_id}
237
+
238
+ # Debug logging
239
+ logger.info(f"Creating project with data: {json.dumps(data)}")
240
+ logger.info(
241
+ f"Base URL: {self.client.base_url}, API endpoint: admin/projects"
242
+ )
243
+
244
+ try:
245
+ response = self.client.post("admin/projects", data=data)
246
+ logger.info(f"Create project response: {json.dumps(response)}")
247
+
248
+ # The response might not include all required fields,
249
+ # Try to get the complete project now
250
+ if isinstance(response, dict) and "id" in response:
251
+ try:
252
+ # Get the full project details
253
+ created_project = self.get_project(response["id"])
254
+ logger.info(
255
+ f"Successfully retrieved full project details: {created_project.name}"
256
+ )
257
+ return created_project
258
+ except Exception as e:
259
+ logger.warning(
260
+ f"Could not retrieve full project details: {str(e)}"
261
+ )
262
+ # Fall back to creating a model with the available data
263
+ # We need to ensure shortName is present
264
+ if "shortName" not in response and short_name:
265
+ response["shortName"] = short_name
266
+ if "name" not in response and name:
267
+ response["name"] = name
268
+
269
+ # Try to validate the model, which might fail if fields are missing
270
+ try:
271
+ return Project.model_validate(response)
272
+ except Exception as e:
273
+ logger.warning(f"Could not validate project model: {str(e)}")
274
+ # As a last resort, create a minimal valid project
275
+ minimal_project = {
276
+ "id": response.get("id", "unknown"),
277
+ "name": name,
278
+ "shortName": short_name,
279
+ "description": description,
280
+ }
281
+ return Project.model_validate(minimal_project)
282
+ except Exception as e:
283
+ logger.error(f"Error creating project: {str(e)}")
284
+ raise
285
+
286
+ def update_project(
287
+ self,
288
+ project_id: str,
289
+ name: Optional[str] = None,
290
+ description: Optional[str] = None,
291
+ lead_id: Optional[str] = None,
292
+ archived: Optional[bool] = None,
293
+ ) -> Project:
294
+ """
295
+ Update an existing project.
296
+
297
+ Args:
298
+ project_id: The project ID
299
+ name: The new project name
300
+ description: The new project description
301
+ lead_id: The new project lead user ID
302
+ archived: Whether the project should be archived
303
+
304
+ Returns:
305
+ The updated project data
306
+ """
307
+ # First get the existing project data
308
+ logger.info(f"Getting existing project data for {project_id}")
309
+
310
+ try:
311
+ # Prepare data for update API call
312
+ data = {}
313
+
314
+ # Include any provided parameters
315
+ if name is not None:
316
+ data["name"] = name
317
+ if description is not None:
318
+ data["description"] = description
319
+ if lead_id is not None:
320
+ data["leader"] = {"id": lead_id}
321
+ if archived is not None:
322
+ data["archived"] = archived
323
+
324
+ # Make sure we have at least one parameter to update
325
+ if not data:
326
+ logger.info(
327
+ "No parameters to update, returning current project data"
328
+ )
329
+ return self.get_project(project_id)
330
+
331
+ logger.info(f"Updating project with data: {data}")
332
+ response = self.client.post(
333
+ f"admin/projects/{project_id}", data=data
334
+ )
335
+ logger.info(f"Update project response: {response}")
336
+
337
+ # The API response might not contain all required fields,
338
+ # so we need to get the full project data after the update
339
+ try:
340
+ # Get the updated project data
341
+ updated_project = self.get_project(project_id)
342
+ logger.info(
343
+ f"Successfully retrieved updated project: {updated_project.name}"
344
+ )
345
+ return updated_project
346
+ except Exception as e:
347
+ logger.error(f"Error getting updated project: {str(e)}")
348
+ # If we can't get the updated project, create a partial project with the data we have
349
+ if isinstance(response, dict) and "id" in response:
350
+ logger.info(
351
+ f"Creating partial project from response: {response}"
352
+ )
353
+ # Try to get the original project to fill in missing fields
354
+ try:
355
+ original_project = self.get_project(project_id)
356
+ # Update with new values
357
+ for key, value in data.items():
358
+ if key == "leader":
359
+ setattr(original_project, "lead", value)
360
+ else:
361
+ setattr(original_project, key, value)
362
+ return original_project
363
+ except Exception:
364
+ # If we can't get the original project either, just return the response
365
+ logger.warning(
366
+ f"Unable to get original project, returning response: {response}"
367
+ )
368
+ return response
369
+ else:
370
+ # If the response doesn't have an ID, just return it
371
+ return response
372
+ except Exception as e:
373
+ logger.error(f"Error updating project {project_id}: {str(e)}")
374
+ raise
375
+
376
+ def delete_project(self, project_id: str) -> None:
377
+ """
378
+ Delete a project.
379
+
380
+ Args:
381
+ project_id: The project ID
382
+ """
383
+ self.client.delete(f"admin/projects/{project_id}")
384
+
385
+ def get_custom_fields(self, project_id: str) -> List[Dict[str, Any]]:
386
+ """
387
+ Get custom fields for a project.
388
+
389
+ Args:
390
+ project_id: The project ID
391
+
392
+ Returns:
393
+ List of custom fields
394
+ """
395
+ return self.client.get(f"admin/projects/{project_id}/customFields")
396
+
397
+ def add_custom_field(
398
+ self,
399
+ project_id: str,
400
+ field_id: str,
401
+ empty_field_text: Optional[str] = None,
402
+ ) -> Dict[str, Any]:
403
+ """
404
+ Add a custom field to a project.
405
+
406
+ Args:
407
+ project_id: The project ID
408
+ field_id: The custom field ID
409
+ empty_field_text: Optional text to show for empty fields
410
+
411
+ Returns:
412
+ The added custom field
413
+ """
414
+ data = {"field": {"id": field_id}}
415
+
416
+ if empty_field_text:
417
+ data["emptyFieldText"] = empty_field_text
418
+
419
+ return self.client.post(
420
+ f"admin/projects/{project_id}/customFields", data=data
421
+ )
422
+
423
+ def get_custom_field_schema(
424
+ self, project_id: str, field_name: str
425
+ ) -> Optional[Dict[str, Any]]:
426
+ """
427
+ Get detailed schema for a specific custom field.
428
+
429
+ Args:
430
+ project_id: The project ID
431
+ field_name: The custom field name
432
+
433
+ Returns:
434
+ Custom field schema with type information and constraints
435
+ """
436
+ try:
437
+ # Use detailed fields query to get complete information
438
+ fields_query = "field(id,name,fieldType($type,valueType,id)),canBeEmpty,autoAttached"
439
+ fields = self.client.get(f"admin/projects/{project_id}/customFields?fields={fields_query}")
440
+
441
+ for field in fields:
442
+ if field.get("field", {}).get("name") == field_name:
443
+ field_schema = field.get("field", {})
444
+ field_type = field_schema.get("fieldType", {})
445
+
446
+ enhanced_schema = {
447
+ "name": field_name,
448
+ "type": field_type.get("valueType", "string"),
449
+ "bundle_type": field_type.get("$type", ""),
450
+ "required": field.get("canBeEmpty", True) == False,
451
+ "multi_value": field_schema.get("isMultiValue", False),
452
+ "auto_attach": field.get("autoAttached", False),
453
+ "field_id": field_schema.get("id"),
454
+ "bundle_id": field_type.get("id")
455
+ }
456
+
457
+ logger.info(f"Found schema for field '{field_name}': {enhanced_schema}")
458
+
459
+ # Add allowed values for enum/state fields
460
+ if field_type.get("valueType") in ["enum", "state"]:
461
+ try:
462
+ enhanced_schema["allowed_values"] = self.get_custom_field_allowed_values(project_id, field_name)
463
+ except Exception as e:
464
+ logger.warning(f"Could not get allowed values for {field_name}: {str(e)}")
465
+ enhanced_schema["allowed_values"] = []
466
+
467
+ return enhanced_schema
468
+
469
+ logger.warning(f"Field '{field_name}' not found in project {project_id} custom fields")
470
+ return None
471
+ except Exception as e:
472
+ logger.error(f"Error getting custom field schema for '{field_name}': {str(e)}")
473
+ return None
474
+
475
+ def get_custom_field_allowed_values(self, project_id: str, field_name: str) -> List[Dict[str, Any]]:
476
+ """
477
+ Get allowed values for a custom field in a specific project.
478
+
479
+ Args:
480
+ project_id: The project identifier
481
+ field_name: The custom field name
482
+
483
+ Returns:
484
+ List of allowed values with id, name, and other properties
485
+ """
486
+ try:
487
+ # Get field information directly to avoid recursion with get_custom_field_schema
488
+ fields_query = "field(id,name,fieldType($type,valueType,id)),canBeEmpty,autoAttached"
489
+ fields = self.client.get(f"admin/projects/{project_id}/customFields?fields={fields_query}")
490
+
491
+ field_info = None
492
+ for field in fields:
493
+ if field.get("field", {}).get("name") == field_name:
494
+ field_info = field
495
+ break
496
+
497
+ if not field_info:
498
+ logger.warning(f"Field '{field_name}' not found in project {project_id}")
499
+ return []
500
+
501
+ # Extract field type information
502
+ field_schema = field_info.get("field", {})
503
+ field_type = field_schema.get("fieldType", {})
504
+
505
+ # More robust field type handling
506
+ if not isinstance(field_type, dict):
507
+ logger.warning(f"Field type for '{field_name}' is not a dictionary: {type(field_type)} - {field_type}")
508
+ return []
509
+
510
+ value_type = field_type.get("valueType", "") # enum, state, user, etc.
511
+ bundle_id = field_type.get("id") # enum[1], state[1], etc.
512
+
513
+ logger.info(f"Field '{field_name}' - valueType: {value_type}, bundleId: {bundle_id}")
514
+
515
+ if not bundle_id:
516
+ logger.warning(f"No bundle ID found for field '{field_name}'")
517
+ return []
518
+
519
+ # For enum fields, we need to resolve the correct bundle
520
+ if value_type == "enum":
521
+ # First, try to extract the actual bundle ID from bundle_id string (e.g., "enum[1]" -> "1")
522
+ actual_bundle_id = None
523
+ if "[" in bundle_id and "]" in bundle_id:
524
+ # Extract index from "enum[1]" format
525
+ bundle_index = bundle_id.split("[")[1].split("]")[0]
526
+
527
+ # Get all enum bundles and find the one at this index
528
+ try:
529
+ all_enum_bundles = self.client.get('admin/customFieldSettings/bundles/enum?fields=id,name,values(id,name,description)')
530
+ if bundle_index.isdigit():
531
+ index = int(bundle_index)
532
+ if 0 <= index < len(all_enum_bundles):
533
+ target_bundle = all_enum_bundles[index]
534
+ actual_bundle_id = target_bundle.get('id')
535
+ logger.info(f"Resolved bundle index {index} to bundle ID {actual_bundle_id} ({target_bundle.get('name')})")
536
+
537
+ # Return values from the correct bundle
538
+ values = target_bundle.get('values', [])
539
+ logger.info(f"Found {len(values)} values for field '{field_name}' in bundle '{target_bundle.get('name')}'")
540
+ return [
541
+ {
542
+ "name": value.get("name", ""),
543
+ "description": value.get("description", ""),
544
+ "id": value.get("id"),
545
+ **{k: v for k, v in value.items() if k not in ["name", "description", "id"]} # Include any additional fields like color
546
+ }
547
+ for value in values
548
+ ]
549
+ except Exception as e:
550
+ logger.error(f"Error resolving enum bundle index: {str(e)}")
551
+
552
+ # Fallback: try the bundle_id directly
553
+ if not actual_bundle_id:
554
+ actual_bundle_id = bundle_id.replace("enum[", "").replace("]", "")
555
+
556
+ try:
557
+ bundle_data = self.client.get(f"admin/customFieldSettings/bundles/enum/{actual_bundle_id}?fields=id,name,values(id,name,description)")
558
+ values = bundle_data.get("values", [])
559
+ logger.info(f"Found {len(values)} values for enum field '{field_name}'")
560
+ return [
561
+ {
562
+ "name": value.get("name", ""),
563
+ "description": value.get("description", ""),
564
+ "id": value.get("id"),
565
+ **{k: v for k, v in value.items() if k not in ["name", "description", "id"]} # Include any additional fields like color
566
+ }
567
+ for value in values
568
+ ]
569
+ except Exception as e:
570
+ logger.error(f"Error getting enum bundle {actual_bundle_id}: {str(e)}")
571
+ # Return enhanced guidance instead of empty array
572
+ return [
573
+ {
574
+ "name": "__ENUM_ACCESS_ERROR__",
575
+ "description": f"Could not access enum values for field '{field_name}'. This may be due to permissions or configuration issues.",
576
+ "id": "access-error",
577
+ "type": "guidance",
578
+ "bundle_id": actual_bundle_id,
579
+ "troubleshooting": [
580
+ "Check if you have admin permissions",
581
+ "Verify the field is properly configured",
582
+ "Try accessing through YouTrack UI: Administration → Custom Fields"
583
+ ]
584
+ }
585
+ ]
586
+
587
+ elif value_type == "state":
588
+ try:
589
+ # Handle both indexed format (state[1]) and direct bundle ID (state-bundle-123)
590
+ if "[" in bundle_id and "]" in bundle_id:
591
+ # Index-based format: state[1] means the first state bundle (0-based index)
592
+ all_bundles = self.client.get("admin/customFieldSettings/bundles/state?fields=id,name,values(id,name,description,isResolved,color)")
593
+
594
+ bundle_index = int(bundle_id.split("[")[1].split("]")[0]) - 1 # Convert to 0-based index
595
+ if 0 <= bundle_index < len(all_bundles):
596
+ target_bundle = all_bundles[bundle_index]
597
+ values = target_bundle.get("values", [])
598
+ logger.info(f"Found {len(values)} state values for field '{field_name}' from bundle '{target_bundle.get('name')}'")
599
+ return [
600
+ {
601
+ "name": value.get("name", ""),
602
+ "description": value.get("description", ""),
603
+ "id": value.get("id"),
604
+ "resolved": value.get("isResolved", False),
605
+ "color": value.get("color", {})
606
+ }
607
+ for value in values
608
+ ]
609
+ else:
610
+ logger.error(f"Bundle index {bundle_index} out of range for {len(all_bundles)} state bundles")
611
+ return []
612
+ else:
613
+ # Direct bundle ID format: get specific bundle
614
+ bundle_data = self.client.get(f"admin/customFieldSettings/bundles/state/{bundle_id}?fields=values(id,name,description,isResolved,color)")
615
+
616
+ values = bundle_data.get("values", [])
617
+ logger.info(f"Found {len(values)} state values for field '{field_name}' from bundle '{bundle_data.get('name', 'unknown')}'")
618
+ return [
619
+ {
620
+ "name": value.get("name", ""),
621
+ "description": value.get("description", ""),
622
+ "id": value.get("id"),
623
+ "resolved": value.get("isResolved", False),
624
+ "color": value.get("color", {})
625
+ }
626
+ for value in values
627
+ ]
628
+ except Exception as e:
629
+ logger.error(f"Error getting state bundle {bundle_id}: {str(e)}")
630
+ return []
631
+
632
+ elif value_type == "user":
633
+ try:
634
+ # For user fields, get available users
635
+ users_data = self.client.get("users?fields=id,login,name,email")
636
+ logger.info(f"Found {len(users_data)} users for field '{field_name}'")
637
+ return [
638
+ {
639
+ "name": user.get("name", ""),
640
+ "login": user.get("login", ""),
641
+ "id": user.get("id"),
642
+ "email": user.get("email", "")
643
+ }
644
+ for user in users_data
645
+ ]
646
+ except Exception as e:
647
+ logger.error(f"Error getting users: {str(e)}")
648
+ return []
649
+
650
+ elif value_type == "ownedField":
651
+ try:
652
+ # For subsystem/owned fields, get subsystems for this project
653
+ subsystems_data = self.client.get(f"admin/projects/{project_id}/subsystems?fields=id,name,description")
654
+ logger.info(f"Found {len(subsystems_data)} subsystems for field '{field_name}'")
655
+ return [
656
+ {
657
+ "name": subsystem.get("name", ""),
658
+ "description": subsystem.get("description", ""),
659
+ "id": subsystem.get("id")
660
+ }
661
+ for subsystem in subsystems_data
662
+ ]
663
+ except Exception as e:
664
+ logger.error(f"Error getting subsystems: {str(e)}")
665
+ # Return comprehensive guidance instead of empty array
666
+ return [
667
+ {
668
+ "name": "__CONFIGURATION_NEEDED__",
669
+ "description": f"No subsystems configured for project {project_id}. To enable subsystem custom fields:",
670
+ "id": "config-required",
671
+ "type": "guidance",
672
+ "action": "create_subsystems",
673
+ "steps": [
674
+ "1. Go to YouTrack project settings",
675
+ "2. Navigate to 'Subsystems' section",
676
+ "3. Click 'New Subsystem'",
677
+ "4. Enter subsystem name and description",
678
+ "5. Save to enable subsystem custom fields"
679
+ ],
680
+ "alternative": "Use the create_subsystem() MCP tool if you have admin permissions"
681
+ }
682
+ ]
683
+
684
+ elif value_type == "version":
685
+ try:
686
+ # For version fields, get versions for this project
687
+ versions_data = self.client.get(f"admin/projects/{project_id}/versions?fields=id,name,description,released,releaseDate")
688
+ logger.info(f"Found {len(versions_data)} versions for field '{field_name}'")
689
+ return [
690
+ {
691
+ "name": version.get("name", ""),
692
+ "description": version.get("description", ""),
693
+ "id": version.get("id"),
694
+ "released": version.get("released", False),
695
+ "releaseDate": version.get("releaseDate")
696
+ }
697
+ for version in versions_data
698
+ ]
699
+ except Exception as e:
700
+ logger.error(f"Error getting versions: {str(e)}")
701
+ # Return comprehensive guidance instead of empty array
702
+ return [
703
+ {
704
+ "name": "__CONFIGURATION_NEEDED__",
705
+ "description": f"No versions configured for project {project_id}. To enable version custom fields:",
706
+ "id": "config-required",
707
+ "type": "guidance",
708
+ "action": "create_versions",
709
+ "steps": [
710
+ "1. Go to YouTrack project settings",
711
+ "2. Navigate to 'Versions' section",
712
+ "3. Click 'New Version'",
713
+ "4. Enter version name (e.g., 'v1.0.0')",
714
+ "5. Set release status and dates",
715
+ "6. Save to enable version custom fields"
716
+ ],
717
+ "alternative": "Use the create_version() MCP tool if you have admin permissions",
718
+ "examples": ["v1.0.0", "2024.1", "Sprint-1", "Release-Jan-2024"]
719
+ },
720
+ {
721
+ "name": "__FALLBACK_OPTION__",
722
+ "description": "Alternative: Use text fields or comments to track version information until versions are configured",
723
+ "id": "fallback-text",
724
+ "type": "workaround",
725
+ "suggested_approach": "Use description field or comments to mention version information"
726
+ }
727
+ ]
728
+
729
+ elif value_type == "build":
730
+ try:
731
+ # For build fields, get builds for this project
732
+ builds_data = self.client.get(f"admin/projects/{project_id}/builds?fields=id,name,description")
733
+ logger.info(f"Found {len(builds_data)} builds for field '{field_name}'")
734
+ return [
735
+ {
736
+ "name": build.get("name", ""),
737
+ "description": build.get("description", ""),
738
+ "id": build.get("id")
739
+ }
740
+ for build in builds_data
741
+ ]
742
+ except Exception as e:
743
+ logger.error(f"Error getting builds: {str(e)}")
744
+ # Return comprehensive guidance instead of empty array
745
+ return [
746
+ {
747
+ "name": "__CONFIGURATION_NEEDED__",
748
+ "description": f"No builds configured for project {project_id}. To enable build custom fields:",
749
+ "id": "config-required",
750
+ "type": "guidance",
751
+ "action": "create_builds",
752
+ "steps": [
753
+ "1. Go to YouTrack project settings",
754
+ "2. Navigate to 'Builds' section",
755
+ "3. Click 'New Build'",
756
+ "4. Enter build name (e.g., 'build-123')",
757
+ "5. Add description and metadata",
758
+ "6. Save to enable build custom fields"
759
+ ],
760
+ "alternative": "Use the create_build() MCP tool if you have admin permissions",
761
+ "examples": ["build-123", "nightly-2024-01-15", "release-1.0", "hotfix-001"]
762
+ }
763
+ ]
764
+
765
+ else:
766
+ logger.info(f"Field '{field_name}' type '{value_type}' doesn't support allowed values")
767
+ return []
768
+
769
+ except Exception as e:
770
+ logger.error(f"Error getting custom field allowed values for '{field_name}': {str(e)}")
771
+ return []
772
+
773
+ def get_available_custom_field_values(
774
+ self, project_id: str, field_name: str
775
+ ) -> List[Dict[str, Any]]:
776
+ """
777
+ Alias for get_custom_field_allowed_values for backward compatibility.
778
+
779
+ Args:
780
+ project_id: The project ID
781
+ field_name: The custom field name
782
+
783
+ Returns:
784
+ List of allowed values with details
785
+ """
786
+ return self.get_custom_field_allowed_values(project_id, field_name)
787
+
788
+ def get_all_custom_fields_schemas(
789
+ self, project_id: str
790
+ ) -> Dict[str, Dict[str, Any]]:
791
+ """
792
+ Get schemas for all custom fields in a project.
793
+
794
+ Args:
795
+ project_id: The project ID
796
+
797
+ Returns:
798
+ Dictionary mapping field names to their schemas
799
+ """
800
+ try:
801
+ # Use the same detailed query that works in other methods
802
+ fields_query = "field(id,name,fieldType($type,valueType,id)),canBeEmpty,autoAttached"
803
+ fields = self.client.get(f"admin/projects/{project_id}/customFields?fields={fields_query}")
804
+ schemas = {}
805
+
806
+ logger.info(f"Got {len(fields)} custom fields for project {project_id}")
807
+
808
+ for field in fields:
809
+ # Extract field name from the correct structure
810
+ field_info = field.get("field", {})
811
+ field_name = field_info.get("name")
812
+
813
+ if field_name:
814
+ logger.info(f"Processing field: {field_name}")
815
+ # Build schema directly from the field data we already have
816
+ field_type = field_info.get("fieldType", {})
817
+
818
+ enhanced_schema = {
819
+ "name": field_name,
820
+ "type": field_type.get("valueType", "string"),
821
+ "bundle_type": field_type.get("$type", ""),
822
+ "required": field.get("canBeEmpty", True) == False,
823
+ "multi_value": field_info.get("isMultiValue", False),
824
+ "auto_attach": field.get("autoAttached", False),
825
+ "field_id": field_info.get("id"),
826
+ "bundle_id": field_type.get("id")
827
+ }
828
+
829
+ # Add allowed values for enum/state fields
830
+ if field_type.get("valueType") in ["enum", "state"]:
831
+ try:
832
+ enhanced_schema["allowed_values"] = self.get_custom_field_allowed_values(project_id, field_name)
833
+ except Exception as e:
834
+ logger.warning(f"Could not get allowed values for {field_name}: {str(e)}")
835
+ enhanced_schema["allowed_values"] = []
836
+
837
+ schemas[field_name] = enhanced_schema
838
+ logger.info(f"Added schema for field '{field_name}': {enhanced_schema}")
839
+ else:
840
+ logger.warning(f"Field missing name: {field}")
841
+
842
+ logger.info(f"Returning {len(schemas)} schemas for project {project_id}")
843
+ return schemas
844
+ except Exception as e:
845
+ logger.error(f"Error getting all custom field schemas: {str(e)}")
846
+ return {}
847
+
848
+ def validate_custom_field_for_project(
849
+ self,
850
+ project_id: str,
851
+ field_name: str,
852
+ field_value: Any
853
+ ) -> Dict[str, Any]:
854
+ """
855
+ Validate a custom field value against project schema.
856
+
857
+ Args:
858
+ project_id: The project ID
859
+ field_name: The custom field name
860
+ field_value: The value to validate
861
+
862
+ Returns:
863
+ Dictionary with validation result
864
+ """
865
+ try:
866
+ # Get field schema directly using the same approach as issues.py
867
+ field_schema = self.get_custom_field_schema(project_id, field_name)
868
+ if not field_schema:
869
+ return {
870
+ "valid": False,
871
+ "error": f"Custom field '{field_name}' not found in project {project_id}",
872
+ "suggestion": "Check field name spelling and project configuration"
873
+ }
874
+
875
+ value_type = field_schema.get("type", "") # This is the valueType from API
876
+ bundle_type = field_schema.get("bundle_type", "") # This is the $type
877
+
878
+ # Check if field is required and value is empty
879
+ if field_schema.get("required", False) and (field_value is None or str(field_value).strip() == ""):
880
+ return {
881
+ "valid": False,
882
+ "error": f"Field '{field_name}' is required and cannot be empty",
883
+ "suggestion": "Provide a valid value for this required field"
884
+ }
885
+
886
+ # Type-specific validation using the correct valueType
887
+ if value_type == "state":
888
+ # State field - validate against available states
889
+ allowed_values = self.get_custom_field_allowed_values(project_id, field_name)
890
+ allowed_names = [v.get("name", "") for v in allowed_values if isinstance(v, dict)]
891
+ if str(field_value) not in allowed_names:
892
+ return {
893
+ "valid": False,
894
+ "error": f"Invalid state value '{field_value}' for field '{field_name}'",
895
+ "suggestion": f"Use one of: {', '.join(allowed_names)}" if allowed_names else "Check field configuration"
896
+ }
897
+
898
+ elif value_type == "enum":
899
+ # Enum field - validate against enum values
900
+ allowed_values = self.get_custom_field_allowed_values(project_id, field_name)
901
+ allowed_names = [v.get("name", "") for v in allowed_values if isinstance(v, dict)]
902
+ if str(field_value) not in allowed_names:
903
+ return {
904
+ "valid": False,
905
+ "error": f"Invalid enum value '{field_value}' for field '{field_name}'",
906
+ "suggestion": f"Use one of: {', '.join(allowed_names)}" if allowed_names else "Check field configuration"
907
+ }
908
+
909
+ elif value_type == "user":
910
+ # User field - validate against available users
911
+ allowed_values = self.get_custom_field_allowed_values(project_id, field_name)
912
+ allowed_logins = [v.get("login", "") for v in allowed_values if isinstance(v, dict)]
913
+ allowed_names = [v.get("name", "") for v in allowed_values if isinstance(v, dict)]
914
+ if str(field_value) not in allowed_logins and str(field_value) not in allowed_names:
915
+ return {
916
+ "valid": False,
917
+ "error": f"User '{field_value}' not found",
918
+ "suggestion": f"Use valid user login: {', '.join(allowed_logins[:5])}" if allowed_logins else "Check user exists"
919
+ }
920
+
921
+ elif value_type == "ownedField":
922
+ # Subsystem field - validate against available subsystems
923
+ allowed_values = self.get_custom_field_allowed_values(project_id, field_name)
924
+ allowed_names = [v.get("name", "") for v in allowed_values if isinstance(v, dict)]
925
+ if str(field_value) not in allowed_names:
926
+ return {
927
+ "valid": False,
928
+ "error": f"Invalid subsystem '{field_value}' for field '{field_name}'",
929
+ "suggestion": f"Use one of: {', '.join(allowed_names)}" if allowed_names else "Create subsystem first"
930
+ }
931
+
932
+ elif value_type == "version":
933
+ # Version field - validate against available versions
934
+ allowed_values = self.get_custom_field_allowed_values(project_id, field_name)
935
+ allowed_names = [v.get("name", "") for v in allowed_values if isinstance(v, dict)]
936
+ if str(field_value) not in allowed_names:
937
+ return {
938
+ "valid": False,
939
+ "error": f"Invalid version '{field_value}' for field '{field_name}'",
940
+ "suggestion": f"Use one of: {', '.join(allowed_names)}" if allowed_names else "Create version first"
941
+ }
942
+
943
+ elif value_type == "build":
944
+ # Build field - validate against available builds
945
+ allowed_values = self.get_custom_field_allowed_values(project_id, field_name)
946
+ allowed_names = [v.get("name", "") for v in allowed_values if isinstance(v, dict)]
947
+ if str(field_value) not in allowed_names:
948
+ return {
949
+ "valid": False,
950
+ "error": f"Invalid build '{field_value}' for field '{field_name}'",
951
+ "suggestion": f"Use one of: {', '.join(allowed_names)}" if allowed_names else "Create build first"
952
+ }
953
+
954
+ elif value_type == "period":
955
+ # Period field - validate format (e.g., "4h", "30m", "1h45m")
956
+ import re
957
+ period_pattern = r'^\d+[mhwd]$|^\d+h\d+m$' # Simple pattern for periods
958
+ if not re.match(period_pattern, str(field_value)):
959
+ return {
960
+ "valid": False,
961
+ "error": f"Invalid period format '{field_value}' for field '{field_name}'",
962
+ "suggestion": "Use format like '4h', '30m', '1h45m', or '2d'"
963
+ }
964
+
965
+ elif value_type == "integer":
966
+ # Integer field - validate that value can be converted to int
967
+ try:
968
+ int(field_value)
969
+ except (ValueError, TypeError):
970
+ return {
971
+ "valid": False,
972
+ "error": f"Invalid integer value '{field_value}' for field '{field_name}'",
973
+ "suggestion": "Provide a valid integer number"
974
+ }
975
+
976
+ elif value_type == "float":
977
+ # Float field - validate that value can be converted to float
978
+ try:
979
+ float(field_value)
980
+ except (ValueError, TypeError):
981
+ return {
982
+ "valid": False,
983
+ "error": f"Invalid float value '{field_value}' for field '{field_name}'",
984
+ "suggestion": "Provide a valid decimal number"
985
+ }
986
+
987
+ # Multi-value field validation (if applicable, after type-specific)
988
+ if field_schema.get("multi_value", False) and not isinstance(field_value, list):
989
+ return {
990
+ "valid": False,
991
+ "error": f"Field '{field_name}' expects multiple values (array)",
992
+ "suggestion": "Provide value as an array, e.g., ['value1', 'value2']"
993
+ }
994
+
995
+ # If we reach here, validation passed
996
+ return {
997
+ "valid": True,
998
+ "field": field_name,
999
+ "value": field_value,
1000
+ "message": "Valid"
1001
+ }
1002
+
1003
+ except Exception as e:
1004
+ logger.error(f"Error validating field '{field_name}': {str(e)}")
1005
+ return {
1006
+ "valid": False,
1007
+ "error": f"Validation error: {str(e)}",
1008
+ "suggestion": "Check field configuration and API connectivity"
1009
+ }