mcp-uof 0.1.0__py3-none-any.whl

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.
mcp_uof/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """UOF MCP server package."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.1.0"
mcp_uof/_log.py ADDED
@@ -0,0 +1,15 @@
1
+ """共用診斷輸出:一律走 stderr。
2
+
3
+ stdio MCP 下 stdout 專供 JSON-RPC,任何混入 stdout 的文字都會破壞協定。診斷/log 訊息一律經
4
+ 本模組輸出到 stderr。這條規則只在這裡有單一實作——各模組 `from .._log import eprint`,不要再各自
5
+ 複製一份(複製就會在某處漏改而污染 stdout)。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+
12
+ def eprint(*args, **kwargs) -> None:
13
+ """print 到 stderr(呼叫端可覆寫 file)。"""
14
+ kwargs.setdefault("file", sys.stderr)
15
+ print(*args, **kwargs)
@@ -0,0 +1,68 @@
1
+ """
2
+ UOF MCP Server — Authentication package.
3
+
4
+ Two providers coexist behind a common ABC:
5
+
6
+ - TokenAuthProvider — RSA-encrypted account/password → SOAP GetToken → bearer token
7
+ (works against UOF deployments with PublicAPI module installed)
8
+ - SessionAuthProvider — Login.aspx form post → ASPXFORMSAUTH cookie + storage state
9
+ (works against UOF deployments without PublicAPI; web-only)
10
+
11
+ There is no user-selectable mode: which mechanism (SOAP/token vs web/session) a tool uses is
12
+ a per-tool decision baked into ops.router. Both providers coexist and are built lazily from the
13
+ same identity (one process = one UOF_ACCOUNT). See docs/architecture.md.
14
+ """
15
+
16
+ from .base import (
17
+ AuthMode,
18
+ AuthProvider,
19
+ get_provider,
20
+ get_session_provider,
21
+ get_token_provider,
22
+ require_auth,
23
+ )
24
+ from .token import TokenAuthProvider
25
+
26
+ # Kept for backwards compatibility with existing imports that did `from .auth import get_token`.
27
+ # Returns the SOAP identity's bearer token; tools/mechanisms should prefer their own provider.
28
+ def get_token() -> str:
29
+ return get_token_provider().fetch_token()
30
+
31
+
32
+ def rsa_encrypt(public_key_base64: str, plaintext: str) -> str:
33
+ """Compatibility shim — RSA-encrypt with the token provider's helper.
34
+
35
+ Only meaningful in token mode (session mode never RSA-encrypts). Kept so existing
36
+ callers/tests that did `from mcp_uof.auth import rsa_encrypt` keep working after the
37
+ auth package split.
38
+ """
39
+ from .token import _rsa_encrypt
40
+ return _rsa_encrypt(public_key_base64, plaintext)
41
+
42
+
43
+ def read_credentials():
44
+ """Compatibility shim used by domains/system/tools.py."""
45
+ provider = get_provider()
46
+ if not hasattr(provider, "read_credentials"):
47
+ return None
48
+ return provider.read_credentials()
49
+
50
+
51
+ def credentials_file() -> str:
52
+ """Compatibility shim — returns the active provider's persistence file."""
53
+ return get_provider().credentials_file()
54
+
55
+
56
+ __all__ = [
57
+ "AuthMode",
58
+ "AuthProvider",
59
+ "TokenAuthProvider",
60
+ "credentials_file",
61
+ "get_provider",
62
+ "get_session_provider",
63
+ "get_token",
64
+ "get_token_provider",
65
+ "read_credentials",
66
+ "require_auth",
67
+ "rsa_encrypt",
68
+ ]
mcp_uof/auth/base.py ADDED
@@ -0,0 +1,176 @@
1
+ """
2
+ Auth provider base + the two per-mechanism providers.
3
+
4
+ `AuthProvider` defines the minimal surface every concrete provider must satisfy. There are
5
+ two: `get_token_provider()` (SOAP/PublicAPI — RSA + GetToken) and `get_session_provider()`
6
+ (web — Login.aspx cookie). Each is cached per process so it can hold long-lived state
7
+ (token cache, browser session). `require_auth` validates whichever the tool's bound
8
+ mechanism needs; `get_provider()` remains as a token-provider alias for compat shims.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from abc import ABC, abstractmethod
14
+ from enum import Enum
15
+ from functools import wraps
16
+ from typing import Any, Callable, Optional
17
+
18
+
19
+ class AuthMode(str, Enum):
20
+ """認證機制描述符:token = RSA + GetToken(SOAP 機制用);session = Login.aspx cookie(web 機制用)。
21
+
22
+ 這只是各 UOF 機制標記自己用哪種認證,不是使用者可選的模式。
23
+ """
24
+ TOKEN = "token"
25
+ SESSION = "session"
26
+
27
+
28
+ class AuthProvider(ABC):
29
+ """Abstract auth provider; both token + session implementations live behind this."""
30
+
31
+ mode: AuthMode # set by subclass
32
+
33
+ @abstractmethod
34
+ def ensure_valid(self) -> None:
35
+ """Refresh credentials if needed. Raises RuntimeError on hard auth failure."""
36
+
37
+ @abstractmethod
38
+ def status_report(self) -> str:
39
+ """Human-readable check_auth output. Triggers a refresh if needed."""
40
+
41
+ @abstractmethod
42
+ def credentials_file(self) -> str:
43
+ """Path to the on-disk credential/cookie cache for this identity."""
44
+
45
+ @abstractmethod
46
+ def clear(self, all_identities: bool = False) -> None:
47
+ """Wipe cached credentials (memory + disk)."""
48
+
49
+ def required_env_help(self) -> str:
50
+ """Bullet list of env vars this provider requires — shown when auth fails."""
51
+ return (
52
+ "- `UOF_BASE_URL`\n"
53
+ "- `UOF_ACCOUNT`\n"
54
+ "- `UOF_PASSWORD`"
55
+ )
56
+
57
+
58
+ def auth_failure_message(detail: str = "") -> str:
59
+ """
60
+ 登入/憑證失敗時給介接 AI 的固定訊息。
61
+
62
+ 目的:帳號密碼失效或登入有誤時,要讓介接的 AI 對使用者清楚、固定地說明這是設定層級
63
+ 的登入問題,而不是讓 AI 自行猜測或沿用對話記憶中的帳號資訊臆測。
64
+ """
65
+ account = os.getenv("UOF_ACCOUNT", "(未設定)")
66
+ base_url = os.getenv("UOF_BASE_URL", "(未設定)")
67
+ detail_line = f"原因:{detail}\n" if detail else ""
68
+ return (
69
+ f"🔒 UOF 登入失敗:無法以帳號「{account}」取得有效憑證。\n"
70
+ f"{detail_line}"
71
+ f"連線目標:{base_url}\n\n"
72
+ "這是設定層級的問題,需要由設定這個 MCP 的人處理,無法在對話中自行解決。常見原因:\n"
73
+ "- UOF_ACCOUNT 或 UOF_PASSWORD 不正確,或密碼已在 UOF 變更\n"
74
+ "- 該帳號在 UOF 被停用,或未設定部門/職級\n"
75
+ "- 此 UOF 站台未提供 PublicAPI,SOAP 認證無法運作\n\n"
76
+ "請直接、明確告訴使用者:「UOF 登入失敗,請檢查 MCP 設定中的帳號、密碼與連線設定是否正確」。\n"
77
+ "不要猜測其他原因、不要沿用先前對話記得的帳號或密碼、也不要假設已經解決。"
78
+ )
79
+
80
+
81
+ _token_provider: Optional[AuthProvider] = None
82
+ _session_provider: Optional[AuthProvider] = None
83
+
84
+
85
+ def get_token_provider() -> AuthProvider:
86
+ """SOAP 機制用的認證(RSA 帳密 → GetToken token)。惰性建立、單例。"""
87
+ global _token_provider
88
+ if _token_provider is None:
89
+ from .token import TokenAuthProvider
90
+ _token_provider = TokenAuthProvider()
91
+ return _token_provider
92
+
93
+
94
+ def get_session_provider() -> AuthProvider:
95
+ """web 機制用的認證(Login.aspx 登入 → cookie / storage state)。惰性建立、單例。"""
96
+ global _session_provider
97
+ if _session_provider is None:
98
+ from .session import SessionAuthProvider
99
+ _session_provider = SessionAuthProvider()
100
+ return _session_provider
101
+
102
+
103
+ def get_provider() -> AuthProvider:
104
+ """相容入口 = SOAP 身份(token)的認證 provider。
105
+
106
+ 現在機制是每工具綁定的(見 ops.router):SOAP 工具用 token、web 工具用 session,各自取得
107
+ 所需 provider。此函式保留為 token provider 的別名,給仍以單一身份語意呼叫的相容 shim
108
+ (get_token / credentials_file / domains.system)。"""
109
+ return get_token_provider()
110
+
111
+
112
+ def reset_provider_for_tests() -> None:
113
+ """Test hook — drop cached providers so the next getter rebuilds them."""
114
+ global _token_provider, _session_provider
115
+ _token_provider = None
116
+ _session_provider = None
117
+
118
+
119
+ def _provider_for(mechanism: str) -> AuthProvider:
120
+ """機制 → 它需要的認證 provider。soap→token、web→session。"""
121
+ if mechanism == "web":
122
+ return get_session_provider()
123
+ return get_token_provider() # "soap" 與未知值都用 token(與舊行為一致)
124
+
125
+
126
+ def _mechanisms_for_call(op: str, args: tuple, kwargs: dict) -> list:
127
+ """依本次呼叫參數決定認證機制。
128
+
129
+ `BINDING` 是工具層預設;起單相關工具還有表單層分派:registry 命中的表單會走 web_apply,
130
+ 因此入口認證也要驗 session,不能先被 SOAP token 擋下。
131
+ """
132
+ from ..ops.router import mechanisms_for
133
+
134
+ key = None
135
+ if op in ("get_form_structure_by_id",):
136
+ key = kwargs.get("form_id") if "form_id" in kwargs else (args[0] if args else None)
137
+ elif op in ("get_form_structure", "preview_workflow", "apply_form"):
138
+ key = kwargs.get("form_version_id") if "form_version_id" in kwargs else (args[0] if args else None)
139
+
140
+ if key:
141
+ from ..ops import web_apply
142
+ if web_apply.resolve(str(key)):
143
+ return ["web"]
144
+ if op in ("get_form_structure", "preview_workflow", "apply_form"):
145
+ return ["web"]
146
+ return mechanisms_for(op)
147
+
148
+
149
+ def require_auth(func: Callable[..., Any]) -> Callable[..., Any]:
150
+ """工具入口的認證閘——依「該工具設計上綁定的機制」驗證對應認證,而非一律驗 SOAP token。
151
+
152
+ 一個工具可走多條機制時(fallback,SOAP 優先)採 **OR**:任一機制的認證通過就放行;
153
+ 只有當它所有可走機制的認證**都**不過,才回固定的失敗訊息(對使用者只有「通過與否」)。
154
+ 驗證集中在這一道入口閘,工具內部不再重複驗證;機制本身的失效重試(token 自動刷新、
155
+ web session 重登)仍各自在 backend 處理。失敗回字串而非 raise,避免 MCP client 收到 isError。
156
+ """
157
+ op = func.__name__.removeprefix("uof_custom_")
158
+ # fail-loud(裝飾期):op 必須在 BINDING 有登錄機制綁定。漏綁、工具改名、或裝飾順序錯誤
159
+ # (@require_auth 被放到 @mcp.tool 外層導致 __name__ 變 wrapper)都會讓 op 解析不到綁定,
160
+ # 在 import server 時就立刻爆,而非讓 web 工具在執行期靜默回歸成被 SOAP token 擋。
161
+ from ..ops.router import mechanisms_for as _validate_op
162
+ _validate_op(op)
163
+
164
+ @wraps(func)
165
+ def wrapper(*args, **kwargs):
166
+ errors = []
167
+ for mech in _mechanisms_for_call(op, args, kwargs):
168
+ try:
169
+ _provider_for(mech).ensure_valid()
170
+ except Exception as e:
171
+ errors.append(f"{mech}: {' '.join(str(e).split())[:120]}")
172
+ continue
173
+ return func(*args, **kwargs) # 任一機制認證通過即放行;工具本體例外不包成登入失敗
174
+ # 所有可走機制都認證不過 → 才是真正不可用
175
+ return auth_failure_message(";".join(errors))
176
+ return wrapper
@@ -0,0 +1,152 @@
1
+ """
2
+ SessionAuthProvider — UOF Login.aspx form post + cookie jar.
3
+
4
+ For UOF deployments without PublicAPI module (the SOAP/ASMX backend is absent), the only
5
+ way to drive operations is to log in as a real user would and reuse the session. This
6
+ provider:
7
+
8
+ 1. Performs the ASP.NET WebForms login (handles __VIEWSTATE round-trip)
9
+ 2. Stores the authenticated `.ASPXFORMSAUTH_UOF` cookie via Playwright's storage_state
10
+ so the cookie jar can be reused by the Web ops backend (which drives Telerik UI)
11
+ and survives process restarts.
12
+ 3. Re-logs in transparently when the session expires (typical ASP.NET idle timeout ~20m).
13
+
14
+ NOTE: The actual browser/Playwright is owned by `ops.web.WebBackend` — this provider only
15
+ *requests* a session refresh and reads identity from the resulting page. We keep the
16
+ Playwright lifecycle in one place to avoid two parallel browsers fighting for the same
17
+ storage_state file.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import re
25
+ import stat
26
+ import time
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ from .base import AuthMode, AuthProvider
31
+
32
+
33
+ from .._log import eprint as _eprint # 診斷一律走 stderr(共用,勿在各檔複製)
34
+
35
+
36
+ CREDENTIALS_DIR = Path(os.path.expanduser("~")) / ".uof"
37
+ DEFAULT_SESSION_TTL = 20 * 60 # ASP.NET 預設 idle timeout = 20 min
38
+
39
+
40
+ def _ensure_dir() -> None:
41
+ if not CREDENTIALS_DIR.exists():
42
+ CREDENTIALS_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
43
+
44
+
45
+ class SessionAuthProvider(AuthProvider):
46
+ mode = AuthMode.SESSION
47
+
48
+ def __init__(self) -> None:
49
+ self._last_validated: float = 0.0
50
+ self._identity_cached: Optional[str] = None
51
+ # Logged-in user's display info, populated after successful login.
52
+ self.logged_in_display_name: Optional[str] = None
53
+
54
+ # ── Identity helpers ────────────────────────────────────────────
55
+ def _identity_key(self) -> str:
56
+ return "|".join(
57
+ [
58
+ os.getenv("UOF_BASE_URL", ""),
59
+ os.getenv("UOF_ACCOUNT", ""),
60
+ ]
61
+ )
62
+
63
+ def credentials_file(self) -> str:
64
+ """Path to the Playwright storage_state JSON for this identity."""
65
+ account = os.getenv("UOF_ACCOUNT", "anonymous")
66
+ safe_account = re.sub(r"[^A-Za-z0-9_.-]", "_", account) or "anonymous"
67
+ digest = hashlib.sha256(self._identity_key().encode("utf-8")).hexdigest()[:8]
68
+ return str(CREDENTIALS_DIR / f"storage_state-{safe_account}-{digest}.json")
69
+
70
+ def required_env_help(self) -> str:
71
+ return (
72
+ "- `UOF_BASE_URL`\n"
73
+ "- `UOF_ACCOUNT`\n"
74
+ "- `UOF_PASSWORD`"
75
+ )
76
+
77
+ # ── AuthProvider surface ────────────────────────────────────────
78
+ def ensure_valid(self) -> None:
79
+ """Trigger the web backend to verify or re-establish the browser session."""
80
+ # Avoid hammering the server: re-validate at most once per 30s.
81
+ if time.time() - self._last_validated < 30 and self._identity_cached == self._identity_key():
82
+ return
83
+ self._validate_env()
84
+ # Delegate to ops.web.WebBackend, which owns the Playwright runtime.
85
+ from ..ops.web import get_web_runtime
86
+ runtime = get_web_runtime()
87
+ display_name = runtime.ensure_logged_in()
88
+ self.logged_in_display_name = display_name
89
+ self._last_validated = time.time()
90
+ self._identity_cached = self._identity_key()
91
+
92
+ def status_report(self) -> str:
93
+ account = os.getenv("UOF_ACCOUNT", "(未設定)")
94
+ base_url = os.getenv("UOF_BASE_URL", "(未設定)")
95
+ try:
96
+ self.ensure_valid()
97
+ except RuntimeError as e:
98
+ from .base import auth_failure_message
99
+ return auth_failure_message(str(e))
100
+ display = self.logged_in_display_name or account
101
+ return (
102
+ f"✅ Session 有效,目前以 **{account}**({display})的身份操作"
103
+ f"(認證:網頁 session)。\n"
104
+ f"🔗 Base URL: {base_url}\n"
105
+ f"📂 Storage state: `{self.credentials_file()}`\n"
106
+ f"⚠️ 網頁機制:操作走 Playwright 驅動 UOF 頁面,並非所有工具皆已實作。"
107
+ )
108
+
109
+ def clear(self, all_identities: bool = False) -> None:
110
+ _ensure_dir()
111
+ if all_identities:
112
+ for path in CREDENTIALS_DIR.glob("storage_state-*.json"):
113
+ path.unlink(missing_ok=True)
114
+ _eprint(f"[auth.session] 🗑️ 已清除 storage state: {path}")
115
+ else:
116
+ p = Path(self.credentials_file())
117
+ if p.exists():
118
+ p.unlink()
119
+ _eprint(f"[auth.session] 🗑️ 已清除 storage state: {p}")
120
+ # Force web runtime to reset on next call
121
+ from ..ops.web import reset_web_runtime
122
+ reset_web_runtime()
123
+ self._last_validated = 0.0
124
+ self._identity_cached = None
125
+
126
+ # ── Internal ────────────────────────────────────────────────────
127
+ def _validate_env(self) -> None:
128
+ missing = [
129
+ k for k in ("UOF_BASE_URL", "UOF_ACCOUNT", "UOF_PASSWORD")
130
+ if not os.getenv(k)
131
+ ]
132
+ if missing:
133
+ raise RuntimeError(
134
+ "UOF_BASE_URL、UOF_ACCOUNT、UOF_PASSWORD 必須全部設定。"
135
+ f"目前缺少: {', '.join(missing)}"
136
+ )
137
+
138
+ # ── Storage-state metadata sidecar (account, timestamps) ────────
139
+ def write_metadata(self) -> None:
140
+ """Write a small JSON next to storage_state recording who logged in + when."""
141
+ _ensure_dir()
142
+ meta_path = self.credentials_file() + ".meta"
143
+ data = {
144
+ "account": os.getenv("UOF_ACCOUNT", ""),
145
+ "base_url": os.getenv("UOF_BASE_URL", ""),
146
+ "identity": self._identity_key(),
147
+ "logged_in_at": time.time(),
148
+ "display_name": self.logged_in_display_name or "",
149
+ }
150
+ with open(meta_path, "w", encoding="utf-8") as f:
151
+ json.dump(data, f, indent=2, ensure_ascii=False)
152
+ os.chmod(meta_path, stat.S_IRUSR | stat.S_IWUSR)
mcp_uof/auth/token.py ADDED
@@ -0,0 +1,256 @@
1
+ """
2
+ TokenAuthProvider — RSA + SOAP GetToken.
3
+
4
+ This is the original mcp-uof authentication path, preserved verbatim and adapted to the
5
+ AuthProvider interface. Token is cached in memory and persisted to
6
+ `~/.uof/credentials-<account>-<hash>.json` keyed by identity so multiple deployments don't
7
+ poison each other's cache.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import stat
17
+ import time
18
+ from typing import Optional
19
+
20
+ from Crypto.Cipher import PKCS1_v1_5
21
+ from Crypto.PublicKey import RSA
22
+
23
+ from .base import AuthMode, AuthProvider
24
+
25
+
26
+ from .._log import eprint as _eprint # 診斷一律走 stderr(共用,勿在各檔複製)
27
+
28
+
29
+ CREDENTIALS_DIR = os.path.join(os.path.expanduser("~"), ".uof")
30
+ DEFAULT_TOKEN_TTL = 14 * 24 * 60 * 60 # 14 days
31
+
32
+ # Pre-package single-file credential layout (kept for cleanup compatibility).
33
+ _LEGACY_CREDENTIALS_FILE = os.path.join(CREDENTIALS_DIR, "credentials.json")
34
+
35
+
36
+ def _ensure_credentials_dir() -> None:
37
+ if not os.path.exists(CREDENTIALS_DIR):
38
+ os.makedirs(CREDENTIALS_DIR, mode=0o700, exist_ok=True)
39
+ _eprint(f"[auth.token] 已建立憑證目錄: {CREDENTIALS_DIR}")
40
+
41
+
42
+ def _rsa_encrypt(public_key_base64: str, plaintext: str) -> str:
43
+ public_key_xml = base64.b64decode(public_key_base64).decode("utf-8")
44
+ key = _import_rsa_xml_key(public_key_xml)
45
+ cipher = PKCS1_v1_5.new(key)
46
+ encrypted = cipher.encrypt(plaintext.encode("utf-8"))
47
+ return base64.b64encode(encrypted).decode("ascii")
48
+
49
+
50
+ def _import_rsa_xml_key(xml_string: str) -> RSA.RsaKey:
51
+ from lxml import etree
52
+
53
+ root = etree.fromstring(xml_string.encode("utf-8"))
54
+ modulus_elem = root.find("Modulus")
55
+ exponent_elem = root.find("Exponent")
56
+ if modulus_elem is None or exponent_elem is None:
57
+ raise ValueError("RSA XML 格式錯誤:缺少 Modulus 或 Exponent 元素")
58
+ n = int.from_bytes(base64.b64decode(modulus_elem.text.strip()), byteorder="big")
59
+ e = int.from_bytes(base64.b64decode(exponent_elem.text.strip()), byteorder="big")
60
+ return RSA.construct((n, e))
61
+
62
+
63
+ class TokenAuthProvider(AuthProvider):
64
+ mode = AuthMode.TOKEN
65
+
66
+ def __init__(self) -> None:
67
+ self._token: Optional[str] = None
68
+ self._expires_at: float = 0.0
69
+ self._identity: Optional[str] = None
70
+
71
+ # ── Identity helpers ────────────────────────────────────────────
72
+ def _identity_key(self) -> str:
73
+ return "|".join(
74
+ [
75
+ os.getenv("UOF_BASE_URL", ""),
76
+ os.getenv("UOF_APP_NAME", ""),
77
+ os.getenv("UOF_ACCOUNT", ""),
78
+ ]
79
+ )
80
+
81
+ def credentials_file(self) -> str:
82
+ account = os.getenv("UOF_ACCOUNT", "anonymous")
83
+ safe_account = re.sub(r"[^A-Za-z0-9_.-]", "_", account) or "anonymous"
84
+ digest = hashlib.sha256(self._identity_key().encode("utf-8")).hexdigest()[:8]
85
+ return os.path.join(CREDENTIALS_DIR, f"credentials-{safe_account}-{digest}.json")
86
+
87
+ def required_env_help(self) -> str:
88
+ return (
89
+ "- `UOF_BASE_URL`\n"
90
+ "- `UOF_APP_NAME`\n"
91
+ "- `UOF_RSA_PUBLIC_KEY`\n"
92
+ "- `UOF_ACCOUNT`\n"
93
+ "- `UOF_PASSWORD`"
94
+ )
95
+
96
+ # ── AuthProvider surface ────────────────────────────────────────
97
+ def ensure_valid(self) -> None:
98
+ self.fetch_token()
99
+
100
+ def status_report(self) -> str:
101
+ account = os.getenv("UOF_ACCOUNT", "(未設定)")
102
+ base_url = os.getenv("UOF_BASE_URL", "(未設定)")
103
+ # 實際向 UOF 重新取得一次 Token 來「真正驗證」登入,而非只看本地快取 TTL——
104
+ # UOF Token 的伺服器端有效期可能短於本地 TTL,只看快取會誤報為有效。
105
+ try:
106
+ self.fetch_token(force_refresh=True)
107
+ return (
108
+ f"✅ 已成功登入 UOF,目前以「{account}」的身份操作。\n"
109
+ f"🔗 連線目標:{base_url}\n"
110
+ f"Token 由系統自動管理,過期時會在工具呼叫時自動更新,使用者不需手動處理。\n"
111
+ f"💡 要改以其他人的身份操作,請切換 MCP 設定(一份設定 = 一個身份)。"
112
+ )
113
+ except RuntimeError as e:
114
+ from .base import auth_failure_message
115
+ return auth_failure_message(str(e))
116
+
117
+ def clear(self, all_identities: bool = False) -> None:
118
+ targets = []
119
+ if all_identities:
120
+ if os.path.isdir(CREDENTIALS_DIR):
121
+ for name in os.listdir(CREDENTIALS_DIR):
122
+ if name.startswith("credentials") and name.endswith(".json"):
123
+ targets.append(os.path.join(CREDENTIALS_DIR, name))
124
+ else:
125
+ targets.append(self.credentials_file())
126
+ if os.path.exists(_LEGACY_CREDENTIALS_FILE):
127
+ targets.append(_LEGACY_CREDENTIALS_FILE)
128
+ for path in targets:
129
+ if os.path.exists(path):
130
+ os.remove(path)
131
+ _eprint(f"[auth.token] 🗑️ 已清除憑證: {path}")
132
+ self._token = None
133
+ self._expires_at = 0.0
134
+ self._identity = None
135
+
136
+ # ── Token-specific API ──────────────────────────────────────────
137
+ def fetch_token(self, force_refresh: bool = False) -> str:
138
+ """取得有效 Token。
139
+
140
+ force_refresh=True 時略過記憶體與磁碟快取,強制重新向 UOF 取得——用於
141
+ 「快取 Token 已被伺服器端判定失效」時的刷新重試(UOF Token 伺服器端有效期
142
+ 可能短於本地 TTL,失效時 Wkf.asmx 回 HTTP 500 而無明確過期訊息)。
143
+ """
144
+ identity = self._identity_key()
145
+ if not force_refresh:
146
+ # 1. memory cache
147
+ if (
148
+ self._token
149
+ and self._identity == identity
150
+ and time.time() < (self._expires_at - 60)
151
+ ):
152
+ return self._token
153
+ # 2. disk cache
154
+ creds = self.read_credentials()
155
+ if creds:
156
+ self._token = creds["token"]
157
+ self._expires_at = creds["expires_at"]
158
+ self._identity = identity
159
+ return self._token
160
+ # 3. SOAP GetToken
161
+ _eprint(
162
+ f"[auth.token] 🔄 正在以 {os.getenv('UOF_ACCOUNT', '?')} 身份呼叫 GetToken..."
163
+ )
164
+ token = self._call_get_token()
165
+ ttl_env = os.getenv("UOF_TOKEN_TTL")
166
+ ttl = int(ttl_env) if ttl_env else DEFAULT_TOKEN_TTL
167
+ self._write_credentials(token, ttl)
168
+ self._token = token
169
+ self._expires_at = time.time() + ttl
170
+ self._identity = identity
171
+ return token
172
+
173
+ def _call_get_token(self) -> str:
174
+ from ..soap_client import uof_client
175
+ from ..domains.system.endpoints import AUTH_ENDPOINT
176
+
177
+ app_name = os.getenv("UOF_APP_NAME", "")
178
+ public_key = os.getenv("UOF_RSA_PUBLIC_KEY", "")
179
+ account = os.getenv("UOF_ACCOUNT", "")
180
+ password = os.getenv("UOF_PASSWORD", "")
181
+ if not all([app_name, public_key, account, password]):
182
+ raise RuntimeError(
183
+ "UOF_APP_NAME、UOF_RSA_PUBLIC_KEY、UOF_ACCOUNT、UOF_PASSWORD "
184
+ "必須全部設定。請檢查 .env 檔案。"
185
+ )
186
+ encrypted_account = _rsa_encrypt(public_key, account)
187
+ encrypted_password = _rsa_encrypt(public_key, password)
188
+ # 登入失敗的回應型態不一致:有時回空 GetTokenResponse(200),有時直接 HTTP 500
189
+ # (如密碼錯誤導致伺服器端 RSADecrypt 失敗)。兩者都收斂為 RuntimeError,
190
+ # 讓上層以固定訊息回報,不把原始 500 堆疊丟給介接 AI。
191
+ try:
192
+ result = uof_client.call(
193
+ endpoint_path=AUTH_ENDPOINT,
194
+ method_name="GetToken",
195
+ params={
196
+ "appName": app_name,
197
+ "account": encrypted_account,
198
+ "password": encrypted_password,
199
+ },
200
+ )
201
+ except Exception as e:
202
+ brief = " ".join(str(e).split()).split("For more information")[0].strip()[:160]
203
+ raise RuntimeError(
204
+ f"呼叫 GetToken 失敗(帳號「{account}」):{brief or e.__class__.__name__}。"
205
+ "可能是帳號或密碼錯誤、RSA 公私鑰不相符,或 UOF 服務暫時無法連線。"
206
+ )
207
+ if not result:
208
+ raise RuntimeError(
209
+ f"GetToken 未回傳憑證(帳號「{account}」):可能是帳號或密碼錯誤、密碼已變更、"
210
+ "帳號被停用,或 RSA 公私鑰不相符。"
211
+ )
212
+ return result.strip()
213
+
214
+ # ── Disk persistence ─────────────────────────────────────────────
215
+ def read_credentials(self) -> Optional[dict]:
216
+ path = self.credentials_file()
217
+ if not os.path.exists(path):
218
+ return None
219
+ try:
220
+ with open(path, "r", encoding="utf-8") as f:
221
+ data = json.load(f)
222
+ except (json.JSONDecodeError, OSError) as e:
223
+ _eprint(f"[auth.token] ❌ 讀取憑證檔失敗: {e}")
224
+ return None
225
+ token = data.get("token")
226
+ expires_at = data.get("expires_at", 0)
227
+ if not token:
228
+ return None
229
+ if data.get("identity") and data.get("identity") != self._identity_key():
230
+ return None
231
+ if time.time() >= (expires_at - 60):
232
+ _eprint(
233
+ f"[auth.token] ⏰ Token 已過期 "
234
+ f"(expires_at={expires_at}, now={time.time():.0f})"
235
+ )
236
+ return None
237
+ return data
238
+
239
+ def _write_credentials(self, token: str, ttl: int) -> None:
240
+ _ensure_credentials_dir()
241
+ path = self.credentials_file()
242
+ now = time.time()
243
+ data = {
244
+ "token": token,
245
+ "expires_at": now + ttl,
246
+ "ttl": ttl,
247
+ "issued_at": now,
248
+ "account": os.getenv("UOF_ACCOUNT", ""),
249
+ "app_name": os.getenv("UOF_APP_NAME", ""),
250
+ "base_url": os.getenv("UOF_BASE_URL", ""),
251
+ "identity": self._identity_key(),
252
+ }
253
+ with open(path, "w", encoding="utf-8") as f:
254
+ json.dump(data, f, indent=2, ensure_ascii=False)
255
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
256
+ _eprint(f"[auth.token] ✅ 憑證已寫入 {path} (ttl={ttl}s)")
@@ -0,0 +1,2 @@
1
+ # UOF MCP Server — Domain 模組
2
+ # 依據 DDD 概念,每個 Domain 對應一個 ASMX WebService 的業務邊界
@@ -0,0 +1,2 @@
1
+ # Domain: DMS — 文件管理 + 檔案服務
2
+ # 暫不實作,待 WKF Domain 驗證通過後再展開