stackless-mcp 1.0.0b2__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,22 @@
1
+ """Stackless MCP server package."""
2
+
3
+ import tomllib
4
+ from importlib.metadata import PackageNotFoundError, version
5
+ from pathlib import Path
6
+
7
+ __all__ = ["__version__"]
8
+
9
+
10
+ def _resolve_version() -> str:
11
+ try:
12
+ return version("stackless-mcp")
13
+ except PackageNotFoundError:
14
+ pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
15
+ try:
16
+ with pyproject.open("rb") as handle:
17
+ return str(tomllib.load(handle)["tool"]["poetry"]["version"])
18
+ except Exception:
19
+ return "0.0.0"
20
+
21
+
22
+ __version__ = _resolve_version()
stackless_mcp/auth.py ADDED
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import secrets
6
+ import stat
7
+ import threading
8
+ import time
9
+ import webbrowser
10
+ from dataclasses import dataclass
11
+ from http.server import BaseHTTPRequestHandler, HTTPServer
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.parse import parse_qs, urlencode, urlparse
15
+
16
+ import httpx
17
+
18
+ from stackless_mcp.client import DEFAULT_TIMEOUT_SECONDS
19
+
20
+ KEYRING_SERVICE = "stackless-mcp"
21
+ FILE_MODE = 0o600
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class StoredCredential:
26
+ base_url: str
27
+ token: str
28
+ expires_at: str | None = None
29
+ user_id: str | None = None
30
+
31
+
32
+ class CredentialStore:
33
+ def __init__(self, storage: str = "keyring") -> None:
34
+ if storage not in {"keyring", "file"}:
35
+ raise ValueError("auth storage must be 'keyring' or 'file'")
36
+ self.storage = storage
37
+
38
+ def load(self, base_url: str) -> StoredCredential | None:
39
+ if self.storage == "keyring":
40
+ return self._load_keyring(base_url)
41
+ return self._load_file(base_url)
42
+
43
+ def save(self, credential: StoredCredential) -> None:
44
+ if self.storage == "keyring":
45
+ self._save_keyring(credential)
46
+ else:
47
+ self._save_file(credential)
48
+
49
+ def delete(self, base_url: str) -> None:
50
+ if self.storage == "keyring":
51
+ self._delete_keyring(base_url)
52
+ else:
53
+ self._delete_file(base_url)
54
+
55
+ @staticmethod
56
+ def _username(base_url: str) -> str:
57
+ return base_url.rstrip("/")
58
+
59
+ @staticmethod
60
+ def _payload(credential: StoredCredential) -> str:
61
+ return json.dumps(
62
+ {
63
+ "base_url": credential.base_url.rstrip("/"),
64
+ "token": credential.token,
65
+ "expires_at": credential.expires_at,
66
+ "user_id": credential.user_id,
67
+ },
68
+ sort_keys=True,
69
+ )
70
+
71
+ @staticmethod
72
+ def _credential_from_payload(payload: str | None) -> StoredCredential | None:
73
+ if not payload:
74
+ return None
75
+ data = json.loads(payload)
76
+ token = str(data.get("token") or "")
77
+ base_url = str(data.get("base_url") or "")
78
+ if not token or not base_url:
79
+ return None
80
+ return StoredCredential(
81
+ base_url=base_url,
82
+ token=token,
83
+ expires_at=data.get("expires_at"),
84
+ user_id=data.get("user_id"),
85
+ )
86
+
87
+ def _keyring(self):
88
+ try:
89
+ import keyring # type: ignore
90
+ except Exception as exc:
91
+ raise RuntimeError(
92
+ "OS keyring storage is unavailable. Install keyring support or "
93
+ "rerun with --auth-storage file for an explicit file fallback."
94
+ ) from exc
95
+ return keyring
96
+
97
+ def _load_keyring(self, base_url: str) -> StoredCredential | None:
98
+ payload = self._keyring().get_password(
99
+ KEYRING_SERVICE, self._username(base_url)
100
+ )
101
+ return self._credential_from_payload(payload)
102
+
103
+ def _save_keyring(self, credential: StoredCredential) -> None:
104
+ self._keyring().set_password(
105
+ KEYRING_SERVICE,
106
+ self._username(credential.base_url),
107
+ self._payload(credential),
108
+ )
109
+
110
+ def _delete_keyring(self, base_url: str) -> None:
111
+ try:
112
+ self._keyring().delete_password(KEYRING_SERVICE, self._username(base_url))
113
+ except Exception:
114
+ return
115
+
116
+ @staticmethod
117
+ def _file_path() -> Path:
118
+ try:
119
+ from platformdirs import user_config_dir # type: ignore
120
+
121
+ root = Path(user_config_dir("stackless-mcp", "Stackless"))
122
+ except Exception:
123
+ root = Path.home() / ".config" / "stackless-mcp"
124
+ return root / "credentials.json"
125
+
126
+ def _load_file(self, base_url: str) -> StoredCredential | None:
127
+ path = self._file_path()
128
+ if not path.is_file():
129
+ return None
130
+ data = json.loads(path.read_text(encoding="utf-8"))
131
+ payload = data.get(self._username(base_url))
132
+ return self._credential_from_payload(json.dumps(payload) if payload else None)
133
+
134
+ def _save_file(self, credential: StoredCredential) -> None:
135
+ path = self._file_path()
136
+ path.parent.mkdir(parents=True, exist_ok=True)
137
+ data: dict[str, Any] = {}
138
+ if path.is_file():
139
+ data = json.loads(path.read_text(encoding="utf-8"))
140
+ data[self._username(credential.base_url)] = json.loads(
141
+ self._payload(credential)
142
+ )
143
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, FILE_MODE)
144
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
145
+ json.dump(data, handle, indent=2, sort_keys=True)
146
+ handle.write("\n")
147
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
148
+
149
+ def _delete_file(self, base_url: str) -> None:
150
+ path = self._file_path()
151
+ if not path.is_file():
152
+ return
153
+ data = json.loads(path.read_text(encoding="utf-8"))
154
+ data.pop(self._username(base_url), None)
155
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, FILE_MODE)
156
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
157
+ json.dump(data, handle, indent=2, sort_keys=True)
158
+ handle.write("\n")
159
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
160
+
161
+
162
+ class _LoginCallbackHandler(BaseHTTPRequestHandler):
163
+ server: "_LoginCallbackServer"
164
+
165
+ def do_GET(self) -> None: # noqa: N802
166
+ parsed = urlparse(self.path)
167
+ if parsed.path != "/callback":
168
+ self.send_error(404)
169
+ return
170
+ params = parse_qs(parsed.query)
171
+ self.server.result = {
172
+ "code": (params.get("code") or [""])[0],
173
+ "state": (params.get("state") or [""])[0],
174
+ "error": (params.get("error") or [""])[0],
175
+ }
176
+ body = b"Stackless MCP login complete. You can close this window."
177
+ self.send_response(200)
178
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
179
+ self.send_header("Content-Length", str(len(body)))
180
+ self.end_headers()
181
+ self.wfile.write(body)
182
+
183
+ def log_message(self, format: str, *args: Any) -> None:
184
+ return
185
+
186
+
187
+ class _LoginCallbackServer(HTTPServer):
188
+ result: dict[str, str] | None = None
189
+
190
+
191
+ def _api_url(base_url: str, path: str) -> str:
192
+ return f"{base_url.rstrip('/')}/api/v1{path}"
193
+
194
+
195
+ def login(
196
+ *,
197
+ base_url: str,
198
+ storage: str = "keyring",
199
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
200
+ open_browser: bool = True,
201
+ client_name: str = "stackless-mcp",
202
+ ) -> StoredCredential:
203
+ state = secrets.token_urlsafe(24)
204
+ server = _LoginCallbackServer(("127.0.0.1", 0), _LoginCallbackHandler)
205
+ redirect_uri = f"http://127.0.0.1:{server.server_port}/callback"
206
+ login_url = _api_url(
207
+ base_url,
208
+ "/mcp/auth/login?"
209
+ + urlencode(
210
+ {
211
+ "state": state,
212
+ "redirect_uri": redirect_uri,
213
+ "client_name": client_name,
214
+ }
215
+ ),
216
+ )
217
+
218
+ thread = threading.Thread(target=server.handle_request, daemon=True)
219
+ thread.start()
220
+ if open_browser:
221
+ webbrowser.open(login_url)
222
+ else:
223
+ print(login_url)
224
+
225
+ deadline = time.monotonic() + timeout
226
+ while server.result is None and time.monotonic() < deadline:
227
+ time.sleep(0.1)
228
+ server.server_close()
229
+ if server.result is None:
230
+ raise TimeoutError("Timed out waiting for Stackless MCP login callback.")
231
+ if server.result.get("error"):
232
+ raise RuntimeError(f"Stackless MCP login failed: {server.result['error']}")
233
+ code = server.result.get("code") or ""
234
+ returned_state = server.result.get("state") or ""
235
+ if not code or returned_state != state:
236
+ raise RuntimeError("Stackless MCP login callback was missing code or state.")
237
+
238
+ response = httpx.post(
239
+ _api_url(base_url, "/mcp/auth/exchange"),
240
+ json={"code": code, "state": state, "client_name": client_name},
241
+ timeout=timeout,
242
+ )
243
+ response.raise_for_status()
244
+ payload = response.json()
245
+ credential = StoredCredential(
246
+ base_url=base_url.rstrip("/"),
247
+ token=payload["token"],
248
+ expires_at=payload.get("expires_at"),
249
+ user_id=payload.get("user_id"),
250
+ )
251
+ CredentialStore(storage).save(credential)
252
+ return credential
253
+
254
+
255
+ def logout(
256
+ *,
257
+ base_url: str,
258
+ storage: str = "keyring",
259
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
260
+ ) -> bool:
261
+ store = CredentialStore(storage)
262
+ credential = store.load(base_url)
263
+ if not credential:
264
+ return False
265
+ try:
266
+ httpx.post(
267
+ _api_url(base_url, "/mcp/auth/revoke"),
268
+ headers={"Authorization": f"Bearer {credential.token}"},
269
+ timeout=timeout,
270
+ )
271
+ finally:
272
+ store.delete(base_url)
273
+ return True
@@ -0,0 +1,318 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from stackless_mcp import __version__
10
+
11
+ DEFAULT_TIMEOUT_SECONDS = 120.0
12
+ API_PREFIX = "/api/v1"
13
+
14
+
15
+ @dataclass
16
+ class StacklessApiError(Exception):
17
+ method: str
18
+ path: str
19
+ status_code: int
20
+ detail: Any
21
+
22
+ def __str__(self) -> str:
23
+ detail = self.detail
24
+ if not isinstance(detail, str):
25
+ detail = json.dumps(detail, sort_keys=True)
26
+ return (
27
+ f"Stackless API {self.method} {self.path} failed "
28
+ f"with HTTP {self.status_code}: {detail}"
29
+ )
30
+
31
+ def to_dict(self) -> dict[str, Any]:
32
+ return {
33
+ "error": str(self),
34
+ "method": self.method,
35
+ "path": self.path,
36
+ "status_code": self.status_code,
37
+ "detail": self.detail,
38
+ }
39
+
40
+
41
+ class StacklessClient:
42
+ def __init__(
43
+ self,
44
+ *,
45
+ base_url: str,
46
+ token: str = "",
47
+ cookie: str = "",
48
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
49
+ transport: httpx.BaseTransport | None = None,
50
+ ) -> None:
51
+ if not base_url:
52
+ raise ValueError("Stackless base URL is required")
53
+ if not token and not cookie:
54
+ raise ValueError("Stackless Bearer token or Cookie header is required")
55
+
56
+ self.base_url = base_url.rstrip("/")
57
+ self.token = token.removeprefix("Bearer ").strip() if token else ""
58
+ self.cookie = cookie.strip()
59
+ if self.cookie.lower().startswith("cookie:"):
60
+ self.cookie = self.cookie.split(":", 1)[1].strip()
61
+ self.timeout = timeout
62
+ headers = {
63
+ "Accept": "application/json",
64
+ "X-Stackless-MCP-Client": f"stackless-mcp/{__version__}",
65
+ }
66
+ if self.token:
67
+ headers["Authorization"] = f"Bearer {self.token}"
68
+ if self.cookie:
69
+ headers["Cookie"] = self.cookie
70
+
71
+ self._client = httpx.Client(
72
+ base_url=self.base_url,
73
+ timeout=timeout,
74
+ headers=headers,
75
+ follow_redirects=False,
76
+ transport=transport,
77
+ )
78
+
79
+ def close(self) -> None:
80
+ self._client.close()
81
+
82
+ def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
83
+ return self.request("GET", path, params=params)
84
+
85
+ def post(
86
+ self,
87
+ path: str,
88
+ *,
89
+ json_body: Any | None = None,
90
+ data: dict[str, Any] | None = None,
91
+ files: dict[str, Any] | None = None,
92
+ params: dict[str, Any] | None = None,
93
+ ) -> Any:
94
+ return self.request(
95
+ "POST",
96
+ path,
97
+ json_body=json_body,
98
+ data=data,
99
+ files=files,
100
+ params=params,
101
+ )
102
+
103
+ def put(self, path: str, *, json_body: Any | None = None) -> Any:
104
+ return self.request("PUT", path, json_body=json_body)
105
+
106
+ def patch(self, path: str, *, json_body: Any | None = None) -> Any:
107
+ return self.request("PATCH", path, json_body=json_body)
108
+
109
+ def delete(self, path: str) -> Any:
110
+ return self.request("DELETE", path)
111
+
112
+ def mcp_tool(
113
+ self,
114
+ tool_name: str,
115
+ *,
116
+ arguments: dict[str, Any] | None = None,
117
+ idempotency_key: str | None = None,
118
+ confirmation: dict[str, Any] | None = None,
119
+ ) -> Any:
120
+ body: dict[str, Any] = {"arguments": arguments or {}}
121
+ if idempotency_key is not None:
122
+ body["idempotency_key"] = idempotency_key
123
+ if confirmation is not None:
124
+ body["confirmation"] = confirmation
125
+ return self.post(f"/mcp/tools/{tool_name}", json_body=body)
126
+
127
+ def mcp_operation(self, operation_id: str) -> Any:
128
+ return self.get(f"/mcp/operations/{operation_id}")
129
+
130
+ def capabilities(self) -> Any:
131
+ return self.get("/mcp/capabilities")
132
+
133
+ def request(
134
+ self,
135
+ method: str,
136
+ path: str,
137
+ *,
138
+ json_body: Any | None = None,
139
+ data: dict[str, Any] | None = None,
140
+ files: dict[str, Any] | None = None,
141
+ params: dict[str, Any] | None = None,
142
+ ) -> Any:
143
+ api_path = self.api_path(path)
144
+ try:
145
+ response = self._client.request(
146
+ method,
147
+ api_path,
148
+ json=json_body,
149
+ data=data,
150
+ files=files,
151
+ params=params,
152
+ )
153
+ except httpx.RequestError as exc:
154
+ raise StacklessApiError(
155
+ method=method,
156
+ path=api_path,
157
+ status_code=0,
158
+ detail={
159
+ "message": "Could not reach the Stackless API.",
160
+ "error": str(exc),
161
+ },
162
+ ) from exc
163
+ return self._parse_response(method, api_path, response)
164
+
165
+ def stream_message(self, conversation_id: str, content: str) -> dict[str, Any]:
166
+ api_path = self.api_path(f"/agents/conversations/{conversation_id}/messages")
167
+ text_parts: list[str] = []
168
+ events: list[dict[str, Any]] = []
169
+ current_event: str | None = None
170
+ data_lines: list[str] = []
171
+
172
+ try:
173
+ with self._client.stream(
174
+ "POST",
175
+ api_path,
176
+ json={"content": content},
177
+ headers={"Accept": "text/event-stream"},
178
+ ) as response:
179
+ content_type = response.headers.get("content-type", "").lower()
180
+ if (
181
+ response.status_code >= 300
182
+ or "text/event-stream" not in content_type
183
+ ):
184
+ response.read()
185
+ parsed = self._parse_response("POST", api_path, response)
186
+ if isinstance(parsed, dict):
187
+ return parsed
188
+ raise StacklessApiError(
189
+ method="POST",
190
+ path=api_path,
191
+ status_code=response.status_code,
192
+ detail={
193
+ "message": (
194
+ "Stackless agent stream returned a non-SSE response."
195
+ ),
196
+ "content_type": content_type,
197
+ "body_preview": self._text_preview(str(parsed)),
198
+ },
199
+ )
200
+ for raw_line in response.iter_lines():
201
+ line = raw_line.strip()
202
+ if not line:
203
+ self._consume_sse_event(
204
+ current_event,
205
+ data_lines,
206
+ events,
207
+ text_parts,
208
+ )
209
+ current_event = None
210
+ data_lines = []
211
+ continue
212
+ if line.startswith("event:"):
213
+ current_event = line.split(":", 1)[1].strip()
214
+ elif line.startswith("data:"):
215
+ data_lines.append(line.split(":", 1)[1].strip())
216
+ except httpx.RequestError as exc:
217
+ raise StacklessApiError(
218
+ method="POST",
219
+ path=api_path,
220
+ status_code=0,
221
+ detail={
222
+ "message": "Could not reach the Stackless API.",
223
+ "error": str(exc),
224
+ },
225
+ ) from exc
226
+
227
+ self._consume_sse_event(current_event, data_lines, events, text_parts)
228
+ return {"text": "".join(text_parts), "events": events}
229
+
230
+ def api_path(self, path: str) -> str:
231
+ normalized = path if path.startswith("/") else f"/{path}"
232
+ if normalized.startswith(API_PREFIX + "/") or normalized == API_PREFIX:
233
+ return normalized
234
+ return f"{API_PREFIX}{normalized}"
235
+
236
+ @staticmethod
237
+ def _parse_response(method: str, path: str, response: httpx.Response) -> Any:
238
+ if response.status_code == 204:
239
+ return {"ok": True}
240
+
241
+ if 300 <= response.status_code < 400:
242
+ raise StacklessApiError(
243
+ method=method,
244
+ path=path,
245
+ status_code=response.status_code,
246
+ detail={
247
+ "message": (
248
+ "Stackless redirected the request. This usually means "
249
+ "ALB cookies are missing or expired, or STACKLESS_BASE_URL "
250
+ "does not point at the Stackless app origin."
251
+ ),
252
+ "location": response.headers.get("location", ""),
253
+ },
254
+ )
255
+
256
+ try:
257
+ body = response.json()
258
+ except ValueError:
259
+ body = response.text
260
+ if StacklessClient._looks_like_html_response(response, body):
261
+ raise StacklessApiError(
262
+ method=method,
263
+ path=path,
264
+ status_code=response.status_code,
265
+ detail={
266
+ "message": (
267
+ "Stackless returned HTML instead of JSON. This usually "
268
+ "means the request reached the ALB/Cognito login flow "
269
+ "instead of the API. Refresh STACKLESS_COOKIE or verify "
270
+ "STACKLESS_BASE_URL."
271
+ ),
272
+ "content_type": response.headers.get("content-type", ""),
273
+ "body_preview": StacklessClient._text_preview(body),
274
+ },
275
+ )
276
+
277
+ if response.status_code >= 400:
278
+ detail = body.get("detail", body) if isinstance(body, dict) else body
279
+ raise StacklessApiError(
280
+ method=method,
281
+ path=path,
282
+ status_code=response.status_code,
283
+ detail=detail,
284
+ )
285
+ return body
286
+
287
+ @staticmethod
288
+ def _looks_like_html_response(response: httpx.Response, body: str) -> bool:
289
+ content_type = response.headers.get("content-type", "").lower()
290
+ if "text/html" in content_type:
291
+ return True
292
+ stripped = body.lstrip().lower()
293
+ return stripped.startswith("<!doctype html") or stripped.startswith("<html")
294
+
295
+ @staticmethod
296
+ def _text_preview(text: str, limit: int = 500) -> str:
297
+ compact = " ".join(text.split())
298
+ if len(compact) <= limit:
299
+ return compact
300
+ return compact[:limit] + "..."
301
+
302
+ @staticmethod
303
+ def _consume_sse_event(
304
+ event: str | None,
305
+ data_lines: list[str],
306
+ events: list[dict[str, Any]],
307
+ text_parts: list[str],
308
+ ) -> None:
309
+ if not event or not data_lines:
310
+ return
311
+ data_text = "\n".join(data_lines)
312
+ try:
313
+ data = json.loads(data_text)
314
+ except ValueError:
315
+ data = {"raw": data_text}
316
+ events.append({"event": event, "data": data})
317
+ if event == "text" and isinstance(data, dict):
318
+ text_parts.append(str(data.get("delta", "")))