rossum-mcp 0.3.4__py3-none-any.whl → 0.4.0__py3-none-any.whl

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.
@@ -1,14 +1,16 @@
1
+ """Schema tools for Rossum MCP Server."""
2
+
1
3
  from __future__ import annotations
2
4
 
3
5
  import copy
4
6
  import logging
5
- from dataclasses import asdict, dataclass
6
- from typing import TYPE_CHECKING, Literal
7
+ from dataclasses import asdict, dataclass, is_dataclass, replace
8
+ from typing import TYPE_CHECKING, Any, Literal
7
9
 
8
10
  from rossum_api.domain_logic.resources import Resource
9
- from rossum_api.models.schema import Schema # noqa: TC002 - needed at runtime for FastMCP
11
+ from rossum_api.models.schema import Schema
10
12
 
11
- from rossum_mcp.tools.base import is_read_write_mode
13
+ from rossum_mcp.tools.base import TRUNCATED_MARKER, is_read_write_mode
12
14
 
13
15
  if TYPE_CHECKING:
14
16
  from fastmcp import FastMCP
@@ -20,6 +22,94 @@ PatchOperation = Literal["add", "update", "remove"]
20
22
  DatapointType = Literal["string", "number", "date", "enum", "button"]
21
23
  NodeCategory = Literal["datapoint", "multivalue", "tuple"]
22
24
 
25
+ MAX_ID_LENGTH = 50
26
+ VALID_DATAPOINT_TYPES = {"string", "number", "date", "enum", "button"}
27
+
28
+
29
+ class SchemaValidationError(ValueError):
30
+ """Raised when schema validation fails."""
31
+
32
+
33
+ def _validate_id(node_id: str, context: str = "") -> None:
34
+ """Validate node ID constraints."""
35
+ if not node_id:
36
+ raise SchemaValidationError(f"Node id is required{context}")
37
+ if len(node_id) > MAX_ID_LENGTH:
38
+ raise SchemaValidationError(f"Node id '{node_id}' exceeds {MAX_ID_LENGTH} characters{context}")
39
+
40
+
41
+ def _validate_datapoint(node: dict, context: str = "") -> None:
42
+ """Validate a datapoint node has required fields."""
43
+ if "label" not in node:
44
+ raise SchemaValidationError(f"Datapoint missing required 'label'{context}")
45
+ if "type" not in node:
46
+ raise SchemaValidationError(f"Datapoint missing required 'type'{context}")
47
+ if node["type"] not in VALID_DATAPOINT_TYPES:
48
+ raise SchemaValidationError(
49
+ f"Invalid datapoint type '{node['type']}'. Must be one of: {', '.join(VALID_DATAPOINT_TYPES)}{context}"
50
+ )
51
+
52
+
53
+ def _validate_tuple(node: dict, node_id: str, context: str) -> None:
54
+ """Validate a tuple node."""
55
+ if "label" not in node:
56
+ raise SchemaValidationError(f"Tuple missing required 'label'{context}")
57
+ if "id" not in node:
58
+ raise SchemaValidationError(f"Tuple missing required 'id'{context}")
59
+ children = node.get("children", [])
60
+ if not isinstance(children, list):
61
+ raise SchemaValidationError(f"Tuple children must be a list{context}")
62
+ for i, child in enumerate(children):
63
+ child_id = child.get("id", f"index {i}")
64
+ _validate_node(child, f" in tuple '{node_id}' child '{child_id}'")
65
+ if "id" not in child:
66
+ raise SchemaValidationError(f"Datapoint inside tuple must have 'id'{context} child index {i}")
67
+
68
+
69
+ def _validate_multivalue(node: dict, node_id: str, context: str) -> None:
70
+ """Validate a multivalue node."""
71
+ if "label" not in node:
72
+ raise SchemaValidationError(f"Multivalue missing required 'label'{context}")
73
+ children = node.get("children")
74
+ if children is None:
75
+ raise SchemaValidationError(f"Multivalue missing required 'children'{context}")
76
+ if isinstance(children, list):
77
+ raise SchemaValidationError(f"Multivalue 'children' must be a single object (dict), not a list{context}")
78
+ if isinstance(children, dict):
79
+ _validate_node(children, f" in multivalue '{node_id}' children")
80
+
81
+
82
+ def _validate_section(node: dict, node_id: str, context: str) -> None:
83
+ """Validate a section node."""
84
+ if "label" not in node:
85
+ raise SchemaValidationError(f"Section missing required 'label'{context}")
86
+ if "id" not in node:
87
+ raise SchemaValidationError(f"Section missing required 'id'{context}")
88
+ children = node.get("children", [])
89
+ if not isinstance(children, list):
90
+ raise SchemaValidationError(f"Section children must be a list{context}")
91
+ for child in children:
92
+ child_id = child.get("id", "unknown")
93
+ _validate_node(child, f" in section '{node_id}' child '{child_id}'")
94
+
95
+
96
+ def _validate_node(node: dict, context: str = "") -> None:
97
+ """Validate a schema node recursively."""
98
+ category = node.get("category")
99
+ node_id = node.get("id", "")
100
+
101
+ if node_id:
102
+ _validate_id(node_id, context)
103
+
104
+ if category == "datapoint":
105
+ _validate_datapoint(node, context)
106
+ elif category == "tuple":
107
+ _validate_tuple(node, node_id, context)
108
+ elif category == "multivalue":
109
+ _validate_multivalue(node, node_id, context)
110
+ elif category == "section":
111
+ _validate_section(node, node_id, context)
112
+
23
113
 
24
114
  @dataclass
25
115
  class SchemaDatapoint:
@@ -162,9 +252,7 @@ def _find_node_in_children(
162
252
  if result[0] is not None:
163
253
  return result
164
254
  elif isinstance(nested_children, dict):
165
- # Multivalue has a single dict child (tuple), not a list
166
255
  if nested_children.get("id") == node_id:
167
- # Return the parent node so caller can modify child["children"] directly
168
256
  return nested_children, 0, None, child
169
257
  if "children" in nested_children:
170
258
  result = _find_node_in_children(nested_children["children"], node_id, nested_children)
@@ -174,28 +262,51 @@ def _find_node_in_children(
174
262
  return None, None, None, None
175
263
 
176
264
 
177
- def _find_parent_children_list(content: list[dict], parent_id: str) -> list[dict] | None:
178
- """Find the children list of a parent node by its ID."""
265
+ def _is_multivalue_node(node: dict) -> bool:
266
+ """Check if a node is a multivalue (has dict children or category is multivalue)."""
267
+ return node.get("category") == "multivalue" or ("children" in node and isinstance(node["children"], dict))
268
+
269
+
270
+ def _find_parent_children_list(content: list[dict], parent_id: str) -> tuple[list[dict] | None, bool]:
271
+ """Find the children list of a parent node by its ID.
272
+
273
+ Returns (children_list, is_multivalue) tuple.
274
+ For multivalue nodes, returns (None, True) since they can't have children added.
275
+ """
179
276
  for section in content:
180
277
  if section.get("id") == parent_id:
278
+ if _is_multivalue_node(section):
279
+ return None, True
181
280
  children: list[dict] = section.setdefault("children", [])
182
- return children
281
+ return children, False
282
+
283
+ section_children = section.get("children")
284
+ if section_children is None:
285
+ continue
286
+
287
+ if isinstance(section_children, list):
288
+ node, _, _, _ = _find_node_in_children(section_children, parent_id)
289
+ else:
290
+ if section_children.get("id") == parent_id:
291
+ node = section_children
292
+ elif "children" in section_children:
293
+ node, _, _, _ = _find_node_in_children(section_children.get("children", []), parent_id)
294
+ else:
295
+ node = None
183
296
 
184
- section_children = section.get("children", [])
185
- node, _, _, _ = _find_node_in_children(section_children, parent_id)
186
297
  if node is not None:
298
+ if _is_multivalue_node(node):
299
+ return None, True
187
300
  if "children" in node:
188
301
  if isinstance(node["children"], list):
189
302
  result: list[dict] = node["children"]
190
- return result
191
- if isinstance(node["children"], dict):
192
- return [node["children"]]
303
+ return result, False
193
304
  else:
194
305
  node["children"] = []
195
306
  node_children: list[dict] = node["children"]
196
- return node_children
307
+ return node_children, False
197
308
 
198
- return None
309
+ return None, False
199
310
 
200
311
 
201
312
  def _apply_add_operation(
@@ -209,7 +320,13 @@ def _apply_add_operation(
209
320
  node_data = copy.deepcopy(node_data)
210
321
  node_data["id"] = node_id
211
322
 
212
- parent_children = _find_parent_children_list(content, parent_id)
323
+ parent_children, is_multivalue = _find_parent_children_list(content, parent_id)
324
+ if is_multivalue:
325
+ raise ValueError(
326
+ f"Cannot add children to multivalue '{parent_id}'. "
327
+ "Multivalue nodes have a single child (tuple or datapoint). "
328
+ "Use 'update' to replace the multivalue's children, or add to the tuple inside it."
329
+ )
213
330
  if parent_children is None:
214
331
  raise ValueError(f"Parent node '{parent_id}' not found in schema")
215
332
 
@@ -220,20 +337,42 @@ def _apply_add_operation(
220
337
  return content
221
338
 
222
339
 
223
- def _apply_update_operation(content: list[dict], node_id: str, node_data: dict | None) -> list[dict]:
224
- if node_data is None:
225
- raise ValueError("node_data is required for 'update' operation")
340
+ def _get_section_children_as_list(section: dict) -> list[dict]:
341
+ """Get section children as a list, handling both list and dict (multivalue) cases."""
342
+ children = section.get("children")
343
+ if children is None:
344
+ return []
345
+ if isinstance(children, list):
346
+ return children
347
+ if isinstance(children, dict):
348
+ return [children]
349
+ return []
226
350
 
351
+
352
+ def _find_node_anywhere(
353
+ content: list[dict], node_id: str
354
+ ) -> tuple[dict | None, int | None, list[dict] | None, dict | None]:
355
+ """Find a node by ID anywhere in the schema content.
356
+
357
+ Returns (node, index, parent_children_list, parent_node).
358
+ """
227
359
  for section in content:
228
360
  if section.get("id") == node_id:
229
- section.update(node_data)
230
- return content
361
+ return section, None, None, None
231
362
 
232
- node: dict | None = None
233
- for section in content:
234
- node, _, _, _ = _find_node_in_children(section.get("children", []), node_id)
235
- if node is not None:
236
- break
363
+ section_children = _get_section_children_as_list(section)
364
+ result = _find_node_in_children(section_children, node_id, section)
365
+ if result[0] is not None:
366
+ return result
367
+
368
+ return None, None, None, None
369
+
370
+
371
+ def _apply_update_operation(content: list[dict], node_id: str, node_data: dict | None) -> list[dict]:
372
+ if node_data is None:
373
+ raise ValueError("node_data is required for 'update' operation")
374
+
375
+ node, _, _, _ = _find_node_anywhere(content, node_id)
237
376
 
238
377
  if node is None:
239
378
  raise ValueError(f"Node '{node_id}' not found in schema")
@@ -244,22 +383,27 @@ def _apply_update_operation(content: list[dict], node_id: str, node_data: dict |
244
383
 
245
384
  def _apply_remove_operation(content: list[dict], node_id: str) -> list[dict]:
246
385
  for section in content:
247
- if section.get("id") == node_id:
386
+ if section.get("id") == node_id and section.get("category") == "section":
248
387
  raise ValueError("Cannot remove a section - sections must exist")
249
388
 
250
- for section in content:
251
- section_children = section.get("children", [])
252
- node, idx, parent_list, parent_node = _find_node_in_children(section_children, node_id)
253
- if node is not None and idx is not None:
254
- if parent_list is not None:
255
- # Node is in a regular list of children
256
- parent_list.pop(idx)
257
- elif parent_node is not None:
258
- # Node is multivalue's single dict child - cannot remove tuple from multivalue
259
- raise ValueError(f"Cannot remove tuple '{node_id}' from multivalue - remove the multivalue instead")
260
- return content
389
+ node, idx, parent_list, parent_node = _find_node_anywhere(content, node_id)
261
390
 
262
- raise ValueError(f"Node '{node_id}' not found in schema")
391
+ if node is None:
392
+ raise ValueError(f"Node '{node_id}' not found in schema")
393
+
394
+ if idx is None and parent_list is None:
395
+ if node.get("category") == "section":
396
+ raise ValueError("Cannot remove a section - sections must exist")
397
+ raise ValueError(f"Cannot determine how to remove node '{node_id}'")
398
+
399
+ if parent_list is not None and idx is not None:
400
+ parent_list.pop(idx)
401
+ elif parent_node is not None:
402
+ if parent_node.get("category") == "multivalue":
403
+ raise ValueError(f"Cannot remove '{node_id}' from multivalue - remove the multivalue instead")
404
+ raise ValueError(f"Cannot remove '{node_id}' - unexpected parent structure")
405
+
406
+ return content
263
407
 
264
408
 
265
409
  def apply_schema_patch(
@@ -270,19 +414,7 @@ def apply_schema_patch(
270
414
  parent_id: str | None = None,
271
415
  position: int | None = None,
272
416
  ) -> list[dict]:
273
- """Apply a patch operation to schema content.
274
-
275
- Args:
276
- content: The schema content (list of sections)
277
- operation: One of "add", "update", "remove"
278
- node_id: ID of the node to operate on
279
- node_data: Data for add/update operations
280
- parent_id: Parent node ID for add operation (section ID or multivalue/tuple ID)
281
- position: Optional position for add operation (appends if not specified)
282
-
283
- Returns:
284
- Modified content
285
- """
417
+ """Apply a patch operation to schema content."""
286
418
  content = copy.deepcopy(content)
287
419
 
288
420
  if operation == "add":
@@ -295,40 +427,318 @@ def apply_schema_patch(
295
427
  return content
296
428
 
297
429
 
430
+ async def _get_schema(client: AsyncRossumAPIClient, schema_id: int) -> Schema:
431
+ schema: Schema = await client.retrieve_schema(schema_id)
432
+ return schema
433
+
434
+
435
+ @dataclass
436
+ class SchemaTreeNode:
437
+ """Lightweight schema node for tree structure display."""
438
+
439
+ id: str
440
+ label: str
441
+ category: str
442
+ type: str | None = None
443
+ children: list[SchemaTreeNode] | None = None
444
+
445
+ def to_dict(self) -> dict:
446
+ """Convert to dict, excluding None values."""
447
+ result: dict = {"id": self.id, "label": self.label, "category": self.category}
448
+ if self.type:
449
+ result["type"] = self.type
450
+ if self.children:
451
+ result["children"] = [child.to_dict() for child in self.children]
452
+ return result
453
+
454
+
455
+ def _build_tree_node(node: dict) -> SchemaTreeNode:
456
+ """Build a lightweight tree node from a schema node."""
457
+ category = node.get("category", "")
458
+ node_id = node.get("id", "")
459
+ label = node.get("label", "")
460
+ node_type = node.get("type") if category == "datapoint" else None
461
+
462
+ children_data = node.get("children")
463
+ children: list[SchemaTreeNode] | None = None
464
+
465
+ if children_data is not None:
466
+ if isinstance(children_data, list):
467
+ children = [_build_tree_node(child) for child in children_data]
468
+ elif isinstance(children_data, dict):
469
+ children = [_build_tree_node(children_data)]
470
+
471
+ return SchemaTreeNode(id=node_id, label=label, category=category, type=node_type, children=children)
472
+
473
+
474
+ def _extract_schema_tree(content: list[dict]) -> list[dict]:
475
+ """Extract lightweight tree structure from schema content."""
476
+ return [_build_tree_node(section).to_dict() for section in content]
477
+
478
+
479
+ def _collect_all_field_ids(content: list[dict]) -> set[str]:
480
+ """Collect all field IDs from schema content recursively."""
481
+ ids: set[str] = set()
482
+
483
+ def _traverse(node: dict) -> None:
484
+ node_id = node.get("id")
485
+ if node_id:
486
+ ids.add(node_id)
487
+ children = node.get("children")
488
+ if children is not None:
489
+ if isinstance(children, list):
490
+ for child in children:
491
+ _traverse(child)
492
+ elif isinstance(children, dict):
493
+ _traverse(children)
494
+
495
+ for section in content:
496
+ _traverse(section)
497
+
498
+ return ids
499
+
500
+
501
+ def _collect_ancestor_ids(content: list[dict], target_ids: set[str]) -> set[str]:
502
+ """Collect all ancestor IDs for the given target field IDs.
503
+
504
+ Returns set of IDs for all parent containers (multivalue, tuple, section) of target fields.
505
+ """
506
+ ancestors: set[str] = set()
507
+
508
+ def _find_ancestors(node: dict, path: list[str]) -> None:
509
+ node_id = node.get("id", "")
510
+ current_path = [*path, node_id] if node_id else path
511
+
512
+ if node_id in target_ids:
513
+ ancestors.update(current_path[:-1])
514
+
515
+ children = node.get("children")
516
+ if children is not None:
517
+ if isinstance(children, list):
518
+ for child in children:
519
+ _find_ancestors(child, current_path)
520
+ elif isinstance(children, dict):
521
+ _find_ancestors(children, current_path)
522
+
523
+ for section in content:
524
+ _find_ancestors(section, [])
525
+
526
+ return ancestors
527
+
528
+
529
+ def _remove_fields_from_content(content: list[dict], fields_to_remove: set[str]) -> tuple[list[dict], list[str]]:
530
+ """Remove multiple fields from schema content.
531
+
532
+ Returns (modified_content, list_of_removed_field_ids).
533
+ Sections cannot be removed.
534
+ """
535
+ content = copy.deepcopy(content)
536
+ removed: list[str] = []
537
+
538
+ def _filter_children(children: list[dict]) -> list[dict]:
539
+ result = []
540
+ for child in children:
541
+ child_id = child.get("id", "")
542
+ category = child.get("category", "")
543
+
544
+ if child_id in fields_to_remove and category != "section":
545
+ removed.append(child_id)
546
+ continue
547
+
548
+ nested = child.get("children")
549
+ if nested is not None:
550
+ if isinstance(nested, list):
551
+ child["children"] = _filter_children(nested)
552
+ elif isinstance(nested, dict):
553
+ nested_id = nested.get("id", "")
554
+ if nested_id in fields_to_remove:
555
+ removed.append(nested_id)
556
+ removed.append(child_id)
557
+ continue
558
+ nested_children = nested.get("children")
559
+ if isinstance(nested_children, list):
560
+ filtered_nested = _filter_children(nested_children)
561
+ if not filtered_nested:
562
+ removed.append(nested_id)
563
+ removed.append(child_id)
564
+ continue
565
+ nested["children"] = filtered_nested
566
+ result.append(child)
567
+ return result
568
+
569
+ for section in content:
570
+ section_children = section.get("children")
571
+ if isinstance(section_children, list):
572
+ section["children"] = _filter_children(section_children)
573
+
574
+ # Remove sections with empty children (API rejects empty sections)
575
+ removed_sections = [s.get("id", "") for s in content if not s.get("children")]
576
+ removed.extend(removed_sections)
577
+ content = [s for s in content if s.get("children")]
578
+
579
+ return content, removed
580
+
581
+
582
+ def _truncate_schema_for_list(schema: Schema) -> Schema:
583
+ """Truncate content field in schema to save context in list responses."""
584
+ return replace(schema, content=TRUNCATED_MARKER)
585
+
586
+
587
+ async def _list_schemas(
588
+ client: AsyncRossumAPIClient, name: str | None = None, queue_id: int | None = None
589
+ ) -> list[Schema]:
590
+ logger.debug(f"Listing schemas: name={name}, queue_id={queue_id}")
591
+ filters: dict[str, int | str] = {}
592
+ if name is not None:
593
+ filters["name"] = name
594
+ if queue_id is not None:
595
+ filters["queue"] = queue_id
596
+
597
+ schemas = [schema async for schema in client.list_schemas(**filters)] # type: ignore[arg-type]
598
+ return [_truncate_schema_for_list(schema) for schema in schemas]
599
+
600
+
601
+ async def _update_schema(client: AsyncRossumAPIClient, schema_id: int, schema_data: dict) -> Schema | dict:
602
+ if not is_read_write_mode():
603
+ return {"error": "update_schema is not available in read-only mode"}
604
+
605
+ logger.debug(f"Updating schema: schema_id={schema_id}")
606
+ await client._http_client.update(Resource.Schema, schema_id, schema_data)
607
+ updated_schema: Schema = await client.retrieve_schema(schema_id)
608
+ return updated_schema
609
+
610
+
611
+ async def _create_schema(client: AsyncRossumAPIClient, name: str, content: list[dict]) -> Schema | dict:
612
+ if not is_read_write_mode():
613
+ return {"error": "create_schema is not available in read-only mode"}
614
+
615
+ logger.debug(f"Creating schema: name={name}")
616
+ schema_data = {"name": name, "content": content}
617
+ schema: Schema = await client.create_new_schema(schema_data)
618
+ return schema
619
+
620
+
621
+ async def _patch_schema(
622
+ client: AsyncRossumAPIClient,
623
+ schema_id: int,
624
+ operation: PatchOperation,
625
+ node_id: str,
626
+ node_data: SchemaNode | SchemaNodeUpdate | None = None,
627
+ parent_id: str | None = None,
628
+ position: int | None = None,
629
+ ) -> Schema | dict:
630
+ if not is_read_write_mode():
631
+ return {"error": "patch_schema is not available in read-only mode"}
632
+
633
+ if operation not in ("add", "update", "remove"):
634
+ return {"error": f"Invalid operation '{operation}'. Must be 'add', 'update', or 'remove'."}
635
+
636
+ logger.debug(f"Patching schema: schema_id={schema_id}, operation={operation}, node_id={node_id}")
637
+
638
+ node_data_dict: dict | None = None
639
+ if node_data is not None:
640
+ if isinstance(node_data, dict):
641
+ node_data_dict = node_data
642
+ elif hasattr(node_data, "to_dict"):
643
+ node_data_dict = node_data.to_dict()
644
+ else:
645
+ node_data_dict = asdict(node_data)
646
+
647
+ current_schema: dict = await client._http_client.request_json("GET", f"schemas/{schema_id}")
648
+ content_list = current_schema.get("content", [])
649
+ if not isinstance(content_list, list):
650
+ return {"error": "Unexpected schema content format"}
651
+
652
+ try:
653
+ patched_content = apply_schema_patch(
654
+ content=content_list,
655
+ operation=operation,
656
+ node_id=node_id,
657
+ node_data=node_data_dict,
658
+ parent_id=parent_id,
659
+ position=position,
660
+ )
661
+ except ValueError as e:
662
+ return {"error": str(e)}
663
+
664
+ await client._http_client.update(Resource.Schema, schema_id, {"content": patched_content})
665
+ updated_schema: Schema = await client.retrieve_schema(schema_id)
666
+ return updated_schema
667
+
668
+
669
+ async def _get_schema_tree_structure(client: AsyncRossumAPIClient, schema_id: int) -> list[dict]:
670
+ schema = await _get_schema(client, schema_id)
671
+ content_dicts: list[dict[str, Any]] = [
672
+ asdict(section) if is_dataclass(section) else dict(section) # type: ignore[arg-type]
673
+ for section in schema.content
674
+ ]
675
+ return _extract_schema_tree(content_dicts)
676
+
677
+
678
+ async def _prune_schema_fields(
679
+ client: AsyncRossumAPIClient,
680
+ schema_id: int,
681
+ fields_to_keep: list[str] | None = None,
682
+ fields_to_remove: list[str] | None = None,
683
+ ) -> dict:
684
+ if not is_read_write_mode():
685
+ return {"error": "prune_schema_fields is not available in read-only mode"}
686
+
687
+ if fields_to_keep and fields_to_remove:
688
+ return {"error": "Specify fields_to_keep OR fields_to_remove, not both"}
689
+ if not fields_to_keep and not fields_to_remove:
690
+ return {"error": "Must specify fields_to_keep or fields_to_remove"}
691
+
692
+ current_schema: dict = await client._http_client.request_json("GET", f"schemas/{schema_id}")
693
+ content = current_schema.get("content", [])
694
+ if not isinstance(content, list):
695
+ return {"error": "Unexpected schema content format"}
696
+ all_ids = _collect_all_field_ids(content)
697
+
698
+ section_ids = {s.get("id") for s in content if s.get("category") == "section"}
699
+
700
+ if fields_to_keep:
701
+ fields_to_keep_set = set(fields_to_keep) | section_ids
702
+ ancestor_ids = _collect_ancestor_ids(content, fields_to_keep_set)
703
+ fields_to_keep_set |= ancestor_ids
704
+ remove_set = all_ids - fields_to_keep_set
705
+ else:
706
+ remove_set = set(fields_to_remove) - section_ids # type: ignore[arg-type]
707
+
708
+ if not remove_set:
709
+ return {"removed_fields": [], "remaining_fields": sorted(all_ids)}
710
+
711
+ pruned_content, removed = _remove_fields_from_content(content, remove_set)
712
+ await client._http_client.update(Resource.Schema, schema_id, {"content": pruned_content})
713
+
714
+ remaining_ids = _collect_all_field_ids(pruned_content)
715
+ return {"removed_fields": sorted(removed), "remaining_fields": sorted(remaining_ids)}
716
+
717
+
298
718
  def register_schema_tools(mcp: FastMCP, client: AsyncRossumAPIClient) -> None:
299
719
  """Register schema-related tools with the FastMCP server."""
300
720
 
301
721
  @mcp.tool(description="Retrieve schema details.")
302
722
  async def get_schema(schema_id: int) -> Schema:
303
- """Retrieve schema details."""
304
- schema: Schema = await client.retrieve_schema(schema_id)
305
- return schema
723
+ return await _get_schema(client, schema_id)
724
+
725
+ @mcp.tool(description="List all schemas with optional filters.")
726
+ async def list_schemas(name: str | None = None, queue_id: int | None = None) -> list[Schema]:
727
+ return await _list_schemas(client, name, queue_id)
306
728
 
307
729
  @mcp.tool(description="Update schema, typically for field-level thresholds.")
308
730
  async def update_schema(schema_id: int, schema_data: dict) -> Schema | dict:
309
- """Update an existing schema."""
310
- if not is_read_write_mode():
311
- return {"error": "update_schema is not available in read-only mode"}
312
-
313
- logger.debug(f"Updating schema: schema_id={schema_id}")
314
- await client._http_client.update(Resource.Schema, schema_id, schema_data)
315
- updated_schema: Schema = await client.retrieve_schema(schema_id)
316
- return updated_schema
731
+ return await _update_schema(client, schema_id, schema_data)
317
732
 
318
733
  @mcp.tool(description="Create a schema. Must have ≥1 section with children (datapoints).")
319
734
  async def create_schema(name: str, content: list[dict]) -> Schema | dict:
320
- """Create a new schema."""
321
- if not is_read_write_mode():
322
- return {"error": "create_schema is not available in read-only mode"}
323
-
324
- logger.debug(f"Creating schema: name={name}")
325
- schema_data = {"name": name, "content": content}
326
- schema: Schema = await client.create_new_schema(schema_data)
327
- return schema
735
+ return await _create_schema(client, name, content)
328
736
 
329
737
  @mcp.tool(
330
738
  description="""Patch schema nodes (add/update/remove fields in a schema).
331
739
 
740
+ You MUST load `schema-patching` skill first to avoid errors.
741
+
332
742
  Operations:
333
743
  - add: Create new field. Requires parent_id (section or tuple id) and node_data.
334
744
  - update: Modify existing field. Requires node_data with fields to change.
@@ -340,7 +750,8 @@ Node types for add:
340
750
  - Multivalue (table): {"label": "Table", "category": "multivalue", "children": <tuple>}
341
751
  - Tuple (table row): {"id": "row_id", "label": "Row", "category": "tuple", "children": [<datapoints with id>]}
342
752
 
343
- Important: Datapoints inside a tuple MUST have an "id" field. Section-level datapoints get id from node_id parameter."""
753
+ Important: Datapoints inside a tuple MUST have an "id" field. Section-level datapoints get id from node_id parameter.
754
+ """
344
755
  )
345
756
  async def patch_schema(
346
757
  schema_id: int,
@@ -350,35 +761,24 @@ Important: Datapoints inside a tuple MUST have an "id" field. Section-level data
350
761
  parent_id: str | None = None,
351
762
  position: int | None = None,
352
763
  ) -> Schema | dict:
353
- if not is_read_write_mode():
354
- return {"error": "patch_schema is not available in read-only mode"}
355
-
356
- if operation not in ("add", "update", "remove"):
357
- return {"error": f"Invalid operation '{operation}'. Must be 'add', 'update', or 'remove'."}
358
-
359
- logger.debug(f"Patching schema: schema_id={schema_id}, operation={operation}, node_id={node_id}")
360
-
361
- node_data_dict: dict | None = None
362
- if node_data is not None:
363
- node_data_dict = node_data.to_dict() if hasattr(node_data, "to_dict") else dict(node_data) # type: ignore[call-overload]
364
-
365
- current_schema: dict = await client._http_client.request_json("GET", f"schemas/{schema_id}")
366
- content_list = current_schema.get("content", [])
367
- if not isinstance(content_list, list):
368
- return {"error": "Unexpected schema content format"}
369
-
370
- try:
371
- patched_content = apply_schema_patch(
372
- content=content_list,
373
- operation=operation, # type: ignore[arg-type]
374
- node_id=node_id,
375
- node_data=node_data_dict,
376
- parent_id=parent_id,
377
- position=position,
378
- )
379
- except ValueError as e:
380
- return {"error": str(e)}
381
-
382
- await client._http_client.update(Resource.Schema, schema_id, {"content": patched_content})
383
- updated_schema: Schema = await client.retrieve_schema(schema_id)
384
- return updated_schema
764
+ return await _patch_schema(client, schema_id, operation, node_id, node_data, parent_id, position)
765
+
766
+ @mcp.tool(description="Get lightweight tree structure of schema with only ids, labels, categories, and types.")
767
+ async def get_schema_tree_structure(schema_id: int) -> list[dict]:
768
+ return await _get_schema_tree_structure(client, schema_id)
769
+
770
+ @mcp.tool(
771
+ description="""Remove multiple fields from schema at once. Efficient for pruning unwanted fields during setup.
772
+
773
+ Use fields_to_keep OR fields_to_remove (not both):
774
+ - fields_to_keep: Keep only these field IDs (plus sections). All others removed.
775
+ - fields_to_remove: Remove these specific field IDs.
776
+
777
+ Returns dict with removed_fields and remaining_fields lists. Sections cannot be removed."""
778
+ )
779
+ async def prune_schema_fields(
780
+ schema_id: int,
781
+ fields_to_keep: list[str] | None = None,
782
+ fields_to_remove: list[str] | None = None,
783
+ ) -> dict:
784
+ return await _prune_schema_fields(client, schema_id, fields_to_keep, fields_to_remove)