activecollab-mcp 1.9.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.
@@ -0,0 +1,10 @@
1
+ """ActiveCollab MCP server."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("activecollab-mcp")
7
+ except PackageNotFoundError: # editable installs without metadata
8
+ __version__ = "0.0.0+local"
9
+
10
+ __all__ = ["__version__"]
@@ -0,0 +1,4 @@
1
+ from activecollab_mcp.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,134 @@
1
+ """ActiveCollab token issuance.
2
+
3
+ Implements the `POST /issue-token` flow described at
4
+ https://developers.activecollab.com/api-documentation/v1/authentication.html
5
+
6
+ Given a base URL + email + password, returns a long-lived API token that can be
7
+ used in subsequent requests via the `X-Angie-AuthApiToken` header.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from datetime import datetime, timezone
13
+ from urllib.parse import urlparse, urlunparse
14
+
15
+ import httpx
16
+
17
+ from activecollab_mcp.credentials import Credentials, write_credentials
18
+
19
+
20
+ class AuthError(Exception):
21
+ """Raised when token issuance fails."""
22
+
23
+
24
+ CLIENT_NAME = "ActiveCollab MCP"
25
+ CLIENT_VENDOR = "activecollab-mcp"
26
+
27
+
28
+ def normalize_base_url(raw: str, *, append_api_v1: bool = True) -> str:
29
+ """Normalize an ActiveCollab base URL the same way config does.
30
+
31
+ Duplicated here (not imported from config) to keep this module
32
+ free of pydantic at call time.
33
+ """
34
+ if not raw or not raw.strip():
35
+ raise AuthError("Base URL is required.")
36
+
37
+ url = raw.strip()
38
+ if not url.endswith("/"):
39
+ url = url + "/"
40
+
41
+ parsed = urlparse(url)
42
+ if not parsed.scheme or not parsed.netloc:
43
+ raise AuthError(f"Invalid base URL: {raw}")
44
+
45
+ if append_api_v1:
46
+ path = parsed.path.rstrip("/")
47
+ if path == "":
48
+ parsed = parsed._replace(path="/api/v1/")
49
+
50
+ final = urlunparse(parsed)
51
+ return final if final.endswith("/") else final + "/"
52
+
53
+
54
+ def issue_token(
55
+ base_url: str,
56
+ email: str,
57
+ password: str,
58
+ *,
59
+ verify: bool = True,
60
+ timeout: float = 20.0,
61
+ ) -> str:
62
+ """Call POST /issue-token. Returns the token on success.
63
+
64
+ Raises AuthError with a clear message on failure.
65
+ """
66
+ if not email or not password:
67
+ raise AuthError("Email and password are required.")
68
+
69
+ url = base_url.rstrip("/") + "/issue-token"
70
+ payload = {
71
+ "username": email,
72
+ "password": password,
73
+ "client_name": CLIENT_NAME,
74
+ "client_vendor": CLIENT_VENDOR,
75
+ }
76
+
77
+ try:
78
+ response = httpx.post(
79
+ url,
80
+ json=payload,
81
+ timeout=timeout,
82
+ verify=verify,
83
+ headers={
84
+ "Accept": "application/json",
85
+ "User-Agent": "activecollab-mcp/python",
86
+ },
87
+ )
88
+ except httpx.TimeoutException as exc:
89
+ raise AuthError(f"Token request timed out after {timeout}s") from exc
90
+ except httpx.HTTPError as exc:
91
+ raise AuthError(f"Token request failed: {exc}") from exc
92
+
93
+ body: object = None
94
+ if response.headers.get("content-type", "").startswith("application/json"):
95
+ try:
96
+ body = response.json()
97
+ except ValueError:
98
+ body = None
99
+
100
+ if response.is_error:
101
+ # ActiveCollab returns 500 with a typed message for known auth failures.
102
+ if isinstance(body, dict) and body.get("message"):
103
+ raise AuthError(str(body["message"]))
104
+ raise AuthError(
105
+ f"Token request failed with status {response.status_code}: {response.text[:300]}"
106
+ )
107
+
108
+ if not isinstance(body, dict) or not body.get("is_ok") or not body.get("token"):
109
+ raise AuthError(
110
+ f"Unexpected response from /issue-token: {response.text[:300]}"
111
+ )
112
+
113
+ return str(body["token"])
114
+
115
+
116
+ def issue_and_store(
117
+ base_url_raw: str,
118
+ email: str,
119
+ password: str,
120
+ *,
121
+ verify: bool = True,
122
+ append_api_v1: bool = True,
123
+ ) -> Credentials:
124
+ """Issue a token and persist it to the credentials file."""
125
+ normalized = normalize_base_url(base_url_raw, append_api_v1=append_api_v1)
126
+ token = issue_token(normalized, email, password, verify=verify)
127
+ creds = Credentials(
128
+ base_url=normalized,
129
+ api_key=token,
130
+ issued_at=datetime.now(timezone.utc).isoformat(),
131
+ issued_for=email,
132
+ )
133
+ write_credentials(creds)
134
+ return creds