@josephyan/qingflow-app-user-mcp 0.2.0-beta.989 → 0.2.0-beta.990

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.989
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.990
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.989 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.990 qingflow-app-user-mcp
13
13
  ```
14
14
 
15
15
  Environment:
@@ -20,6 +20,13 @@
20
20
 
21
21
  `auth_use_credential` 是本地唯一鉴权主路径。
22
22
 
23
+ 补充说明:
24
+
25
+ - 对 stdio MCP 来说,主路径仍然只有 `auth_use_credential`。
26
+ - 如果你是在终端里直接使用 `qingflow` CLI,可以额外使用 `qingflow auth login` 作为“人类登录”入口;默认会提示轻流邮箱和隐藏密码,拿到 `token` 后建立本地 CLI 会话。
27
+ - 如果需要浏览器 Authorization Code + PKCE 或 Device Flow,请显式使用 `qingflow auth login --browser` 或 `qingflow auth login --device`;这条 OAuth 路径最终拿到 `credential` 后再复用 `auth_use_credential` 建会话。
28
+ - 也就是说,这次新增的是 CLI 的登录入口,不是给 MCP 增加第二套会话模型。
29
+
23
30
  ## npm 安装器适用场景
24
31
 
25
32
  适合这类本地 agent / gateway:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-user-mcp",
3
- "version": "0.2.0-beta.989",
3
+ "version": "0.2.0-beta.990",
4
4
  "description": "Operational end-user MCP for Qingflow records, tasks, comments, and directory 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.0b989"
7
+ version = "0.2.0b990"
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.0b989"
8
+ _FALLBACK_VERSION = "0.2.0b990"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -1,8 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import getpass
5
+ import sys
4
6
 
7
+ from ...errors import QingflowApiError
5
8
  from ..context import CliContext
9
+ from ..oauth_login import login_with_cli_oauth
10
+ from ..qingflow_login import login_with_qingflow_password
6
11
  from .common import read_secret_arg
7
12
 
8
13
 
@@ -10,6 +15,23 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
10
15
  parser = subparsers.add_parser("auth", help="认证与会话")
11
16
  auth_subparsers = parser.add_subparsers(dest="auth_command", required=True)
12
17
 
18
+ login = auth_subparsers.add_parser("login", help="登录 CLI:默认轻流账号密码交互;--browser 使用 OAuth")
19
+ login.add_argument("--base-url")
20
+ login.add_argument("--qf-version")
21
+ login.add_argument("--client-id")
22
+ login.add_argument("--authorization-endpoint")
23
+ login.add_argument("--token-endpoint")
24
+ login.add_argument("--device-authorization-endpoint")
25
+ login.add_argument("--scope")
26
+ login.add_argument("--credential-field")
27
+ login.add_argument("--browser", action="store_true", help="使用浏览器 OAuth 登录;无浏览器时可配合 --device")
28
+ login.add_argument("--device", action="store_true", help="使用 OAuth Device Flow(隐含 --browser)")
29
+ login.add_argument("--email", help="轻流账号邮箱;不传时在交互终端提示输入")
30
+ login.add_argument("--password", help="轻流账号密码;建议仅用于本地调试,脚本请优先使用 --password-stdin")
31
+ login.add_argument("--password-stdin", action="store_true", help="从标准输入读取轻流账号密码")
32
+ login.add_argument("--persist", action=argparse.BooleanOptionalAction, default=True)
33
+ login.set_defaults(handler=_handle_login, format_hint="auth_whoami")
34
+
13
35
  use_credential = auth_subparsers.add_parser("use-credential", help="直接注入 credential")
14
36
  use_credential.add_argument("--base-url")
15
37
  use_credential.add_argument("--qf-version")
@@ -26,6 +48,112 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
26
48
  logout.set_defaults(handler=_handle_logout, format_hint="")
27
49
 
28
50
 
51
+ def _handle_login(args: argparse.Namespace, context: CliContext) -> dict:
52
+ login_summary: dict[str, str | None]
53
+ if _should_use_browser_login(args):
54
+ if _has_account_login_inputs(args):
55
+ raise QingflowApiError.config_error(
56
+ "Choose either account/password login or --browser OAuth login; do not combine both."
57
+ )
58
+ oauth_result = login_with_cli_oauth(
59
+ base_url=args.base_url,
60
+ client_id=args.client_id,
61
+ authorization_endpoint=args.authorization_endpoint,
62
+ token_endpoint=args.token_endpoint,
63
+ device_authorization_endpoint=args.device_authorization_endpoint,
64
+ scope=args.scope,
65
+ credential_field=args.credential_field,
66
+ force_device=bool(args.device),
67
+ )
68
+ result = context.auth.auth_use_credential(
69
+ profile=args.profile,
70
+ base_url=args.base_url,
71
+ qf_version=args.qf_version,
72
+ credential=oauth_result.credential,
73
+ persist=bool(args.persist),
74
+ )
75
+ login_summary = {
76
+ "flow": oauth_result.flow,
77
+ "authorize_url": oauth_result.authorize_url,
78
+ "verification_uri": oauth_result.verification_uri,
79
+ "user_code": oauth_result.user_code,
80
+ }
81
+ else:
82
+ email = _resolve_login_email(args)
83
+ password = _resolve_login_password(args)
84
+ login_result = login_with_qingflow_password(
85
+ base_url=args.base_url,
86
+ email=email,
87
+ password=password,
88
+ )
89
+ result = context.auth.auth_use_token(
90
+ profile=args.profile,
91
+ base_url=args.base_url,
92
+ qf_version=args.qf_version,
93
+ token=login_result.token,
94
+ login_token=login_result.login_token,
95
+ user_info=login_result.user_info,
96
+ persist=bool(args.persist),
97
+ )
98
+ login_summary = {
99
+ "flow": login_result.flow,
100
+ "authorize_url": None,
101
+ "verification_uri": None,
102
+ "user_code": None,
103
+ }
104
+ warnings = list(result.get("warnings") or []) if isinstance(result, dict) else []
105
+ if isinstance(result, dict):
106
+ result["cli_auth"] = {key: value for key, value in login_summary.items() if value}
107
+ if warnings:
108
+ result["warnings"] = warnings
109
+ return result
110
+
111
+
112
+ def _should_use_browser_login(args: argparse.Namespace) -> bool:
113
+ return any(
114
+ bool(getattr(args, name, None))
115
+ for name in (
116
+ "browser",
117
+ "device",
118
+ "client_id",
119
+ "authorization_endpoint",
120
+ "token_endpoint",
121
+ "device_authorization_endpoint",
122
+ "scope",
123
+ "credential_field",
124
+ )
125
+ )
126
+
127
+
128
+ def _has_account_login_inputs(args: argparse.Namespace) -> bool:
129
+ return bool(args.email or args.password or bool(args.password_stdin))
130
+
131
+
132
+ def _resolve_login_email(args: argparse.Namespace) -> str:
133
+ email = str(args.email or "").strip()
134
+ if email:
135
+ return email
136
+ if sys.stdin.isatty():
137
+ return input("Qingflow email: ").strip()
138
+ raise QingflowApiError.config_error(
139
+ "qingflow auth login needs an interactive terminal, or pass --email with --password-stdin, or use --browser."
140
+ )
141
+
142
+
143
+ def _resolve_login_password(args: argparse.Namespace) -> str:
144
+ if args.password or bool(args.password_stdin):
145
+ return read_secret_arg(
146
+ args.password,
147
+ stdin_enabled=bool(args.password_stdin),
148
+ label="password",
149
+ )
150
+ if sys.stdin.isatty():
151
+ return getpass.getpass("Qingflow password: ")
152
+ raise QingflowApiError.config_error(
153
+ "qingflow auth login needs an interactive terminal, --password-stdin, or --browser."
154
+ )
155
+
156
+
29
157
  def _handle_use_credential(args: argparse.Namespace, context: CliContext) -> dict:
30
158
  credential = (
31
159
  read_secret_arg(args.credential, stdin_enabled=bool(args.credential_stdin), label="credential")
@@ -40,6 +40,13 @@ def _format_whoami(result: dict[str, Any]) -> str:
40
40
  f"Workspace: {result.get('selected_ws_name') or '-'} ({result.get('selected_ws_id') or '-'})",
41
41
  f"Workspace QF Version: {result.get('qf_version') or '-'}",
42
42
  ]
43
+ cli_auth = result.get("cli_auth") if isinstance(result.get("cli_auth"), dict) else {}
44
+ if cli_auth:
45
+ lines.append(f"Login Flow: {cli_auth.get('flow') or '-'}")
46
+ if cli_auth.get("verification_uri"):
47
+ lines.append(f"Verification URL: {cli_auth.get('verification_uri')}")
48
+ if cli_auth.get("user_code"):
49
+ lines.append(f"User Code: {cli_auth.get('user_code')}")
43
50
  request_route = result.get("request_route") if isinstance(result.get("request_route"), dict) else {}
44
51
  route_qf_version = request_route.get("qf_version")
45
52
  if route_qf_version and route_qf_version != result.get("qf_version"):
@@ -0,0 +1,626 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import sys
8
+ import threading
9
+ import time
10
+ import webbrowser
11
+ from dataclasses import dataclass
12
+ from http.server import BaseHTTPRequestHandler, HTTPServer
13
+ from typing import Any
14
+ from urllib.parse import parse_qs, urlencode, urljoin, urlparse
15
+ from uuid import uuid4
16
+
17
+ import httpx
18
+
19
+ from ..config import get_config_value, get_timeout_seconds
20
+ from ..errors import QingflowApiError
21
+
22
+
23
+ _DEFAULT_SCOPE = "openid profile email"
24
+ _DEFAULT_CALLBACK_HOST = "127.0.0.1"
25
+ _DEFAULT_CALLBACK_PATH = "/callback"
26
+ _DEFAULT_BROWSER_TIMEOUT_SECONDS = 180.0
27
+ _DEFAULT_DEVICE_INTERVAL_SECONDS = 5.0
28
+ _SUCCESS_HTML = """<!doctype html>
29
+ <html lang="en">
30
+ <head>
31
+ <meta charset="utf-8">
32
+ <title>Qingflow CLI Login Complete</title>
33
+ </head>
34
+ <body>
35
+ <p>Qingflow CLI login complete. You can return to the terminal.</p>
36
+ </body>
37
+ </html>
38
+ """
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class CliOAuthConfig:
43
+ client_id: str
44
+ authorization_endpoint: str
45
+ token_endpoint: str
46
+ device_authorization_endpoint: str | None
47
+ scope: str
48
+ callback_host: str
49
+ callback_port: int
50
+ callback_path: str
51
+ browser_timeout_seconds: float
52
+ credential_field: str
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class CliOAuthCredential:
57
+ credential: str
58
+ flow: str
59
+ authorize_url: str | None = None
60
+ verification_uri: str | None = None
61
+ user_code: str | None = None
62
+ warnings: list[str] | None = None
63
+
64
+
65
+ class _LoopbackAuthServer:
66
+ def __init__(
67
+ self,
68
+ *,
69
+ host: str,
70
+ port: int,
71
+ callback_path: str,
72
+ timeout_seconds: float,
73
+ status_stream,
74
+ ) -> None:
75
+ self._host = host
76
+ self._port = port
77
+ self._callback_path = callback_path
78
+ self._timeout_seconds = timeout_seconds
79
+ self._status_stream = status_stream
80
+ self._event = threading.Event()
81
+ self._params: dict[str, str] | None = None
82
+ self._error: str | None = None
83
+ self._server = self._build_server()
84
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
85
+
86
+ @property
87
+ def redirect_uri(self) -> str:
88
+ return f"http://{self._host}:{self._server.server_port}{self._callback_path}"
89
+
90
+ def start(self) -> None:
91
+ self._thread.start()
92
+
93
+ def wait(self) -> dict[str, str]:
94
+ if not self._event.wait(timeout=self._timeout_seconds):
95
+ raise QingflowApiError.config_error(
96
+ f"timed out waiting for browser callback after {int(self._timeout_seconds)} seconds"
97
+ )
98
+ if self._error:
99
+ raise QingflowApiError(category="auth", message=self._error)
100
+ if self._params is None:
101
+ raise QingflowApiError(category="auth", message="browser callback did not return any parameters")
102
+ return self._params
103
+
104
+ def close(self) -> None:
105
+ self._server.shutdown()
106
+ self._server.server_close()
107
+ self._thread.join(timeout=1)
108
+
109
+ def _build_server(self) -> HTTPServer:
110
+ parent = self
111
+
112
+ class CallbackServer(HTTPServer):
113
+ allow_reuse_address = True
114
+
115
+ class Handler(BaseHTTPRequestHandler):
116
+ def do_GET(self) -> None: # noqa: N802
117
+ parsed = urlparse(self.path)
118
+ if parsed.path != parent._callback_path:
119
+ self.send_error(404)
120
+ return
121
+ query = {
122
+ key: values[0]
123
+ for key, values in parse_qs(parsed.query, keep_blank_values=True).items()
124
+ if values
125
+ }
126
+ parent._params = query
127
+ parent._event.set()
128
+ self.send_response(200)
129
+ self.send_header("Content-Type", "text/html; charset=utf-8")
130
+ self.end_headers()
131
+ self.wfile.write(_SUCCESS_HTML.encode("utf-8"))
132
+
133
+ def log_message(self, format: str, *args: object) -> None: # noqa: A003
134
+ return
135
+
136
+ try:
137
+ return CallbackServer((self._host, self._port), Handler)
138
+ except OSError as exc:
139
+ raise QingflowApiError.config_error(f"failed to start local callback server: {exc}") from exc
140
+
141
+
142
+ class CliOAuthLoginHelper:
143
+ def __init__(
144
+ self,
145
+ *,
146
+ http_client: httpx.Client | None = None,
147
+ browser_opener=webbrowser.open,
148
+ sleep_fn=time.sleep,
149
+ status_stream=None,
150
+ ) -> None:
151
+ self._owns_client = http_client is None
152
+ self._http_client = http_client or httpx.Client(
153
+ timeout=get_timeout_seconds(),
154
+ follow_redirects=True,
155
+ trust_env=False,
156
+ )
157
+ self._browser_opener = browser_opener
158
+ self._sleep = sleep_fn
159
+ self._status_stream = status_stream or sys.stderr
160
+
161
+ def close(self) -> None:
162
+ if self._owns_client:
163
+ self._http_client.close()
164
+
165
+ def login(
166
+ self,
167
+ *,
168
+ base_url: str | None,
169
+ client_id: str | None = None,
170
+ authorization_endpoint: str | None = None,
171
+ token_endpoint: str | None = None,
172
+ device_authorization_endpoint: str | None = None,
173
+ scope: str | None = None,
174
+ credential_field: str | None = None,
175
+ force_device: bool = False,
176
+ ) -> CliOAuthCredential:
177
+ config = load_cli_oauth_config(
178
+ base_url=base_url,
179
+ client_id=client_id,
180
+ authorization_endpoint=authorization_endpoint,
181
+ token_endpoint=token_endpoint,
182
+ device_authorization_endpoint=device_authorization_endpoint,
183
+ scope=scope,
184
+ credential_field=credential_field,
185
+ )
186
+ if force_device:
187
+ return self._device_flow(config)
188
+ try:
189
+ return self._authorization_code_flow(config)
190
+ except _BrowserUnavailableError as exc:
191
+ if not config.device_authorization_endpoint:
192
+ raise QingflowApiError.config_error(
193
+ "browser auth is unavailable and no device_authorization_endpoint is configured"
194
+ ) from exc
195
+ self._write_status("Browser unavailable, switching to device flow.")
196
+ return self._device_flow(config)
197
+
198
+ def _authorization_code_flow(self, config: CliOAuthConfig) -> CliOAuthCredential:
199
+ state = uuid4().hex
200
+ code_verifier = _generate_code_verifier()
201
+ code_challenge = _code_challenge_s256(code_verifier)
202
+ server = _LoopbackAuthServer(
203
+ host=config.callback_host,
204
+ port=config.callback_port,
205
+ callback_path=config.callback_path,
206
+ timeout_seconds=config.browser_timeout_seconds,
207
+ status_stream=self._status_stream,
208
+ )
209
+ try:
210
+ server.start()
211
+ authorize_url = self._build_authorize_url(
212
+ config=config,
213
+ redirect_uri=server.redirect_uri,
214
+ state=state,
215
+ code_challenge=code_challenge,
216
+ )
217
+ self._write_status(f"Opening browser for Qingflow login: {authorize_url}")
218
+ opened = False
219
+ try:
220
+ opened = bool(self._browser_opener(authorize_url))
221
+ except Exception as exc: # pragma: no cover - depends on local browser runtime
222
+ raise _BrowserUnavailableError(str(exc)) from exc
223
+ if not opened:
224
+ raise _BrowserUnavailableError("system browser refused to open authorization URL")
225
+ callback_params = server.wait()
226
+ finally:
227
+ server.close()
228
+
229
+ if callback_params.get("error"):
230
+ error_description = callback_params.get("error_description") or callback_params["error"]
231
+ raise QingflowApiError(category="auth", message=f"authorization failed: {error_description}")
232
+ returned_state = str(callback_params.get("state") or "")
233
+ if returned_state != state:
234
+ raise QingflowApiError(category="auth", message="authorization callback state mismatch")
235
+ credential = self._extract_credential(callback_params, credential_field=config.credential_field)
236
+ if credential:
237
+ return CliOAuthCredential(
238
+ credential=credential,
239
+ flow="authorization_code",
240
+ authorize_url=authorize_url,
241
+ )
242
+ code = str(callback_params.get("code") or "").strip()
243
+ if not code:
244
+ raise QingflowApiError(category="auth", message="authorization callback did not return a code")
245
+ token_payload = self._post_form(
246
+ config.token_endpoint,
247
+ {
248
+ "grant_type": "authorization_code",
249
+ "client_id": config.client_id,
250
+ "code": code,
251
+ "redirect_uri": server.redirect_uri,
252
+ "code_verifier": code_verifier,
253
+ },
254
+ )
255
+ credential = self._extract_credential(token_payload, credential_field=config.credential_field)
256
+ if not credential:
257
+ raise QingflowApiError(
258
+ category="auth",
259
+ message=(
260
+ "token exchange succeeded but did not return a Qingflow credential; "
261
+ "configure cli_auth.credential_field if your broker uses a custom field"
262
+ ),
263
+ )
264
+ return CliOAuthCredential(
265
+ credential=credential,
266
+ flow="authorization_code",
267
+ authorize_url=authorize_url,
268
+ )
269
+
270
+ def _device_flow(self, config: CliOAuthConfig) -> CliOAuthCredential:
271
+ if not config.device_authorization_endpoint:
272
+ raise QingflowApiError.config_error("device_authorization_endpoint is required for device flow")
273
+ start_payload = self._post_form(
274
+ config.device_authorization_endpoint,
275
+ {
276
+ "client_id": config.client_id,
277
+ "scope": config.scope,
278
+ },
279
+ )
280
+ device_code = str(start_payload.get("device_code") or "").strip()
281
+ if not device_code:
282
+ raise QingflowApiError(category="auth", message="device authorization did not return device_code")
283
+ verification_uri = (
284
+ str(start_payload.get("verification_uri_complete") or "").strip()
285
+ or str(start_payload.get("verification_uri") or "").strip()
286
+ )
287
+ user_code = str(start_payload.get("user_code") or "").strip() or None
288
+ interval_seconds = _coerce_positive_float(start_payload.get("interval")) or _DEFAULT_DEVICE_INTERVAL_SECONDS
289
+ expires_in_seconds = _coerce_positive_float(start_payload.get("expires_in")) or config.browser_timeout_seconds
290
+ if verification_uri:
291
+ self._write_status(f"Open this URL to continue Qingflow login: {verification_uri}")
292
+ if user_code:
293
+ self._write_status(f"Device code: {user_code}")
294
+ deadline = time.monotonic() + expires_in_seconds
295
+ interval = interval_seconds
296
+ while time.monotonic() < deadline:
297
+ payload, status_code = self._post_form_with_status(
298
+ config.token_endpoint,
299
+ {
300
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
301
+ "device_code": device_code,
302
+ "client_id": config.client_id,
303
+ },
304
+ )
305
+ if 200 <= status_code < 300:
306
+ credential = self._extract_credential(payload, credential_field=config.credential_field)
307
+ if not credential:
308
+ raise QingflowApiError(
309
+ category="auth",
310
+ message=(
311
+ "device token exchange succeeded but did not return a Qingflow credential; "
312
+ "configure cli_auth.credential_field if your broker uses a custom field"
313
+ ),
314
+ )
315
+ return CliOAuthCredential(
316
+ credential=credential,
317
+ flow="device_code",
318
+ verification_uri=verification_uri or None,
319
+ user_code=user_code,
320
+ )
321
+ error_code = str(payload.get("error") or "").strip()
322
+ if error_code == "authorization_pending":
323
+ self._sleep(interval)
324
+ continue
325
+ if error_code == "slow_down":
326
+ interval += interval_seconds
327
+ self._sleep(interval)
328
+ continue
329
+ if error_code:
330
+ error_description = str(payload.get("error_description") or error_code).strip()
331
+ raise QingflowApiError(category="auth", message=f"device flow failed: {error_description}")
332
+ raise QingflowApiError(
333
+ category="auth",
334
+ message=f"device flow token polling failed with HTTP {status_code}",
335
+ )
336
+ raise QingflowApiError(
337
+ category="auth",
338
+ message="device flow expired before authorization completed",
339
+ )
340
+
341
+ def _build_authorize_url(
342
+ self,
343
+ *,
344
+ config: CliOAuthConfig,
345
+ redirect_uri: str,
346
+ state: str,
347
+ code_challenge: str,
348
+ ) -> str:
349
+ query = urlencode(
350
+ {
351
+ "response_type": "code",
352
+ "client_id": config.client_id,
353
+ "redirect_uri": redirect_uri,
354
+ "scope": config.scope,
355
+ "state": state,
356
+ "code_challenge": code_challenge,
357
+ "code_challenge_method": "S256",
358
+ }
359
+ )
360
+ separator = "&" if "?" in config.authorization_endpoint else "?"
361
+ return f"{config.authorization_endpoint}{separator}{query}"
362
+
363
+ def _post_form(self, url: str, form: dict[str, str]) -> dict[str, Any]:
364
+ payload, status_code = self._post_form_with_status(url, form)
365
+ if status_code >= 400:
366
+ error_text = str(payload.get("error_description") or payload.get("message") or payload.get("error") or "")
367
+ raise QingflowApiError(category="auth", message=error_text or f"HTTP {status_code}")
368
+ return payload
369
+
370
+ def _post_form_with_status(self, url: str, form: dict[str, str]) -> tuple[dict[str, Any], int]:
371
+ try:
372
+ response = self._http_client.post(
373
+ url,
374
+ data=form,
375
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
376
+ )
377
+ except httpx.RequestError as exc:
378
+ raise QingflowApiError(category="network", message=str(exc)) from exc
379
+ return _parse_response_payload(response), response.status_code
380
+
381
+ def _extract_credential(self, payload: dict[str, Any], *, credential_field: str) -> str | None:
382
+ if not payload:
383
+ return None
384
+ candidate = _extract_nested_field(payload, credential_field)
385
+ if candidate:
386
+ return candidate
387
+ for alias in ("credential", "qingflow_credential", "x-qingflow-client-id"):
388
+ candidate = _extract_nested_field(payload, alias)
389
+ if candidate:
390
+ return candidate
391
+ return None
392
+
393
+ def _write_status(self, message: str) -> None:
394
+ self._status_stream.write(message.rstrip() + "\n")
395
+ self._status_stream.flush()
396
+
397
+
398
+ class _BrowserUnavailableError(RuntimeError):
399
+ pass
400
+
401
+
402
+ def login_with_cli_oauth(
403
+ *,
404
+ base_url: str | None,
405
+ client_id: str | None = None,
406
+ authorization_endpoint: str | None = None,
407
+ token_endpoint: str | None = None,
408
+ device_authorization_endpoint: str | None = None,
409
+ scope: str | None = None,
410
+ credential_field: str | None = None,
411
+ force_device: bool = False,
412
+ ) -> CliOAuthCredential:
413
+ helper = CliOAuthLoginHelper()
414
+ try:
415
+ return helper.login(
416
+ base_url=base_url,
417
+ client_id=client_id,
418
+ authorization_endpoint=authorization_endpoint,
419
+ token_endpoint=token_endpoint,
420
+ device_authorization_endpoint=device_authorization_endpoint,
421
+ scope=scope,
422
+ credential_field=credential_field,
423
+ force_device=force_device,
424
+ )
425
+ finally:
426
+ helper.close()
427
+
428
+
429
+ def load_cli_oauth_config(
430
+ *,
431
+ base_url: str | None,
432
+ client_id: str | None = None,
433
+ authorization_endpoint: str | None = None,
434
+ token_endpoint: str | None = None,
435
+ device_authorization_endpoint: str | None = None,
436
+ scope: str | None = None,
437
+ credential_field: str | None = None,
438
+ ) -> CliOAuthConfig:
439
+ resolved_base_url = str(base_url or "").strip() or None
440
+ resolved_client_id = _first_text(
441
+ client_id,
442
+ os.getenv("QINGFLOW_CLI_AUTH_CLIENT_ID"),
443
+ get_config_value("cli_auth.client_id", default=None),
444
+ )
445
+ resolved_authorization_endpoint = _resolve_endpoint(
446
+ authorization_endpoint,
447
+ resolved_base_url,
448
+ env_var="QINGFLOW_CLI_AUTH_AUTHORIZATION_ENDPOINT",
449
+ config_key="cli_auth.authorization_endpoint",
450
+ )
451
+ resolved_token_endpoint = _resolve_endpoint(
452
+ token_endpoint,
453
+ resolved_base_url,
454
+ env_var="QINGFLOW_CLI_AUTH_TOKEN_ENDPOINT",
455
+ config_key="cli_auth.token_endpoint",
456
+ )
457
+ resolved_device_endpoint = _resolve_endpoint(
458
+ device_authorization_endpoint,
459
+ resolved_base_url,
460
+ env_var="QINGFLOW_CLI_AUTH_DEVICE_AUTHORIZATION_ENDPOINT",
461
+ config_key="cli_auth.device_authorization_endpoint",
462
+ required=False,
463
+ )
464
+ resolved_scope = _first_text(
465
+ scope,
466
+ os.getenv("QINGFLOW_CLI_AUTH_SCOPE"),
467
+ get_config_value("cli_auth.scope", default=None),
468
+ fallback=_DEFAULT_SCOPE,
469
+ )
470
+ callback_host = _first_text(
471
+ os.getenv("QINGFLOW_CLI_AUTH_CALLBACK_HOST"),
472
+ get_config_value("cli_auth.callback_host", default=None),
473
+ fallback=_DEFAULT_CALLBACK_HOST,
474
+ )
475
+ callback_path = _normalize_callback_path(
476
+ _first_text(
477
+ os.getenv("QINGFLOW_CLI_AUTH_CALLBACK_PATH"),
478
+ get_config_value("cli_auth.callback_path", default=None),
479
+ fallback=_DEFAULT_CALLBACK_PATH,
480
+ )
481
+ )
482
+ callback_port = _coerce_port(
483
+ os.getenv("QINGFLOW_CLI_AUTH_CALLBACK_PORT")
484
+ or get_config_value("cli_auth.callback_port", default=0)
485
+ )
486
+ browser_timeout_seconds = _coerce_positive_float(
487
+ os.getenv("QINGFLOW_CLI_AUTH_BROWSER_TIMEOUT_SECONDS")
488
+ or get_config_value("cli_auth.browser_timeout_seconds", default=_DEFAULT_BROWSER_TIMEOUT_SECONDS)
489
+ ) or _DEFAULT_BROWSER_TIMEOUT_SECONDS
490
+ resolved_credential_field = _first_text(
491
+ credential_field,
492
+ os.getenv("QINGFLOW_CLI_AUTH_CREDENTIAL_FIELD"),
493
+ get_config_value("cli_auth.credential_field", default=None),
494
+ fallback="credential",
495
+ )
496
+
497
+ missing: list[str] = []
498
+ if not resolved_client_id:
499
+ missing.append("cli_auth.client_id / QINGFLOW_CLI_AUTH_CLIENT_ID")
500
+ if not resolved_authorization_endpoint:
501
+ missing.append("cli_auth.authorization_endpoint / QINGFLOW_CLI_AUTH_AUTHORIZATION_ENDPOINT")
502
+ if not resolved_token_endpoint:
503
+ missing.append("cli_auth.token_endpoint / QINGFLOW_CLI_AUTH_TOKEN_ENDPOINT")
504
+ if missing:
505
+ raise QingflowApiError.config_error(
506
+ "CLI auth login requires OAuth config: " + ", ".join(missing)
507
+ )
508
+
509
+ return CliOAuthConfig(
510
+ client_id=resolved_client_id,
511
+ authorization_endpoint=resolved_authorization_endpoint,
512
+ token_endpoint=resolved_token_endpoint,
513
+ device_authorization_endpoint=resolved_device_endpoint,
514
+ scope=resolved_scope,
515
+ callback_host=callback_host,
516
+ callback_port=callback_port,
517
+ callback_path=callback_path,
518
+ browser_timeout_seconds=browser_timeout_seconds,
519
+ credential_field=resolved_credential_field,
520
+ )
521
+
522
+
523
+ def _resolve_endpoint(
524
+ override: str | None,
525
+ base_url: str | None,
526
+ *,
527
+ env_var: str,
528
+ config_key: str,
529
+ required: bool = True,
530
+ ) -> str | None:
531
+ value = _first_text(
532
+ override,
533
+ os.getenv(env_var),
534
+ get_config_value(config_key, default=None),
535
+ )
536
+ if not value:
537
+ return None if not required else ""
538
+ parsed = urlparse(value)
539
+ if parsed.scheme and parsed.netloc:
540
+ return value
541
+ if not base_url:
542
+ raise QingflowApiError.config_error(
543
+ f"{config_key} is relative, but no base_url was provided to resolve it"
544
+ )
545
+ return urljoin(base_url.rstrip("/") + "/", value)
546
+
547
+
548
+ def _first_text(*values: Any, fallback: str | None = None) -> str | None:
549
+ for value in values:
550
+ text = str(value or "").strip()
551
+ if text:
552
+ return text
553
+ return fallback
554
+
555
+
556
+ def _normalize_callback_path(value: str) -> str:
557
+ normalized = str(value or "").strip() or _DEFAULT_CALLBACK_PATH
558
+ return normalized if normalized.startswith("/") else f"/{normalized}"
559
+
560
+
561
+ def _coerce_positive_float(value: Any) -> float | None:
562
+ if value in (None, ""):
563
+ return None
564
+ try:
565
+ parsed = float(value)
566
+ except (TypeError, ValueError):
567
+ return None
568
+ return parsed if parsed > 0 else None
569
+
570
+
571
+ def _coerce_port(value: Any) -> int:
572
+ if value in (None, ""):
573
+ return 0
574
+ try:
575
+ parsed = int(value)
576
+ except (TypeError, ValueError):
577
+ raise QingflowApiError.config_error("cli_auth.callback_port must be an integer") from None
578
+ if parsed < 0 or parsed > 65535:
579
+ raise QingflowApiError.config_error("cli_auth.callback_port must be between 0 and 65535")
580
+ return parsed
581
+
582
+
583
+ def _generate_code_verifier() -> str:
584
+ random_bytes = os.urandom(32)
585
+ return base64.urlsafe_b64encode(random_bytes).decode("ascii").rstrip("=")
586
+
587
+
588
+ def _code_challenge_s256(code_verifier: str) -> str:
589
+ digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
590
+ return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
591
+
592
+
593
+ def _parse_response_payload(response: httpx.Response) -> dict[str, Any]:
594
+ if not response.content:
595
+ return {}
596
+ content_type = str(response.headers.get("Content-Type") or "").lower()
597
+ if "application/json" in content_type:
598
+ payload = response.json()
599
+ return payload if isinstance(payload, dict) else {"value": payload}
600
+ if "application/x-www-form-urlencoded" in content_type:
601
+ return {
602
+ key: values[0]
603
+ for key, values in parse_qs(response.text, keep_blank_values=True).items()
604
+ if values
605
+ }
606
+ try:
607
+ payload = response.json()
608
+ except ValueError:
609
+ try:
610
+ return json.loads(response.text)
611
+ except (TypeError, ValueError):
612
+ return {"message": response.text}
613
+ return payload if isinstance(payload, dict) else {"value": payload}
614
+
615
+
616
+ def _extract_nested_field(payload: dict[str, Any], key: str) -> str | None:
617
+ value = payload.get(key)
618
+ if isinstance(value, str) and value.strip():
619
+ return value.strip()
620
+ for container_key in ("data", "result", "payload"):
621
+ nested = payload.get(container_key)
622
+ if isinstance(nested, dict):
623
+ nested_value = nested.get(key)
624
+ if isinstance(nested_value, str) and nested_value.strip():
625
+ return nested_value.strip()
626
+ return None
@@ -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")
@@ -43,14 +43,14 @@ 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
@@ -30,6 +30,7 @@ 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")),
@@ -164,6 +164,136 @@ class AuthTools(ToolBase):
164
164
  ),
165
165
  }
166
166
 
167
+ def auth_use_token(
168
+ self,
169
+ *,
170
+ profile: str = DEFAULT_PROFILE,
171
+ base_url: str | None = None,
172
+ qf_version: str | None = None,
173
+ token: str | None = None,
174
+ login_token: str | None = None,
175
+ persist: bool = False,
176
+ user_info: dict[str, Any] | None = None,
177
+ ) -> dict[str, Any]:
178
+ """使用已获得的 Qingflow token 建立本地会话。"""
179
+ normalized_base_url = self._normalize_base_url(base_url)
180
+ normalized_qf_version, qf_version_source = self._resolve_qf_version_input(qf_version)
181
+ normalized_token = self._normalize_text(token)
182
+ normalized_login_token = self._normalize_text(login_token)
183
+ if not normalized_token:
184
+ raise_tool_error(QingflowApiError.config_error("token is required"))
185
+
186
+ resolved_user_info = user_info if isinstance(user_info, dict) else None
187
+ response_qf_version: str | None = None
188
+ if resolved_user_info is None:
189
+ resolved_user_info, response_qf_version = self._fetch_user_info(
190
+ normalized_base_url,
191
+ normalized_token,
192
+ None,
193
+ qf_version=normalized_qf_version,
194
+ qf_version_source=qf_version_source,
195
+ )
196
+
197
+ last_workspace = resolved_user_info.get("lastWsInfo")
198
+ selected_ws_id = self._coerce_positive_int(
199
+ last_workspace.get("wsId") if isinstance(last_workspace, dict) else None
200
+ )
201
+ selected_ws_name = self._normalize_text(
202
+ (last_workspace.get("wsName") if isinstance(last_workspace, dict) else None)
203
+ or (last_workspace.get("workspaceName") if isinstance(last_workspace, dict) else None)
204
+ or (last_workspace.get("remark") if isinstance(last_workspace, dict) else None)
205
+ )
206
+ workspace_qf_version = (
207
+ self._workspace_system_version(last_workspace) if isinstance(last_workspace, dict) else None
208
+ )
209
+ if selected_ws_id is None:
210
+ fallback_workspace, fallback_qf_version = self._fetch_first_workspace(
211
+ normalized_base_url,
212
+ normalized_token,
213
+ qf_version=normalized_qf_version,
214
+ qf_version_source=qf_version_source,
215
+ )
216
+ if isinstance(fallback_workspace, dict):
217
+ selected_ws_id = self._coerce_positive_int(fallback_workspace.get("wsId"))
218
+ selected_ws_name = self._normalize_text(
219
+ fallback_workspace.get("workspaceName")
220
+ or fallback_workspace.get("wsName")
221
+ or fallback_workspace.get("remark")
222
+ ) or selected_ws_name
223
+ workspace_qf_version = self._workspace_system_version(fallback_workspace) or fallback_qf_version
224
+ elif selected_ws_name is None or workspace_qf_version is None:
225
+ workspace = self._fetch_workspace_with_name_fallback(
226
+ normalized_base_url,
227
+ normalized_token,
228
+ selected_ws_id,
229
+ qf_version=normalized_qf_version,
230
+ qf_version_source=qf_version_source,
231
+ )
232
+ if isinstance(workspace, dict):
233
+ selected_ws_name = self._normalize_text(
234
+ workspace.get("workspaceName")
235
+ or workspace.get("wsName")
236
+ or workspace.get("remark")
237
+ ) or selected_ws_name
238
+ workspace_qf_version = self._workspace_system_version(workspace) or workspace_qf_version
239
+
240
+ if workspace_qf_version is not None:
241
+ resolved_qf_version, resolved_qf_version_source = workspace_qf_version, "workspace_system_version"
242
+ else:
243
+ resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
244
+ response_qf_version,
245
+ fallback_qf_version=normalized_qf_version,
246
+ fallback_source=qf_version_source,
247
+ )
248
+
249
+ uid = self._coerce_positive_int(resolved_user_info.get("uid"))
250
+ if uid is None:
251
+ raise_tool_error(QingflowApiError(category="auth", message="Token validation did not return valid user info"))
252
+
253
+ session_profile = self.sessions.save_session(
254
+ profile=profile,
255
+ base_url=normalized_base_url,
256
+ qf_version=resolved_qf_version,
257
+ qf_version_source=resolved_qf_version_source,
258
+ token=normalized_token,
259
+ login_token=normalized_login_token,
260
+ credential=None,
261
+ uid=uid,
262
+ email=self._normalize_text(resolved_user_info.get("email")),
263
+ nick_name=self._normalize_text(
264
+ resolved_user_info.get("nickName")
265
+ or resolved_user_info.get("displayName")
266
+ or resolved_user_info.get("name")
267
+ ),
268
+ persist=persist,
269
+ )
270
+ if selected_ws_id is not None:
271
+ session_profile = self.sessions.select_workspace(profile, ws_id=selected_ws_id, ws_name=selected_ws_name)
272
+
273
+ return {
274
+ "profile": session_profile.profile,
275
+ "base_url": session_profile.base_url,
276
+ "qf_version": session_profile.qf_version,
277
+ "qf_version_source": session_profile.qf_version_source,
278
+ "uid": session_profile.uid,
279
+ "email": session_profile.email,
280
+ "nick_name": session_profile.nick_name,
281
+ "selected_ws_id": session_profile.selected_ws_id,
282
+ "selected_ws_name": session_profile.selected_ws_name,
283
+ "suggested_ws_id": session_profile.selected_ws_id,
284
+ "suggested_ws_name": session_profile.selected_ws_name,
285
+ "persisted": session_profile.persisted,
286
+ "request_route": self._request_route_payload(
287
+ BackendRequestContext(
288
+ base_url=session_profile.base_url,
289
+ token=normalized_token,
290
+ ws_id=session_profile.selected_ws_id,
291
+ qf_version=session_profile.qf_version,
292
+ qf_version_source=session_profile.qf_version_source,
293
+ )
294
+ ),
295
+ }
296
+
167
297
  def _resolve_mcporter_auth_inputs(self, *, base_url: str | None, credential: str | None) -> tuple[str | None, str]:
168
298
  """从参数或 mcporter 配置解析登录所需 base_url 与 credential。"""
169
299
  normalized_base_url = self._normalize_text(base_url)
@@ -100,7 +100,7 @@ class ToolBase:
100
100
  self.sessions.invalidate(profile)
101
101
  error = QingflowApiError(
102
102
  category="auth",
103
- message=f"Qingflow session for profile '{profile}' has expired. Run auth_use_credential again.",
103
+ message=f"Qingflow session for profile '{profile}' has expired. Run auth login or auth_use_credential again.",
104
104
  backend_code=error.backend_code,
105
105
  request_id=error.request_id,
106
106
  http_status=error.http_status,