tlnt-cli 1.5.1__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.
- tlnt/__init__.py +3 -0
- tlnt/__main__.py +4 -0
- tlnt/browser/__init__.py +0 -0
- tlnt/browser/login.py +414 -0
- tlnt/browser/oauth.py +50 -0
- tlnt/browser/scripted.py +183 -0
- tlnt/cli/__init__.py +0 -0
- tlnt/cli/avatars.py +51 -0
- tlnt/cli/commands/__init__.py +0 -0
- tlnt/cli/commands/approvals.py +98 -0
- tlnt/cli/commands/attendance.py +283 -0
- tlnt/cli/commands/calendar.py +108 -0
- tlnt/cli/commands/login.py +225 -0
- tlnt/cli/commands/logout.py +12 -0
- tlnt/cli/commands/mcp.py +8 -0
- tlnt/cli/commands/notifications.py +102 -0
- tlnt/cli/commands/profile.py +13 -0
- tlnt/cli/commands/setup.py +12 -0
- tlnt/cli/commands/status.py +12 -0
- tlnt/cli/commands/timeoff.py +491 -0
- tlnt/cli/commands/update.py +86 -0
- tlnt/cli/commands/version.py +15 -0
- tlnt/cli/main.py +163 -0
- tlnt/cli/options.py +56 -0
- tlnt/cli/output.py +321 -0
- tlnt/cli/prompts.py +76 -0
- tlnt/core/__init__.py +0 -0
- tlnt/core/clients/__init__.py +0 -0
- tlnt/core/clients/mekari/__init__.py +0 -0
- tlnt/core/clients/mekari/client.py +826 -0
- tlnt/core/clients/mekari/constants.py +174 -0
- tlnt/core/config.py +53 -0
- tlnt/core/http_client.py +110 -0
- tlnt/core/logs.py +34 -0
- tlnt/core/models.py +84 -0
- tlnt/core/play_store.py +85 -0
- tlnt/core/services/__init__.py +46 -0
- tlnt/core/services/attendance.py +213 -0
- tlnt/core/services/calendar.py +54 -0
- tlnt/core/services/login.py +100 -0
- tlnt/core/services/logout.py +38 -0
- tlnt/core/services/notifications.py +97 -0
- tlnt/core/services/profile.py +11 -0
- tlnt/core/services/setup.py +85 -0
- tlnt/core/services/status.py +35 -0
- tlnt/core/services/timeoff.py +146 -0
- tlnt/core/session.py +189 -0
- tlnt/core/user_agent.py +71 -0
- tlnt/mcp/__init__.py +0 -0
- tlnt/mcp/main.py +176 -0
- tlnt_cli-1.5.1.dist-info/METADATA +315 -0
- tlnt_cli-1.5.1.dist-info/RECORD +55 -0
- tlnt_cli-1.5.1.dist-info/WHEEL +4 -0
- tlnt_cli-1.5.1.dist-info/entry_points.txt +3 -0
- tlnt_cli-1.5.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from tlnt.core.clients.mekari.client import MekariClient
|
|
4
|
+
from tlnt.core.models import Result
|
|
5
|
+
from tlnt.core.services import positive_id, with_client
|
|
6
|
+
from tlnt.core.session import SessionStore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def timeoff_types_list(
|
|
10
|
+
store: SessionStore | None = None,
|
|
11
|
+
client: MekariClient | None = None,
|
|
12
|
+
) -> Result:
|
|
13
|
+
return with_client(lambda c, token: c.timeoff_types_list(token), store, client)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def timeoff_balance(
|
|
17
|
+
date: str = "",
|
|
18
|
+
keyword: str = "",
|
|
19
|
+
show_unlimited_balance: bool = False,
|
|
20
|
+
policy_id: str = "",
|
|
21
|
+
limited_by_date: str = "",
|
|
22
|
+
refresh: bool = True,
|
|
23
|
+
show_special_leave: bool = False,
|
|
24
|
+
store: SessionStore | None = None,
|
|
25
|
+
client: MekariClient | None = None,
|
|
26
|
+
) -> Result:
|
|
27
|
+
def balance(c: MekariClient, token: str) -> Result:
|
|
28
|
+
profile = c.employment_info(token)
|
|
29
|
+
if not profile.ok:
|
|
30
|
+
return profile
|
|
31
|
+
data = profile.data if isinstance(profile.data, dict) else {}
|
|
32
|
+
company_id = positive_id(data.get("company_id"))
|
|
33
|
+
user_id = positive_id(data.get("id"))
|
|
34
|
+
if company_id is None or user_id is None:
|
|
35
|
+
return Result(
|
|
36
|
+
False,
|
|
37
|
+
"remote_error",
|
|
38
|
+
"Mekari profile is missing or has invalid time-off balance identifiers",
|
|
39
|
+
)
|
|
40
|
+
return c.timeoff_balance(
|
|
41
|
+
token,
|
|
42
|
+
company_id,
|
|
43
|
+
user_id,
|
|
44
|
+
date=date,
|
|
45
|
+
keyword=keyword,
|
|
46
|
+
show_unlimited_balance=show_unlimited_balance,
|
|
47
|
+
policy_id=policy_id,
|
|
48
|
+
limited_by_date=limited_by_date,
|
|
49
|
+
refresh=refresh,
|
|
50
|
+
show_special_leave=show_special_leave,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return with_client(balance, store, client)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def timeoff_delegation_list(
|
|
57
|
+
page: int = 1,
|
|
58
|
+
search: str = "",
|
|
59
|
+
store: SessionStore | None = None,
|
|
60
|
+
client: MekariClient | None = None,
|
|
61
|
+
) -> Result:
|
|
62
|
+
return with_client(
|
|
63
|
+
lambda c, token: c.timeoff_delegation_list(token, page, search), store, client
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def timeoff_list(
|
|
68
|
+
page: int = 1,
|
|
69
|
+
limit: int = 30,
|
|
70
|
+
date: str = "",
|
|
71
|
+
end_date: str = "",
|
|
72
|
+
status: int = 0,
|
|
73
|
+
policy_id: str = "",
|
|
74
|
+
all_period: bool = True,
|
|
75
|
+
show_metrics: bool = False,
|
|
76
|
+
store: SessionStore | None = None,
|
|
77
|
+
client: MekariClient | None = None,
|
|
78
|
+
) -> Result:
|
|
79
|
+
return with_client(
|
|
80
|
+
lambda c, token: c.timeoff_list(
|
|
81
|
+
token,
|
|
82
|
+
page,
|
|
83
|
+
limit,
|
|
84
|
+
date,
|
|
85
|
+
end_date,
|
|
86
|
+
status,
|
|
87
|
+
policy_id,
|
|
88
|
+
all_period,
|
|
89
|
+
show_metrics,
|
|
90
|
+
),
|
|
91
|
+
store,
|
|
92
|
+
client,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def timeoff_detail(
|
|
97
|
+
request_id: int,
|
|
98
|
+
store: SessionStore | None = None,
|
|
99
|
+
client: MekariClient | None = None,
|
|
100
|
+
) -> Result:
|
|
101
|
+
return with_client(lambda c, token: c.timeoff_detail(token, request_id), store, client)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def timeoff_request(
|
|
105
|
+
date: str,
|
|
106
|
+
policy_id: str,
|
|
107
|
+
reason: str,
|
|
108
|
+
*,
|
|
109
|
+
half_day_after_break: bool = False,
|
|
110
|
+
half_day_before_break: bool = False,
|
|
111
|
+
delegate_to: str = "",
|
|
112
|
+
file_path: str | None = None,
|
|
113
|
+
schedule_in: str = "",
|
|
114
|
+
schedule_out: str = "",
|
|
115
|
+
store: SessionStore | None = None,
|
|
116
|
+
client: MekariClient | None = None,
|
|
117
|
+
) -> Result:
|
|
118
|
+
return with_client(
|
|
119
|
+
lambda c, token: c.timeoff_request(
|
|
120
|
+
token,
|
|
121
|
+
date,
|
|
122
|
+
policy_id,
|
|
123
|
+
reason,
|
|
124
|
+
half_day_after_break=half_day_after_break,
|
|
125
|
+
half_day_before_break=half_day_before_break,
|
|
126
|
+
delegate_to=delegate_to,
|
|
127
|
+
file_path=file_path,
|
|
128
|
+
schedule_in=schedule_in,
|
|
129
|
+
schedule_out=schedule_out,
|
|
130
|
+
),
|
|
131
|
+
store,
|
|
132
|
+
client,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def timeoff_cancel(
|
|
137
|
+
request_id: int,
|
|
138
|
+
request_type: str = "1002",
|
|
139
|
+
store: SessionStore | None = None,
|
|
140
|
+
client: MekariClient | None = None,
|
|
141
|
+
) -> Result:
|
|
142
|
+
return with_client(
|
|
143
|
+
lambda c, token: c.timeoff_cancel(token, request_id, request_type),
|
|
144
|
+
store,
|
|
145
|
+
client,
|
|
146
|
+
)
|
tlnt/core/session.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Secure session persistence and access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import keyring
|
|
13
|
+
from keyring.errors import PasswordDeleteError
|
|
14
|
+
from platformdirs import user_data_path
|
|
15
|
+
|
|
16
|
+
from tlnt.core.config import get_setting
|
|
17
|
+
from tlnt.core.models import MobileToken, Profile, Result, SessionState
|
|
18
|
+
|
|
19
|
+
KEYRING_SERVICE = "tlnt"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SessionMissing(FileNotFoundError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InvalidSession(ValueError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SessionStore:
|
|
31
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
32
|
+
configured_path = get_setting(
|
|
33
|
+
"TALENTA_SESSION_PATH",
|
|
34
|
+
"session_path",
|
|
35
|
+
"",
|
|
36
|
+
)
|
|
37
|
+
self.path = (
|
|
38
|
+
path
|
|
39
|
+
if path is not None
|
|
40
|
+
else (
|
|
41
|
+
Path(configured_path).expanduser()
|
|
42
|
+
if configured_path
|
|
43
|
+
else user_data_path("tlnt", appauthor=False) / "session.json"
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def load(self) -> SessionState:
|
|
48
|
+
try:
|
|
49
|
+
payload = json.loads(self.path.read_text(encoding="utf-8"))
|
|
50
|
+
except FileNotFoundError as error:
|
|
51
|
+
raise SessionMissing("no saved session; run `tlnt login`") from error
|
|
52
|
+
except (json.JSONDecodeError, OSError) as error:
|
|
53
|
+
raise InvalidSession("session file is unreadable") from error
|
|
54
|
+
return self._parse(payload)
|
|
55
|
+
|
|
56
|
+
def save(self, session: SessionState) -> None:
|
|
57
|
+
parent = self.path.parent
|
|
58
|
+
# Tighten only a folder tlnt creates or its own data folder, never a
|
|
59
|
+
# shared one like /tmp or the home folder.
|
|
60
|
+
owned = not parent.exists() or parent == user_data_path("tlnt", appauthor=False)
|
|
61
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
if owned:
|
|
63
|
+
parent.chmod(0o700)
|
|
64
|
+
temporary_path: Path | None = None
|
|
65
|
+
try:
|
|
66
|
+
with tempfile.NamedTemporaryFile(
|
|
67
|
+
"w",
|
|
68
|
+
encoding="utf-8",
|
|
69
|
+
dir=self.path.parent,
|
|
70
|
+
delete=False,
|
|
71
|
+
) as temporary:
|
|
72
|
+
temporary_path = Path(temporary.name)
|
|
73
|
+
temporary_path.chmod(0o600)
|
|
74
|
+
json.dump(
|
|
75
|
+
asdict(session),
|
|
76
|
+
temporary,
|
|
77
|
+
ensure_ascii=False,
|
|
78
|
+
separators=(",", ":"),
|
|
79
|
+
)
|
|
80
|
+
temporary.flush()
|
|
81
|
+
os.fsync(temporary.fileno())
|
|
82
|
+
os.replace(temporary_path, self.path)
|
|
83
|
+
self.path.chmod(0o600)
|
|
84
|
+
finally:
|
|
85
|
+
if temporary_path is not None and temporary_path.exists():
|
|
86
|
+
temporary_path.unlink()
|
|
87
|
+
|
|
88
|
+
def delete(self) -> None:
|
|
89
|
+
self.path.unlink(missing_ok=True)
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def delete_password(keyring_ref: str) -> None:
|
|
93
|
+
try:
|
|
94
|
+
keyring.delete_password(KEYRING_SERVICE, keyring_ref)
|
|
95
|
+
except PasswordDeleteError:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def _parse(payload: Any) -> SessionState:
|
|
100
|
+
if not isinstance(payload, dict) or payload.get("version") != 1:
|
|
101
|
+
raise InvalidSession("unsupported session version")
|
|
102
|
+
|
|
103
|
+
profile = payload.get("profile")
|
|
104
|
+
mobile = payload.get("mobile")
|
|
105
|
+
web_storage = payload.get("web_storage")
|
|
106
|
+
saved_at = payload.get("saved_at")
|
|
107
|
+
if (
|
|
108
|
+
(profile is not None and not isinstance(profile, dict))
|
|
109
|
+
or (mobile is not None and not isinstance(mobile, dict))
|
|
110
|
+
or not isinstance(web_storage, dict)
|
|
111
|
+
or not isinstance(saved_at, str)
|
|
112
|
+
):
|
|
113
|
+
raise InvalidSession("session file has an invalid shape")
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
return SessionState(
|
|
117
|
+
version=1,
|
|
118
|
+
saved_at=saved_at,
|
|
119
|
+
web_storage=web_storage,
|
|
120
|
+
profile=(
|
|
121
|
+
Profile(
|
|
122
|
+
name=str(profile["name"]),
|
|
123
|
+
email=str(profile["email"]),
|
|
124
|
+
talenta_user_id=str(profile["talenta_user_id"]),
|
|
125
|
+
company_id=str(profile["company_id"]),
|
|
126
|
+
company_name=(
|
|
127
|
+
str(profile["company_name"])
|
|
128
|
+
if profile.get("company_name") is not None
|
|
129
|
+
else None
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
if profile is not None
|
|
133
|
+
else None
|
|
134
|
+
),
|
|
135
|
+
mobile=(
|
|
136
|
+
MobileToken(
|
|
137
|
+
access_token=str(mobile["access_token"]),
|
|
138
|
+
captured_at=str(mobile["captured_at"]),
|
|
139
|
+
expires_at=(
|
|
140
|
+
str(mobile["expires_at"])
|
|
141
|
+
if mobile.get("expires_at") is not None
|
|
142
|
+
else None
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
if mobile is not None
|
|
146
|
+
else None
|
|
147
|
+
),
|
|
148
|
+
credential_email=(
|
|
149
|
+
str(payload["credential_email"])
|
|
150
|
+
if payload.get("credential_email") is not None
|
|
151
|
+
else None
|
|
152
|
+
),
|
|
153
|
+
keyring_ref=(
|
|
154
|
+
str(payload["keyring_ref"])
|
|
155
|
+
if payload.get("keyring_ref") is not None
|
|
156
|
+
else None
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
except KeyError as error:
|
|
160
|
+
raise InvalidSession("session profile is incomplete") from error
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def load_session(store: SessionStore | None = None) -> SessionState | Result:
|
|
164
|
+
try:
|
|
165
|
+
return (store or SessionStore()).load()
|
|
166
|
+
except SessionMissing as error:
|
|
167
|
+
return Result(False, "session_missing", str(error))
|
|
168
|
+
except InvalidSession as error:
|
|
169
|
+
return Result(False, "session_invalid", str(error))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def mobile_token(
|
|
173
|
+
store: SessionStore | None = None,
|
|
174
|
+
session: SessionState | None = None,
|
|
175
|
+
) -> str | Result:
|
|
176
|
+
env_token = os.environ.get("TALENTA_BEARER")
|
|
177
|
+
if env_token:
|
|
178
|
+
return env_token
|
|
179
|
+
|
|
180
|
+
loaded = session or load_session(store)
|
|
181
|
+
if isinstance(loaded, Result):
|
|
182
|
+
return loaded
|
|
183
|
+
if loaded.mobile is not None:
|
|
184
|
+
return loaded.mobile.access_token
|
|
185
|
+
return Result(
|
|
186
|
+
False,
|
|
187
|
+
"mobile_token_missing",
|
|
188
|
+
"No mobile token; run `tlnt login` or set TALENTA_BEARER",
|
|
189
|
+
)
|
tlnt/core/user_agent.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Random Android device headers, fixed for the life of the process."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
import secrets
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_ANDROID_VERSIONS = ("10.0", "11.0", "12.0", "13.0", "14.0", "15.0", "16.0")
|
|
11
|
+
_ANDROID_DEVICES = (
|
|
12
|
+
"SM-G960F",
|
|
13
|
+
"Pixel 5",
|
|
14
|
+
"SM-A505F",
|
|
15
|
+
"Pixel 4a",
|
|
16
|
+
"Pixel 6 Pro",
|
|
17
|
+
"SM-N975F",
|
|
18
|
+
"SM-G973F",
|
|
19
|
+
"Pixel 3",
|
|
20
|
+
"SM-G980F",
|
|
21
|
+
"Pixel 5a",
|
|
22
|
+
"SM-G998B",
|
|
23
|
+
"Pixel 4",
|
|
24
|
+
"SM-G991B",
|
|
25
|
+
"SM-G996B",
|
|
26
|
+
"SM-F711B",
|
|
27
|
+
"SM-F916B",
|
|
28
|
+
"SM-G781B",
|
|
29
|
+
"SM-N986B",
|
|
30
|
+
"SM-N981B",
|
|
31
|
+
"Pixel 2",
|
|
32
|
+
"Pixel 2 XL",
|
|
33
|
+
"Pixel 3 XL",
|
|
34
|
+
"Pixel 4 XL",
|
|
35
|
+
"Pixel 5 XL",
|
|
36
|
+
"Pixel 6",
|
|
37
|
+
"Pixel 6 XL",
|
|
38
|
+
"Pixel 6a",
|
|
39
|
+
"Pixel 7",
|
|
40
|
+
"Pixel 7 Pro",
|
|
41
|
+
"OnePlus 8",
|
|
42
|
+
"OnePlus 8 Pro",
|
|
43
|
+
"OnePlus 9",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@lru_cache(maxsize=1)
|
|
48
|
+
def random_device_headers() -> dict[str, str]:
|
|
49
|
+
"""Headers for one random Android device; every call in a process agrees."""
|
|
50
|
+
device_model = random.choice(_ANDROID_DEVICES)
|
|
51
|
+
os_version = random.choice(_ANDROID_VERSIONS)
|
|
52
|
+
chrome_version = ".".join(
|
|
53
|
+
str(part)
|
|
54
|
+
for part in (
|
|
55
|
+
random.randrange(125, 138),
|
|
56
|
+
random.randrange(10),
|
|
57
|
+
random.randrange(1000, 10000),
|
|
58
|
+
random.randrange(100),
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
return {
|
|
62
|
+
"User-Agent": (
|
|
63
|
+
f"Mozilla/5.0 (Linux; Android {os_version}; {device_model}) "
|
|
64
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
65
|
+
f"Chrome/{chrome_version} Mobile Safari/537.36"
|
|
66
|
+
),
|
|
67
|
+
"X-Device-Fingerprint": secrets.token_hex(32),
|
|
68
|
+
"X-Device-ID": secrets.token_hex(8),
|
|
69
|
+
"X-Device-Model": device_model,
|
|
70
|
+
"X-OS-Version": os_version,
|
|
71
|
+
}
|
tlnt/mcp/__init__.py
ADDED
|
File without changes
|
tlnt/mcp/main.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Read-only MCP adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from importlib import metadata
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from mcp.server import MCPServer
|
|
12
|
+
from mcp.types import ToolAnnotations
|
|
13
|
+
|
|
14
|
+
from tlnt import DISTRIBUTION
|
|
15
|
+
from tlnt.core.logs import logger
|
|
16
|
+
from tlnt.core.models import Result
|
|
17
|
+
from tlnt.core.services.attendance import (
|
|
18
|
+
preview_clock,
|
|
19
|
+
validate_coordinates,
|
|
20
|
+
validate_notes,
|
|
21
|
+
)
|
|
22
|
+
from tlnt.core.services.profile import profile
|
|
23
|
+
from tlnt.core.services.status import status
|
|
24
|
+
|
|
25
|
+
_log = logger.bind(component="mcp")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _read_only(*, open_world: bool) -> ToolAnnotations:
|
|
29
|
+
return ToolAnnotations(
|
|
30
|
+
readOnlyHint=True,
|
|
31
|
+
destructiveHint=False,
|
|
32
|
+
idempotentHint=True,
|
|
33
|
+
openWorldHint=open_world,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _call_tool(
|
|
38
|
+
name: str,
|
|
39
|
+
action: Callable[..., Result],
|
|
40
|
+
*args: Any,
|
|
41
|
+
argument_keys: tuple[str, ...] = (),
|
|
42
|
+
**kwargs: Any,
|
|
43
|
+
) -> dict[str, Any]:
|
|
44
|
+
started_at = time.monotonic()
|
|
45
|
+
_log.bind(
|
|
46
|
+
event="tool_started",
|
|
47
|
+
tool=name,
|
|
48
|
+
argument_keys=argument_keys,
|
|
49
|
+
).info("tool started")
|
|
50
|
+
try:
|
|
51
|
+
result = action(*args, **kwargs)
|
|
52
|
+
except Exception as error:
|
|
53
|
+
_log.bind(
|
|
54
|
+
event="tool_failed",
|
|
55
|
+
tool=name,
|
|
56
|
+
error_type=type(error).__name__,
|
|
57
|
+
duration_ms=round((time.monotonic() - started_at) * 1000, 2),
|
|
58
|
+
).warning("tool failed")
|
|
59
|
+
raise
|
|
60
|
+
|
|
61
|
+
finished_log = _log.bind(
|
|
62
|
+
event="tool_finished",
|
|
63
|
+
tool=name,
|
|
64
|
+
ok=result.ok,
|
|
65
|
+
code=result.code,
|
|
66
|
+
duration_ms=round((time.monotonic() - started_at) * 1000, 2),
|
|
67
|
+
data_type=type(result.data).__name__,
|
|
68
|
+
data_keys=(
|
|
69
|
+
tuple(sorted(str(key) for key in result.data))
|
|
70
|
+
if isinstance(result.data, dict)
|
|
71
|
+
else ()
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
if result.status_code is not None:
|
|
75
|
+
finished_log = finished_log.bind(status_code=result.status_code)
|
|
76
|
+
finished_log.info("tool finished")
|
|
77
|
+
return result.as_dict()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def create_server(
|
|
81
|
+
*,
|
|
82
|
+
status_action: Callable[[], Result] = status,
|
|
83
|
+
profile_action: Callable[[], Result] = profile,
|
|
84
|
+
preview_action: Callable[..., Result] = preview_clock,
|
|
85
|
+
) -> MCPServer:
|
|
86
|
+
server = MCPServer(
|
|
87
|
+
"tlnt",
|
|
88
|
+
version=metadata.version(DISTRIBUTION),
|
|
89
|
+
instructions="Read-only Talenta session, profile, and clock-in preview tools.",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@server.tool(annotations=_read_only(open_world=False))
|
|
93
|
+
def get_session_status() -> dict[str, Any]:
|
|
94
|
+
"""Check whether a saved, unexpired Talenta mobile token is available."""
|
|
95
|
+
return _call_tool(
|
|
96
|
+
"get_session_status",
|
|
97
|
+
status_action,
|
|
98
|
+
argument_keys=(),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
@server.tool(annotations=_read_only(open_world=True))
|
|
102
|
+
def get_profile() -> dict[str, Any]:
|
|
103
|
+
"""Read Talenta employment information with the mobile token."""
|
|
104
|
+
return _call_tool(
|
|
105
|
+
"get_profile",
|
|
106
|
+
profile_action,
|
|
107
|
+
argument_keys=(),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
@server.tool(annotations=_read_only(open_world=True))
|
|
111
|
+
def preview_clockin(
|
|
112
|
+
event_type: str,
|
|
113
|
+
latitude: str,
|
|
114
|
+
longitude: str,
|
|
115
|
+
notes: str = "",
|
|
116
|
+
location_id: str | None = None,
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
"""Preview a mobile attendance request without submitting it."""
|
|
119
|
+
validation = validate_notes(notes) or validate_coordinates(latitude, longitude)
|
|
120
|
+
if validation is not None:
|
|
121
|
+
return validation.as_dict()
|
|
122
|
+
return _call_tool(
|
|
123
|
+
"preview_clockin",
|
|
124
|
+
preview_action,
|
|
125
|
+
event_type,
|
|
126
|
+
notes,
|
|
127
|
+
argument_keys=(
|
|
128
|
+
"event_type",
|
|
129
|
+
"latitude",
|
|
130
|
+
"longitude",
|
|
131
|
+
"notes",
|
|
132
|
+
"location_id",
|
|
133
|
+
),
|
|
134
|
+
latitude=latitude,
|
|
135
|
+
longitude=longitude,
|
|
136
|
+
location_id=location_id,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return server
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _is_expected_stdio_shutdown(error: BaseException) -> bool:
|
|
143
|
+
if isinstance(error, OSError):
|
|
144
|
+
return error.errno == errno.EIO
|
|
145
|
+
|
|
146
|
+
nested = getattr(error, "exceptions", ())
|
|
147
|
+
return isinstance(nested, tuple) and bool(nested) and all(
|
|
148
|
+
_is_expected_stdio_shutdown(item) for item in nested
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _log_server_stopped(reason: str) -> None:
|
|
153
|
+
_log.bind(
|
|
154
|
+
event="server_stopped",
|
|
155
|
+
transport="stdio",
|
|
156
|
+
reason=reason,
|
|
157
|
+
).info("MCP server stopped")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def run_server() -> None:
|
|
161
|
+
_log.bind(
|
|
162
|
+
event="server_starting",
|
|
163
|
+
transport="stdio",
|
|
164
|
+
).info("MCP server starting")
|
|
165
|
+
try:
|
|
166
|
+
create_server().run()
|
|
167
|
+
except KeyboardInterrupt:
|
|
168
|
+
_log_server_stopped("interrupt")
|
|
169
|
+
return
|
|
170
|
+
except Exception as error:
|
|
171
|
+
if _is_expected_stdio_shutdown(error):
|
|
172
|
+
_log_server_stopped("interrupt")
|
|
173
|
+
return
|
|
174
|
+
_log.exception("unhandled MCP server error")
|
|
175
|
+
raise
|
|
176
|
+
_log_server_stopped("completed")
|