@qingflow-tech/qingflow-cli 1.1.43-beta.5 → 1.1.44-beta.1
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 +3 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/reference/builder/30-schema-fields.md +4 -0
- package/src/qingflow_mcp/auth_provider.py +522 -0
- package/src/qingflow_mcp/builder_facade/app_form.py +254 -50
- package/src/qingflow_mcp/cli/commands/auth.py +125 -23
- package/src/qingflow_mcp/cli/context.py +13 -2
- package/src/qingflow_mcp/cli/main.py +9 -1
- package/src/qingflow_mcp/cli/oauth_callback.py +140 -0
- package/src/qingflow_mcp/cli/oauth_client.py +276 -0
- package/src/qingflow_mcp/cloud_auth.py +20 -0
- package/src/qingflow_mcp/cloud_oauth_proxy.py +1 -0
- package/src/qingflow_mcp/config.py +12 -0
- package/src/qingflow_mcp/oauth_profile_store.py +264 -0
- package/src/qingflow_mcp/oauth_token_store.py +210 -0
- package/src/qingflow_mcp/server_cloud.py +21 -1
- package/src/qingflow_mcp/tools/auth_tools.py +163 -1
- package/src/qingflow_mcp/tools/base.py +41 -2
|
@@ -6,6 +6,7 @@ from typing import Any
|
|
|
6
6
|
|
|
7
7
|
from mcp.server.fastmcp import FastMCP
|
|
8
8
|
|
|
9
|
+
from ..auth_provider import CliSessionFacade
|
|
9
10
|
from ..backend_client import BackendRequestContext, BackendResponse
|
|
10
11
|
from ..cloud_context import current_cloud_context
|
|
11
12
|
from ..config import (
|
|
@@ -16,6 +17,7 @@ from ..config import (
|
|
|
16
17
|
normalize_base_url,
|
|
17
18
|
)
|
|
18
19
|
from ..errors import QingflowApiError, backend_code_int, is_auth_like_error, raise_tool_error
|
|
20
|
+
from ..oauth_token_store import OAuthTokenBundle
|
|
19
21
|
from ..session_store import SessionStore
|
|
20
22
|
from .base import ToolBase
|
|
21
23
|
|
|
@@ -29,7 +31,7 @@ class AuthTools(ToolBase):
|
|
|
29
31
|
3. 退出并清理当前 profile 会话。
|
|
30
32
|
"""
|
|
31
33
|
|
|
32
|
-
def __init__(self, sessions: SessionStore, backend) -> None:
|
|
34
|
+
def __init__(self, sessions: SessionStore | CliSessionFacade, backend) -> None:
|
|
33
35
|
"""执行内部辅助逻辑。"""
|
|
34
36
|
super().__init__(sessions, backend)
|
|
35
37
|
self.sessions.set_profile_refresher(self._refresh_profile_via_credential)
|
|
@@ -307,6 +309,130 @@ class AuthTools(ToolBase):
|
|
|
307
309
|
),
|
|
308
310
|
}
|
|
309
311
|
|
|
312
|
+
def auth_use_oauth_token(
|
|
313
|
+
self,
|
|
314
|
+
*,
|
|
315
|
+
profile: str,
|
|
316
|
+
base_url: str,
|
|
317
|
+
qf_version: str | None,
|
|
318
|
+
issuer: str,
|
|
319
|
+
resource: str,
|
|
320
|
+
bundle: OAuthTokenBundle,
|
|
321
|
+
) -> dict[str, Any]:
|
|
322
|
+
"""使用 OAuth Token Bundle 建立 CLI 专用会话。"""
|
|
323
|
+
if not isinstance(self.sessions, CliSessionFacade):
|
|
324
|
+
raise_tool_error(QingflowApiError.config_error("OAuth login is only available in the Qingflow CLI."))
|
|
325
|
+
normalized_base_url = self._normalize_base_url(base_url)
|
|
326
|
+
normalized_qf_version, qf_version_source = self._resolve_qf_version_input(qf_version)
|
|
327
|
+
resolved_user_info, response_qf_version = self._fetch_user_info(
|
|
328
|
+
normalized_base_url,
|
|
329
|
+
bundle.access_token,
|
|
330
|
+
None,
|
|
331
|
+
qf_version=normalized_qf_version,
|
|
332
|
+
qf_version_source=qf_version_source,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
last_workspace = resolved_user_info.get("lastWsInfo")
|
|
336
|
+
selected_ws_id = self._coerce_positive_int(
|
|
337
|
+
last_workspace.get("wsId") if isinstance(last_workspace, dict) else None
|
|
338
|
+
)
|
|
339
|
+
selected_ws_name = self._normalize_text(
|
|
340
|
+
(last_workspace.get("wsName") if isinstance(last_workspace, dict) else None)
|
|
341
|
+
or (last_workspace.get("workspaceName") if isinstance(last_workspace, dict) else None)
|
|
342
|
+
or (last_workspace.get("remark") if isinstance(last_workspace, dict) else None)
|
|
343
|
+
)
|
|
344
|
+
workspace_qf_version = (
|
|
345
|
+
self._workspace_system_version(last_workspace) if isinstance(last_workspace, dict) else None
|
|
346
|
+
)
|
|
347
|
+
if selected_ws_id is None:
|
|
348
|
+
fallback_workspace, fallback_qf_version = self._fetch_first_workspace(
|
|
349
|
+
normalized_base_url,
|
|
350
|
+
bundle.access_token,
|
|
351
|
+
qf_version=normalized_qf_version,
|
|
352
|
+
qf_version_source=qf_version_source,
|
|
353
|
+
)
|
|
354
|
+
if isinstance(fallback_workspace, dict):
|
|
355
|
+
selected_ws_id = self._coerce_positive_int(fallback_workspace.get("wsId"))
|
|
356
|
+
selected_ws_name = self._normalize_text(
|
|
357
|
+
fallback_workspace.get("workspaceName")
|
|
358
|
+
or fallback_workspace.get("wsName")
|
|
359
|
+
or fallback_workspace.get("remark")
|
|
360
|
+
) or selected_ws_name
|
|
361
|
+
workspace_qf_version = self._workspace_system_version(fallback_workspace) or fallback_qf_version
|
|
362
|
+
elif selected_ws_name is None or workspace_qf_version is None:
|
|
363
|
+
workspace = self._fetch_workspace_with_name_fallback(
|
|
364
|
+
normalized_base_url,
|
|
365
|
+
bundle.access_token,
|
|
366
|
+
selected_ws_id,
|
|
367
|
+
qf_version=normalized_qf_version,
|
|
368
|
+
qf_version_source=qf_version_source,
|
|
369
|
+
)
|
|
370
|
+
if isinstance(workspace, dict):
|
|
371
|
+
selected_ws_name = self._normalize_text(
|
|
372
|
+
workspace.get("workspaceName")
|
|
373
|
+
or workspace.get("wsName")
|
|
374
|
+
or workspace.get("remark")
|
|
375
|
+
) or selected_ws_name
|
|
376
|
+
workspace_qf_version = self._workspace_system_version(workspace) or workspace_qf_version
|
|
377
|
+
|
|
378
|
+
if workspace_qf_version is not None:
|
|
379
|
+
resolved_qf_version, resolved_qf_version_source = workspace_qf_version, "workspace_system_version"
|
|
380
|
+
else:
|
|
381
|
+
resolved_qf_version, resolved_qf_version_source = self._resolve_backend_qf_version(
|
|
382
|
+
response_qf_version,
|
|
383
|
+
fallback_qf_version=normalized_qf_version,
|
|
384
|
+
fallback_source=qf_version_source,
|
|
385
|
+
)
|
|
386
|
+
uid = self._coerce_positive_int(resolved_user_info.get("uid"))
|
|
387
|
+
if uid is None:
|
|
388
|
+
raise_tool_error(QingflowApiError(category="auth", message="Token validation did not return valid user info"))
|
|
389
|
+
|
|
390
|
+
state = self.sessions.activate_oauth(
|
|
391
|
+
profile=profile,
|
|
392
|
+
base_url=normalized_base_url,
|
|
393
|
+
qf_version=resolved_qf_version,
|
|
394
|
+
qf_version_source=resolved_qf_version_source,
|
|
395
|
+
uid=uid,
|
|
396
|
+
email=self._normalize_text(resolved_user_info.get("email")),
|
|
397
|
+
nick_name=self._normalize_text(
|
|
398
|
+
resolved_user_info.get("nickName")
|
|
399
|
+
or resolved_user_info.get("displayName")
|
|
400
|
+
or resolved_user_info.get("name")
|
|
401
|
+
),
|
|
402
|
+
selected_ws_id=selected_ws_id,
|
|
403
|
+
selected_ws_name=selected_ws_name,
|
|
404
|
+
issuer=issuer,
|
|
405
|
+
resource=resource,
|
|
406
|
+
bundle=bundle,
|
|
407
|
+
)
|
|
408
|
+
return {
|
|
409
|
+
"profile": state.profile,
|
|
410
|
+
"base_url": state.base_url,
|
|
411
|
+
"qf_version": state.qf_version,
|
|
412
|
+
"qf_version_source": state.qf_version_source,
|
|
413
|
+
"uid": state.uid,
|
|
414
|
+
"email": state.email,
|
|
415
|
+
"nick_name": state.nick_name,
|
|
416
|
+
"selected_ws_id": state.selected_ws_id,
|
|
417
|
+
"selected_ws_name": state.selected_ws_name,
|
|
418
|
+
"suggested_ws_id": state.selected_ws_id,
|
|
419
|
+
"suggested_ws_name": state.selected_ws_name,
|
|
420
|
+
"persisted": True,
|
|
421
|
+
"active_auth_type": "oauth",
|
|
422
|
+
"auth_source": "oauth_pkce",
|
|
423
|
+
"source_code": state.source_code,
|
|
424
|
+
"access_expires_at": state.access_expires_at,
|
|
425
|
+
"request_route": self._request_route_payload(
|
|
426
|
+
BackendRequestContext(
|
|
427
|
+
base_url=state.base_url or normalized_base_url,
|
|
428
|
+
token=bundle.access_token,
|
|
429
|
+
ws_id=state.selected_ws_id,
|
|
430
|
+
qf_version=state.qf_version,
|
|
431
|
+
qf_version_source=state.qf_version_source,
|
|
432
|
+
)
|
|
433
|
+
),
|
|
434
|
+
}
|
|
435
|
+
|
|
310
436
|
def _resolve_auth_inputs(
|
|
311
437
|
self,
|
|
312
438
|
*,
|
|
@@ -380,6 +506,14 @@ class AuthTools(ToolBase):
|
|
|
380
506
|
|
|
381
507
|
def auth_whoami(self, *, profile: str = DEFAULT_PROFILE) -> dict[str, Any]:
|
|
382
508
|
"""执行认证与会话相关逻辑。"""
|
|
509
|
+
if isinstance(self.sessions, CliSessionFacade) and self.sessions.oauth_state(profile) == "none":
|
|
510
|
+
return {
|
|
511
|
+
"profile": profile,
|
|
512
|
+
"active_auth_type": "none",
|
|
513
|
+
"auth_source": "none",
|
|
514
|
+
"persisted": False,
|
|
515
|
+
}
|
|
516
|
+
|
|
383
517
|
def build_response(
|
|
384
518
|
session_profile, # type: ignore[no-untyped-def]
|
|
385
519
|
backend_session, # type: ignore[no-untyped-def]
|
|
@@ -439,6 +573,13 @@ class AuthTools(ToolBase):
|
|
|
439
573
|
"persisted": session_profile.persisted,
|
|
440
574
|
"request_route": self._request_route_payload(context),
|
|
441
575
|
}
|
|
576
|
+
if isinstance(self.sessions, CliSessionFacade):
|
|
577
|
+
active_auth_type = self.sessions.active_auth_type(profile)
|
|
578
|
+
response["active_auth_type"] = active_auth_type
|
|
579
|
+
if active_auth_type == "oauth":
|
|
580
|
+
response.update(self.sessions.oauth_details(profile))
|
|
581
|
+
else:
|
|
582
|
+
response["auth_source"] = active_auth_type
|
|
442
583
|
member_info, member_warnings = self._workspace_member_info(
|
|
443
584
|
session_profile=session_profile,
|
|
444
585
|
backend_session=backend_session,
|
|
@@ -468,8 +609,18 @@ class AuthTools(ToolBase):
|
|
|
468
609
|
return build_response(refreshed_profile, refreshed_backend_session, refreshed_context)
|
|
469
610
|
except QingflowApiError as refreshed_error:
|
|
470
611
|
self._handle_error(profile, refreshed_error)
|
|
612
|
+
if error.looks_like_invalid_token() and self._refresh_oauth_session(profile):
|
|
613
|
+
try:
|
|
614
|
+
refreshed_profile, refreshed_backend_session, refreshed_context = self._require_context(
|
|
615
|
+
profile,
|
|
616
|
+
require_workspace=False,
|
|
617
|
+
)
|
|
618
|
+
return build_response(refreshed_profile, refreshed_backend_session, refreshed_context)
|
|
619
|
+
except QingflowApiError as refreshed_error:
|
|
620
|
+
self._handle_error(profile, refreshed_error)
|
|
471
621
|
if (
|
|
472
622
|
error.looks_like_invalid_token()
|
|
623
|
+
and not self._uses_oauth(profile)
|
|
473
624
|
and session_profile is not None
|
|
474
625
|
and backend_session is not None
|
|
475
626
|
and self._refresh_session_from_credential(
|
|
@@ -511,6 +662,17 @@ class AuthTools(ToolBase):
|
|
|
511
662
|
|
|
512
663
|
def auth_logout(self, *, profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict[str, Any]:
|
|
513
664
|
"""执行认证与会话相关逻辑。"""
|
|
665
|
+
if isinstance(self.sessions, CliSessionFacade) and self.sessions.uses_oauth(profile):
|
|
666
|
+
warnings = self.sessions.oauth_logout(profile)
|
|
667
|
+
result: dict[str, Any] = {
|
|
668
|
+
"profile": profile,
|
|
669
|
+
"logged_out": True,
|
|
670
|
+
"forgot_persisted": True,
|
|
671
|
+
"active_auth_type": "none",
|
|
672
|
+
}
|
|
673
|
+
if warnings:
|
|
674
|
+
result["warnings"] = warnings
|
|
675
|
+
return result
|
|
514
676
|
if not self.sessions.has_profile(profile):
|
|
515
677
|
raise_tool_error(QingflowApiError.auth_required(profile))
|
|
516
678
|
self.sessions.logout(profile, forget_persisted=forget_persisted)
|
|
@@ -5,6 +5,7 @@ from contextvars import ContextVar
|
|
|
5
5
|
from typing import Any, Callable, TypeVar
|
|
6
6
|
|
|
7
7
|
from ..backend_client import BackendRequestContext, BackendClient
|
|
8
|
+
from ..auth_provider import CliSessionFacade
|
|
8
9
|
from ..cloud_context import current_call_platform, current_cloud_context
|
|
9
10
|
from ..config import (
|
|
10
11
|
get_credit_meter_enabled,
|
|
@@ -21,7 +22,7 @@ _RUN_DEPTH: ContextVar[int] = ContextVar("qingflow_mcp_tool_run_depth", default=
|
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
class ToolBase:
|
|
24
|
-
def __init__(self, sessions: SessionStore, backend: BackendClient) -> None:
|
|
25
|
+
def __init__(self, sessions: SessionStore | CliSessionFacade, backend: BackendClient) -> None:
|
|
25
26
|
self.sessions = sessions
|
|
26
27
|
self.backend = backend
|
|
27
28
|
|
|
@@ -30,7 +31,8 @@ class ToolBase:
|
|
|
30
31
|
if cloud_context is not None:
|
|
31
32
|
return cloud_context.as_tool_context(profile)
|
|
32
33
|
|
|
33
|
-
self.
|
|
34
|
+
if not self._uses_oauth(profile):
|
|
35
|
+
self._ensure_environment_credential_matches(profile)
|
|
34
36
|
session_profile = self.sessions.get_profile(profile)
|
|
35
37
|
if session_profile is None:
|
|
36
38
|
raise QingflowApiError.auth_required(profile)
|
|
@@ -83,8 +85,18 @@ class ToolBase:
|
|
|
83
85
|
return func(refreshed_profile, refreshed_context)
|
|
84
86
|
except QingflowApiError as refreshed_error:
|
|
85
87
|
self._handle_error(profile, refreshed_error)
|
|
88
|
+
if error.looks_like_invalid_token() and self._refresh_oauth_session(profile):
|
|
89
|
+
try:
|
|
90
|
+
refreshed_profile, _, refreshed_context = self._require_context(
|
|
91
|
+
profile,
|
|
92
|
+
require_workspace=require_workspace,
|
|
93
|
+
)
|
|
94
|
+
return func(refreshed_profile, refreshed_context)
|
|
95
|
+
except QingflowApiError as refreshed_error:
|
|
96
|
+
self._handle_error(profile, refreshed_error)
|
|
86
97
|
if (
|
|
87
98
|
error.looks_like_invalid_token()
|
|
99
|
+
and not self._uses_oauth(profile)
|
|
88
100
|
and session_profile is not None
|
|
89
101
|
and backend_session is not None
|
|
90
102
|
and self._refresh_session_from_credential(
|
|
@@ -121,6 +133,19 @@ class ToolBase:
|
|
|
121
133
|
http_status=error.http_status,
|
|
122
134
|
)
|
|
123
135
|
raise_tool_error(refreshed_error)
|
|
136
|
+
if self._uses_oauth(profile):
|
|
137
|
+
oauth_error = QingflowApiError(
|
|
138
|
+
category="auth",
|
|
139
|
+
message=(
|
|
140
|
+
f"OAuth session for profile '{profile}' has expired and could not be refreshed. "
|
|
141
|
+
"Run auth login --method oauth again."
|
|
142
|
+
),
|
|
143
|
+
backend_code=error.backend_code,
|
|
144
|
+
request_id=error.request_id,
|
|
145
|
+
http_status=error.http_status,
|
|
146
|
+
details={"reason": "oauth_relogin_required"},
|
|
147
|
+
)
|
|
148
|
+
raise_tool_error(oauth_error)
|
|
124
149
|
self.sessions.invalidate(profile)
|
|
125
150
|
error = QingflowApiError(
|
|
126
151
|
category="auth",
|
|
@@ -142,6 +167,20 @@ class ToolBase:
|
|
|
142
167
|
current_cloud_context.set(refreshed_context)
|
|
143
168
|
return True
|
|
144
169
|
|
|
170
|
+
def _refresh_oauth_session(self, profile: str) -> bool:
|
|
171
|
+
if not self._uses_oauth(profile):
|
|
172
|
+
return False
|
|
173
|
+
assert isinstance(self.sessions, CliSessionFacade)
|
|
174
|
+
try:
|
|
175
|
+
return self.sessions.refresh_oauth_session(profile, force=True)
|
|
176
|
+
except QingflowApiError as error:
|
|
177
|
+
if (error.details or {}).get("reason") == "oauth_relogin_required":
|
|
178
|
+
raise
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
def _uses_oauth(self, profile: str) -> bool:
|
|
182
|
+
return isinstance(self.sessions, CliSessionFacade) and self.sessions.uses_oauth(profile)
|
|
183
|
+
|
|
145
184
|
def _refresh_session_from_credential(
|
|
146
185
|
self,
|
|
147
186
|
profile: str,
|