@qingflow-tech/qingflow-app-user-mcp 1.0.2 → 1.0.4

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 (56) hide show
  1. package/README.md +2 -2
  2. package/docs/local-agent-install.md +9 -3
  3. package/npm/lib/runtime.mjs +10 -3
  4. package/package.json +1 -1
  5. package/pyproject.toml +1 -1
  6. package/skills/qingflow-app-user/SKILL.md +21 -12
  7. package/skills/qingflow-app-user/references/data-gotchas.md +1 -1
  8. package/skills/qingflow-app-user/references/public-surface-sync.md +70 -0
  9. package/skills/qingflow-app-user/references/record-patterns.md +1 -1
  10. package/skills/qingflow-record-analysis/SKILL.md +44 -2
  11. package/skills/qingflow-record-insert/SKILL.md +3 -0
  12. package/skills/qingflow-record-update/SKILL.md +3 -0
  13. package/skills/qingflow-task-ops/SKILL.md +31 -10
  14. package/src/qingflow_mcp/__init__.py +33 -1
  15. package/src/qingflow_mcp/backend_client.py +109 -0
  16. package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
  17. package/src/qingflow_mcp/builder_facade/models.py +58 -9
  18. package/src/qingflow_mcp/builder_facade/service.py +1711 -240
  19. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  20. package/src/qingflow_mcp/cli/commands/app.py +47 -1
  21. package/src/qingflow_mcp/cli/commands/auth.py +63 -0
  22. package/src/qingflow_mcp/cli/commands/builder.py +11 -3
  23. package/src/qingflow_mcp/cli/commands/exports.py +111 -0
  24. package/src/qingflow_mcp/cli/commands/record.py +5 -5
  25. package/src/qingflow_mcp/cli/commands/task.py +701 -27
  26. package/src/qingflow_mcp/cli/commands/workspace.py +84 -0
  27. package/src/qingflow_mcp/cli/context.py +3 -0
  28. package/src/qingflow_mcp/cli/formatters.py +424 -50
  29. package/src/qingflow_mcp/cli/interaction.py +72 -0
  30. package/src/qingflow_mcp/cli/main.py +11 -1
  31. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  32. package/src/qingflow_mcp/cli/terminal_ui.py +218 -0
  33. package/src/qingflow_mcp/config.py +1 -1
  34. package/src/qingflow_mcp/errors.py +4 -4
  35. package/src/qingflow_mcp/export_store.py +14 -0
  36. package/src/qingflow_mcp/id_utils.py +49 -0
  37. package/src/qingflow_mcp/public_surface.py +16 -1
  38. package/src/qingflow_mcp/response_trim.py +394 -9
  39. package/src/qingflow_mcp/server.py +26 -0
  40. package/src/qingflow_mcp/server_app_builder.py +15 -1
  41. package/src/qingflow_mcp/server_app_user.py +113 -0
  42. package/src/qingflow_mcp/session_store.py +126 -21
  43. package/src/qingflow_mcp/solution/compiler/form_compiler.py +2 -2
  44. package/src/qingflow_mcp/solution/executor.py +2 -2
  45. package/src/qingflow_mcp/tools/ai_builder_tools.py +107 -34
  46. package/src/qingflow_mcp/tools/app_tools.py +1 -0
  47. package/src/qingflow_mcp/tools/auth_tools.py +243 -9
  48. package/src/qingflow_mcp/tools/base.py +6 -2
  49. package/src/qingflow_mcp/tools/code_block_tools.py +2 -2
  50. package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
  51. package/src/qingflow_mcp/tools/export_tools.py +1565 -0
  52. package/src/qingflow_mcp/tools/import_tools.py +78 -4
  53. package/src/qingflow_mcp/tools/record_tools.py +551 -165
  54. package/src/qingflow_mcp/tools/resource_read_tools.py +154 -33
  55. package/src/qingflow_mcp/tools/task_context_tools.py +917 -141
  56. package/src/qingflow_mcp/tools/workspace_tools.py +141 -0
@@ -13,6 +13,7 @@ from .tools.app_tools import AppTools
13
13
  from .tools.auth_tools import AuthTools
14
14
  from .tools.code_block_tools import CodeBlockTools
15
15
  from .tools.directory_tools import DirectoryTools
16
+ from .tools.export_tools import ExportTools
16
17
  from .tools.feedback_tools import FeedbackTools
17
18
  from .tools.file_tools import FileTools
18
19
  from .tools.import_tools import ImportTools
@@ -33,6 +34,7 @@ def build_user_server() -> FastMCP:
33
34
  If `app_key` is unknown, use `app_list` or `app_search` first.
34
35
  If the app is known but the data range is not, use `app_get` first and choose from `accessible_views`.
35
36
  If an accessible view has `analysis_supported=false`, do not use it for `record_list` or `record_analyze`. `boardView` and `ganttView` are special UI views, not list/analyze targets.
37
+ `view_get(view_id=...)` also returns `export_capability`; it only means there is a supported export route, not that export permission has been verified.
36
38
 
37
39
  ## Shared Helper
38
40
 
@@ -142,10 +144,33 @@ Use `record_code_block_run` when the user wants to execute a form code-block fie
142
144
  - Do not modify user-uploaded files unless the user explicitly authorizes repair.
143
145
  - If repair is authorized, keep the original file and repair a copy, then run `record_import_verify` again before `record_import_start`.
144
146
 
147
+ ## Export Path
148
+
149
+ `view_get -> record_export_start -> record_export_status_get -> record_export_get`
150
+
151
+ - `record_export_direct` is the one-shot path that starts export, waits, downloads locally, and still returns remote download links.
152
+ - Export v1 supports record views only and follows the same public `view_id` semantics as `record_list`.
153
+ - `record_export_start` / `record_export_direct` support frontend-like row selection:
154
+ - omit `record_ids` to export all rows in the selected view
155
+ - pass `record_ids` to export selected rows only
156
+ - `record_export_start` / `record_export_direct` also support internal query selection:
157
+ - pass `where` to resolve matching `record_id` values first
158
+ - pass `order_by` to keep the internal query and export row order aligned with `record_list`
159
+ - then run native export as selected rows
160
+ - `where/order_by` and `record_ids` are mutually exclusive
161
+ - `record_export_start` / `record_export_direct` also support frontend-like column selection:
162
+ - omit `columns` to export all current-view fields
163
+ - pass `columns` to export only selected fields, preserving the provided order
164
+ - `include_workflow_log=true` maps to the native workflow-log export switch.
165
+
145
166
  ## Task Workflow Path
146
167
 
147
168
  `task_list -> task_get -> task_action_execute`
148
169
 
170
+ - `task_list` returns task-card summaries keyed by `task_id`.
171
+ - Prefer `task_get(task_id=...)` for detail reads; MCP resolves the current todo locator internally.
172
+ - `task_action_execute(task_id=..., action=...)` is also supported; MCP resolves the current todo locator internally before calling the real action route.
173
+ - `task_workflow_log_get(task_id=...)` and `task_associated_report_detail_get(task_id=...)` are also supported for the current todo context.
149
174
  - Use `task_associated_report_detail_get` for associated view or report details.
150
175
  - Use `task_workflow_log_get` for full workflow log history.
151
176
  - Task actions operate on `app_key + record_id + workflow_node_id`, not `task_id`.
@@ -184,6 +209,7 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
184
209
  workspace = wrap_trimmed_methods(WorkspaceTools(sessions, backend), USER_SERVER_METHOD_MAP)
185
210
  file_tools = wrap_trimmed_methods(FileTools(sessions, backend), USER_SERVER_METHOD_MAP)
186
211
  imports = wrap_trimmed_methods(ImportTools(sessions, backend), USER_SERVER_METHOD_MAP)
212
+ exports = wrap_trimmed_methods(ExportTools(sessions, backend), USER_SERVER_METHOD_MAP)
187
213
  resources = wrap_trimmed_methods(ResourceReadTools(sessions, backend), USER_SERVER_METHOD_MAP)
188
214
  feedback = FeedbackTools(backend, mcp_side="App User MCP")
189
215
  code_block_tools = wrap_trimmed_methods(CodeBlockTools(sessions, backend), USER_SERVER_METHOD_MAP)
@@ -214,6 +240,73 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
214
240
  def auth_logout(profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict:
215
241
  return auth.auth_logout(profile=profile, forget_persisted=forget_persisted)
216
242
 
243
+ @server.tool()
244
+ def record_export_start(
245
+ profile: str = DEFAULT_PROFILE,
246
+ app_key: str = "",
247
+ view_id: str = "system:all",
248
+ columns: list[dict | int] | None = None,
249
+ where: list[dict] | None = None,
250
+ order_by: list[dict] | None = None,
251
+ record_ids: list[str | int] | None = None,
252
+ include_workflow_log: bool = False,
253
+ ) -> dict:
254
+ return exports.record_export_start(
255
+ profile=profile,
256
+ app_key=app_key,
257
+ view_id=view_id,
258
+ columns=columns or [],
259
+ where=where or [],
260
+ order_by=order_by or [],
261
+ record_ids=record_ids or [],
262
+ include_workflow_log=include_workflow_log,
263
+ )
264
+
265
+ @server.tool()
266
+ def record_export_status_get(
267
+ profile: str = DEFAULT_PROFILE,
268
+ export_handle: str = "",
269
+ ) -> dict:
270
+ return exports.record_export_status_get(profile=profile, export_handle=export_handle)
271
+
272
+ @server.tool()
273
+ def record_export_get(
274
+ profile: str = DEFAULT_PROFILE,
275
+ export_handle: str = "",
276
+ download_to_path: str | None = None,
277
+ ) -> dict:
278
+ return exports.record_export_get(
279
+ profile=profile,
280
+ export_handle=export_handle,
281
+ download_to_path=download_to_path,
282
+ )
283
+
284
+ @server.tool()
285
+ def record_export_direct(
286
+ profile: str = DEFAULT_PROFILE,
287
+ app_key: str = "",
288
+ view_id: str = "system:all",
289
+ columns: list[dict | int] | None = None,
290
+ where: list[dict] | None = None,
291
+ order_by: list[dict] | None = None,
292
+ record_ids: list[str | int] | None = None,
293
+ include_workflow_log: bool = False,
294
+ download_to_path: str | None = None,
295
+ wait_timeout_seconds: float | None = None,
296
+ ) -> dict:
297
+ return exports.record_export_direct(
298
+ profile=profile,
299
+ app_key=app_key,
300
+ view_id=view_id,
301
+ columns=columns or [],
302
+ where=where or [],
303
+ order_by=order_by or [],
304
+ record_ids=record_ids or [],
305
+ include_workflow_log=include_workflow_log,
306
+ download_to_path=download_to_path,
307
+ wait_timeout_seconds=wait_timeout_seconds,
308
+ )
309
+
217
310
  @server.tool()
218
311
  def workspace_list(
219
312
  profile: str = DEFAULT_PROFILE,
@@ -228,6 +321,26 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
228
321
  include_external=include_external,
229
322
  )
230
323
 
324
+ @server.tool()
325
+ def workspace_get(
326
+ profile: str = DEFAULT_PROFILE,
327
+ ws_id: int | None = None,
328
+ ) -> dict:
329
+ return workspace.workspace_get(
330
+ profile=profile,
331
+ ws_id=ws_id,
332
+ )
333
+
334
+ @server.tool()
335
+ def workspace_select(
336
+ profile: str = DEFAULT_PROFILE,
337
+ ws_id: int = 0,
338
+ ) -> dict:
339
+ return workspace.workspace_select(
340
+ profile=profile,
341
+ ws_id=ws_id,
342
+ )
343
+
231
344
  @server.tool()
232
345
  def app_list(profile: str = DEFAULT_PROFILE) -> dict:
233
346
  return apps.app_list(profile=profile)
@@ -1,8 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import os
5
+ from collections.abc import Callable
4
6
  from dataclasses import asdict, dataclass
5
- from datetime import datetime, timezone
7
+ from datetime import datetime, timedelta, timezone
6
8
  from pathlib import Path
7
9
 
8
10
  try:
@@ -77,9 +79,15 @@ class SessionStore:
77
79
  profiles_path = get_profiles_path() if base_dir is None else Path(base_dir) / "profiles.json"
78
80
  self._profiles_path = profiles_path
79
81
  self._profiles_path.parent.mkdir(parents=True, exist_ok=True)
82
+ self._secrets_path = self._profiles_path.parent / "secrets.json"
80
83
  self._keyring = keyring_backend if keyring_backend is not None else keyring
81
84
  self._memory_sessions: dict[str, BackendSession] = {}
82
85
  self._logged_out_profiles: set[str] = set()
86
+ self._profile_refresher: Callable[[str, SessionProfile | None], bool] | None = None
87
+ self._refreshing_profiles: set[str] = set()
88
+
89
+ def set_profile_refresher(self, refresher: Callable[[str, SessionProfile | None], bool] | None) -> None:
90
+ self._profile_refresher = refresher
83
91
 
84
92
  def save_session(
85
93
  self,
@@ -146,9 +154,14 @@ class SessionStore:
146
154
  def get_profile(self, profile: str) -> SessionProfile | None:
147
155
  payload = self._load_profiles()
148
156
  raw_profile = payload.get("profiles", {}).get(profile)
149
- if not raw_profile:
150
- return None
151
- return SessionProfile.from_dict(raw_profile)
157
+ session_profile = SessionProfile.from_dict(raw_profile) if isinstance(raw_profile, dict) else None
158
+ if profile in self._refreshing_profiles:
159
+ return session_profile
160
+ if self._should_refresh_profile(payload, session_profile) and self._refresh_profile(profile, session_profile):
161
+ payload = self._load_profiles()
162
+ raw_profile = payload.get("profiles", {}).get(profile)
163
+ session_profile = SessionProfile.from_dict(raw_profile) if isinstance(raw_profile, dict) else None
164
+ return session_profile
152
165
 
153
166
  def get_backend_session(self, profile: str) -> BackendSession | None:
154
167
  if profile in self._logged_out_profiles:
@@ -281,8 +294,48 @@ class SessionStore:
281
294
  def _load_profiles(self) -> JSONObject:
282
295
  if not self._profiles_path.exists():
283
296
  return {"profiles": {}}
284
- with self._profiles_path.open("r", encoding="utf-8") as handle:
285
- return json.load(handle)
297
+ try:
298
+ with self._profiles_path.open("r", encoding="utf-8") as handle:
299
+ payload = json.load(handle)
300
+ except (OSError, json.JSONDecodeError):
301
+ return {"profiles": {}}
302
+ if not isinstance(payload, dict):
303
+ return {"profiles": {}}
304
+ profiles = payload.get("profiles")
305
+ if not isinstance(profiles, dict):
306
+ payload["profiles"] = {}
307
+ return payload
308
+
309
+ def _should_refresh_profile(self, payload: JSONObject, session_profile: SessionProfile | None) -> bool:
310
+ if self._profile_refresher is None:
311
+ return False
312
+ profiles = payload.get("profiles")
313
+ if not isinstance(profiles, dict) or not profiles:
314
+ return True
315
+ if session_profile is None:
316
+ return True
317
+ return self._is_profile_stale(session_profile)
318
+
319
+ def _refresh_profile(self, profile: str, session_profile: SessionProfile | None) -> bool:
320
+ if self._profile_refresher is None:
321
+ return False
322
+ self._refreshing_profiles.add(profile)
323
+ try:
324
+ return bool(self._profile_refresher(profile, session_profile))
325
+ except Exception:
326
+ return False
327
+ finally:
328
+ self._refreshing_profiles.discard(profile)
329
+
330
+ def _is_profile_stale(self, session_profile: SessionProfile) -> bool:
331
+ timestamp_text = session_profile.updated_at or session_profile.created_at
332
+ try:
333
+ timestamp = datetime.fromisoformat(timestamp_text)
334
+ except (TypeError, ValueError):
335
+ return True
336
+ if timestamp.tzinfo is None:
337
+ timestamp = timestamp.replace(tzinfo=timezone.utc)
338
+ return datetime.now(timezone.utc) - timestamp > timedelta(days=7)
286
339
 
287
340
  def _save_profiles(self, payload: JSONObject) -> None:
288
341
  self._profiles_path.parent.mkdir(parents=True, exist_ok=True)
@@ -290,26 +343,78 @@ class SessionStore:
290
343
  json.dump(payload, handle, ensure_ascii=False, indent=2)
291
344
 
292
345
  def _set_secret(self, key: str, value: str) -> bool:
293
- if self._keyring is None:
294
- return False
346
+ if self._keyring is not None:
347
+ try:
348
+ self._keyring.set_password(KEYRING_SERVICE_NAME, key, value)
349
+ self._delete_file_secret(key)
350
+ return True
351
+ except Exception:
352
+ pass
353
+ return self._set_file_secret(key, value)
354
+
355
+ def _get_secret(self, key: str) -> str | None:
356
+ if self._keyring is not None:
357
+ try:
358
+ value = self._keyring.get_password(KEYRING_SERVICE_NAME, key)
359
+ except Exception:
360
+ value = None
361
+ if value:
362
+ return value
363
+ return self._get_file_secret(key)
364
+
365
+ def _delete_secret(self, key: str) -> None:
366
+ if self._keyring is not None:
367
+ try:
368
+ self._keyring.delete_password(KEYRING_SERVICE_NAME, key)
369
+ except Exception:
370
+ pass
371
+ self._delete_file_secret(key)
372
+
373
+ def _load_file_secrets(self) -> dict[str, str]:
374
+ if not self._secrets_path.exists():
375
+ return {}
376
+ try:
377
+ with self._secrets_path.open("r", encoding="utf-8") as handle:
378
+ payload = json.load(handle)
379
+ except (OSError, json.JSONDecodeError):
380
+ return {}
381
+ if not isinstance(payload, dict):
382
+ return {}
383
+ return {str(key): str(value) for key, value in payload.items() if isinstance(value, str)}
384
+
385
+ def _save_file_secrets(self, payload: dict[str, str]) -> bool:
386
+ self._secrets_path.parent.mkdir(parents=True, exist_ok=True)
295
387
  try:
296
- self._keyring.set_password(KEYRING_SERVICE_NAME, key, value)
388
+ fd = os.open(self._secrets_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
389
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
390
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
391
+ try:
392
+ os.chmod(self._secrets_path, 0o600)
393
+ except OSError:
394
+ pass
297
395
  return True
298
- except Exception:
396
+ except OSError:
299
397
  return False
300
398
 
301
- def _get_secret(self, key: str) -> str | None:
302
- if self._keyring is None:
303
- return None
304
- try:
305
- return self._keyring.get_password(KEYRING_SERVICE_NAME, key)
306
- except Exception:
307
- return None
399
+ def _set_file_secret(self, key: str, value: str) -> bool:
400
+ payload = self._load_file_secrets()
401
+ payload[key] = value
402
+ return self._save_file_secrets(payload)
308
403
 
309
- def _delete_secret(self, key: str) -> None:
310
- if self._keyring is None:
404
+ def _get_file_secret(self, key: str) -> str | None:
405
+ return self._load_file_secrets().get(key)
406
+
407
+ def _delete_file_secret(self, key: str) -> None:
408
+ payload = self._load_file_secrets()
409
+ if key not in payload:
410
+ return
411
+ payload.pop(key, None)
412
+ if payload:
413
+ self._save_file_secrets(payload)
311
414
  return
312
415
  try:
313
- self._keyring.delete_password(KEYRING_SERVICE_NAME, key)
314
- except Exception:
416
+ self._secrets_path.unlink()
417
+ except FileNotFoundError:
418
+ return
419
+ except OSError:
315
420
  return
@@ -307,7 +307,7 @@ def build_reference_config(field: dict[str, Any], temp_id: int) -> dict[str, Any
307
307
  "queId": que_id,
308
308
  "queTitle": label,
309
309
  "queType": _normalize_reference_que_type(raw_type) or "2",
310
- "queAuth": 1,
310
+ "queAuth": 3,
311
311
  "ordinal": ordinal,
312
312
  "quoteId": temp_id,
313
313
  "_field_id": field_id,
@@ -320,7 +320,7 @@ def build_reference_config(field: dict[str, Any], temp_id: int) -> dict[str, Any
320
320
  auth_ques = []
321
321
  for ordinal, field_id in enumerate(auth_field_ids, start=1):
322
322
  que_id = auth_field_que_ids[ordinal - 1] if ordinal - 1 < len(auth_field_que_ids) else 0
323
- auth_ques.append({"queId": que_id, "queAuth": 1, "_field_id": field_id})
323
+ auth_ques.append({"queId": que_id, "queAuth": 3, "_field_id": field_id})
324
324
  return {
325
325
  "referAppKey": "__TARGET_APP_KEY__",
326
326
  "referQueId": display_field_que_id,
@@ -857,12 +857,12 @@ class SolutionExecutor:
857
857
  if refer_que_id is None:
858
858
  continue
859
859
  resolved["queId"] = refer_que_id
860
- resolved["queAuth"] = int(resolved.get("queAuth", 1))
860
+ resolved["queAuth"] = int(resolved.get("queAuth", 3))
861
861
  auth_ques.append(resolved)
862
862
  if not auth_ques:
863
863
  fallback_que_id = target_meta.get("by_field_id", {}).get(target_field_id)
864
864
  if fallback_que_id is not None:
865
- auth_ques.append({"queId": fallback_que_id, "queAuth": 1})
865
+ auth_ques.append({"queId": fallback_que_id, "queAuth": 3})
866
866
  reference_config["referAuthQues"] = auth_ques
867
867
  reference_config["fieldNameShow"] = bool(reference_config.get("fieldNameShow", True))
868
868
  fill_rules = []