@qingflow-tech/qingflow-app-builder-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.
- package/README.md +2 -2
- package/docs/local-agent-install.md +9 -3
- package/npm/lib/runtime.mjs +10 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-builder/SKILL.md +88 -184
- package/skills/qingflow-app-builder/references/create-app.md +15 -34
- package/skills/qingflow-app-builder/references/gotchas.md +3 -3
- package/skills/qingflow-app-builder/references/solution-playbooks.md +1 -2
- package/skills/qingflow-app-builder/references/tool-selection.md +9 -10
- package/src/qingflow_mcp/__init__.py +33 -1
- package/src/qingflow_mcp/backend_client.py +109 -0
- package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
- package/src/qingflow_mcp/builder_facade/models.py +58 -9
- package/src/qingflow_mcp/builder_facade/service.py +1711 -240
- package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
- package/src/qingflow_mcp/cli/commands/app.py +47 -1
- package/src/qingflow_mcp/cli/commands/auth.py +63 -0
- package/src/qingflow_mcp/cli/commands/builder.py +11 -3
- package/src/qingflow_mcp/cli/commands/exports.py +111 -0
- package/src/qingflow_mcp/cli/commands/record.py +5 -5
- package/src/qingflow_mcp/cli/commands/task.py +701 -27
- package/src/qingflow_mcp/cli/commands/workspace.py +84 -0
- package/src/qingflow_mcp/cli/context.py +3 -0
- package/src/qingflow_mcp/cli/formatters.py +424 -50
- package/src/qingflow_mcp/cli/interaction.py +72 -0
- package/src/qingflow_mcp/cli/main.py +11 -1
- package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
- package/src/qingflow_mcp/cli/terminal_ui.py +218 -0
- package/src/qingflow_mcp/config.py +1 -1
- package/src/qingflow_mcp/errors.py +4 -4
- package/src/qingflow_mcp/export_store.py +14 -0
- package/src/qingflow_mcp/id_utils.py +49 -0
- package/src/qingflow_mcp/public_surface.py +16 -1
- package/src/qingflow_mcp/response_trim.py +394 -9
- package/src/qingflow_mcp/server.py +26 -0
- package/src/qingflow_mcp/server_app_builder.py +15 -1
- package/src/qingflow_mcp/server_app_user.py +113 -0
- package/src/qingflow_mcp/session_store.py +126 -21
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +2 -2
- package/src/qingflow_mcp/solution/executor.py +2 -2
- package/src/qingflow_mcp/tools/ai_builder_tools.py +107 -34
- package/src/qingflow_mcp/tools/app_tools.py +1 -0
- package/src/qingflow_mcp/tools/auth_tools.py +243 -9
- package/src/qingflow_mcp/tools/base.py +6 -2
- package/src/qingflow_mcp/tools/code_block_tools.py +2 -2
- package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
- package/src/qingflow_mcp/tools/export_tools.py +1565 -0
- package/src/qingflow_mcp/tools/import_tools.py +78 -4
- package/src/qingflow_mcp/tools/record_tools.py +551 -165
- package/src/qingflow_mcp/tools/resource_read_tools.py +154 -33
- package/src/qingflow_mcp/tools/task_context_tools.py +917 -141
- package/src/qingflow_mcp/tools/workspace_tools.py +141 -0
|
@@ -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)
|
|
@@ -53,6 +56,8 @@ def run(
|
|
|
53
56
|
if not bool(args.json):
|
|
54
57
|
_emit_cli_effective_context_notice(args, context, stream=err)
|
|
55
58
|
result = handler(args, context)
|
|
59
|
+
except SystemExit as exc:
|
|
60
|
+
return int(exc.code or 0)
|
|
56
61
|
except RuntimeError as exc:
|
|
57
62
|
payload = trim_error_response(_parse_error_payload(exc))
|
|
58
63
|
return _emit_error(payload, json_mode=bool(args.json), stdout=out, stderr=err)
|
|
@@ -152,6 +157,7 @@ def _emit_cli_effective_context_notice(args: argparse.Namespace, context: CliCon
|
|
|
152
157
|
spec = cli_public_tool_spec_from_namespace(args)
|
|
153
158
|
if spec is None or not spec.cli_show_effective_context:
|
|
154
159
|
return
|
|
160
|
+
hide_context_line = bool(getattr(args, "hide_effective_context_line", False))
|
|
155
161
|
sessions = getattr(context, "sessions", None)
|
|
156
162
|
if sessions is None or not hasattr(sessions, "get_profile"):
|
|
157
163
|
return
|
|
@@ -168,9 +174,13 @@ def _emit_cli_effective_context_notice(args: argparse.Namespace, context: CliCon
|
|
|
168
174
|
workspace_label = f"{workspace_name} ({workspace_id})"
|
|
169
175
|
else:
|
|
170
176
|
workspace_label = str(workspace_id)
|
|
171
|
-
lines
|
|
177
|
+
lines: list[str] = []
|
|
178
|
+
if not hide_context_line:
|
|
179
|
+
lines.append(f"Context: profile={profile_name} workspace={workspace_label}")
|
|
172
180
|
if spec.cli_context_write and profile_name == "default":
|
|
173
181
|
lines.append("Warning: using default profile for a workspace-sensitive write command")
|
|
182
|
+
if not lines:
|
|
183
|
+
return
|
|
174
184
|
stream.write("\n".join(lines) + "\n")
|
|
175
185
|
|
|
176
186
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from Crypto.Cipher import PKCS1_v1_5
|
|
8
|
+
from Crypto.PublicKey import RSA
|
|
9
|
+
|
|
10
|
+
from ..backend_client import BackendClient
|
|
11
|
+
from ..config import get_default_base_url, get_timeout_seconds, normalize_base_url
|
|
12
|
+
from ..errors import QingflowApiError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class QingflowNativeLoginResult:
|
|
17
|
+
token: str
|
|
18
|
+
user_info: dict[str, Any]
|
|
19
|
+
login_token: str | None = None
|
|
20
|
+
flow: str = "qingflow_password"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class QingflowNativeLoginHelper:
|
|
24
|
+
def __init__(self, *, backend: BackendClient | None = None) -> None:
|
|
25
|
+
self._owns_backend = backend is None
|
|
26
|
+
self._backend = backend or BackendClient(timeout=get_timeout_seconds())
|
|
27
|
+
|
|
28
|
+
def close(self) -> None:
|
|
29
|
+
if self._owns_backend:
|
|
30
|
+
self._backend.close()
|
|
31
|
+
|
|
32
|
+
def login_with_password(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
base_url: str | None,
|
|
36
|
+
email: str,
|
|
37
|
+
password: str,
|
|
38
|
+
) -> QingflowNativeLoginResult:
|
|
39
|
+
normalized_base_url = normalize_base_url(base_url) or get_default_base_url()
|
|
40
|
+
normalized_email = str(email or "").strip()
|
|
41
|
+
normalized_password = str(password or "")
|
|
42
|
+
if not normalized_base_url:
|
|
43
|
+
raise QingflowApiError.config_error("base_url is required or configure default_base_url")
|
|
44
|
+
if not normalized_email:
|
|
45
|
+
raise QingflowApiError.config_error("email is required for Qingflow account login")
|
|
46
|
+
if not normalized_password:
|
|
47
|
+
raise QingflowApiError.config_error("password is required for Qingflow account login")
|
|
48
|
+
|
|
49
|
+
pubkey_payload = self._backend.public_request("GET", normalized_base_url, "/user/pubkey", qf_version=None)
|
|
50
|
+
pubkey = self._extract_pubkey(pubkey_payload)
|
|
51
|
+
encrypted_password = _encrypt_password(normalized_password, pubkey)
|
|
52
|
+
login_payload = self._backend.public_request(
|
|
53
|
+
"POST",
|
|
54
|
+
normalized_base_url,
|
|
55
|
+
"/user/login",
|
|
56
|
+
json_body={"email": normalized_email, "password": encrypted_password},
|
|
57
|
+
qf_version=None,
|
|
58
|
+
)
|
|
59
|
+
if not isinstance(login_payload, dict):
|
|
60
|
+
raise QingflowApiError(category="auth", message="Qingflow login did not return a valid response")
|
|
61
|
+
|
|
62
|
+
token = str(login_payload.get("token") or "").strip()
|
|
63
|
+
login_token = str(login_payload.get("loginToken") or "").strip() or None
|
|
64
|
+
if not token:
|
|
65
|
+
if login_token:
|
|
66
|
+
raise QingflowApiError(
|
|
67
|
+
category="auth",
|
|
68
|
+
message=(
|
|
69
|
+
"Qingflow account login requires additional security verification. "
|
|
70
|
+
"CLI password login currently does not complete the loginToken verification step."
|
|
71
|
+
),
|
|
72
|
+
details={"login_token_present": True},
|
|
73
|
+
)
|
|
74
|
+
raise QingflowApiError(
|
|
75
|
+
category="auth",
|
|
76
|
+
message="Qingflow login succeeded but did not return a token",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
user_info = login_payload.get("userInfo")
|
|
80
|
+
if not isinstance(user_info, dict):
|
|
81
|
+
user_info = {}
|
|
82
|
+
return QingflowNativeLoginResult(
|
|
83
|
+
token=token,
|
|
84
|
+
login_token=login_token,
|
|
85
|
+
user_info=user_info,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def _extract_pubkey(self, payload: Any) -> str:
|
|
89
|
+
if not isinstance(payload, dict):
|
|
90
|
+
raise QingflowApiError(category="auth", message="Qingflow pubkey response is invalid")
|
|
91
|
+
pubkey = str(payload.get("pubkey") or "").strip()
|
|
92
|
+
if not pubkey:
|
|
93
|
+
raise QingflowApiError(category="auth", message="Qingflow pubkey response did not include pubkey")
|
|
94
|
+
return pubkey
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def login_with_qingflow_password(
|
|
98
|
+
*,
|
|
99
|
+
base_url: str | None,
|
|
100
|
+
email: str,
|
|
101
|
+
password: str,
|
|
102
|
+
) -> QingflowNativeLoginResult:
|
|
103
|
+
helper = QingflowNativeLoginHelper()
|
|
104
|
+
try:
|
|
105
|
+
return helper.login_with_password(base_url=base_url, email=email, password=password)
|
|
106
|
+
finally:
|
|
107
|
+
helper.close()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _encrypt_password(password: str, pubkey: str) -> str:
|
|
111
|
+
public_key = RSA.import_key(
|
|
112
|
+
"-----BEGIN PUBLIC KEY-----\n" + pubkey.strip() + "\n-----END PUBLIC KEY-----\n"
|
|
113
|
+
)
|
|
114
|
+
cipher = PKCS1_v1_5.new(public_key)
|
|
115
|
+
encrypted = cipher.encrypt(password.encode("utf-8"))
|
|
116
|
+
return base64.b64encode(encrypted).decode("ascii")
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import select
|
|
5
|
+
import shutil
|
|
6
|
+
import textwrap
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Generic, Sequence, TextIO, TypeVar
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import termios
|
|
12
|
+
import tty
|
|
13
|
+
except ImportError: # pragma: no cover - non-POSIX fallback
|
|
14
|
+
termios = None
|
|
15
|
+
tty = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
RAW_TTY_NEWLINE = "\r\n"
|
|
20
|
+
# Give terminal escape sequences a slightly roomier window so arrow keys
|
|
21
|
+
# still parse correctly when an outer Node/npm launcher adds a bit of PTY lag.
|
|
22
|
+
ESCAPE_SEQUENCE_TIMEOUT_SECONDS = 0.2
|
|
23
|
+
ESCAPE_SEQUENCE_FOLLOWUP_TIMEOUT_SECONDS = 0.02
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class SelectionOption(Generic[T]):
|
|
28
|
+
value: T
|
|
29
|
+
label: str
|
|
30
|
+
hint: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def select_option(
|
|
34
|
+
*,
|
|
35
|
+
title: str,
|
|
36
|
+
options: Sequence[SelectionOption[T]],
|
|
37
|
+
input_stream: TextIO,
|
|
38
|
+
output_stream: TextIO,
|
|
39
|
+
page_size: int = 8,
|
|
40
|
+
) -> T | None:
|
|
41
|
+
if not options:
|
|
42
|
+
return None
|
|
43
|
+
if len(options) == 1:
|
|
44
|
+
return options[0].value
|
|
45
|
+
if not _supports_raw_selection(input_stream=input_stream, output_stream=output_stream):
|
|
46
|
+
return _select_option_via_prompt(title=title, options=options, input_stream=input_stream, output_stream=output_stream)
|
|
47
|
+
return _select_option_via_raw_terminal(
|
|
48
|
+
title=title,
|
|
49
|
+
options=options,
|
|
50
|
+
input_stream=input_stream,
|
|
51
|
+
output_stream=output_stream,
|
|
52
|
+
page_size=max(3, page_size),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _supports_raw_selection(*, input_stream: TextIO, output_stream: TextIO) -> bool:
|
|
57
|
+
if termios is None or tty is None:
|
|
58
|
+
return False
|
|
59
|
+
if not bool(getattr(input_stream, "isatty", lambda: False)()) or not bool(getattr(output_stream, "isatty", lambda: False)()):
|
|
60
|
+
return False
|
|
61
|
+
return hasattr(input_stream, "fileno")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _select_option_via_prompt(
|
|
65
|
+
*,
|
|
66
|
+
title: str,
|
|
67
|
+
options: Sequence[SelectionOption[T]],
|
|
68
|
+
input_stream: TextIO,
|
|
69
|
+
output_stream: TextIO,
|
|
70
|
+
) -> T | None:
|
|
71
|
+
output_stream.write(title + "\n")
|
|
72
|
+
for index, option in enumerate(options, start=1):
|
|
73
|
+
suffix = f" ({option.hint})" if option.hint else ""
|
|
74
|
+
output_stream.write(f"{index}. {option.label}{suffix}\n")
|
|
75
|
+
output_stream.write("请输入编号并回车,留空取消: ")
|
|
76
|
+
output_stream.flush()
|
|
77
|
+
line = input_stream.readline()
|
|
78
|
+
selected = str(line or "").strip()
|
|
79
|
+
if not selected:
|
|
80
|
+
return None
|
|
81
|
+
if not selected.isdigit():
|
|
82
|
+
return None
|
|
83
|
+
index = int(selected) - 1
|
|
84
|
+
if index < 0 or index >= len(options):
|
|
85
|
+
return None
|
|
86
|
+
return options[index].value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _select_option_via_raw_terminal(
|
|
90
|
+
*,
|
|
91
|
+
title: str,
|
|
92
|
+
options: Sequence[SelectionOption[T]],
|
|
93
|
+
input_stream: TextIO,
|
|
94
|
+
output_stream: TextIO,
|
|
95
|
+
page_size: int,
|
|
96
|
+
) -> T | None:
|
|
97
|
+
fd = input_stream.fileno()
|
|
98
|
+
original_mode = termios.tcgetattr(fd)
|
|
99
|
+
selected_index = 0
|
|
100
|
+
output_stream.write("\x1b[?1049h\x1b[?25l")
|
|
101
|
+
output_stream.flush()
|
|
102
|
+
try:
|
|
103
|
+
tty.setraw(fd)
|
|
104
|
+
while True:
|
|
105
|
+
_render_options(
|
|
106
|
+
title=title,
|
|
107
|
+
options=options,
|
|
108
|
+
selected_index=selected_index,
|
|
109
|
+
output_stream=output_stream,
|
|
110
|
+
page_size=page_size,
|
|
111
|
+
)
|
|
112
|
+
key = _read_key(input_stream)
|
|
113
|
+
if key in ("\r", "\n"):
|
|
114
|
+
return options[selected_index].value
|
|
115
|
+
if key in ("\x03", "\x1b", "q", "Q"):
|
|
116
|
+
return None
|
|
117
|
+
if key in ("\x1b[A", "k", "K"):
|
|
118
|
+
selected_index = (selected_index - 1) % len(options)
|
|
119
|
+
continue
|
|
120
|
+
if key in ("\x1b[B", "j", "J"):
|
|
121
|
+
selected_index = (selected_index + 1) % len(options)
|
|
122
|
+
finally:
|
|
123
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, original_mode)
|
|
124
|
+
output_stream.write("\x1b[?25h\x1b[?1049l")
|
|
125
|
+
output_stream.flush()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _render_options(
|
|
129
|
+
*,
|
|
130
|
+
title: str,
|
|
131
|
+
options: Sequence[SelectionOption[object]],
|
|
132
|
+
selected_index: int,
|
|
133
|
+
output_stream: TextIO,
|
|
134
|
+
page_size: int,
|
|
135
|
+
) -> None:
|
|
136
|
+
terminal_width = shutil.get_terminal_size((100, 20)).columns
|
|
137
|
+
total = len(options)
|
|
138
|
+
page_start = max(0, min(selected_index - page_size // 2, max(total - page_size, 0)))
|
|
139
|
+
visible = options[page_start: page_start + page_size]
|
|
140
|
+
lines = _render_multiline_text(title, width=terminal_width)
|
|
141
|
+
lines.extend(
|
|
142
|
+
[
|
|
143
|
+
"↑/↓ 或 j/k 选择,Enter 确认,q / Esc 取消",
|
|
144
|
+
"",
|
|
145
|
+
]
|
|
146
|
+
)
|
|
147
|
+
for offset, option in enumerate(visible, start=page_start):
|
|
148
|
+
marker = ">" if offset == selected_index else " "
|
|
149
|
+
suffix = f" [{option.hint}]" if option.hint else ""
|
|
150
|
+
lines.append(_truncate_line(f"{marker} {option.label}{suffix}", width=terminal_width))
|
|
151
|
+
if total > page_size:
|
|
152
|
+
lines.append("")
|
|
153
|
+
lines.append(f"{selected_index + 1}/{total}")
|
|
154
|
+
output_stream.write("\x1b[2J\x1b[H")
|
|
155
|
+
output_stream.write(RAW_TTY_NEWLINE.join(lines))
|
|
156
|
+
output_stream.flush()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _truncate_line(text: str, *, width: int) -> str:
|
|
160
|
+
if width <= 0 or len(text) <= width:
|
|
161
|
+
return text
|
|
162
|
+
if width <= 1:
|
|
163
|
+
return text[:width]
|
|
164
|
+
return text[: width - 1] + "…"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _render_multiline_text(text: str, *, width: int) -> list[str]:
|
|
168
|
+
parts = text.splitlines() or [text]
|
|
169
|
+
rendered: list[str] = []
|
|
170
|
+
wrap_width = max(1, width)
|
|
171
|
+
for part in parts:
|
|
172
|
+
if not part:
|
|
173
|
+
rendered.append("")
|
|
174
|
+
continue
|
|
175
|
+
initial_indent = ""
|
|
176
|
+
subsequent_indent = ""
|
|
177
|
+
stripped = part.lstrip()
|
|
178
|
+
if stripped.startswith("- "):
|
|
179
|
+
leading_spaces = len(part) - len(stripped)
|
|
180
|
+
initial_indent = part[:leading_spaces] + "- "
|
|
181
|
+
subsequent_indent = part[:leading_spaces] + " "
|
|
182
|
+
content = stripped[2:]
|
|
183
|
+
else:
|
|
184
|
+
content = part
|
|
185
|
+
wrapped = textwrap.wrap(
|
|
186
|
+
content,
|
|
187
|
+
width=wrap_width,
|
|
188
|
+
initial_indent=initial_indent,
|
|
189
|
+
subsequent_indent=subsequent_indent,
|
|
190
|
+
replace_whitespace=False,
|
|
191
|
+
drop_whitespace=False,
|
|
192
|
+
break_long_words=True,
|
|
193
|
+
)
|
|
194
|
+
rendered.extend(wrapped or [""])
|
|
195
|
+
return rendered
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _read_key(input_stream: TextIO) -> str:
|
|
199
|
+
fd = input_stream.fileno()
|
|
200
|
+
first_bytes = os.read(fd, 1)
|
|
201
|
+
if not first_bytes:
|
|
202
|
+
return ""
|
|
203
|
+
first = first_bytes.decode("utf-8", errors="ignore")
|
|
204
|
+
if first != "\x1b":
|
|
205
|
+
return first
|
|
206
|
+
chunks = [first]
|
|
207
|
+
if not select.select([fd], [], [], ESCAPE_SEQUENCE_TIMEOUT_SECONDS)[0]:
|
|
208
|
+
return first
|
|
209
|
+
while True:
|
|
210
|
+
chunk = os.read(fd, 1).decode("utf-8", errors="ignore")
|
|
211
|
+
if not chunk:
|
|
212
|
+
break
|
|
213
|
+
chunks.append(chunk)
|
|
214
|
+
if len(chunks) >= 3:
|
|
215
|
+
break
|
|
216
|
+
if not select.select([fd], [], [], ESCAPE_SEQUENCE_FOLLOWUP_TIMEOUT_SECONDS)[0]:
|
|
217
|
+
break
|
|
218
|
+
return "".join(chunks)
|
|
@@ -22,7 +22,7 @@ DEFAULT_REPOSITORY_AUTHOR_NAME = "qingflow-mcp"
|
|
|
22
22
|
DEFAULT_REPOSITORY_AUTHOR_EMAIL = "qingflow-mcp@local.invalid"
|
|
23
23
|
DEFAULT_REPOSITORY_INTERNAL_SHARE_TOKEN_KEY = "tokenKey"
|
|
24
24
|
DEFAULT_CREDIT_USAGE_RECORD_PATH = "/user/credit/usage"
|
|
25
|
-
DEFAULT_MCPORTER_CONFIG_PATH = "
|
|
25
|
+
DEFAULT_MCPORTER_CONFIG_PATH = "~/.openclaw/workspace/config/mcporter.json"
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def get_mcp_home() -> Path:
|
|
@@ -43,19 +43,19 @@ class QingflowApiError(Exception):
|
|
|
43
43
|
def auth_required(cls, profile: str) -> "QingflowApiError":
|
|
44
44
|
return cls(
|
|
45
45
|
category="auth",
|
|
46
|
-
message=f"Profile '{profile}' is not logged in. Run auth_use_credential first.",
|
|
46
|
+
message=f"Profile '{profile}' is not logged in. Run auth login or auth_use_credential first.",
|
|
47
47
|
)
|
|
48
48
|
|
|
49
49
|
@classmethod
|
|
50
50
|
def workspace_not_selected(cls, profile: str) -> "QingflowApiError":
|
|
51
51
|
return cls(
|
|
52
52
|
category="workspace",
|
|
53
|
-
message=f"WORKSPACE_NOT_SELECTED: profile '{profile}' has no workspace from auth context. Re-run auth_use_credential.",
|
|
53
|
+
message=f"WORKSPACE_NOT_SELECTED: profile '{profile}' has no workspace from auth context. Re-run auth login or auth_use_credential.",
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
@classmethod
|
|
57
|
-
def config_error(cls, message: str) -> "QingflowApiError":
|
|
58
|
-
return cls(category="config", message=message)
|
|
57
|
+
def config_error(cls, message: str, *, details: JSONObject | None = None) -> "QingflowApiError":
|
|
58
|
+
return cls(category="config", message=message, details=details)
|
|
59
59
|
|
|
60
60
|
@classmethod
|
|
61
61
|
def not_supported(cls, message: str) -> "QingflowApiError":
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .import_store import _JsonEntryStore, _store_dir
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ExportJobStore(_JsonEntryStore):
|
|
10
|
+
def __init__(self, base_dir: Path | None = None, *, ttl_seconds: int = 24 * 3600) -> None:
|
|
11
|
+
super().__init__(
|
|
12
|
+
base_dir=base_dir or _store_dir("QINGFLOW_MCP_EXPORT_JOB_HOME", "export-jobs"),
|
|
13
|
+
ttl=timedelta(seconds=ttl_seconds),
|
|
14
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .errors import QingflowApiError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
JS_MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def stringify_backend_id(value: Any) -> str | None:
|
|
12
|
+
"""Return an exact public id string for backend-originated identifiers."""
|
|
13
|
+
if value in (None, ""):
|
|
14
|
+
return None
|
|
15
|
+
if isinstance(value, bool):
|
|
16
|
+
return None
|
|
17
|
+
text = str(value).strip()
|
|
18
|
+
return text or None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_positive_id_text(value: Any, *, field_name: str) -> str:
|
|
22
|
+
"""Normalize a user-supplied id while rejecting JS-unsafe numeric input."""
|
|
23
|
+
if value in (None, "") or isinstance(value, bool):
|
|
24
|
+
raise QingflowApiError.config_error(f"{field_name} must be positive")
|
|
25
|
+
if isinstance(value, int):
|
|
26
|
+
if value <= 0:
|
|
27
|
+
raise QingflowApiError.config_error(f"{field_name} must be positive")
|
|
28
|
+
if value > JS_MAX_SAFE_INTEGER:
|
|
29
|
+
raise QingflowApiError.config_error(
|
|
30
|
+
f"{field_name} exceeds JavaScript's safe integer range; pass it as a string to avoid precision loss"
|
|
31
|
+
)
|
|
32
|
+
return str(value)
|
|
33
|
+
if isinstance(value, str):
|
|
34
|
+
text = value.strip()
|
|
35
|
+
if not text.isdecimal() or int(text) <= 0:
|
|
36
|
+
raise QingflowApiError.config_error(f"{field_name} must be a positive integer string")
|
|
37
|
+
return text
|
|
38
|
+
raise QingflowApiError.config_error(f"{field_name} must be a positive integer string")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize_positive_id_int(value: Any, *, field_name: str) -> int:
|
|
42
|
+
"""Normalize an id to Python int after the public boundary preserves it as text."""
|
|
43
|
+
return int(normalize_positive_id_text(value, field_name=field_name))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ids_equal(left: Any, right: Any) -> bool:
|
|
47
|
+
left_text = stringify_backend_id(left)
|
|
48
|
+
right_text = stringify_backend_id(right)
|
|
49
|
+
return left_text is not None and right_text is not None and left_text == right_text
|
|
@@ -30,10 +30,13 @@ def tool_key(domain: str, tool_name: str) -> str:
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
33
|
+
PublicToolSpec(USER_DOMAIN, "auth_login", cli_route=("auth", "login"), mcp_public=False),
|
|
33
34
|
PublicToolSpec(USER_DOMAIN, "auth_use_credential", ("auth_use_credential",), ("auth", "use-credential")),
|
|
34
35
|
PublicToolSpec(USER_DOMAIN, "auth_whoami", ("auth_whoami",), ("auth", "whoami")),
|
|
35
36
|
PublicToolSpec(USER_DOMAIN, "auth_logout", ("auth_logout",), ("auth", "logout")),
|
|
36
37
|
PublicToolSpec(USER_DOMAIN, "workspace_list", ("workspace_list",), ("workspace", "list")),
|
|
38
|
+
PublicToolSpec(USER_DOMAIN, "workspace_get", ("workspace_get",), ("workspace", "get")),
|
|
39
|
+
PublicToolSpec(USER_DOMAIN, "workspace_select", ("workspace_select",), ("workspace", "select")),
|
|
37
40
|
PublicToolSpec(USER_DOMAIN, "app_list", ("app_list",), ("app", "list"), cli_show_effective_context=True),
|
|
38
41
|
PublicToolSpec(USER_DOMAIN, "app_search", ("app_search",), ("app", "search"), cli_show_effective_context=True),
|
|
39
42
|
PublicToolSpec(USER_DOMAIN, "app_get", ("app_get",), ("app", "get"), cli_show_effective_context=True),
|
|
@@ -88,11 +91,21 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
88
91
|
PublicToolSpec(USER_DOMAIN, "record_import_repair_local", ("record_import_repair_local",), ("import", "repair")),
|
|
89
92
|
PublicToolSpec(USER_DOMAIN, "record_import_start", ("record_import_start",), ("import", "start")),
|
|
90
93
|
PublicToolSpec(USER_DOMAIN, "record_import_status_get", ("record_import_status_get",), ("import", "status")),
|
|
94
|
+
PublicToolSpec(USER_DOMAIN, "record_export_start", ("record_export_start",), ("export", "start"), cli_show_effective_context=True),
|
|
95
|
+
PublicToolSpec(USER_DOMAIN, "record_export_status_get", ("record_export_status_get",), ("export", "status"), cli_show_effective_context=True),
|
|
96
|
+
PublicToolSpec(USER_DOMAIN, "record_export_get", ("record_export_get",), ("export", "get"), cli_show_effective_context=True),
|
|
97
|
+
PublicToolSpec(USER_DOMAIN, "record_export_direct", ("record_export_direct",), ("export", "direct"), cli_show_effective_context=True),
|
|
91
98
|
PublicToolSpec(USER_DOMAIN, "record_code_block_run", ("record_code_block_run",), ("record", "code-block-run"), cli_show_effective_context=True, cli_context_write=True),
|
|
92
99
|
PublicToolSpec(USER_DOMAIN, "task_list", ("task_list",), ("task", "list"), cli_show_effective_context=True),
|
|
93
100
|
PublicToolSpec(USER_DOMAIN, "task_get", ("task_get",), ("task", "get"), cli_show_effective_context=True),
|
|
94
101
|
PublicToolSpec(USER_DOMAIN, "task_action_execute", ("task_action_execute",), ("task", "action"), cli_show_effective_context=True, cli_context_write=True),
|
|
95
|
-
PublicToolSpec(
|
|
102
|
+
PublicToolSpec(
|
|
103
|
+
USER_DOMAIN,
|
|
104
|
+
"task_associated_report_detail_get",
|
|
105
|
+
("task_associated_report_detail_get",),
|
|
106
|
+
("task", "report"),
|
|
107
|
+
cli_show_effective_context=True,
|
|
108
|
+
),
|
|
96
109
|
PublicToolSpec(USER_DOMAIN, "task_workflow_log_get", ("task_workflow_log_get",), ("task", "log"), cli_show_effective_context=True),
|
|
97
110
|
PublicToolSpec(USER_DOMAIN, "directory_search", ("directory_search",), cli_public=False),
|
|
98
111
|
PublicToolSpec(USER_DOMAIN, "directory_list_internal_users", ("directory_list_internal_users",), cli_public=False),
|
|
@@ -109,6 +122,7 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
109
122
|
PublicToolSpec(BUILDER_DOMAIN, "auth_whoami", ("auth_whoami",), ("builder", "auth", "whoami"), cli_public=False),
|
|
110
123
|
PublicToolSpec(BUILDER_DOMAIN, "auth_logout", ("auth_logout",), ("builder", "auth", "logout"), cli_public=False),
|
|
111
124
|
PublicToolSpec(BUILDER_DOMAIN, "workspace_list", ("workspace_list",), ("builder", "workspace", "list"), cli_public=False),
|
|
125
|
+
PublicToolSpec(BUILDER_DOMAIN, "workspace_get", ("workspace_get",), ("builder", "workspace", "get"), cli_public=False),
|
|
112
126
|
PublicToolSpec(BUILDER_DOMAIN, "file_upload_local", ("file_upload_local",), ("builder", "file", "upload-local"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
113
127
|
PublicToolSpec(BUILDER_DOMAIN, "feedback_submit", ("feedback_submit",), ("builder", "feedback", "submit"), has_contract=True),
|
|
114
128
|
PublicToolSpec(BUILDER_DOMAIN, "builder_tool_contract", ("builder_tool_contract",), ("builder", "contract"), has_contract=False),
|
|
@@ -120,6 +134,7 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
120
134
|
PublicToolSpec(BUILDER_DOMAIN, "role_create", ("role_create",), ("builder", "role", "create"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
121
135
|
PublicToolSpec(BUILDER_DOMAIN, "app_release_edit_lock_if_mine", ("app_release_edit_lock_if_mine",), ("builder", "app", "release-edit-lock-if-mine"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
122
136
|
PublicToolSpec(BUILDER_DOMAIN, "app_resolve", ("app_resolve",), ("builder", "app", "resolve"), has_contract=True, cli_show_effective_context=True),
|
|
137
|
+
PublicToolSpec(BUILDER_DOMAIN, "button_style_catalog_get", ("button_style_catalog_get",), ("builder", "button", "catalog"), has_contract=True, cli_show_effective_context=True),
|
|
123
138
|
PublicToolSpec(BUILDER_DOMAIN, "app_custom_button_list", ("app_custom_button_list",), ("builder", "button", "list"), has_contract=True, cli_show_effective_context=True),
|
|
124
139
|
PublicToolSpec(BUILDER_DOMAIN, "app_custom_button_get", ("app_custom_button_get",), ("builder", "button", "get"), has_contract=True, cli_show_effective_context=True),
|
|
125
140
|
PublicToolSpec(BUILDER_DOMAIN, "app_custom_button_create", ("app_custom_button_create",), ("builder", "button", "create"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|