@josephyan/qingflow-app-builder-mcp 0.2.0-beta.997 → 0.2.0-beta.999

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-builder-mcp@0.2.0-beta.997
6
+ npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.999
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.997 qingflow-app-builder-mcp
12
+ npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.999 qingflow-app-builder-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-builder-mcp",
3
- "version": "0.2.0-beta.997",
3
+ "version": "0.2.0-beta.999",
4
4
  "description": "Builder MCP for Qingflow app/package/system design and staged solution workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "0.2.0b997"
7
+ version = "0.2.0b999"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  __all__ = ["__version__"]
7
7
 
8
- _FALLBACK_VERSION = "0.2.0b997"
8
+ _FALLBACK_VERSION = "0.2.0b999"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -1,8 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import json
4
5
 
5
6
  from ..context import CliContext
7
+ from ..terminal_ui import SelectionOption, select_option
6
8
 
7
9
 
8
10
  def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
@@ -20,7 +22,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
20
22
  get_parser.set_defaults(handler=_handle_get, format_hint="workspace_get")
21
23
 
22
24
  select_parser = workspace_subparsers.add_parser("select", help="切换当前工作区")
23
- select_parser.add_argument("--ws-id", type=int, required=True)
25
+ select_parser.add_argument("--ws-id", type=int, default=0, help="不传时在交互终端中选择工作区")
24
26
  select_parser.set_defaults(handler=_handle_select, format_hint="workspace_get")
25
27
 
26
28
 
@@ -41,7 +43,68 @@ def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
41
43
 
42
44
 
43
45
  def _handle_select(args: argparse.Namespace, context: CliContext) -> dict:
46
+ if int(args.ws_id or 0) <= 0:
47
+ selected_ws_id = _choose_workspace_interactively(args, context)
48
+ if selected_ws_id is None:
49
+ raise RuntimeError(
50
+ json.dumps(
51
+ {
52
+ "category": "config",
53
+ "message": "workspace select requires --ws-id, or an interactive terminal to choose a workspace",
54
+ },
55
+ ensure_ascii=False,
56
+ )
57
+ )
58
+ args.ws_id = selected_ws_id
44
59
  return context.workspace.workspace_select(
45
60
  profile=args.profile,
46
61
  ws_id=int(args.ws_id),
47
62
  )
63
+
64
+
65
+ def _choose_workspace_interactively(args: argparse.Namespace, context: CliContext) -> int | None:
66
+ input_stream = getattr(args, "_stdin", None)
67
+ output_stream = getattr(args, "_stderr_stream", None)
68
+ if input_stream is None or output_stream is None:
69
+ return None
70
+ if not bool(getattr(input_stream, "isatty", lambda: False)()):
71
+ return None
72
+ page = context.workspace.workspace_list(
73
+ profile=args.profile,
74
+ page_num=1,
75
+ page_size=100,
76
+ include_external=False,
77
+ ).get("page")
78
+ items = page.get("list") if isinstance(page, dict) and isinstance(page.get("list"), list) else []
79
+ if not items:
80
+ return None
81
+ current_ws_id = None
82
+ sessions = getattr(context, "sessions", None)
83
+ if sessions is not None and hasattr(sessions, "get_profile"):
84
+ try:
85
+ session_profile = sessions.get_profile(args.profile)
86
+ except Exception:
87
+ session_profile = None
88
+ current_ws_id = getattr(session_profile, "selected_ws_id", None) if session_profile is not None else None
89
+ options: list[SelectionOption[int]] = []
90
+ for item in items:
91
+ if not isinstance(item, dict):
92
+ continue
93
+ ws_id = int(item.get("wsId") or 0)
94
+ if ws_id <= 0:
95
+ continue
96
+ workspace_name = str(item.get("workspaceName") or item.get("wsName") or f"Workspace {ws_id}")
97
+ remark = str(item.get("remark") or "").strip()
98
+ label = workspace_name
99
+ if remark:
100
+ label = f"{workspace_name} - {remark}"
101
+ hint = f"ws_id={ws_id}"
102
+ if current_ws_id == ws_id:
103
+ hint += " · 当前"
104
+ options.append(SelectionOption(value=ws_id, label=label, hint=hint))
105
+ return select_option(
106
+ title="选择工作区",
107
+ options=options,
108
+ input_stream=input_stream,
109
+ output_stream=output_stream,
110
+ )
@@ -44,6 +44,9 @@ def run(
44
44
  args = parser.parse_args(normalized_argv)
45
45
  except SystemExit as exc:
46
46
  return int(exc.code or 0)
47
+ setattr(args, "_stdin", sys.stdin)
48
+ setattr(args, "_stdout_stream", out)
49
+ setattr(args, "_stderr_stream", err)
47
50
  handler = getattr(args, "handler", None)
48
51
  if handler is None:
49
52
  parser.print_help(out)
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import select
5
+ import shutil
6
+ from dataclasses import dataclass
7
+ from typing import Generic, Sequence, TextIO, TypeVar
8
+
9
+ try:
10
+ import termios
11
+ import tty
12
+ except ImportError: # pragma: no cover - non-POSIX fallback
13
+ termios = None
14
+ tty = None
15
+
16
+
17
+ T = TypeVar("T")
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class SelectionOption(Generic[T]):
22
+ value: T
23
+ label: str
24
+ hint: str | None = None
25
+
26
+
27
+ def select_option(
28
+ *,
29
+ title: str,
30
+ options: Sequence[SelectionOption[T]],
31
+ input_stream: TextIO,
32
+ output_stream: TextIO,
33
+ page_size: int = 8,
34
+ ) -> T | None:
35
+ if not options:
36
+ return None
37
+ if len(options) == 1:
38
+ return options[0].value
39
+ if not _supports_raw_selection(input_stream=input_stream, output_stream=output_stream):
40
+ return _select_option_via_prompt(title=title, options=options, input_stream=input_stream, output_stream=output_stream)
41
+ return _select_option_via_raw_terminal(
42
+ title=title,
43
+ options=options,
44
+ input_stream=input_stream,
45
+ output_stream=output_stream,
46
+ page_size=max(3, page_size),
47
+ )
48
+
49
+
50
+ def _supports_raw_selection(*, input_stream: TextIO, output_stream: TextIO) -> bool:
51
+ if termios is None or tty is None:
52
+ return False
53
+ if not bool(getattr(input_stream, "isatty", lambda: False)()) or not bool(getattr(output_stream, "isatty", lambda: False)()):
54
+ return False
55
+ return hasattr(input_stream, "fileno")
56
+
57
+
58
+ def _select_option_via_prompt(
59
+ *,
60
+ title: str,
61
+ options: Sequence[SelectionOption[T]],
62
+ input_stream: TextIO,
63
+ output_stream: TextIO,
64
+ ) -> T | None:
65
+ output_stream.write(title + "\n")
66
+ for index, option in enumerate(options, start=1):
67
+ suffix = f" ({option.hint})" if option.hint else ""
68
+ output_stream.write(f"{index}. {option.label}{suffix}\n")
69
+ output_stream.write("请输入编号并回车,留空取消: ")
70
+ output_stream.flush()
71
+ line = input_stream.readline()
72
+ selected = str(line or "").strip()
73
+ if not selected:
74
+ return None
75
+ if not selected.isdigit():
76
+ return None
77
+ index = int(selected) - 1
78
+ if index < 0 or index >= len(options):
79
+ return None
80
+ return options[index].value
81
+
82
+
83
+ def _select_option_via_raw_terminal(
84
+ *,
85
+ title: str,
86
+ options: Sequence[SelectionOption[T]],
87
+ input_stream: TextIO,
88
+ output_stream: TextIO,
89
+ page_size: int,
90
+ ) -> T | None:
91
+ fd = input_stream.fileno()
92
+ original_mode = termios.tcgetattr(fd)
93
+ selected_index = 0
94
+ output_stream.write("\x1b[?1049h\x1b[?25l")
95
+ output_stream.flush()
96
+ try:
97
+ tty.setraw(fd)
98
+ while True:
99
+ _render_options(
100
+ title=title,
101
+ options=options,
102
+ selected_index=selected_index,
103
+ output_stream=output_stream,
104
+ page_size=page_size,
105
+ )
106
+ key = _read_key(input_stream)
107
+ if key in ("\r", "\n"):
108
+ return options[selected_index].value
109
+ if key in ("\x03", "\x1b", "q", "Q"):
110
+ return None
111
+ if key in ("\x1b[A", "k", "K"):
112
+ selected_index = (selected_index - 1) % len(options)
113
+ continue
114
+ if key in ("\x1b[B", "j", "J"):
115
+ selected_index = (selected_index + 1) % len(options)
116
+ finally:
117
+ termios.tcsetattr(fd, termios.TCSADRAIN, original_mode)
118
+ output_stream.write("\x1b[?25h\x1b[?1049l")
119
+ output_stream.flush()
120
+
121
+
122
+ def _render_options(
123
+ *,
124
+ title: str,
125
+ options: Sequence[SelectionOption[object]],
126
+ selected_index: int,
127
+ output_stream: TextIO,
128
+ page_size: int,
129
+ ) -> None:
130
+ terminal_width = shutil.get_terminal_size((100, 20)).columns
131
+ total = len(options)
132
+ page_start = max(0, min(selected_index - page_size // 2, max(total - page_size, 0)))
133
+ visible = options[page_start: page_start + page_size]
134
+ lines = [
135
+ title,
136
+ "↑/↓ 或 j/k 选择,Enter 确认,q / Esc 取消",
137
+ "",
138
+ ]
139
+ for offset, option in enumerate(visible, start=page_start):
140
+ marker = ">" if offset == selected_index else " "
141
+ suffix = f" [{option.hint}]" if option.hint else ""
142
+ lines.append(_truncate_line(f"{marker} {option.label}{suffix}", width=terminal_width))
143
+ if total > page_size:
144
+ lines.append("")
145
+ lines.append(f"{selected_index + 1}/{total}")
146
+ output_stream.write("\x1b[2J\x1b[H")
147
+ output_stream.write("\n".join(lines))
148
+ output_stream.flush()
149
+
150
+
151
+ def _truncate_line(text: str, *, width: int) -> str:
152
+ if width <= 0 or len(text) <= width:
153
+ return text
154
+ if width <= 1:
155
+ return text[:width]
156
+ return text[: width - 1] + "…"
157
+
158
+
159
+ def _read_key(input_stream: TextIO) -> str:
160
+ first = input_stream.read(1)
161
+ if first != "\x1b":
162
+ return first
163
+ chunks = [first]
164
+ fd = input_stream.fileno()
165
+ while select.select([fd], [], [], 0.02)[0]:
166
+ chunk = os.read(fd, 1).decode("utf-8", errors="ignore")
167
+ if not chunk:
168
+ break
169
+ chunks.append(chunk)
170
+ if len(chunks) >= 3:
171
+ break
172
+ return "".join(chunks)
@@ -1148,53 +1148,61 @@ class TaskContextTools(ToolBase):
1148
1148
  "reported_total": matched_total,
1149
1149
  }
1150
1150
 
1151
- def _resolve_todo_task_locator(self, *, profile: str, task_id: Any) -> dict[str, Any]:
1151
+ def _resolve_task_locator_by_task_id(self, *, profile: str, task_id: Any) -> dict[str, Any]:
1152
1152
  task_id_text = normalize_positive_id_text(task_id, field_name="task_id")
1153
- page = 1
1153
+ searched_task_boxes = ("todo", "initiated", "cc", "done")
1154
+ incomplete_task_boxes: list[str] = []
1154
1155
  page_size = 100
1155
- page_amount: int | None = None
1156
- while True:
1157
- response = self._list_normalized_task_items(
1158
- profile=profile,
1159
- task_box="todo",
1160
- flow_status="all",
1161
- app_key=None,
1162
- workflow_node_id=None,
1163
- query=None,
1164
- page=page,
1165
- page_size=page_size,
1156
+ for task_box in searched_task_boxes:
1157
+ page = 1
1158
+ page_amount: int | None = None
1159
+ while True:
1160
+ response = self._list_normalized_task_items(
1161
+ profile=profile,
1162
+ task_box=task_box,
1163
+ flow_status="all",
1164
+ app_key=None,
1165
+ workflow_node_id=None,
1166
+ query=None,
1167
+ page=page,
1168
+ page_size=page_size,
1169
+ )
1170
+ items = response.get("items") if isinstance(response.get("items"), list) else []
1171
+ for item in items:
1172
+ if not isinstance(item, dict) or not ids_equal(item.get("task_id"), task_id_text):
1173
+ continue
1174
+ app_key = str(item.get("app_key") or "").strip()
1175
+ record_id = stringify_backend_id(item.get("record_id"))
1176
+ workflow_node_id = int(item.get("workflow_node_id") or 0)
1177
+ if not app_key or record_id is None or workflow_node_id <= 0:
1178
+ incomplete_task_boxes.append(task_box)
1179
+ continue
1180
+ return {
1181
+ "task_id": task_id_text,
1182
+ "task_box": task_box,
1183
+ "app_key": app_key,
1184
+ "record_id": record_id,
1185
+ "workflow_node_id": workflow_node_id,
1186
+ }
1187
+ if page_amount is None:
1188
+ coerced_page_amount = _coerce_count(response.get("page_amount"))
1189
+ if coerced_page_amount is not None and coerced_page_amount > 0:
1190
+ page_amount = coerced_page_amount
1191
+ if page_amount is not None and page >= page_amount:
1192
+ break
1193
+ if not items or len(items) < page_size:
1194
+ break
1195
+ page += 1
1196
+ if incomplete_task_boxes:
1197
+ searched = ", ".join(incomplete_task_boxes)
1198
+ raise_tool_error(
1199
+ QingflowApiError.config_error(
1200
+ f"task_id={task_id_text} resolved to an incomplete task locator in task_box={searched}; please refresh the task list and retry"
1201
+ )
1166
1202
  )
1167
- items = response.get("items") if isinstance(response.get("items"), list) else []
1168
- for item in items:
1169
- if not isinstance(item, dict) or not ids_equal(item.get("task_id"), task_id_text):
1170
- continue
1171
- app_key = str(item.get("app_key") or "").strip()
1172
- record_id = stringify_backend_id(item.get("record_id"))
1173
- workflow_node_id = int(item.get("workflow_node_id") or 0)
1174
- if not app_key or record_id is None or workflow_node_id <= 0:
1175
- raise_tool_error(
1176
- QingflowApiError.config_error(
1177
- f"task_id={task_id_text} resolved to an incomplete task locator; please refresh the todo list and retry"
1178
- )
1179
- )
1180
- return {
1181
- "task_id": task_id_text,
1182
- "app_key": app_key,
1183
- "record_id": record_id,
1184
- "workflow_node_id": workflow_node_id,
1185
- }
1186
- if page_amount is None:
1187
- coerced_page_amount = _coerce_count(response.get("page_amount"))
1188
- if coerced_page_amount is not None and coerced_page_amount > 0:
1189
- page_amount = coerced_page_amount
1190
- if page_amount is not None and page >= page_amount:
1191
- break
1192
- if not items or len(items) < page_size:
1193
- break
1194
- page += 1
1195
1203
  raise_tool_error(
1196
1204
  QingflowApiError.config_error(
1197
- f"task_id={task_id_text} was not found in the current todo list; task context tools currently resolve actionable todo tasks by task_id"
1205
+ f"task_id={task_id_text} was not found in the current visible task boxes (todo, initiated, cc, done)"
1198
1206
  )
1199
1207
  )
1200
1208
 
@@ -1212,7 +1220,7 @@ class TaskContextTools(ToolBase):
1212
1220
  resolved_record_id: int
1213
1221
  resolved_workflow_node_id: int
1214
1222
  if task_id_text is not None:
1215
- locator = self._resolve_todo_task_locator(profile=profile, task_id=task_id_text)
1223
+ locator = self._resolve_task_locator_by_task_id(profile=profile, task_id=task_id_text)
1216
1224
  resolved_app_key = str(locator["app_key"])
1217
1225
  resolved_record_id = normalize_positive_id_int(locator["record_id"], field_name="record_id")
1218
1226
  resolved_workflow_node_id = int(locator["workflow_node_id"])