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