@josephyan/qingflow-mcp 0.1.0-beta.2

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 (52) hide show
  1. package/README.md +517 -0
  2. package/docs/local-agent-install.md +213 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow-mcp.mjs +7 -0
  5. package/npm/lib/runtime.mjs +146 -0
  6. package/npm/scripts/postinstall.mjs +12 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +63 -0
  9. package/qingflow-mcp +15 -0
  10. package/src/qingflow_mcp/__init__.py +5 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +336 -0
  13. package/src/qingflow_mcp/config.py +166 -0
  14. package/src/qingflow_mcp/errors.py +66 -0
  15. package/src/qingflow_mcp/json_types.py +18 -0
  16. package/src/qingflow_mcp/server.py +70 -0
  17. package/src/qingflow_mcp/session_store.py +235 -0
  18. package/src/qingflow_mcp/solution/__init__.py +6 -0
  19. package/src/qingflow_mcp/solution/build_assembly_store.py +137 -0
  20. package/src/qingflow_mcp/solution/compiler/__init__.py +265 -0
  21. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  22. package/src/qingflow_mcp/solution/compiler/form_compiler.py +456 -0
  23. package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
  24. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  25. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  26. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  27. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  28. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +134 -0
  29. package/src/qingflow_mcp/solution/design_session.py +222 -0
  30. package/src/qingflow_mcp/solution/design_store.py +100 -0
  31. package/src/qingflow_mcp/solution/executor.py +2064 -0
  32. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  33. package/src/qingflow_mcp/solution/run_store.py +221 -0
  34. package/src/qingflow_mcp/solution/spec_models.py +755 -0
  35. package/src/qingflow_mcp/tools/__init__.py +1 -0
  36. package/src/qingflow_mcp/tools/app_tools.py +239 -0
  37. package/src/qingflow_mcp/tools/approval_tools.py +481 -0
  38. package/src/qingflow_mcp/tools/auth_tools.py +496 -0
  39. package/src/qingflow_mcp/tools/base.py +81 -0
  40. package/src/qingflow_mcp/tools/directory_tools.py +476 -0
  41. package/src/qingflow_mcp/tools/file_tools.py +375 -0
  42. package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
  43. package/src/qingflow_mcp/tools/package_tools.py +142 -0
  44. package/src/qingflow_mcp/tools/portal_tools.py +100 -0
  45. package/src/qingflow_mcp/tools/qingbi_report_tools.py +258 -0
  46. package/src/qingflow_mcp/tools/record_tools.py +4305 -0
  47. package/src/qingflow_mcp/tools/role_tools.py +94 -0
  48. package/src/qingflow_mcp/tools/solution_tools.py +1860 -0
  49. package/src/qingflow_mcp/tools/task_tools.py +677 -0
  50. package/src/qingflow_mcp/tools/view_tools.py +324 -0
  51. package/src/qingflow_mcp/tools/workflow_tools.py +311 -0
  52. package/src/qingflow_mcp/tools/workspace_tools.py +143 -0
@@ -0,0 +1,476 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import deque
4
+ from typing import Any
5
+
6
+ from mcp.server.fastmcp import FastMCP
7
+
8
+ from ..config import DEFAULT_PROFILE
9
+ from ..errors import QingflowApiError, raise_tool_error
10
+ from .base import ToolBase
11
+
12
+
13
+ ALLOWED_DIRECTORY_SEARCH_SCOPES = {"MEMBER", "DEPT"}
14
+
15
+
16
+ class DirectoryTools(ToolBase):
17
+ def register(self, mcp: FastMCP) -> None:
18
+ @mcp.tool()
19
+ def directory_search(
20
+ profile: str = DEFAULT_PROFILE,
21
+ query: str = "",
22
+ scopes: list[str] | None = None,
23
+ page_num: int = 1,
24
+ page_size: int = 20,
25
+ ) -> dict[str, Any]:
26
+ return self.directory_search(
27
+ profile=profile,
28
+ query=query,
29
+ scopes=scopes,
30
+ page_num=page_num,
31
+ page_size=page_size,
32
+ )
33
+
34
+ @mcp.tool()
35
+ def directory_list_internal_users(
36
+ profile: str = DEFAULT_PROFILE,
37
+ keyword: str | None = None,
38
+ dept_id: int | None = None,
39
+ role_id: int | None = None,
40
+ page_num: int = 1,
41
+ page_size: int = 20,
42
+ contain_disable: bool = False,
43
+ ) -> dict[str, Any]:
44
+ return self.directory_list_internal_users(
45
+ profile=profile,
46
+ keyword=keyword,
47
+ dept_id=dept_id,
48
+ role_id=role_id,
49
+ page_num=page_num,
50
+ page_size=page_size,
51
+ contain_disable=contain_disable,
52
+ )
53
+
54
+ @mcp.tool()
55
+ def directory_list_all_internal_users(
56
+ profile: str = DEFAULT_PROFILE,
57
+ keyword: str | None = None,
58
+ dept_id: int | None = None,
59
+ role_id: int | None = None,
60
+ page_size: int = 200,
61
+ contain_disable: bool = False,
62
+ max_pages: int = 100,
63
+ ) -> dict[str, Any]:
64
+ return self.directory_list_all_internal_users(
65
+ profile=profile,
66
+ keyword=keyword,
67
+ dept_id=dept_id,
68
+ role_id=role_id,
69
+ page_size=page_size,
70
+ contain_disable=contain_disable,
71
+ max_pages=max_pages,
72
+ )
73
+
74
+ @mcp.tool()
75
+ def directory_list_internal_departments(
76
+ profile: str = DEFAULT_PROFILE,
77
+ keyword: str = "",
78
+ page_num: int = 1,
79
+ page_size: int = 20,
80
+ ) -> dict[str, Any]:
81
+ return self.directory_list_internal_departments(
82
+ profile=profile,
83
+ keyword=keyword,
84
+ page_num=page_num,
85
+ page_size=page_size,
86
+ )
87
+
88
+ @mcp.tool()
89
+ def directory_list_all_departments(
90
+ profile: str = DEFAULT_PROFILE,
91
+ parent_dept_id: int | None = None,
92
+ max_depth: int = 20,
93
+ max_items: int = 2000,
94
+ ) -> dict[str, Any]:
95
+ return self.directory_list_all_departments(
96
+ profile=profile,
97
+ parent_dept_id=parent_dept_id,
98
+ max_depth=max_depth,
99
+ max_items=max_items,
100
+ )
101
+
102
+ @mcp.tool()
103
+ def directory_list_sub_departments(
104
+ profile: str = DEFAULT_PROFILE,
105
+ parent_dept_id: int | None = None,
106
+ ) -> dict[str, Any]:
107
+ return self.directory_list_sub_departments(profile=profile, parent_dept_id=parent_dept_id)
108
+
109
+ @mcp.tool()
110
+ def directory_list_external_members(
111
+ profile: str = DEFAULT_PROFILE,
112
+ keyword: str | None = None,
113
+ page_num: int = 1,
114
+ page_size: int = 20,
115
+ simple: bool = False,
116
+ ) -> dict[str, Any]:
117
+ return self.directory_list_external_members(
118
+ profile=profile,
119
+ keyword=keyword,
120
+ page_num=page_num,
121
+ page_size=page_size,
122
+ simple=simple,
123
+ )
124
+
125
+ def directory_search(
126
+ self,
127
+ *,
128
+ profile: str,
129
+ query: str,
130
+ scopes: list[str] | None,
131
+ page_num: int,
132
+ page_size: int,
133
+ ) -> dict[str, Any]:
134
+ normalized_scopes = scopes or ["MEMBER", "DEPT"]
135
+ invalid_scopes = [scope for scope in normalized_scopes if scope not in ALLOWED_DIRECTORY_SEARCH_SCOPES]
136
+ if invalid_scopes:
137
+ raise_tool_error(QingflowApiError.not_supported(f"directory_search only supports internal scopes {sorted(ALLOWED_DIRECTORY_SEARCH_SCOPES)}; got {invalid_scopes}"))
138
+ if not query:
139
+ raise_tool_error(QingflowApiError.config_error("query is required"))
140
+
141
+ def runner(session_profile, context):
142
+ result = self.backend.request(
143
+ "POST",
144
+ context,
145
+ "/member/search",
146
+ json_body={
147
+ "dimensions": normalized_scopes,
148
+ "searchKey": query,
149
+ "pageNum": page_num,
150
+ "pageSize": page_size,
151
+ },
152
+ )
153
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "result": result}
154
+
155
+ return self._run(profile, runner)
156
+
157
+ def directory_list_internal_users(
158
+ self,
159
+ *,
160
+ profile: str,
161
+ keyword: str | None,
162
+ dept_id: int | None,
163
+ role_id: int | None,
164
+ page_num: int,
165
+ page_size: int,
166
+ contain_disable: bool,
167
+ ) -> dict[str, Any]:
168
+ def runner(session_profile, context):
169
+ params: dict[str, Any] = {
170
+ "pageNum": page_num,
171
+ "pageSize": page_size,
172
+ "containDisable": contain_disable,
173
+ }
174
+ if keyword:
175
+ params["keyword"] = keyword
176
+ if dept_id is not None:
177
+ params["deptId"] = dept_id
178
+ if role_id is not None:
179
+ params["roleId"] = role_id
180
+ result = self.backend.request("GET", context, "/contact", params=params)
181
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "result": result}
182
+
183
+ return self._run(profile, runner)
184
+
185
+ def directory_list_all_internal_users(
186
+ self,
187
+ *,
188
+ profile: str,
189
+ keyword: str | None,
190
+ dept_id: int | None,
191
+ role_id: int | None,
192
+ page_size: int,
193
+ contain_disable: bool,
194
+ max_pages: int,
195
+ ) -> dict[str, Any]:
196
+ if page_size <= 0:
197
+ raise_tool_error(QingflowApiError.config_error("page_size must be positive"))
198
+ if max_pages <= 0:
199
+ raise_tool_error(QingflowApiError.config_error("max_pages must be positive"))
200
+
201
+ def runner(session_profile, context):
202
+ current_page = 1
203
+ fetched_pages = 0
204
+ has_more = False
205
+ reported_total: int | None = None
206
+ seen_keys: set[str] = set()
207
+ items: list[dict[str, Any]] = []
208
+ while fetched_pages < max_pages:
209
+ params: dict[str, Any] = {
210
+ "pageNum": current_page,
211
+ "pageSize": page_size,
212
+ "containDisable": contain_disable,
213
+ }
214
+ if keyword:
215
+ params["keyword"] = keyword
216
+ if dept_id is not None:
217
+ params["deptId"] = dept_id
218
+ if role_id is not None:
219
+ params["roleId"] = role_id
220
+ result = self.backend.request("GET", context, "/contact", params=params)
221
+ page_items = _directory_items(result)
222
+ if reported_total is None:
223
+ reported_total = _coerce_int(_payload_value(result, "total"))
224
+ for item in page_items:
225
+ if not isinstance(item, dict):
226
+ continue
227
+ member_key = _directory_member_key(item)
228
+ if member_key in seen_keys:
229
+ continue
230
+ seen_keys.add(member_key)
231
+ items.append(dict(item))
232
+ fetched_pages += 1
233
+ has_more = _directory_has_more(result, current_page=current_page, page_size=page_size, returned_items=len(page_items))
234
+ if not has_more:
235
+ break
236
+ current_page += 1
237
+ return {
238
+ "profile": profile,
239
+ "ws_id": session_profile.selected_ws_id,
240
+ "items": items,
241
+ "pagination": {
242
+ "page_size": page_size,
243
+ "fetched_pages": fetched_pages,
244
+ "returned_items": len(items),
245
+ "reported_total": reported_total,
246
+ "is_complete": not has_more,
247
+ "has_more": has_more,
248
+ "next_page_num": current_page + 1 if has_more else None,
249
+ "max_pages": max_pages,
250
+ },
251
+ }
252
+
253
+ return self._run(profile, runner)
254
+
255
+ def directory_list_internal_departments(
256
+ self,
257
+ *,
258
+ profile: str,
259
+ keyword: str,
260
+ page_num: int,
261
+ page_size: int,
262
+ ) -> dict[str, Any]:
263
+ if not keyword:
264
+ raise_tool_error(QingflowApiError.config_error("keyword is required"))
265
+
266
+ def runner(session_profile, context):
267
+ result = self.backend.request(
268
+ "GET",
269
+ context,
270
+ "/contact/deptByPage",
271
+ params={"keyword": keyword, "pageNum": page_num, "pageSize": page_size},
272
+ )
273
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "page": result}
274
+
275
+ return self._run(profile, runner)
276
+
277
+ def directory_list_all_departments(
278
+ self,
279
+ *,
280
+ profile: str,
281
+ parent_dept_id: int | None,
282
+ max_depth: int,
283
+ max_items: int,
284
+ ) -> dict[str, Any]:
285
+ if max_depth < 0:
286
+ raise_tool_error(QingflowApiError.config_error("max_depth must be non-negative"))
287
+ if max_items <= 0:
288
+ raise_tool_error(QingflowApiError.config_error("max_items must be positive"))
289
+
290
+ def runner(session_profile, context):
291
+ items, truncated, deepest_depth = self._walk_department_tree(
292
+ context,
293
+ parent_dept_id=parent_dept_id,
294
+ max_depth=max_depth,
295
+ max_items=max_items,
296
+ )
297
+ if not items and parent_dept_id is None:
298
+ items, truncated, deepest_depth = self._walk_department_tree(
299
+ context,
300
+ parent_dept_id=0,
301
+ max_depth=max_depth,
302
+ max_items=max_items,
303
+ )
304
+ return {
305
+ "profile": profile,
306
+ "ws_id": session_profile.selected_ws_id,
307
+ "items": items,
308
+ "traversal": {
309
+ "root_parent_dept_id": parent_dept_id,
310
+ "returned_items": len(items),
311
+ "max_depth": deepest_depth,
312
+ "truncated": truncated,
313
+ "is_complete": not truncated,
314
+ "max_items": max_items,
315
+ },
316
+ }
317
+
318
+ return self._run(profile, runner)
319
+
320
+ def directory_list_sub_departments(self, *, profile: str, parent_dept_id: int | None) -> dict[str, Any]:
321
+ def runner(session_profile, context):
322
+ params: dict[str, Any] = {}
323
+ if parent_dept_id is not None:
324
+ params["parentDeptId"] = parent_dept_id
325
+ result = self.backend.request("GET", context, "/contact/subDeptList", params=params)
326
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "items": result}
327
+
328
+ return self._run(profile, runner)
329
+
330
+ def directory_list_external_members(
331
+ self,
332
+ *,
333
+ profile: str,
334
+ keyword: str | None,
335
+ page_num: int,
336
+ page_size: int,
337
+ simple: bool,
338
+ ) -> dict[str, Any]:
339
+ def runner(session_profile, context):
340
+ if simple:
341
+ result = self.backend.request(
342
+ "POST",
343
+ context,
344
+ "/external/member/simple/pageList",
345
+ json_body={"keyword": keyword, "pageNum": page_num, "pageSize": page_size},
346
+ )
347
+ else:
348
+ params: dict[str, Any] = {"pageNum": page_num, "pageSize": page_size}
349
+ if keyword:
350
+ params["keyword"] = keyword
351
+ result = self.backend.request("GET", context, "/external/member/pageList", params=params)
352
+ return {"profile": profile, "ws_id": session_profile.selected_ws_id, "page": result, "simple": simple}
353
+
354
+ return self._run(profile, runner)
355
+
356
+ def _walk_department_tree(
357
+ self,
358
+ context, # type: ignore[no-untyped-def]
359
+ *,
360
+ parent_dept_id: int | None,
361
+ max_depth: int,
362
+ max_items: int,
363
+ ) -> tuple[list[dict[str, Any]], bool, int]:
364
+ queue: deque[tuple[int | None, int]] = deque([(parent_dept_id, 0)])
365
+ seen_ids: set[int] = set()
366
+ requested_parents: set[int | None] = set()
367
+ items: list[dict[str, Any]] = []
368
+ truncated = False
369
+ deepest_depth = 0
370
+ while queue:
371
+ current_parent, depth = queue.popleft()
372
+ if current_parent in requested_parents:
373
+ continue
374
+ requested_parents.add(current_parent)
375
+ params: dict[str, Any] = {}
376
+ if current_parent is not None:
377
+ params["parentDeptId"] = current_parent
378
+ result = self.backend.request("GET", context, "/contact/subDeptList", params=params)
379
+ page_items = _directory_items(result)
380
+ for item in page_items:
381
+ if not isinstance(item, dict):
382
+ continue
383
+ dept_id = _department_id(item)
384
+ if dept_id is None or dept_id in seen_ids:
385
+ continue
386
+ seen_ids.add(dept_id)
387
+ entry = dict(item)
388
+ if current_parent is not None and "parentDeptId" not in entry:
389
+ entry["parentDeptId"] = current_parent
390
+ entry.setdefault("depth", depth)
391
+ items.append(entry)
392
+ deepest_depth = max(deepest_depth, depth)
393
+ if len(items) >= max_items:
394
+ truncated = True
395
+ return items, truncated, deepest_depth
396
+ if depth < max_depth:
397
+ queue.append((dept_id, depth + 1))
398
+ else:
399
+ truncated = True
400
+ return items, truncated, deepest_depth
401
+
402
+
403
+ def _payload_value(payload: Any, key: str) -> Any:
404
+ if isinstance(payload, dict):
405
+ if key in payload:
406
+ return payload.get(key)
407
+ for container_key in ("data", "result", "page"):
408
+ nested = payload.get(container_key)
409
+ if isinstance(nested, dict) and key in nested:
410
+ return nested.get(key)
411
+ return None
412
+
413
+
414
+ def _directory_items(payload: Any) -> list[Any]:
415
+ if isinstance(payload, list):
416
+ return payload
417
+ if not isinstance(payload, dict):
418
+ return []
419
+ for key in ("list", "items", "rows"):
420
+ value = payload.get(key)
421
+ if isinstance(value, list):
422
+ return value
423
+ for key in ("data", "result", "page"):
424
+ nested = payload.get(key)
425
+ if isinstance(nested, list):
426
+ return nested
427
+ if isinstance(nested, dict):
428
+ for nested_key in ("list", "items", "rows", "result"):
429
+ value = nested.get(nested_key)
430
+ if isinstance(value, list):
431
+ return value
432
+ return []
433
+
434
+
435
+ def _directory_has_more(payload: Any, *, current_page: int, page_size: int, returned_items: int) -> bool:
436
+ page_amount = _coerce_int(_payload_value(payload, "pageAmount"))
437
+ if page_amount is not None:
438
+ return current_page < page_amount
439
+ total = _coerce_int(_payload_value(payload, "total"))
440
+ if total is not None:
441
+ return current_page * page_size < total
442
+ return returned_items >= page_size and returned_items > 0
443
+
444
+
445
+ def _directory_member_key(item: dict[str, Any]) -> str:
446
+ for key in ("uid", "id", "userId"):
447
+ value = item.get(key)
448
+ if value is not None:
449
+ return f"{key}:{value}"
450
+ for key in ("email", "nickName", "name"):
451
+ value = item.get(key)
452
+ if isinstance(value, str) and value:
453
+ return f"{key}:{value}"
454
+ return repr(sorted(item.items()))
455
+
456
+
457
+ def _department_id(item: dict[str, Any]) -> int | None:
458
+ return _coerce_int(item.get("deptId", item.get("id")))
459
+
460
+
461
+ def _coerce_int(value: Any) -> int | None:
462
+ if isinstance(value, bool) or value is None:
463
+ return None
464
+ if isinstance(value, int):
465
+ return value
466
+ if isinstance(value, float):
467
+ return int(value)
468
+ if isinstance(value, str):
469
+ text = value.strip()
470
+ if not text:
471
+ return None
472
+ try:
473
+ return int(text)
474
+ except ValueError:
475
+ return None
476
+ return None