@josephyan/qingflow-app-builder-mcp 0.2.0-beta.4 → 0.2.0-beta.41

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 (39) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +3 -1
  4. package/skills/qingflow-app-builder/SKILL.md +154 -22
  5. package/skills/qingflow-app-builder/references/create-app.md +51 -21
  6. package/skills/qingflow-app-builder/references/environments.md +1 -1
  7. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
  8. package/skills/qingflow-app-builder/references/gotchas.md +28 -1
  9. package/skills/qingflow-app-builder/references/solution-playbooks.md +14 -12
  10. package/skills/qingflow-app-builder/references/tool-selection.md +45 -17
  11. package/skills/qingflow-app-builder/references/update-flow.md +112 -25
  12. package/skills/qingflow-app-builder/references/update-layout.md +11 -24
  13. package/skills/qingflow-app-builder/references/update-schema.md +1 -23
  14. package/skills/qingflow-app-builder/references/update-views.md +87 -21
  15. package/src/qingflow_mcp/__init__.py +1 -1
  16. package/src/qingflow_mcp/backend_client.py +189 -0
  17. package/src/qingflow_mcp/builder_facade/models.py +584 -1
  18. package/src/qingflow_mcp/builder_facade/service.py +4698 -262
  19. package/src/qingflow_mcp/config.py +39 -0
  20. package/src/qingflow_mcp/import_store.py +121 -0
  21. package/src/qingflow_mcp/list_type_labels.py +24 -0
  22. package/src/qingflow_mcp/server.py +131 -16
  23. package/src/qingflow_mcp/server_app_builder.py +132 -72
  24. package/src/qingflow_mcp/server_app_user.py +143 -187
  25. package/src/qingflow_mcp/solution/compiler/form_compiler.py +14 -4
  26. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +41 -2
  27. package/src/qingflow_mcp/solution/executor.py +44 -7
  28. package/src/qingflow_mcp/tools/ai_builder_tools.py +1567 -144
  29. package/src/qingflow_mcp/tools/app_tools.py +243 -14
  30. package/src/qingflow_mcp/tools/approval_tools.py +411 -76
  31. package/src/qingflow_mcp/tools/directory_tools.py +203 -31
  32. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  33. package/src/qingflow_mcp/tools/file_tools.py +1 -0
  34. package/src/qingflow_mcp/tools/import_tools.py +1164 -0
  35. package/src/qingflow_mcp/tools/portal_tools.py +31 -0
  36. package/src/qingflow_mcp/tools/record_tools.py +4943 -1025
  37. package/src/qingflow_mcp/tools/task_context_tools.py +1335 -0
  38. package/src/qingflow_mcp/tools/task_tools.py +376 -225
  39. package/src/qingflow_mcp/tools/workflow_tools.py +78 -4
@@ -12,6 +12,8 @@ DEFAULT_USER_AGENT = "qingflow-mcp/1.0"
12
12
  DEFAULT_RECORD_LIST_TYPE = 8
13
13
  ATTACHMENT_QUESTION_TYPE = 13
14
14
  DEFAULT_BASE_URL = "https://qingflow.com/api"
15
+ DEFAULT_FEEDBACK_APP_KEY = "e0d017kju002"
16
+ DEFAULT_FEEDBACK_QSOURCE_TOKEN = "mcp-feedback-7755d14748fc"
15
17
 
16
18
 
17
19
  def get_mcp_home() -> Path:
@@ -136,6 +138,43 @@ def get_default_qf_version() -> str | None:
136
138
  return normalized or None
137
139
 
138
140
 
141
+ def get_feedback_qsource_token() -> str | None:
142
+ """获取反馈 q-source 被动入口 token"""
143
+ value = get_config_value(
144
+ "feedback.qsource_token",
145
+ env_var="QINGFLOW_MCP_FEEDBACK_QSOURCE_TOKEN",
146
+ default=DEFAULT_FEEDBACK_QSOURCE_TOKEN,
147
+ )
148
+ if value is None:
149
+ return None
150
+ normalized = str(value).strip()
151
+ return normalized or None
152
+
153
+
154
+ def get_feedback_base_url() -> str | None:
155
+ """获取反馈 q-source 使用的 base URL"""
156
+ value = get_config_value(
157
+ "feedback.base_url",
158
+ env_var="QINGFLOW_MCP_FEEDBACK_BASE_URL",
159
+ default=None,
160
+ )
161
+ if value is None:
162
+ return get_default_base_url()
163
+ normalized = normalize_base_url(value)
164
+ return normalized or get_default_base_url()
165
+
166
+
167
+ def get_feedback_app_key() -> str:
168
+ """获取内部反馈表 app_key"""
169
+ value = get_config_value(
170
+ "feedback.app_key",
171
+ env_var="QINGFLOW_MCP_FEEDBACK_APP_KEY",
172
+ default=DEFAULT_FEEDBACK_APP_KEY,
173
+ )
174
+ normalized = str(value or "").strip()
175
+ return normalized or DEFAULT_FEEDBACK_APP_KEY
176
+
177
+
139
178
  def get_timeout_seconds() -> float:
140
179
  """获取 HTTP 超时秒数"""
141
180
  value = get_config_value(
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .config import get_mcp_home
11
+
12
+
13
+ def _utc_now() -> datetime:
14
+ return datetime.now(timezone.utc)
15
+
16
+
17
+ def _parse_utc(value: Any) -> datetime | None:
18
+ if not isinstance(value, str) or not value.strip():
19
+ return None
20
+ normalized = value.strip().replace("Z", "+00:00")
21
+ try:
22
+ parsed = datetime.fromisoformat(normalized)
23
+ except ValueError:
24
+ return None
25
+ if parsed.tzinfo is None:
26
+ return parsed.replace(tzinfo=timezone.utc)
27
+ return parsed.astimezone(timezone.utc)
28
+
29
+
30
+ def _json_safe_key(value: str) -> str:
31
+ keep = []
32
+ for char in value:
33
+ if char.isalnum() or char in {"-", "_"}:
34
+ keep.append(char)
35
+ else:
36
+ keep.append("_")
37
+ result = "".join(keep).strip("_")
38
+ return result or "entry"
39
+
40
+
41
+ def _store_dir(env_var: str, default_name: str) -> Path:
42
+ custom = os.getenv(env_var)
43
+ if custom:
44
+ return Path(custom).expanduser()
45
+ return get_mcp_home() / default_name
46
+
47
+
48
+ @dataclass(slots=True)
49
+ class _JsonEntryStore:
50
+ base_dir: Path
51
+ ttl: timedelta
52
+
53
+ def __post_init__(self) -> None:
54
+ self.base_dir.mkdir(parents=True, exist_ok=True)
55
+ self.prune()
56
+
57
+ def put(self, entry_id: str, payload: dict[str, Any]) -> None:
58
+ data = dict(payload)
59
+ data["id"] = entry_id
60
+ data["updated_at"] = _utc_now().isoformat()
61
+ self._path(entry_id).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
62
+
63
+ def get(self, entry_id: str) -> dict[str, Any] | None:
64
+ path = self._path(entry_id)
65
+ if not path.exists():
66
+ return None
67
+ try:
68
+ payload = json.loads(path.read_text(encoding="utf-8"))
69
+ except (OSError, json.JSONDecodeError):
70
+ path.unlink(missing_ok=True)
71
+ return None
72
+ created_at = _parse_utc(payload.get("created_at")) or _parse_utc(payload.get("updated_at"))
73
+ if created_at is None or _utc_now() - created_at > self.ttl:
74
+ path.unlink(missing_ok=True)
75
+ return None
76
+ return payload
77
+
78
+ def prune(self) -> None:
79
+ for path in self.base_dir.glob("*.json"):
80
+ try:
81
+ payload = json.loads(path.read_text(encoding="utf-8"))
82
+ except (OSError, json.JSONDecodeError):
83
+ path.unlink(missing_ok=True)
84
+ continue
85
+ created_at = _parse_utc(payload.get("created_at")) or _parse_utc(payload.get("updated_at"))
86
+ if created_at is None or _utc_now() - created_at > self.ttl:
87
+ path.unlink(missing_ok=True)
88
+
89
+ def list(self) -> list[dict[str, Any]]:
90
+ entries: list[dict[str, Any]] = []
91
+ self.prune()
92
+ for path in self.base_dir.glob("*.json"):
93
+ try:
94
+ payload = json.loads(path.read_text(encoding="utf-8"))
95
+ except (OSError, json.JSONDecodeError):
96
+ continue
97
+ created_at = _parse_utc(payload.get("created_at")) or _parse_utc(payload.get("updated_at"))
98
+ if created_at is None:
99
+ continue
100
+ entries.append(payload)
101
+ entries.sort(key=lambda item: item.get("created_at") or item.get("updated_at") or "", reverse=True)
102
+ return entries
103
+
104
+ def _path(self, entry_id: str) -> Path:
105
+ return self.base_dir / f"{_json_safe_key(entry_id)}.json"
106
+
107
+
108
+ class ImportVerificationStore(_JsonEntryStore):
109
+ def __init__(self, base_dir: Path | None = None, *, ttl_seconds: int = 3600) -> None:
110
+ super().__init__(
111
+ base_dir=base_dir or _store_dir("QINGFLOW_MCP_IMPORT_VERIFY_HOME", "import-verifications"),
112
+ ttl=timedelta(seconds=ttl_seconds),
113
+ )
114
+
115
+
116
+ class ImportJobStore(_JsonEntryStore):
117
+ def __init__(self, base_dir: Path | None = None, *, ttl_seconds: int = 24 * 3600) -> None:
118
+ super().__init__(
119
+ base_dir=base_dir or _store_dir("QINGFLOW_MCP_IMPORT_JOB_HOME", "import-jobs"),
120
+ ttl=timedelta(seconds=ttl_seconds),
121
+ )
@@ -20,6 +20,18 @@ RECORD_LIST_TYPE_LABELS: dict[int, str] = {
20
20
  16: "我发起的-已结束",
21
21
  }
22
22
 
23
+ SYSTEM_VIEW_DEFINITIONS: tuple[tuple[str, int, str], ...] = (
24
+ ("system:all", 8, "全部数据"),
25
+ ("system:initiated", 14, "我发起的"),
26
+ ("system:todo", 1, "待办"),
27
+ ("system:done", 2, "已办"),
28
+ ("system:cc", 12, "抄送我的"),
29
+ )
30
+
31
+ SYSTEM_VIEW_ID_TO_LIST_TYPE: dict[str, int] = {view_id: list_type for view_id, list_type, _ in SYSTEM_VIEW_DEFINITIONS}
32
+ SYSTEM_VIEW_ID_TO_NAME: dict[str, str] = {view_id: name for view_id, _, name in SYSTEM_VIEW_DEFINITIONS}
33
+ SYSTEM_LIST_TYPE_TO_VIEW_ID: dict[int, str] = {list_type: view_id for view_id, list_type, _ in SYSTEM_VIEW_DEFINITIONS}
34
+
23
35
  TASK_TYPE_LABELS: dict[int, str] = {
24
36
  1: "待办",
25
37
  2: "我发起的",
@@ -40,6 +52,18 @@ def get_record_list_type_label(list_type: int | None) -> str | None:
40
52
  return RECORD_LIST_TYPE_LABELS.get(list_type)
41
53
 
42
54
 
55
+ def get_system_view_id(list_type: int | None) -> str | None:
56
+ if list_type is None:
57
+ return None
58
+ return SYSTEM_LIST_TYPE_TO_VIEW_ID.get(list_type)
59
+
60
+
61
+ def get_system_view_name(view_id: str | None) -> str | None:
62
+ if view_id is None:
63
+ return None
64
+ return SYSTEM_VIEW_ID_TO_NAME.get(view_id)
65
+
66
+
43
67
  def get_task_type_label(type_value: int | None) -> str | None:
44
68
  if type_value is None:
45
69
  return None
@@ -1,61 +1,176 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from datetime import date
4
+
3
5
  from mcp.server.fastmcp import FastMCP
4
6
 
5
7
  from .backend_client import BackendClient
6
8
  from .session_store import SessionStore
7
9
  from .tools.app_tools import AppTools
8
10
  from .tools.auth_tools import AuthTools
11
+ from .tools.feedback_tools import FeedbackTools
9
12
  from .tools.file_tools import FileTools
13
+ from .tools.import_tools import ImportTools
10
14
  from .tools.package_tools import PackageTools
11
15
  from .tools.navigation_tools import NavigationTools
12
- from .tools.approval_tools import ApprovalTools
13
16
  from .tools.directory_tools import DirectoryTools
14
17
  from .tools.portal_tools import PortalTools
15
18
  from .tools.qingbi_report_tools import QingbiReportTools
16
19
  from .tools.record_tools import RecordTools
17
20
  from .tools.role_tools import RoleTools
18
21
  from .tools.solution_tools import SolutionTools
19
- from .tools.task_tools import TaskTools
22
+ from .tools.task_context_tools import TaskContextTools
20
23
  from .tools.view_tools import ViewTools
21
24
  from .tools.workflow_tools import WorkflowTools
22
25
  from .tools.workspace_tools import WorkspaceTools
23
26
 
24
27
 
25
28
  def build_server() -> FastMCP:
29
+ today = date.today()
30
+ current_year = today.year
26
31
  server = FastMCP(
27
32
  "Qingflow MCP",
28
- instructions=(
29
- "Use auth_login first, then workspace_list and workspace_select. "
30
- "All resource tools operate with the logged-in user's Qingflow permissions.\n\n"
31
- "Task Center (待办/已办) handling:\n"
32
- "- Use task_statistics to get counts of pending tasks (todo_count), timeouts, urged, etc.\n"
33
- "- Use task_list to query tasks. Type values: 1=todo (待办), 2=initiated (我发起的), 3=cc (抄送), 5=done (已办).\n"
34
- "- Use task_list_grouped to get tasks grouped by form/worksheet.\n"
35
- "- Use task_mark_read to mark a specific task as read.\n"
36
- "- Use task_urge to send an urgent reminder for a pending task.\n"
37
- "- Process status values: 1=all, 2=processing, 3=passed, 4=refused, 5=need_supply, 6=urged, 7=timeout, 8=pre_timeout, 9=unread.\n"
38
- "- After identifying the exact task node and record, use record_approve, record_reject, record_rollback, record_transfer, record_reassign, or record_countersign as needed."
39
- ),
33
+ instructions=f"""Use this server for Qingflow operational workflows. Current date: `{today.isoformat()}`.
34
+
35
+ ## Authentication
36
+
37
+ Use `auth_login` first, then `workspace_list` and `workspace_select`.
38
+ All resource tools operate with the logged-in user's Qingflow permissions.
39
+
40
+ ## Shared Helper
41
+
42
+ `feedback_submit` is always available as a cross-cutting helper.
43
+
44
+ - Use it when the current MCP capability is unsupported, awkward, or still cannot satisfy the user's need after reasonable use.
45
+ - It does not require Qingflow login or workspace selection.
46
+ - Call it only after the user explicitly confirms submission.
47
+
48
+ ## App Discovery
49
+
50
+ If `app_key` is unknown, use `app_list` or `app_search` first.
51
+ If the app is known but the data range is not, use `app_get` first and choose from `accessible_views`.
52
+ 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.
53
+
54
+ ## Schema-First Rule
55
+
56
+ Call `record_schema_get(schema_mode="applicant")` before `record_write`.
57
+ Call `app_get` first when the data range is unclear, then use `record_schema_get(schema_mode="browse", view_id=...)` before `record_list`, `record_get`, or `record_analyze`.
58
+
59
+ - All `field_id` values must come from the schema response.
60
+ - Never guess field names or ids.
61
+
62
+ ## Schema Scope
63
+
64
+ `record_schema_get(schema_mode="applicant")` returns the current user's applicant-node visible fields for write/create.
65
+ `record_schema_get(schema_mode="browse", view_id=...)` returns browse-schema fields for the selected accessible view.
66
+
67
+ - Hidden fields are omitted.
68
+ - Missing fields mean the field is not visible in the current permission scope.
69
+ - Read `fields` and `suggested_*` from the top level of the schema response.
70
+
71
+ ## Analytics Path
72
+
73
+ `app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_analyze`
74
+
75
+ Prefer `view_id` entries from `accessible_views` where `analysis_supported=true`.
76
+
77
+ Use this DSL shape:
78
+
79
+ - `dimensions`: `{{field_id, alias, bucket}}`
80
+ - `metrics`: `{{op, field_id, alias}}`
81
+ - `filters`: `{{field_id, op, value}}`
82
+ - `sort`: `{{by, order}}`
83
+
84
+ Important key rules:
85
+
86
+ - Use `op`
87
+ - Do **not** use `type`
88
+ - Do **not** use `agg`
89
+ - Do **not** use `aggregation`
90
+ - Do **not** use `operator`
91
+
92
+ Analysis answers must include concrete numbers. When applicable, include percentages based on the returned totals.
93
+
94
+ ## Record CRUD Path
95
+
96
+ `app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_list / record_get`
97
+ `record_schema_get(schema_mode="applicant") -> record_write`
98
+
99
+ - Use `columns` as `[{{field_id}}]`
100
+ - Use `where` items as `{{field_id, op, value}}`
101
+ - Use `order_by` items as `{{field_id, direction}}`
102
+ - Legacy forms such as bare integer `field_id`, `fieldId`, `operator`, `values`, or `order` may still parse, but they are compatibility-only and not the canonical DSL
103
+
104
+ `record_write` uses SQL-like JSON clauses:
105
+
106
+ - `insert` -> `values`
107
+ - `update` -> `record_id + set`
108
+ - `delete` -> `record_id` or `record_ids`
109
+
110
+ - Read relation targets from `record_schema_get.target_app_key` / `target_app_name` before preparing relation writes.
111
+ - If a member or department field id is known but candidate ids are not, use `record_member_candidates` or `record_department_candidates` before `record_write`.
112
+ - For default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
113
+
114
+ ## Import Path
115
+
116
+ `record_import_template_get -> record_import_verify -> (optional authorized record_import_repair_local) -> record_import_start -> record_import_status_get`
117
+
118
+ - Import must go through `verify -> start`; do not start directly from a raw file path.
119
+ - `record_import_start` requires an explicit `being_enter_auditing` choice. Do not assume a default.
120
+ - Do not modify user-uploaded files unless the user explicitly authorizes repair.
121
+ - If repair is authorized, keep the original file and repair a copy, then run `record_import_verify` again before `record_import_start`.
122
+
123
+ ## Task Workflow Path
124
+
125
+ `task_list -> task_get -> task_action_execute`
126
+
127
+ - Use `task_associated_report_detail_get` for associated view or report details.
128
+ - Use `task_workflow_log_get` for full workflow log history.
129
+ - Task actions operate on `app_key + record_id + workflow_node_id`, not `task_id`.
130
+
131
+ ## Time Handling
132
+
133
+ Normalize relative dates before building DSL.
134
+
135
+ - If the user says `3月` without a year, use the current year: `{current_year}`
136
+ - Convert month-only phrases into explicit legal date ranges
137
+ - Never send impossible dates such as `2026-02-29`
138
+
139
+ ## Environment
140
+
141
+ Default to `prod` unless the user explicitly specifies `test`.
142
+
143
+ ## Constraints
144
+
145
+ Avoid builder-side app or schema changes here.
146
+
147
+ ## Feedback Path
148
+
149
+ If the current MCP capability is unsupported, the workflow is awkward, or the user's need still cannot be satisfied after reasonable use, offer to submit product feedback.
150
+
151
+ - First summarize what is still not working
152
+ - Ask the user whether to submit feedback
153
+ - Call `feedback_submit` only after explicit user confirmation""",
40
154
  )
41
155
  sessions = SessionStore()
42
156
  backend = BackendClient()
43
157
  AuthTools(sessions, backend).register(server)
158
+ FeedbackTools(backend, mcp_side="通用").register(server)
44
159
  WorkspaceTools(sessions, backend).register(server)
45
160
  FileTools(sessions, backend).register(server)
161
+ ImportTools(sessions, backend).register(server)
46
162
  RecordTools(sessions, backend).register(server)
163
+ TaskContextTools(sessions, backend).register(server)
47
164
  RoleTools(sessions, backend).register(server)
48
165
  AppTools(sessions, backend).register(server)
49
166
  QingbiReportTools(sessions, backend).register(server)
50
167
  PackageTools(sessions, backend).register(server)
51
168
  NavigationTools(sessions, backend).register(server)
52
- ApprovalTools(sessions, backend).register(server)
53
169
  PortalTools(sessions, backend).register(server)
54
170
  DirectoryTools(sessions, backend).register(server)
55
171
  WorkflowTools(sessions, backend).register(server)
56
172
  ViewTools(sessions, backend).register(server)
57
173
  SolutionTools(sessions, backend).register(server)
58
- TaskTools(sessions, backend).register(server)
59
174
  return server
60
175
 
61
176
 
@@ -7,6 +7,7 @@ from .config import DEFAULT_PROFILE
7
7
  from .session_store import SessionStore
8
8
  from .tools.ai_builder_tools import AiBuilderTools
9
9
  from .tools.auth_tools import AuthTools
10
+ from .tools.feedback_tools import FeedbackTools
10
11
  from .tools.file_tools import FileTools
11
12
  from .tools.workspace_tools import WorkspaceTools
12
13
 
@@ -16,13 +17,18 @@ def build_builder_server() -> FastMCP:
16
17
  "Qingflow App Builder MCP",
17
18
  instructions=(
18
19
  "Use this server for AI-native Qingflow builder workflows. "
19
- "Follow the resource path resolve -> summary read -> plan -> apply -> attach -> publish_verify. "
20
- "Use package_resolve/package_list and app_resolve to locate resources, "
21
- "app_read_summary/app_read_fields/app_read_layout_summary/app_read_views_summary/app_read_flow_summary for compact reads, "
22
- "app_schema_plan/app_layout_plan/app_flow_plan/app_views_plan before writes when the target patch is non-trivial, "
23
- "then app_schema_apply/app_layout_apply/app_flow_apply/app_views_apply to execute normalized patches; these apply tools publish by default unless publish=false. "
20
+ "`feedback_submit` is always available as a cross-cutting helper when the current capability is unsupported, awkward, or still cannot satisfy the user's need after reasonable use; it does not require Qingflow login or workspace selection, and it should be called only after explicit user confirmation. "
21
+ "Follow the resource path resolve -> summary read -> apply -> attach -> publish_verify. "
22
+ "Use builder_tool_contract when you need a machine-readable contract, aliases, allowed enums, or a minimal valid example for a public builder tool. "
23
+ "If creating a new package may be appropriate, ask the user to confirm package creation before calling package_create; otherwise use package_resolve/package_list and app_resolve to locate resources, "
24
+ "app_read_summary/app_read_fields/app_read_layout_summary/app_read_views_summary/app_read_flow_summary/app_read_charts_summary/portal_read_summary for compact reads, "
25
+ "member_search/role_search/role_create when workflow assignees must come from the directory or role catalog, preferring roles over explicit members unless the user explicitly names members, "
26
+ "then app_schema_apply/app_layout_apply/app_flow_apply/app_views_apply/app_charts_apply/portal_apply to execute normalized patches; these apply tools perform planning, normalization, and dependency checks internally where applicable. Schema/layout/views noop requests skip publish, charts are immediate-live without publish and resolve targets by chart_id first then exact unique chart name, portal updates are replace-only and publish=false only guarantees draft/base-info updates, and flow should use publish=false whenever you only want draft/precheck behavior. "
24
27
  "Use package_attach_app to attach apps to packages, and app_publish_verify for explicit final publish verification. "
25
- "Do not handcraft internal solution payloads or rely on build_id/stage/repair."
28
+ "For workflow edits, keep the public builder surface on stable linear flows only: start/approve/fill/copy/webhook/end. Branch and condition nodes are intentionally disabled because the backend workflow route is not front-end stable for those node types. Declare node assignees and editable fields explicitly. "
29
+ "If builder writes are blocked by the current user's own edit lock, use app_release_edit_lock_if_mine with the lock owner details from the failed result. "
30
+ "Do not handcraft internal solution payloads or rely on build_id/stage/repair. "
31
+ "If the current MCP capability is unsupported, the workflow is awkward, or the user's need still cannot be satisfied after reasonable use, first summarize the gap, ask whether to submit feedback, and call feedback_submit only after explicit user confirmation."
26
32
  ),
27
33
  )
28
34
  sessions = SessionStore()
@@ -31,6 +37,7 @@ def build_builder_server() -> FastMCP:
31
37
  workspace = WorkspaceTools(sessions, backend)
32
38
  files = FileTools(sessions, backend)
33
39
  ai_builder = AiBuilderTools(sessions, backend)
40
+ feedback = FeedbackTools(backend, mcp_side="App Builder MCP")
34
41
 
35
42
  @server.tool()
36
43
  def auth_login(
@@ -116,6 +123,8 @@ def build_builder_server() -> FastMCP:
116
123
  file_related_url=file_related_url,
117
124
  )
118
125
 
126
+ feedback.register(server)
127
+
119
128
  @server.tool()
120
129
  def package_list(profile: str = DEFAULT_PROFILE, trial_status: str = "all") -> dict:
121
130
  return ai_builder.package_list(profile=profile, trial_status=trial_status)
@@ -124,6 +133,57 @@ def build_builder_server() -> FastMCP:
124
133
  def package_resolve(profile: str = DEFAULT_PROFILE, package_name: str = "") -> dict:
125
134
  return ai_builder.package_resolve(profile=profile, package_name=package_name)
126
135
 
136
+ @server.tool()
137
+ def builder_tool_contract(tool_name: str = "") -> dict:
138
+ return ai_builder.builder_tool_contract(tool_name=tool_name)
139
+
140
+ @server.tool()
141
+ def package_create(profile: str = DEFAULT_PROFILE, package_name: str = "") -> dict:
142
+ return ai_builder.package_create(profile=profile, package_name=package_name)
143
+
144
+ @server.tool()
145
+ def member_search(
146
+ profile: str = DEFAULT_PROFILE,
147
+ query: str = "",
148
+ page_num: int = 1,
149
+ page_size: int = 20,
150
+ contain_disable: bool = False,
151
+ ) -> dict:
152
+ return ai_builder.member_search(
153
+ profile=profile,
154
+ query=query,
155
+ page_num=page_num,
156
+ page_size=page_size,
157
+ contain_disable=contain_disable,
158
+ )
159
+
160
+ @server.tool()
161
+ def role_search(
162
+ profile: str = DEFAULT_PROFILE,
163
+ keyword: str = "",
164
+ page_num: int = 1,
165
+ page_size: int = 20,
166
+ ) -> dict:
167
+ return ai_builder.role_search(profile=profile, keyword=keyword, page_num=page_num, page_size=page_size)
168
+
169
+ @server.tool()
170
+ def role_create(
171
+ profile: str = DEFAULT_PROFILE,
172
+ role_name: str = "",
173
+ member_uids: list[int] | None = None,
174
+ member_emails: list[str] | None = None,
175
+ member_names: list[str] | None = None,
176
+ role_icon: str = "ex-user-outlined",
177
+ ) -> dict:
178
+ return ai_builder.role_create(
179
+ profile=profile,
180
+ role_name=role_name,
181
+ member_uids=member_uids or [],
182
+ member_emails=member_emails or [],
183
+ member_names=member_names or [],
184
+ role_icon=role_icon,
185
+ )
186
+
127
187
  @server.tool()
128
188
  def package_attach_app(
129
189
  profile: str = DEFAULT_PROFILE,
@@ -133,6 +193,20 @@ def build_builder_server() -> FastMCP:
133
193
  ) -> dict:
134
194
  return ai_builder.package_attach_app(profile=profile, tag_id=tag_id, app_key=app_key, app_title=app_title)
135
195
 
196
+ @server.tool()
197
+ def app_release_edit_lock_if_mine(
198
+ profile: str = DEFAULT_PROFILE,
199
+ app_key: str = "",
200
+ lock_owner_email: str = "",
201
+ lock_owner_name: str = "",
202
+ ) -> dict:
203
+ return ai_builder.app_release_edit_lock_if_mine(
204
+ profile=profile,
205
+ app_key=app_key,
206
+ lock_owner_email=lock_owner_email,
207
+ lock_owner_name=lock_owner_name,
208
+ )
209
+
136
210
  @server.tool()
137
211
  def app_resolve(
138
212
  profile: str = DEFAULT_PROFILE,
@@ -163,76 +237,16 @@ def build_builder_server() -> FastMCP:
163
237
  return ai_builder.app_read_flow_summary(profile=profile, app_key=app_key)
164
238
 
165
239
  @server.tool()
166
- def app_schema_plan(
167
- profile: str = DEFAULT_PROFILE,
168
- app_key: str = "",
169
- package_tag_id: int | None = None,
170
- app_name: str = "",
171
- create_if_missing: bool = False,
172
- add_fields: list[dict] | None = None,
173
- update_fields: list[dict] | None = None,
174
- remove_fields: list[dict] | None = None,
175
- ) -> dict:
176
- return ai_builder.app_schema_plan(
177
- profile=profile,
178
- app_key=app_key,
179
- package_tag_id=package_tag_id,
180
- app_name=app_name,
181
- create_if_missing=create_if_missing,
182
- add_fields=add_fields or [],
183
- update_fields=update_fields or [],
184
- remove_fields=remove_fields or [],
185
- )
240
+ def app_read_charts_summary(profile: str = DEFAULT_PROFILE, app_key: str = "") -> dict:
241
+ return ai_builder.app_read_charts_summary(profile=profile, app_key=app_key)
186
242
 
187
243
  @server.tool()
188
- def app_layout_plan(
244
+ def portal_read_summary(
189
245
  profile: str = DEFAULT_PROFILE,
190
- app_key: str = "",
191
- mode: str = "merge",
192
- sections: list[dict] | None = None,
193
- preset: str | None = None,
246
+ dash_key: str = "",
247
+ being_draft: bool = True,
194
248
  ) -> dict:
195
- return ai_builder.app_layout_plan(
196
- profile=profile,
197
- app_key=app_key,
198
- mode=mode,
199
- sections=sections or [],
200
- preset=preset,
201
- )
202
-
203
- @server.tool()
204
- def app_flow_plan(
205
- profile: str = DEFAULT_PROFILE,
206
- app_key: str = "",
207
- mode: str = "replace",
208
- nodes: list[dict] | None = None,
209
- transitions: list[dict] | None = None,
210
- preset: str | None = None,
211
- ) -> dict:
212
- return ai_builder.app_flow_plan(
213
- profile=profile,
214
- app_key=app_key,
215
- mode=mode,
216
- nodes=nodes or [],
217
- transitions=transitions or [],
218
- preset=preset,
219
- )
220
-
221
- @server.tool()
222
- def app_views_plan(
223
- profile: str = DEFAULT_PROFILE,
224
- app_key: str = "",
225
- upsert_views: list[dict] | None = None,
226
- remove_views: list[str] | None = None,
227
- preset: str | None = None,
228
- ) -> dict:
229
- return ai_builder.app_views_plan(
230
- profile=profile,
231
- app_key=app_key,
232
- upsert_views=upsert_views or [],
233
- remove_views=remove_views or [],
234
- preset=preset,
235
- )
249
+ return ai_builder.portal_read_summary(profile=profile, dash_key=dash_key, being_draft=being_draft)
236
250
 
237
251
  @server.tool()
238
252
  def app_schema_apply(
@@ -304,6 +318,52 @@ def build_builder_server() -> FastMCP:
304
318
  remove_views=remove_views or [],
305
319
  )
306
320
 
321
+ @server.tool()
322
+ def app_charts_apply(
323
+ profile: str = DEFAULT_PROFILE,
324
+ app_key: str = "",
325
+ upsert_charts: list[dict] | None = None,
326
+ remove_chart_ids: list[str] | None = None,
327
+ reorder_chart_ids: list[str] | None = None,
328
+ ) -> dict:
329
+ return ai_builder.app_charts_apply(
330
+ profile=profile,
331
+ app_key=app_key,
332
+ upsert_charts=upsert_charts or [],
333
+ remove_chart_ids=remove_chart_ids or [],
334
+ reorder_chart_ids=reorder_chart_ids or [],
335
+ )
336
+
337
+ @server.tool()
338
+ def portal_apply(
339
+ profile: str = DEFAULT_PROFILE,
340
+ dash_key: str = "",
341
+ dash_name: str = "",
342
+ package_tag_id: int | None = None,
343
+ publish: bool = True,
344
+ sections: list[dict] | None = None,
345
+ auth: dict | None = None,
346
+ icon: str | None = None,
347
+ color: str | None = None,
348
+ hide_copyright: bool | None = None,
349
+ dash_global_config: dict | None = None,
350
+ config: dict | None = None,
351
+ ) -> dict:
352
+ return ai_builder.portal_apply(
353
+ profile=profile,
354
+ dash_key=dash_key,
355
+ dash_name=dash_name,
356
+ package_tag_id=package_tag_id,
357
+ publish=publish,
358
+ sections=sections or [],
359
+ auth=auth,
360
+ icon=icon,
361
+ color=color,
362
+ hide_copyright=hide_copyright,
363
+ dash_global_config=dash_global_config,
364
+ config=config or {},
365
+ )
366
+
307
367
  @server.tool()
308
368
  def app_publish_verify(
309
369
  profile: str = DEFAULT_PROFILE,