@qingflow-tech/qingflow-cli 1.1.41 → 1.1.43
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/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md +1 -1
- package/skills/qingflow-cli/reference/task/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md +1 -1
- package/src/qingflow_mcp/auth_provider.py +522 -0
- 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 +31 -0
- package/src/qingflow_mcp/cloud_context.py +5 -0
- package/src/qingflow_mcp/cloud_oauth_proxy.py +454 -0
- package/src/qingflow_mcp/cloud_redis.py +25 -0
- package/src/qingflow_mcp/config.py +56 -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_app_user.py +2 -1
- package/src/qingflow_mcp/server_cloud.py +265 -22
- package/src/qingflow_mcp/tools/auth_tools.py +163 -1
- package/src/qingflow_mcp/tools/base.py +43 -3
- package/src/qingflow_mcp/tools/task_context_tools.py +6 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Iterator, Literal
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from .config import get_mcp_home, normalize_base_url
|
|
14
|
+
from .errors import QingflowApiError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
OAUTH_PROFILE_SCHEMA_VERSION = 1
|
|
18
|
+
OAuthStateValue = Literal["oauth", "none"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _utcnow() -> str:
|
|
22
|
+
return datetime.now(timezone.utc).isoformat()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def profile_storage_key(profile: str) -> str:
|
|
26
|
+
return hashlib.sha256(profile.encode("utf-8")).hexdigest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class OAuthProfileState:
|
|
31
|
+
active_auth_type: OAuthStateValue
|
|
32
|
+
profile: str
|
|
33
|
+
base_url: str | None = None
|
|
34
|
+
qf_version: str | None = None
|
|
35
|
+
qf_version_source: str | None = None
|
|
36
|
+
uid: int | None = None
|
|
37
|
+
email: str | None = None
|
|
38
|
+
nick_name: str | None = None
|
|
39
|
+
selected_ws_id: int | None = None
|
|
40
|
+
selected_ws_name: str | None = None
|
|
41
|
+
persisted: bool = True
|
|
42
|
+
secret_storage: str | None = None
|
|
43
|
+
issuer: str | None = None
|
|
44
|
+
client_id: str | None = None
|
|
45
|
+
resource: str | None = None
|
|
46
|
+
source_code: str | None = None
|
|
47
|
+
access_expires_at: str | None = None
|
|
48
|
+
created_at: str = ""
|
|
49
|
+
updated_at: str = ""
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, profile: str, value: object) -> "OAuthProfileState":
|
|
53
|
+
if not isinstance(value, dict):
|
|
54
|
+
raise _config_error(profile, "profile entry must be an object")
|
|
55
|
+
active_auth_type = value.get("active_auth_type")
|
|
56
|
+
if active_auth_type not in {"oauth", "none"}:
|
|
57
|
+
raise _config_error(profile, "active_auth_type must be 'oauth' or 'none'")
|
|
58
|
+
stored_profile = value.get("profile")
|
|
59
|
+
if stored_profile != profile:
|
|
60
|
+
raise _config_error(profile, "stored profile name does not match the requested profile")
|
|
61
|
+
secret_storage = _normalized_text(value.get("secret_storage"))
|
|
62
|
+
if active_auth_type == "oauth":
|
|
63
|
+
required_text = ("base_url", "issuer", "client_id", "resource", "source_code", "access_expires_at")
|
|
64
|
+
missing = [name for name in required_text if not _normalized_text(value.get(name))]
|
|
65
|
+
if secret_storage not in {"keyring", "file"}:
|
|
66
|
+
missing.append("secret_storage")
|
|
67
|
+
uid = _positive_int(value.get("uid"))
|
|
68
|
+
if uid is None:
|
|
69
|
+
missing.append("uid")
|
|
70
|
+
if missing:
|
|
71
|
+
raise _config_error(profile, "missing OAuth fields: " + ", ".join(missing))
|
|
72
|
+
created_at = _normalized_text(value.get("created_at")) or _utcnow()
|
|
73
|
+
updated_at = _normalized_text(value.get("updated_at")) or created_at
|
|
74
|
+
return cls(
|
|
75
|
+
active_auth_type=active_auth_type,
|
|
76
|
+
profile=profile,
|
|
77
|
+
base_url=_normalized_text(value.get("base_url")),
|
|
78
|
+
qf_version=_normalized_text(value.get("qf_version")),
|
|
79
|
+
qf_version_source=_normalized_text(value.get("qf_version_source")),
|
|
80
|
+
uid=_positive_int(value.get("uid")),
|
|
81
|
+
email=_normalized_text(value.get("email")),
|
|
82
|
+
nick_name=_normalized_text(value.get("nick_name")),
|
|
83
|
+
selected_ws_id=_positive_int(value.get("selected_ws_id")),
|
|
84
|
+
selected_ws_name=_normalized_text(value.get("selected_ws_name")),
|
|
85
|
+
persisted=bool(value.get("persisted", True)),
|
|
86
|
+
secret_storage=secret_storage,
|
|
87
|
+
issuer=_normalized_text(value.get("issuer")),
|
|
88
|
+
client_id=_normalized_text(value.get("client_id")),
|
|
89
|
+
resource=_normalized_text(value.get("resource")),
|
|
90
|
+
source_code=_normalized_text(value.get("source_code")),
|
|
91
|
+
access_expires_at=_normalized_text(value.get("access_expires_at")),
|
|
92
|
+
created_at=created_at,
|
|
93
|
+
updated_at=updated_at,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def to_dict(self) -> dict[str, object]:
|
|
97
|
+
return asdict(self)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class OAuthProfileStore:
|
|
101
|
+
def __init__(self, base_dir: Path | None = None) -> None:
|
|
102
|
+
self.base_dir = Path(base_dir) if base_dir is not None else get_mcp_home()
|
|
103
|
+
self._profiles_dir = self.base_dir / "oauth-profiles"
|
|
104
|
+
self._locks_dir = self.base_dir / "locks"
|
|
105
|
+
|
|
106
|
+
def get(self, profile: str) -> OAuthProfileState | None:
|
|
107
|
+
path = self.profile_path(profile)
|
|
108
|
+
if not path.exists():
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
112
|
+
payload = json.load(handle)
|
|
113
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
114
|
+
raise _config_error(profile, f"state file cannot be read: {exc}") from exc
|
|
115
|
+
if not isinstance(payload, dict) or payload.get("schema_version") != OAUTH_PROFILE_SCHEMA_VERSION:
|
|
116
|
+
raise _config_error(profile, "unsupported or missing schema_version")
|
|
117
|
+
profiles = payload.get("profiles")
|
|
118
|
+
if not isinstance(profiles, dict) or profile not in profiles:
|
|
119
|
+
raise _config_error(profile, "state file does not contain the requested profile")
|
|
120
|
+
return OAuthProfileState.from_dict(profile, profiles[profile])
|
|
121
|
+
|
|
122
|
+
def save(self, state: OAuthProfileState, *, locked: bool = False) -> None:
|
|
123
|
+
if locked:
|
|
124
|
+
self._save_locked(state)
|
|
125
|
+
return
|
|
126
|
+
with self.lock(state.profile):
|
|
127
|
+
self._save_locked(state)
|
|
128
|
+
|
|
129
|
+
def set_none(self, profile: str, *, locked: bool = False) -> OAuthProfileState:
|
|
130
|
+
now = _utcnow()
|
|
131
|
+
try:
|
|
132
|
+
previous = self.get(profile)
|
|
133
|
+
except QingflowApiError:
|
|
134
|
+
previous = None
|
|
135
|
+
state = OAuthProfileState(
|
|
136
|
+
active_auth_type="none",
|
|
137
|
+
profile=profile,
|
|
138
|
+
persisted=True,
|
|
139
|
+
created_at=previous.created_at if previous is not None else now,
|
|
140
|
+
updated_at=now,
|
|
141
|
+
)
|
|
142
|
+
self.save(state, locked=locked)
|
|
143
|
+
return state
|
|
144
|
+
|
|
145
|
+
def delete(self, profile: str, *, locked: bool = False) -> None:
|
|
146
|
+
if not locked:
|
|
147
|
+
with self.lock(profile):
|
|
148
|
+
self.delete(profile, locked=True)
|
|
149
|
+
return
|
|
150
|
+
try:
|
|
151
|
+
self.profile_path(profile).unlink()
|
|
152
|
+
except FileNotFoundError:
|
|
153
|
+
return
|
|
154
|
+
except OSError as exc:
|
|
155
|
+
raise _config_error(profile, f"state file cannot be removed: {exc}") from exc
|
|
156
|
+
|
|
157
|
+
@contextmanager
|
|
158
|
+
def lock(self, profile: str) -> Iterator[None]:
|
|
159
|
+
lock_path = self._locks_dir / f"{profile_storage_key(profile)}.oauth.lock"
|
|
160
|
+
with _FileLock(lock_path):
|
|
161
|
+
yield
|
|
162
|
+
|
|
163
|
+
def profile_path(self, profile: str) -> Path:
|
|
164
|
+
return self._profiles_dir / f"{profile_storage_key(profile)}.json"
|
|
165
|
+
|
|
166
|
+
def _save_locked(self, state: OAuthProfileState) -> None:
|
|
167
|
+
normalized_base_url = normalize_base_url(state.base_url) if state.base_url else None
|
|
168
|
+
if state.active_auth_type == "oauth" and not normalized_base_url:
|
|
169
|
+
raise _config_error(state.profile, "base_url is invalid")
|
|
170
|
+
state.base_url = normalized_base_url
|
|
171
|
+
state.updated_at = _utcnow()
|
|
172
|
+
if not state.created_at:
|
|
173
|
+
state.created_at = state.updated_at
|
|
174
|
+
payload = {
|
|
175
|
+
"schema_version": OAUTH_PROFILE_SCHEMA_VERSION,
|
|
176
|
+
"profiles": {state.profile: state.to_dict()},
|
|
177
|
+
}
|
|
178
|
+
_atomic_json_write(self.profile_path(state.profile), payload)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class _FileLock:
|
|
182
|
+
def __init__(self, path: Path) -> None:
|
|
183
|
+
self.path = path
|
|
184
|
+
self._handle = None
|
|
185
|
+
|
|
186
|
+
def __enter__(self) -> "_FileLock":
|
|
187
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
self._handle = self.path.open("a+b")
|
|
189
|
+
self._handle.seek(0, os.SEEK_END)
|
|
190
|
+
if self._handle.tell() == 0:
|
|
191
|
+
self._handle.write(b"0")
|
|
192
|
+
self._handle.flush()
|
|
193
|
+
self._handle.seek(0)
|
|
194
|
+
if os.name == "nt":
|
|
195
|
+
import msvcrt
|
|
196
|
+
|
|
197
|
+
msvcrt.locking(self._handle.fileno(), msvcrt.LK_LOCK, 1)
|
|
198
|
+
else:
|
|
199
|
+
import fcntl
|
|
200
|
+
|
|
201
|
+
fcntl.flock(self._handle.fileno(), fcntl.LOCK_EX)
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
def __exit__(self, _exc_type, _exc, _traceback) -> None:
|
|
205
|
+
if self._handle is None:
|
|
206
|
+
return
|
|
207
|
+
try:
|
|
208
|
+
self._handle.seek(0)
|
|
209
|
+
if os.name == "nt":
|
|
210
|
+
import msvcrt
|
|
211
|
+
|
|
212
|
+
msvcrt.locking(self._handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
213
|
+
else:
|
|
214
|
+
import fcntl
|
|
215
|
+
|
|
216
|
+
fcntl.flock(self._handle.fileno(), fcntl.LOCK_UN)
|
|
217
|
+
finally:
|
|
218
|
+
self._handle.close()
|
|
219
|
+
self._handle = None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _atomic_json_write(path: Path, payload: object) -> None:
|
|
223
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
temporary = path.parent / f".{path.name}.{os.getpid()}.{uuid4().hex}.tmp"
|
|
225
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
226
|
+
try:
|
|
227
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
228
|
+
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
|
229
|
+
handle.flush()
|
|
230
|
+
os.fsync(handle.fileno())
|
|
231
|
+
os.replace(temporary, path)
|
|
232
|
+
try:
|
|
233
|
+
os.chmod(path, 0o600)
|
|
234
|
+
except OSError:
|
|
235
|
+
pass
|
|
236
|
+
finally:
|
|
237
|
+
try:
|
|
238
|
+
temporary.unlink()
|
|
239
|
+
except FileNotFoundError:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _normalized_text(value: object) -> str | None:
|
|
244
|
+
if value is None:
|
|
245
|
+
return None
|
|
246
|
+
normalized = str(value).strip()
|
|
247
|
+
return normalized or None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _positive_int(value: object) -> int | None:
|
|
251
|
+
if value is None or isinstance(value, bool):
|
|
252
|
+
return None
|
|
253
|
+
try:
|
|
254
|
+
parsed = int(value)
|
|
255
|
+
except (TypeError, ValueError):
|
|
256
|
+
return None
|
|
257
|
+
return parsed if parsed > 0 else None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _config_error(profile: str, reason: str) -> QingflowApiError:
|
|
261
|
+
return QingflowApiError.config_error(
|
|
262
|
+
f"OAuth state for profile '{profile}' is invalid: {reason}.",
|
|
263
|
+
details={"reason": "oauth_state_invalid", "profile": profile},
|
|
264
|
+
)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import keyring
|
|
9
|
+
except ImportError:
|
|
10
|
+
keyring = None
|
|
11
|
+
|
|
12
|
+
from .errors import QingflowApiError
|
|
13
|
+
from .json_types import KeyringBackend
|
|
14
|
+
from .oauth_profile_store import OAuthProfileStore, _atomic_json_write, profile_storage_key
|
|
15
|
+
from .session_store import KEYRING_SERVICE_NAME
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class OAuthTokenBundle:
|
|
20
|
+
access_token: str
|
|
21
|
+
refresh_token: str
|
|
22
|
+
access_expires_at: str
|
|
23
|
+
generation: int
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_json(cls, profile: str, value: object) -> "OAuthTokenBundle":
|
|
27
|
+
if not isinstance(value, dict):
|
|
28
|
+
raise _bundle_error(profile, "bundle must be an object")
|
|
29
|
+
access_token = _text(value.get("access_token"))
|
|
30
|
+
refresh_token = _text(value.get("refresh_token"))
|
|
31
|
+
access_expires_at = _text(value.get("access_expires_at"))
|
|
32
|
+
generation_value = value.get("generation")
|
|
33
|
+
if isinstance(generation_value, bool):
|
|
34
|
+
generation = -1
|
|
35
|
+
else:
|
|
36
|
+
try:
|
|
37
|
+
generation = int(generation_value)
|
|
38
|
+
except (TypeError, ValueError):
|
|
39
|
+
generation = -1
|
|
40
|
+
missing = []
|
|
41
|
+
if not access_token:
|
|
42
|
+
missing.append("access_token")
|
|
43
|
+
if not refresh_token:
|
|
44
|
+
missing.append("refresh_token")
|
|
45
|
+
if not access_expires_at:
|
|
46
|
+
missing.append("access_expires_at")
|
|
47
|
+
if generation < 0:
|
|
48
|
+
missing.append("generation")
|
|
49
|
+
if missing:
|
|
50
|
+
raise _bundle_error(profile, "missing or invalid fields: " + ", ".join(missing))
|
|
51
|
+
return cls(
|
|
52
|
+
access_token=access_token,
|
|
53
|
+
refresh_token=refresh_token,
|
|
54
|
+
access_expires_at=access_expires_at,
|
|
55
|
+
generation=generation,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def to_json(self) -> dict[str, object]:
|
|
59
|
+
return asdict(self)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class OAuthTokenStore:
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
profile_store: OAuthProfileStore,
|
|
66
|
+
*,
|
|
67
|
+
keyring_backend: KeyringBackend | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
self.profile_store = profile_store
|
|
70
|
+
self._keyring = keyring_backend if keyring_backend is not None else keyring
|
|
71
|
+
self._secrets_dir = profile_store.base_dir / "oauth-secrets"
|
|
72
|
+
|
|
73
|
+
def load(self, profile: str) -> OAuthTokenBundle | None:
|
|
74
|
+
state = self.profile_store.get(profile)
|
|
75
|
+
if state is not None and state.active_auth_type == "none":
|
|
76
|
+
return None
|
|
77
|
+
if state is not None and state.secret_storage == "file":
|
|
78
|
+
return self._load_file(profile)
|
|
79
|
+
if state is not None and state.secret_storage == "keyring":
|
|
80
|
+
keyring_value = self._get_keyring(profile)
|
|
81
|
+
return self._decode(profile, keyring_value, storage="keyring") if keyring_value is not None else None
|
|
82
|
+
keyring_value = self._get_keyring(profile)
|
|
83
|
+
if keyring_value is not None:
|
|
84
|
+
return self._decode(profile, keyring_value, storage="keyring")
|
|
85
|
+
return self._load_file(profile)
|
|
86
|
+
|
|
87
|
+
def _load_file(self, profile: str) -> OAuthTokenBundle | None:
|
|
88
|
+
path = self.secret_path(profile)
|
|
89
|
+
if not path.exists():
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
93
|
+
payload = json.load(handle)
|
|
94
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
95
|
+
raise _bundle_error(profile, f"file bundle cannot be read: {exc}") from exc
|
|
96
|
+
return OAuthTokenBundle.from_json(profile, payload)
|
|
97
|
+
|
|
98
|
+
def save(
|
|
99
|
+
self,
|
|
100
|
+
profile: str,
|
|
101
|
+
bundle: OAuthTokenBundle,
|
|
102
|
+
*,
|
|
103
|
+
storage: str | None = None,
|
|
104
|
+
locked: bool = False,
|
|
105
|
+
) -> str:
|
|
106
|
+
if not locked:
|
|
107
|
+
with self.profile_store.lock(profile):
|
|
108
|
+
state = self.profile_store.get(profile)
|
|
109
|
+
selected_storage = storage
|
|
110
|
+
if selected_storage is None and state is not None and state.active_auth_type == "oauth":
|
|
111
|
+
selected_storage = state.secret_storage
|
|
112
|
+
saved_storage = self.save(profile, bundle, storage=selected_storage, locked=True)
|
|
113
|
+
if state is not None and state.active_auth_type == "oauth":
|
|
114
|
+
state.secret_storage = saved_storage
|
|
115
|
+
state.access_expires_at = bundle.access_expires_at
|
|
116
|
+
self.profile_store.save(state, locked=True)
|
|
117
|
+
return saved_storage
|
|
118
|
+
if storage not in {None, "keyring", "file"}:
|
|
119
|
+
raise _bundle_error(profile, f"unsupported secret storage: {storage}")
|
|
120
|
+
encoded = json.dumps(bundle.to_json(), ensure_ascii=False, separators=(",", ":"))
|
|
121
|
+
if storage == "keyring":
|
|
122
|
+
if not self._set_keyring(profile, encoded):
|
|
123
|
+
raise _bundle_error(profile, "keyring write failed")
|
|
124
|
+
self._delete_file(profile)
|
|
125
|
+
return "keyring"
|
|
126
|
+
if storage == "file":
|
|
127
|
+
self._save_file(profile, bundle)
|
|
128
|
+
self._delete_keyring(profile)
|
|
129
|
+
return "file"
|
|
130
|
+
if self._set_keyring(profile, encoded):
|
|
131
|
+
self._delete_file(profile)
|
|
132
|
+
return "keyring"
|
|
133
|
+
self._save_file(profile, bundle)
|
|
134
|
+
self._delete_keyring(profile)
|
|
135
|
+
return "file"
|
|
136
|
+
|
|
137
|
+
def delete(self, profile: str, *, locked: bool = False) -> None:
|
|
138
|
+
if not locked:
|
|
139
|
+
with self.profile_store.lock(profile):
|
|
140
|
+
self.delete(profile, locked=True)
|
|
141
|
+
return
|
|
142
|
+
self._delete_keyring(profile)
|
|
143
|
+
self._delete_file(profile)
|
|
144
|
+
|
|
145
|
+
def secret_path(self, profile: str) -> Path:
|
|
146
|
+
return self._secrets_dir / f"{profile_storage_key(profile)}.json"
|
|
147
|
+
|
|
148
|
+
def _decode(self, profile: str, encoded: str, *, storage: str) -> OAuthTokenBundle:
|
|
149
|
+
try:
|
|
150
|
+
value = json.loads(encoded)
|
|
151
|
+
except json.JSONDecodeError as exc:
|
|
152
|
+
raise _bundle_error(profile, f"{storage} bundle is not valid JSON") from exc
|
|
153
|
+
return OAuthTokenBundle.from_json(profile, value)
|
|
154
|
+
|
|
155
|
+
def _key(self, profile: str) -> str:
|
|
156
|
+
return f"{profile}:oauth-token-set"
|
|
157
|
+
|
|
158
|
+
def _get_keyring(self, profile: str) -> str | None:
|
|
159
|
+
if self._keyring is None:
|
|
160
|
+
return None
|
|
161
|
+
try:
|
|
162
|
+
value = self._keyring.get_password(KEYRING_SERVICE_NAME, self._key(profile))
|
|
163
|
+
except Exception:
|
|
164
|
+
return None
|
|
165
|
+
return value if value else None
|
|
166
|
+
|
|
167
|
+
def _set_keyring(self, profile: str, value: str) -> bool:
|
|
168
|
+
if self._keyring is None:
|
|
169
|
+
return False
|
|
170
|
+
try:
|
|
171
|
+
self._keyring.set_password(KEYRING_SERVICE_NAME, self._key(profile), value)
|
|
172
|
+
except Exception:
|
|
173
|
+
return False
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
def _delete_keyring(self, profile: str) -> None:
|
|
177
|
+
if self._keyring is None:
|
|
178
|
+
return
|
|
179
|
+
try:
|
|
180
|
+
self._keyring.delete_password(KEYRING_SERVICE_NAME, self._key(profile))
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
def _save_file(self, profile: str, bundle: OAuthTokenBundle) -> None:
|
|
185
|
+
try:
|
|
186
|
+
_atomic_json_write(self.secret_path(profile), bundle.to_json())
|
|
187
|
+
except OSError as exc:
|
|
188
|
+
raise _bundle_error(profile, f"file bundle cannot be written: {exc}") from exc
|
|
189
|
+
|
|
190
|
+
def _delete_file(self, profile: str) -> None:
|
|
191
|
+
try:
|
|
192
|
+
self.secret_path(profile).unlink()
|
|
193
|
+
except FileNotFoundError:
|
|
194
|
+
return
|
|
195
|
+
except OSError as exc:
|
|
196
|
+
raise _bundle_error(profile, f"file bundle cannot be removed: {exc}") from exc
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _text(value: object) -> str | None:
|
|
200
|
+
if value is None:
|
|
201
|
+
return None
|
|
202
|
+
normalized = str(value).strip()
|
|
203
|
+
return normalized or None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _bundle_error(profile: str, reason: str) -> QingflowApiError:
|
|
207
|
+
return QingflowApiError.config_error(
|
|
208
|
+
f"OAuth token bundle for profile '{profile}' is invalid: {reason}.",
|
|
209
|
+
details={"reason": "oauth_token_bundle_invalid", "profile": profile},
|
|
210
|
+
)
|
|
@@ -22,11 +22,12 @@ from .tools.task_context_tools import TaskContextTools
|
|
|
22
22
|
from .tools.workspace_tools import WorkspaceTools
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def build_user_server() -> FastMCP:
|
|
25
|
+
def build_user_server(*, stateless_http: bool = False) -> FastMCP:
|
|
26
26
|
today = date.today()
|
|
27
27
|
current_year = today.year
|
|
28
28
|
server = FastMCP(
|
|
29
29
|
"Qingflow App User MCP",
|
|
30
|
+
stateless_http=stateless_http,
|
|
30
31
|
instructions=f"""Use this server for Qingflow operational workflows. Current date: `{today.isoformat()}`.
|
|
31
32
|
|
|
32
33
|
## App Discovery
|