@josephyan/qingflow-app-user-mcp 0.2.0-beta.89 → 0.2.0-beta.90

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.
package/README.md CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.89
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.90
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.89 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.90 qingflow-app-user-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-user-mcp",
3
- "version": "0.2.0-beta.89",
3
+ "version": "0.2.0-beta.90",
4
4
  "description": "Operational end-user MCP for Qingflow records, tasks, comments, and directory workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -180,6 +180,16 @@ class AccessibleViewRoute:
180
180
  view_type: str | None = None
181
181
 
182
182
 
183
+ @dataclass(slots=True)
184
+ class RecordContextRouteProbe:
185
+ route: AccessibleViewRoute
186
+ answer_list: list[JSONObject] | None
187
+ used_list_type: int | None
188
+ readable: bool
189
+ transport_error: bool
190
+ error_payload: JSONObject | None
191
+
192
+
183
193
  @dataclass(slots=True)
184
194
  class WorkflowNodeRef:
185
195
  workflow_node_id: int
@@ -782,55 +792,29 @@ class RecordTools(ToolBase):
782
792
  index=app_index,
783
793
  question_relations=question_relations,
784
794
  )
785
- try:
786
- current_answers = self._load_record_answers_for_preflight(context, app_key=app_key, apply_id=record_id)
787
- except QingflowApiError:
788
- return self._record_update_schema_blocked_response(
789
- profile=profile,
790
- ws_id=session_profile.selected_ws_id,
791
- request_route=request_route,
792
- app_key=app_key,
793
- record_id=record_id,
794
- blockers=["CURRENT_RECORD_CONTEXT_UNAVAILABLE"],
795
- warnings=[
796
- "update schema could not load the current record; context-sensitive lookup requirements cannot be derived safely."
797
- ],
798
- recommended_next_actions=[
799
- "Retry after the record becomes readable in the current workspace/profile context.",
800
- "If the issue persists, verify that the current profile still has read access to this record.",
801
- ],
802
- output_profile=normalized_output_profile,
803
- view_probe_summary=[],
804
- ambiguous_fields=[],
805
- )
806
-
807
795
  candidate_routes = self._candidate_update_views(profile, context, app_key)
796
+ probes = self._probe_candidate_record_contexts(
797
+ context,
798
+ app_key=app_key,
799
+ apply_id=record_id,
800
+ candidate_routes=candidate_routes,
801
+ )
808
802
  probe_summary: list[JSONObject] = []
809
- matched_any = False
803
+ matched_probes: list[RecordContextRouteProbe] = []
810
804
  ordered_field_ids: list[int] = []
811
805
  field_payloads_by_id: dict[int, JSONObject] = {}
812
806
  title_to_field_ids: dict[str, list[int]] = {}
813
807
  ws_id = session_profile.selected_ws_id
814
808
 
815
- for candidate in candidate_routes:
816
- matched_record = self._record_matches_accessible_view(
817
- context,
818
- current_answers,
819
- resolved_view=candidate,
820
- )
821
- candidate_summary: JSONObject = {
822
- "view_id": candidate.view_id,
823
- "name": candidate.name,
824
- "kind": candidate.kind,
825
- "matched_record": matched_record,
826
- "context_complete": True,
827
- "writable_field_titles": [],
828
- }
829
- if not matched_record:
809
+ for probe in probes:
810
+ candidate = probe.route
811
+ candidate_summary = self._record_context_probe_summary_payload(probe)
812
+ candidate_summary["writable_field_titles"] = []
813
+ if not probe.readable:
830
814
  probe_summary.append(candidate_summary)
831
815
  continue
832
816
 
833
- matched_any = True
817
+ matched_probes.append(probe)
834
818
  browse_scope = self._build_browse_write_scope(
835
819
  profile,
836
820
  context,
@@ -874,19 +858,39 @@ class RecordTools(ToolBase):
874
858
  candidate_summary["writable_field_titles"] = candidate_titles
875
859
  probe_summary.append(candidate_summary)
876
860
 
877
- if not matched_any:
861
+ if not matched_probes:
862
+ blockers = (
863
+ ["CURRENT_RECORD_CONTEXT_UNAVAILABLE"]
864
+ if probes and all(probe.transport_error for probe in probes)
865
+ else ["NO_MATCHING_ACCESSIBLE_VIEW_FOR_RECORD"]
866
+ )
867
+ warnings = (
868
+ [
869
+ "update schema could not load the current record from any candidate route; context-sensitive lookup requirements cannot be derived safely."
870
+ ]
871
+ if blockers == ["CURRENT_RECORD_CONTEXT_UNAVAILABLE"]
872
+ else []
873
+ )
874
+ recommended_next_actions = (
875
+ [
876
+ "Retry after the record becomes readable in the current workspace/profile context.",
877
+ "If the issue persists, verify that the current profile still has read access to this record.",
878
+ ]
879
+ if blockers == ["CURRENT_RECORD_CONTEXT_UNAVAILABLE"]
880
+ else [
881
+ "Check whether this record is still visible in any accessible view for the current profile.",
882
+ "Use record_get or record_list to confirm the record still exists in the current workspace.",
883
+ ]
884
+ )
878
885
  return self._record_update_schema_blocked_response(
879
886
  profile=profile,
880
887
  ws_id=ws_id,
881
888
  request_route=request_route,
882
889
  app_key=app_key,
883
890
  record_id=record_id,
884
- blockers=["NO_MATCHING_ACCESSIBLE_VIEW_FOR_RECORD"],
885
- warnings=[],
886
- recommended_next_actions=[
887
- "Check whether this record is still visible in any accessible view for the current profile.",
888
- "Use record_get or record_list to confirm the record still exists in the current workspace.",
889
- ],
891
+ blockers=blockers,
892
+ warnings=warnings,
893
+ recommended_next_actions=recommended_next_actions,
890
894
  output_profile=normalized_output_profile,
891
895
  view_probe_summary=probe_summary,
892
896
  ambiguous_fields=[],
@@ -894,7 +898,10 @@ class RecordTools(ToolBase):
894
898
 
895
899
  ambiguous_field_ids: set[int] = set()
896
900
  ambiguous_fields: list[JSONObject] = []
897
- warnings: list[JSONObject] = []
901
+ warnings: list[JSONObject] = [
902
+ {"code": "RECORD_CONTEXT_ROUTE_FALLBACK", "message": message}
903
+ for message in self._record_context_probe_fallback_warnings(probes)
904
+ ]
898
905
  for title, field_ids in title_to_field_ids.items():
899
906
  unique_ids = list(dict.fromkeys(field_ids))
900
907
  if len(unique_ids) <= 1:
@@ -953,6 +960,7 @@ class RecordTools(ToolBase):
953
960
  }
954
961
  if normalized_output_profile == "verbose":
955
962
  response["view_probe_summary"] = probe_summary
963
+ response["record_context_probe"] = probe_summary
956
964
  response["ambiguous_fields"] = ambiguous_fields
957
965
  return response
958
966
 
@@ -2286,9 +2294,15 @@ class RecordTools(ToolBase):
2286
2294
  def runner(session_profile, context):
2287
2295
  request_route = self._request_route_payload(context)
2288
2296
  def build_once(*, effective_force_refresh: bool) -> JSONObject:
2289
- try:
2290
- current_answers = self._load_record_answers_for_preflight(context, app_key=app_key, apply_id=record_id)
2291
- except QingflowApiError:
2297
+ candidate_routes = self._candidate_update_views(profile, context, app_key)
2298
+ probes = self._probe_candidate_record_contexts(
2299
+ context,
2300
+ app_key=app_key,
2301
+ apply_id=record_id,
2302
+ candidate_routes=candidate_routes,
2303
+ )
2304
+ matched_probes = [probe for probe in probes if probe.readable]
2305
+ if not matched_probes and probes and all(probe.transport_error for probe in probes):
2292
2306
  return {
2293
2307
  "profile": profile,
2294
2308
  "ws_id": session_profile.selected_ws_id,
@@ -2299,46 +2313,40 @@ class RecordTools(ToolBase):
2299
2313
  record_id=record_id,
2300
2314
  blockers=["CURRENT_RECORD_CONTEXT_UNAVAILABLE"],
2301
2315
  warnings=[
2302
- "update preflight could not load the current record; automatic view selection was blocked to avoid resolving lookup fields against a partial patch."
2316
+ "update preflight could not load the current record from any candidate route; automatic view selection was blocked to avoid resolving lookup fields against a partial patch."
2303
2317
  ],
2304
2318
  recommended_next_actions=[
2305
2319
  "Retry after the record becomes readable in the current workspace/profile context.",
2306
2320
  "Call record_update_schema_get to inspect the overall writable field set for this record after context access is restored.",
2307
2321
  ],
2308
- view_probe_summary=[],
2322
+ view_probe_summary=[
2323
+ self._record_context_probe_summary_payload(probe)
2324
+ for probe in probes
2325
+ ],
2309
2326
  ),
2310
2327
  }
2311
2328
 
2312
- candidate_routes = self._candidate_update_views(profile, context, app_key)
2313
2329
  probe_summary: list[JSONObject] = []
2314
- matched_any = False
2315
2330
  matched_routes: list[AccessibleViewRoute] = []
2331
+ matched_answers_for_union: list[JSONObject] | None = None
2316
2332
  first_blocked_plan: JSONObject | None = None
2317
2333
  first_confirmation_plan: JSONObject | None = None
2318
-
2319
- for candidate in candidate_routes:
2320
- matched_record = self._record_matches_accessible_view(
2321
- context,
2322
- current_answers,
2323
- resolved_view=candidate,
2324
- )
2325
- if not matched_record:
2326
- probe_summary.append(
2327
- {
2328
- "view_id": candidate.view_id,
2329
- "name": candidate.name,
2330
- "kind": candidate.kind,
2331
- "matched_record": False,
2332
- "writable_field_titles": [],
2333
- "missing_field_titles": [],
2334
- "context_complete": True,
2335
- "selected": False,
2336
- }
2337
- )
2334
+ fallback_warning_messages = self._record_context_probe_fallback_warnings(probes)
2335
+
2336
+ for probe in probes:
2337
+ candidate = probe.route
2338
+ candidate_summary = self._record_context_probe_summary_payload(probe)
2339
+ candidate_summary["writable_field_titles"] = []
2340
+ candidate_summary["missing_field_titles"] = []
2341
+ candidate_summary["selected"] = False
2342
+ if not probe.readable:
2343
+ probe_summary.append(candidate_summary)
2338
2344
  continue
2339
2345
 
2340
- matched_any = True
2346
+ current_answers = probe.answer_list or []
2341
2347
  matched_routes.append(candidate)
2348
+ if matched_answers_for_union is None:
2349
+ matched_answers_for_union = current_answers
2342
2350
  browse_scope = self._build_browse_write_scope(
2343
2351
  profile,
2344
2352
  context,
@@ -2394,16 +2402,8 @@ class RecordTools(ToolBase):
2394
2402
  title = _normalize_optional_text(field_payload.get("que_title"))
2395
2403
  if title and title not in missing_field_titles:
2396
2404
  missing_field_titles.append(title)
2397
- candidate_summary: JSONObject = {
2398
- "view_id": candidate.view_id,
2399
- "name": candidate.name,
2400
- "kind": candidate.kind,
2401
- "matched_record": True,
2402
- "writable_field_titles": candidate_titles,
2403
- "missing_field_titles": missing_field_titles,
2404
- "context_complete": True,
2405
- "selected": False,
2406
- }
2405
+ candidate_summary["writable_field_titles"] = candidate_titles
2406
+ candidate_summary["missing_field_titles"] = missing_field_titles
2407
2407
  if plan_data.get("blockers"):
2408
2408
  confirmation_requests = plan_data.get("confirmation_requests")
2409
2409
  if (
@@ -2422,8 +2422,26 @@ class RecordTools(ToolBase):
2422
2422
  view_payload["auto_selected"] = True
2423
2423
  view_payload["selection_source"] = "first_satisfying_accessible_view"
2424
2424
  candidate_summary["selected"] = True
2425
+ validation = plan_data.get("validation")
2426
+ if isinstance(validation, dict):
2427
+ warnings = validation.get("warnings")
2428
+ if not isinstance(warnings, list):
2429
+ warnings = []
2430
+ validation["warnings"] = warnings
2431
+ for message in fallback_warning_messages:
2432
+ if message not in warnings:
2433
+ warnings.append(message)
2425
2434
  first_confirmation_plan = plan_data
2426
2435
  elif first_blocked_plan is None:
2436
+ validation = plan_data.get("validation")
2437
+ if isinstance(validation, dict):
2438
+ warnings = validation.get("warnings")
2439
+ if not isinstance(warnings, list):
2440
+ warnings = []
2441
+ validation["warnings"] = warnings
2442
+ for message in fallback_warning_messages:
2443
+ if message not in warnings:
2444
+ warnings.append(message)
2427
2445
  first_blocked_plan = plan_data
2428
2446
  probe_summary.append(candidate_summary)
2429
2447
  continue
@@ -2439,8 +2457,18 @@ class RecordTools(ToolBase):
2439
2457
  view_payload["auto_selected"] = True
2440
2458
  view_payload["selection_source"] = "first_satisfying_accessible_view"
2441
2459
  candidate_summary["selected"] = True
2460
+ validation = plan_data.get("validation")
2461
+ if isinstance(validation, dict):
2462
+ warnings = validation.get("warnings")
2463
+ if not isinstance(warnings, list):
2464
+ warnings = []
2465
+ validation["warnings"] = warnings
2466
+ for message in fallback_warning_messages:
2467
+ if message not in warnings:
2468
+ warnings.append(message)
2442
2469
  probe_summary.append(candidate_summary)
2443
2470
  plan_data["view_probe_summary"] = probe_summary
2471
+ plan_data["record_context_probe"] = probe_summary
2444
2472
  return {
2445
2473
  "profile": profile,
2446
2474
  "ws_id": session_profile.selected_ws_id,
@@ -2449,7 +2477,7 @@ class RecordTools(ToolBase):
2449
2477
  "data": plan_data,
2450
2478
  }
2451
2479
 
2452
- if not matched_any:
2480
+ if not matched_probes:
2453
2481
  blocked_data = self._build_auto_view_blocked_preflight_data(
2454
2482
  app_key=app_key,
2455
2483
  record_id=record_id,
@@ -2471,6 +2499,7 @@ class RecordTools(ToolBase):
2471
2499
 
2472
2500
  if first_confirmation_plan is not None:
2473
2501
  first_confirmation_plan["view_probe_summary"] = probe_summary
2502
+ first_confirmation_plan["record_context_probe"] = probe_summary
2474
2503
  return {
2475
2504
  "profile": profile,
2476
2505
  "ws_id": session_profile.selected_ws_id,
@@ -2485,12 +2514,22 @@ class RecordTools(ToolBase):
2485
2514
  app_key=app_key,
2486
2515
  record_id=record_id,
2487
2516
  fields=fields,
2488
- current_answers=current_answers,
2517
+ current_answers=matched_answers_for_union or [],
2489
2518
  matched_routes=matched_routes,
2490
2519
  force_refresh_form=effective_force_refresh,
2491
2520
  )
2492
2521
  if union_plan is not None:
2522
+ validation = union_plan.get("validation")
2523
+ if isinstance(validation, dict):
2524
+ warnings = validation.get("warnings")
2525
+ if not isinstance(warnings, list):
2526
+ warnings = []
2527
+ validation["warnings"] = warnings
2528
+ for message in fallback_warning_messages:
2529
+ if message not in warnings:
2530
+ warnings.append(message)
2493
2531
  union_plan["view_probe_summary"] = probe_summary
2532
+ union_plan["record_context_probe"] = probe_summary
2494
2533
  return {
2495
2534
  "profile": profile,
2496
2535
  "ws_id": session_profile.selected_ws_id,
@@ -2785,6 +2824,161 @@ class RecordTools(ToolBase):
2785
2824
  )
2786
2825
  return candidates
2787
2826
 
2827
+ def _route_view_key(self, resolved_view: AccessibleViewRoute) -> str | None:
2828
+ if resolved_view.view_selection is not None:
2829
+ view_key = _normalize_optional_text(resolved_view.view_selection.view_key)
2830
+ if view_key:
2831
+ return view_key
2832
+ if resolved_view.kind == "custom" and resolved_view.view_id.startswith("custom:"):
2833
+ view_key = resolved_view.view_id.split(":", 1)[1].strip()
2834
+ return view_key or None
2835
+ return None
2836
+
2837
+ def _record_context_route_error_payload(self, error: QingflowApiError) -> JSONObject:
2838
+ payload: JSONObject = {"message": error.message}
2839
+ if error.category:
2840
+ payload["category"] = error.category
2841
+ if error.backend_code is not None:
2842
+ payload["backend_code"] = error.backend_code
2843
+ if error.http_status is not None:
2844
+ payload["http_status"] = error.http_status
2845
+ return payload
2846
+
2847
+ def _is_record_context_route_miss(self, error: QingflowApiError) -> bool:
2848
+ if error.backend_code in {40002, 40027, 40038, 404}:
2849
+ return True
2850
+ if error.http_status == 404:
2851
+ return True
2852
+ return False
2853
+
2854
+ def _load_record_answers_for_accessible_route(
2855
+ self,
2856
+ context, # type: ignore[no-untyped-def]
2857
+ *,
2858
+ app_key: str,
2859
+ apply_id: int,
2860
+ resolved_view: AccessibleViewRoute,
2861
+ ) -> tuple[list[JSONObject], int | None]:
2862
+ if resolved_view.kind == "custom":
2863
+ view_key = self._route_view_key(resolved_view)
2864
+ if not view_key:
2865
+ raise_tool_error(
2866
+ QingflowApiError.config_error(
2867
+ f"cannot resolve custom view route for '{resolved_view.view_id}'"
2868
+ )
2869
+ )
2870
+ record = self.backend.request(
2871
+ "GET",
2872
+ context,
2873
+ f"/view/{view_key}/apply/{apply_id}",
2874
+ )
2875
+ used_list_type = None
2876
+ else:
2877
+ used_list_type = resolved_view.list_type if resolved_view.list_type is not None else DEFAULT_RECORD_LIST_TYPE
2878
+ record = self.backend.request(
2879
+ "GET",
2880
+ context,
2881
+ f"/app/{app_key}/apply/{apply_id}",
2882
+ params={"role": 1, "listType": used_list_type},
2883
+ )
2884
+ answers = record.get("answers") if isinstance(record, dict) else None
2885
+ normalized_answers = [item for item in answers if isinstance(item, dict)] if isinstance(answers, list) else []
2886
+ return normalized_answers, used_list_type
2887
+
2888
+ def _probe_record_context_route(
2889
+ self,
2890
+ context, # type: ignore[no-untyped-def]
2891
+ *,
2892
+ app_key: str,
2893
+ apply_id: int,
2894
+ resolved_view: AccessibleViewRoute,
2895
+ ) -> RecordContextRouteProbe:
2896
+ try:
2897
+ answer_list, used_list_type = self._load_record_answers_for_accessible_route(
2898
+ context,
2899
+ app_key=app_key,
2900
+ apply_id=apply_id,
2901
+ resolved_view=resolved_view,
2902
+ )
2903
+ return RecordContextRouteProbe(
2904
+ route=resolved_view,
2905
+ answer_list=answer_list,
2906
+ used_list_type=used_list_type,
2907
+ readable=True,
2908
+ transport_error=False,
2909
+ error_payload=None,
2910
+ )
2911
+ except QingflowApiError as exc:
2912
+ return RecordContextRouteProbe(
2913
+ route=resolved_view,
2914
+ answer_list=None,
2915
+ used_list_type=resolved_view.list_type if resolved_view.kind == "system" else None,
2916
+ readable=False,
2917
+ transport_error=not self._is_record_context_route_miss(exc),
2918
+ error_payload=self._record_context_route_error_payload(exc),
2919
+ )
2920
+
2921
+ def _probe_candidate_record_contexts(
2922
+ self,
2923
+ context, # type: ignore[no-untyped-def]
2924
+ *,
2925
+ app_key: str,
2926
+ apply_id: int,
2927
+ candidate_routes: list[AccessibleViewRoute],
2928
+ ) -> list[RecordContextRouteProbe]:
2929
+ return [
2930
+ self._probe_record_context_route(
2931
+ context,
2932
+ app_key=app_key,
2933
+ apply_id=apply_id,
2934
+ resolved_view=candidate,
2935
+ )
2936
+ for candidate in candidate_routes
2937
+ ]
2938
+
2939
+ def _record_context_probe_summary_payload(self, probe: RecordContextRouteProbe) -> JSONObject:
2940
+ payload: JSONObject = {
2941
+ "view_id": probe.route.view_id,
2942
+ "name": probe.route.name,
2943
+ "kind": probe.route.kind,
2944
+ "matched_record": probe.readable,
2945
+ "readable": probe.readable,
2946
+ "context_complete": probe.readable,
2947
+ "used_list_type": probe.used_list_type,
2948
+ }
2949
+ if probe.error_payload is not None:
2950
+ payload["error"] = probe.error_payload
2951
+ payload["transport_error"] = probe.transport_error
2952
+ return payload
2953
+
2954
+ def _record_context_probe_fallback_warnings(
2955
+ self,
2956
+ probes: list[RecordContextRouteProbe],
2957
+ ) -> list[str]:
2958
+ matched_probes = [probe for probe in probes if probe.readable]
2959
+ if not matched_probes:
2960
+ return []
2961
+ if any(
2962
+ probe.route.kind == "system" and probe.used_list_type == DEFAULT_RECORD_LIST_TYPE
2963
+ for probe in matched_probes
2964
+ ):
2965
+ return []
2966
+ first_probe = matched_probes[0]
2967
+ if first_probe.route.kind == "custom":
2968
+ return [
2969
+ "current record context was not accessible via listType="
2970
+ f"{DEFAULT_RECORD_LIST_TYPE}; preflight resolved it via custom view "
2971
+ f"'{first_probe.route.name}'."
2972
+ ]
2973
+ used_list_type = first_probe.used_list_type
2974
+ if used_list_type is None:
2975
+ return []
2976
+ return [
2977
+ "current record context was not accessible via listType="
2978
+ f"{DEFAULT_RECORD_LIST_TYPE}; preflight resolved it via listType={used_list_type} "
2979
+ f"({get_record_list_type_label(used_list_type)})."
2980
+ ]
2981
+
2788
2982
  def _record_matches_accessible_view(
2789
2983
  self,
2790
2984
  context, # type: ignore[no-untyped-def]
@@ -5276,7 +5470,19 @@ class RecordTools(ToolBase):
5276
5470
  existing_answers_loaded = True
5277
5471
  else:
5278
5472
  try:
5279
- existing_answers_for_update = self._load_record_answers_for_preflight(context, app_key=app_key, apply_id=apply_id)
5473
+ if resolved_view is not None:
5474
+ existing_answers_for_update, _used_list_type = self._load_record_answers_for_accessible_route(
5475
+ context,
5476
+ app_key=app_key,
5477
+ apply_id=apply_id,
5478
+ resolved_view=resolved_view,
5479
+ )
5480
+ else:
5481
+ existing_answers_for_update = self._load_record_answers_for_preflight(
5482
+ context,
5483
+ app_key=app_key,
5484
+ apply_id=apply_id,
5485
+ )
5280
5486
  existing_answers_loaded = True
5281
5487
  except QingflowApiError:
5282
5488
  validation_warnings.append(