rossum-mcp 0.3.4__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.
@@ -0,0 +1,384 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import logging
5
+ from dataclasses import asdict, dataclass
6
+ from typing import TYPE_CHECKING, Literal
7
+
8
+ from rossum_api.domain_logic.resources import Resource
9
+ from rossum_api.models.schema import Schema # noqa: TC002 - needed at runtime for FastMCP
10
+
11
+ from rossum_mcp.tools.base import is_read_write_mode
12
+
13
+ if TYPE_CHECKING:
14
+ from fastmcp import FastMCP
15
+ from rossum_api import AsyncRossumAPIClient
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ PatchOperation = Literal["add", "update", "remove"]
20
+ DatapointType = Literal["string", "number", "date", "enum", "button"]
21
+ NodeCategory = Literal["datapoint", "multivalue", "tuple"]
22
+
23
+
24
+ @dataclass
25
+ class SchemaDatapoint:
26
+ """A datapoint node for schema patch operations.
27
+
28
+ Use for adding/updating fields that capture or display values.
29
+ When used inside a tuple (table), id is required.
30
+ """
31
+
32
+ label: str
33
+ id: str | None = None
34
+ category: Literal["datapoint"] = "datapoint"
35
+ type: DatapointType | None = None
36
+ rir_field_names: list[str] | None = None
37
+ default_value: str | None = None
38
+ score_threshold: float | None = None
39
+ hidden: bool = False
40
+ disable_prediction: bool = False
41
+ can_export: bool = True
42
+ constraints: dict | None = None
43
+ options: list[dict] | None = None
44
+ ui_configuration: dict | None = None
45
+ formula: str | None = None
46
+ prompt: str | None = None
47
+ context: list[str] | None = None
48
+ width: int | None = None
49
+ stretch: bool | None = None
50
+
51
+ def to_dict(self) -> dict:
52
+ """Convert to dict, excluding None values."""
53
+ return {k: v for k, v in asdict(self).items() if v is not None}
54
+
55
+
56
+ @dataclass
57
+ class SchemaTuple:
58
+ """A tuple node for schema patch operations.
59
+
60
+ Use within multivalue to define table row structure with multiple columns.
61
+ """
62
+
63
+ id: str
64
+ label: str
65
+ children: list[SchemaDatapoint]
66
+ category: Literal["tuple"] = "tuple"
67
+ hidden: bool = False
68
+
69
+ def to_dict(self) -> dict:
70
+ """Convert to dict, excluding None values."""
71
+ result: dict = {"id": self.id, "category": self.category, "label": self.label}
72
+ if self.hidden:
73
+ result["hidden"] = self.hidden
74
+ result["children"] = [child.to_dict() for child in self.children]
75
+ return result
76
+
77
+
78
+ @dataclass
79
+ class SchemaMultivalue:
80
+ """A multivalue node for schema patch operations.
81
+
82
+ Use for repeating fields or tables. Children is a single Tuple or Datapoint (NOT a list).
83
+ The id is optional here since it gets set from node_id in patch_schema.
84
+ """
85
+
86
+ label: str
87
+ children: SchemaTuple | SchemaDatapoint
88
+ id: str | None = None
89
+ category: Literal["multivalue"] = "multivalue"
90
+ rir_field_names: list[str] | None = None
91
+ min_occurrences: int | None = None
92
+ max_occurrences: int | None = None
93
+ hidden: bool = False
94
+
95
+ def to_dict(self) -> dict:
96
+ """Convert to dict, excluding None values."""
97
+ result: dict = {"label": self.label, "category": self.category}
98
+ if self.id:
99
+ result["id"] = self.id
100
+ if self.rir_field_names:
101
+ result["rir_field_names"] = self.rir_field_names
102
+ if self.min_occurrences is not None:
103
+ result["min_occurrences"] = self.min_occurrences
104
+ if self.max_occurrences is not None:
105
+ result["max_occurrences"] = self.max_occurrences
106
+ if self.hidden:
107
+ result["hidden"] = self.hidden
108
+ result["children"] = self.children.to_dict()
109
+ return result
110
+
111
+
112
+ @dataclass
113
+ class SchemaNodeUpdate:
114
+ """Partial update for an existing schema node.
115
+
116
+ Only include fields you want to update - all fields are optional.
117
+ """
118
+
119
+ label: str | None = None
120
+ type: DatapointType | None = None
121
+ score_threshold: float | None = None
122
+ hidden: bool | None = None
123
+ disable_prediction: bool | None = None
124
+ can_export: bool | None = None
125
+ default_value: str | None = None
126
+ rir_field_names: list[str] | None = None
127
+ constraints: dict | None = None
128
+ options: list[dict] | None = None
129
+ ui_configuration: dict | None = None
130
+ formula: str | None = None
131
+ prompt: str | None = None
132
+ context: list[str] | None = None
133
+ width: int | None = None
134
+ stretch: bool | None = None
135
+ min_occurrences: int | None = None
136
+ max_occurrences: int | None = None
137
+
138
+ def to_dict(self) -> dict:
139
+ """Convert to dict, excluding None values."""
140
+ return {k: v for k, v in asdict(self).items() if v is not None}
141
+
142
+
143
+ SchemaNode = SchemaDatapoint | SchemaMultivalue | SchemaTuple
144
+
145
+
146
+ def _find_node_in_children(
147
+ children: list[dict], node_id: str, parent_node: dict | None = None
148
+ ) -> tuple[dict | None, int | None, list[dict] | None, dict | None]:
149
+ """Recursively find a node by ID in schema children.
150
+
151
+ Returns (node, index, parent_children_list, parent_node) or (None, None, None, None) if not found.
152
+ The parent_node is needed for multivalue's dict children where we need to modify the parent directly.
153
+ """
154
+ for i, child in enumerate(children):
155
+ if child.get("id") == node_id:
156
+ return child, i, children, parent_node
157
+
158
+ nested_children = child.get("children")
159
+ if nested_children:
160
+ if isinstance(nested_children, list):
161
+ result = _find_node_in_children(nested_children, node_id, child)
162
+ if result[0] is not None:
163
+ return result
164
+ elif isinstance(nested_children, dict):
165
+ # Multivalue has a single dict child (tuple), not a list
166
+ if nested_children.get("id") == node_id:
167
+ # Return the parent node so caller can modify child["children"] directly
168
+ return nested_children, 0, None, child
169
+ if "children" in nested_children:
170
+ result = _find_node_in_children(nested_children["children"], node_id, nested_children)
171
+ if result[0] is not None:
172
+ return result
173
+
174
+ return None, None, None, None
175
+
176
+
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."""
179
+ for section in content:
180
+ if section.get("id") == parent_id:
181
+ children: list[dict] = section.setdefault("children", [])
182
+ return children
183
+
184
+ section_children = section.get("children", [])
185
+ node, _, _, _ = _find_node_in_children(section_children, parent_id)
186
+ if node is not None:
187
+ if "children" in node:
188
+ if isinstance(node["children"], list):
189
+ result: list[dict] = node["children"]
190
+ return result
191
+ if isinstance(node["children"], dict):
192
+ return [node["children"]]
193
+ else:
194
+ node["children"] = []
195
+ node_children: list[dict] = node["children"]
196
+ return node_children
197
+
198
+ return None
199
+
200
+
201
+ def _apply_add_operation(
202
+ content: list[dict], node_id: str, node_data: dict | None, parent_id: str | None, position: int | None
203
+ ) -> list[dict]:
204
+ if node_data is None:
205
+ raise ValueError("node_data is required for 'add' operation")
206
+ if parent_id is None:
207
+ raise ValueError("parent_id is required for 'add' operation")
208
+
209
+ node_data = copy.deepcopy(node_data)
210
+ node_data["id"] = node_id
211
+
212
+ parent_children = _find_parent_children_list(content, parent_id)
213
+ if parent_children is None:
214
+ raise ValueError(f"Parent node '{parent_id}' not found in schema")
215
+
216
+ if position is not None and 0 <= position <= len(parent_children):
217
+ parent_children.insert(position, node_data)
218
+ else:
219
+ parent_children.append(node_data)
220
+ return content
221
+
222
+
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")
226
+
227
+ for section in content:
228
+ if section.get("id") == node_id:
229
+ section.update(node_data)
230
+ return content
231
+
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
237
+
238
+ if node is None:
239
+ raise ValueError(f"Node '{node_id}' not found in schema")
240
+
241
+ node.update(node_data)
242
+ return content
243
+
244
+
245
+ def _apply_remove_operation(content: list[dict], node_id: str) -> list[dict]:
246
+ for section in content:
247
+ if section.get("id") == node_id:
248
+ raise ValueError("Cannot remove a section - sections must exist")
249
+
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
261
+
262
+ raise ValueError(f"Node '{node_id}' not found in schema")
263
+
264
+
265
+ def apply_schema_patch(
266
+ content: list[dict],
267
+ operation: PatchOperation,
268
+ node_id: str,
269
+ node_data: dict | None = None,
270
+ parent_id: str | None = None,
271
+ position: int | None = None,
272
+ ) -> 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
+ """
286
+ content = copy.deepcopy(content)
287
+
288
+ if operation == "add":
289
+ return _apply_add_operation(content, node_id, node_data, parent_id, position)
290
+ if operation == "update":
291
+ return _apply_update_operation(content, node_id, node_data)
292
+ if operation == "remove":
293
+ return _apply_remove_operation(content, node_id)
294
+
295
+ return content
296
+
297
+
298
+ def register_schema_tools(mcp: FastMCP, client: AsyncRossumAPIClient) -> None:
299
+ """Register schema-related tools with the FastMCP server."""
300
+
301
+ @mcp.tool(description="Retrieve schema details.")
302
+ async def get_schema(schema_id: int) -> Schema:
303
+ """Retrieve schema details."""
304
+ schema: Schema = await client.retrieve_schema(schema_id)
305
+ return schema
306
+
307
+ @mcp.tool(description="Update schema, typically for field-level thresholds.")
308
+ 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
317
+
318
+ @mcp.tool(description="Create a schema. Must have ≥1 section with children (datapoints).")
319
+ 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
328
+
329
+ @mcp.tool(
330
+ description="""Patch schema nodes (add/update/remove fields in a schema).
331
+
332
+ Operations:
333
+ - add: Create new field. Requires parent_id (section or tuple id) and node_data.
334
+ - update: Modify existing field. Requires node_data with fields to change.
335
+ - remove: Delete field. Only requires node_id.
336
+
337
+ Node types for add:
338
+ - Datapoint (simple field): {"label": "Field Name", "category": "datapoint", "type": "string|number|date|enum"}
339
+ - Enum field: Include "options": [{"value": "v1", "label": "Label 1"}, ...]
340
+ - Multivalue (table): {"label": "Table", "category": "multivalue", "children": <tuple>}
341
+ - Tuple (table row): {"id": "row_id", "label": "Row", "category": "tuple", "children": [<datapoints with id>]}
342
+
343
+ Important: Datapoints inside a tuple MUST have an "id" field. Section-level datapoints get id from node_id parameter."""
344
+ )
345
+ async def patch_schema(
346
+ schema_id: int,
347
+ operation: PatchOperation,
348
+ node_id: str,
349
+ node_data: SchemaNode | SchemaNodeUpdate | None = None,
350
+ parent_id: str | None = None,
351
+ position: int | None = None,
352
+ ) -> 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
@@ -0,0 +1,60 @@
1
+ """User tools for Rossum MCP Server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ from rossum_api.models.group import Group # noqa: TC002 - needed at runtime for FastMCP
9
+ from rossum_api.models.user import User # noqa: TC002 - needed at runtime for FastMCP
10
+
11
+ if TYPE_CHECKING:
12
+ from fastmcp import FastMCP
13
+ from rossum_api import AsyncRossumAPIClient
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def register_user_tools(mcp: FastMCP, client: AsyncRossumAPIClient) -> None:
19
+ @mcp.tool(description="Retrieve a single user by ID. Use list_users first to find users by username/email.")
20
+ async def get_user(user_id: int) -> User:
21
+ user: User = await client.retrieve_user(user_id)
22
+ return user
23
+
24
+ @mcp.tool(
25
+ description="List users. Filter by username/email to find specific users. Beware that users with 'organization_group_admin' role are special, e.g. cannot be used as token owners; you can filter them out with `is_organization_group_admin=False`."
26
+ )
27
+ async def list_users(
28
+ username: str | None = None,
29
+ email: str | None = None,
30
+ first_name: str | None = None,
31
+ last_name: str | None = None,
32
+ is_active: bool | None = None,
33
+ is_organization_group_admin: bool | None = None,
34
+ ) -> list[User]:
35
+ filter_mapping: dict = {
36
+ "username": username,
37
+ "email": email,
38
+ "first_name": first_name,
39
+ "last_name": last_name,
40
+ "is_active": is_active,
41
+ }
42
+ filters = {k: v for k, v in filter_mapping.items() if v is not None}
43
+
44
+ users_list: list[User] = [user async for user in client.list_users(**filters)]
45
+
46
+ if is_organization_group_admin is not None:
47
+ org_admin_role_urls: set[str] = {
48
+ group.url async for group in client.list_user_roles() if group.name == "organization_group_admin"
49
+ }
50
+ if is_organization_group_admin:
51
+ users_list = [user for user in users_list if set(user.groups) & org_admin_role_urls]
52
+ else:
53
+ users_list = [user for user in users_list if not (set(user.groups) & org_admin_role_urls)]
54
+
55
+ return users_list
56
+
57
+ @mcp.tool(description="List all user roles (groups of permissions) in the organization.")
58
+ async def list_user_roles() -> list[Group]:
59
+ groups_list: list[Group] = [group async for group in client.list_user_roles()]
60
+ return groups_list
@@ -0,0 +1,65 @@
1
+ """Workspace tools for Rossum MCP Server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ from rossum_api.models.workspace import Workspace # noqa: TC002 - needed at runtime for FastMCP
9
+
10
+ from rossum_mcp.tools.base import build_resource_url, is_read_write_mode
11
+
12
+ if TYPE_CHECKING:
13
+ from fastmcp import FastMCP
14
+ from rossum_api import AsyncRossumAPIClient
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def register_workspace_tools(mcp: FastMCP, client: AsyncRossumAPIClient) -> None:
20
+ """Register workspace-related tools with the FastMCP server."""
21
+
22
+ @mcp.tool(description="Retrieve workspace details.")
23
+ async def get_workspace(workspace_id: int) -> Workspace:
24
+ """Retrieve workspace details."""
25
+ logger.debug(f"Retrieving workspace: workspace_id={workspace_id}")
26
+ workspace: Workspace = await client.retrieve_workspace(workspace_id)
27
+ return workspace
28
+
29
+ @mcp.tool(description="List all workspaces with optional filters.")
30
+ async def list_workspaces(organization_id: int | None = None, name: str | None = None) -> list[Workspace]:
31
+ """List all workspaces with optional filters."""
32
+ logger.debug(f"Listing workspaces: organization_id={organization_id}, name={name}")
33
+ filters: dict[str, int | str] = {}
34
+ if organization_id is not None:
35
+ filters["organization"] = organization_id
36
+ if name is not None:
37
+ filters["name"] = name
38
+
39
+ return [
40
+ workspace
41
+ async for workspace in client.list_workspaces(**filters) # type: ignore[arg-type]
42
+ ]
43
+
44
+ @mcp.tool(description="Create a new workspace.")
45
+ async def create_workspace(name: str, organization_id: int, metadata: dict | None = None) -> Workspace | dict:
46
+ """Create a new workspace."""
47
+ if not is_read_write_mode():
48
+ return {"error": "create_workspace is not available in read-only mode"}
49
+
50
+ organization_url = build_resource_url("organizations", organization_id)
51
+ logger.info(
52
+ f"Creating workspace: name={name}, organization_id={organization_id}, "
53
+ f"organization_url={organization_url}, metadata={metadata}"
54
+ )
55
+ workspace_data: dict = {
56
+ "name": name,
57
+ "organization": organization_url,
58
+ }
59
+ if metadata is not None:
60
+ workspace_data["metadata"] = metadata
61
+
62
+ logger.debug(f"Workspace creation payload: {workspace_data}")
63
+ workspace: Workspace = await client.create_new_workspace(workspace_data)
64
+ logger.info(f"Successfully created workspace: id={workspace.id}, name={workspace.name}")
65
+ return workspace