slidecold 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.
- slidecold/__init__.py +32 -0
- slidecold/_client.py +235 -0
- slidecold/_config.py +44 -0
- slidecold/_exceptions.py +48 -0
- slidecold/_files.py +21 -0
- slidecold/_mcp.py +121 -0
- slidecold/_operations.py +1387 -0
- slidecold/_resources.py +1307 -0
- slidecold/_version.py +1 -0
- slidecold/_webhooks.py +12 -0
- slidecold/cli.py +195 -0
- slidecold/mcp_server.py +25 -0
- slidecold/py.typed +0 -0
- slidecold/types.py +565 -0
- slidecold-0.1.0.dist-info/METADATA +249 -0
- slidecold-0.1.0.dist-info/RECORD +19 -0
- slidecold-0.1.0.dist-info/WHEEL +4 -0
- slidecold-0.1.0.dist-info/entry_points.txt +3 -0
- slidecold-0.1.0.dist-info/licenses/LICENSE +21 -0
slidecold/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Official Python SDK for the SlideCold API."""
|
|
2
|
+
|
|
3
|
+
from ._client import AsyncSlideCold, SlideCold
|
|
4
|
+
from ._config import DEFAULT_BASE_URL
|
|
5
|
+
from ._exceptions import (
|
|
6
|
+
APIConnectionError,
|
|
7
|
+
APIError,
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
PermissionDeniedError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
SlideColdError,
|
|
13
|
+
)
|
|
14
|
+
from ._operations import OPERATIONS
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
from ._webhooks import verify_webhook_signature
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"SlideCold",
|
|
20
|
+
"AsyncSlideCold",
|
|
21
|
+
"SlideColdError",
|
|
22
|
+
"APIError",
|
|
23
|
+
"APIConnectionError",
|
|
24
|
+
"AuthenticationError",
|
|
25
|
+
"PermissionDeniedError",
|
|
26
|
+
"NotFoundError",
|
|
27
|
+
"RateLimitError",
|
|
28
|
+
"verify_webhook_signature",
|
|
29
|
+
"OPERATIONS",
|
|
30
|
+
"DEFAULT_BASE_URL",
|
|
31
|
+
"__version__",
|
|
32
|
+
]
|
slidecold/_client.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from . import _config
|
|
9
|
+
from ._exceptions import APIConnectionError, AuthenticationError, error_for_status
|
|
10
|
+
from ._files import FileInput, to_upload
|
|
11
|
+
from ._operations import OPERATIONS
|
|
12
|
+
from ._resources import AsyncResources, SyncResources
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
|
|
15
|
+
RETRY_STATUSES = {429, 500, 502, 503, 504}
|
|
16
|
+
TERMINAL_JOB_STATES = {"completed", "failed", "unknown"}
|
|
17
|
+
NO_KEY = "No SlideCold API key found. Pass api_key=..., set SLIDECOLD_API_KEY, or run `slidecold login`"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _BaseClient:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
api_key: Optional[str] = None,
|
|
24
|
+
base_url: Optional[str] = None,
|
|
25
|
+
timeout: float = 30,
|
|
26
|
+
max_retries: int = 2,
|
|
27
|
+
) -> None:
|
|
28
|
+
key, url = _config.resolve(api_key, base_url)
|
|
29
|
+
if not key:
|
|
30
|
+
raise AuthenticationError(None, NO_KEY)
|
|
31
|
+
self.api_key = key
|
|
32
|
+
self.base_url = url
|
|
33
|
+
self.timeout = timeout
|
|
34
|
+
self.max_retries = max_retries
|
|
35
|
+
self._headers = {
|
|
36
|
+
"Authorization": f"Bearer {key}",
|
|
37
|
+
"User-Agent": f"slidecold-python/{__version__}",
|
|
38
|
+
"Accept": "application/json",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
def _build(
|
|
42
|
+
self,
|
|
43
|
+
operation_id: str,
|
|
44
|
+
path: Optional[Dict[str, Any]],
|
|
45
|
+
query: Optional[Dict[str, Any]],
|
|
46
|
+
body: Optional[Any],
|
|
47
|
+
file: Optional[FileInput],
|
|
48
|
+
) -> Dict[str, Any]:
|
|
49
|
+
op = OPERATIONS[operation_id]
|
|
50
|
+
url = op.path
|
|
51
|
+
for p in op.path_params:
|
|
52
|
+
value = (path or {}).get(p.name)
|
|
53
|
+
if value is None or value == "":
|
|
54
|
+
raise ValueError(f"{operation_id}: missing path parameter {p.py_name!r}")
|
|
55
|
+
url = url.replace("{" + p.name + "}", quote(str(value), safe=""))
|
|
56
|
+
params = {}
|
|
57
|
+
for k, v in (query or {}).items():
|
|
58
|
+
if v is not None:
|
|
59
|
+
params[k] = ("true" if v else "false") if isinstance(v, bool) else v
|
|
60
|
+
request: Dict[str, Any] = {"method": op.method, "url": self.base_url + url, "params": params}
|
|
61
|
+
if file is not None:
|
|
62
|
+
if not op.file_field:
|
|
63
|
+
raise ValueError(f"{operation_id} does not accept a file")
|
|
64
|
+
request["files"] = {op.file_field: to_upload(file)}
|
|
65
|
+
elif op.body == "multipart":
|
|
66
|
+
raise ValueError(f"{operation_id} requires a file")
|
|
67
|
+
elif op.body:
|
|
68
|
+
if body is None and op.body_required:
|
|
69
|
+
body = {}
|
|
70
|
+
if body is not None:
|
|
71
|
+
request["json"] = body
|
|
72
|
+
return request
|
|
73
|
+
|
|
74
|
+
def _backoff(self, attempt: int, response: Optional[httpx.Response]) -> float:
|
|
75
|
+
retry_after = response.headers.get("retry-after") if response is not None else None
|
|
76
|
+
if retry_after and retry_after.replace(".", "", 1).isdigit():
|
|
77
|
+
return min(float(retry_after), 30.0)
|
|
78
|
+
return min(0.5 * 2.0**attempt, 8.0)
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _parse(response: httpx.Response) -> Any:
|
|
82
|
+
ctype = response.headers.get("content-type", "")
|
|
83
|
+
if response.status_code >= 400:
|
|
84
|
+
body: Any = response.text
|
|
85
|
+
if "json" in ctype:
|
|
86
|
+
try:
|
|
87
|
+
body = response.json()
|
|
88
|
+
except ValueError:
|
|
89
|
+
pass
|
|
90
|
+
message = body.get("error") if isinstance(body, dict) else None
|
|
91
|
+
if not isinstance(message, str):
|
|
92
|
+
message = (
|
|
93
|
+
(body if isinstance(body, str) and body else None) or response.reason_phrase or "Error"
|
|
94
|
+
)
|
|
95
|
+
raise error_for_status(response.status_code, message, body)
|
|
96
|
+
if not response.content:
|
|
97
|
+
return None
|
|
98
|
+
if "json" in ctype:
|
|
99
|
+
return response.json()
|
|
100
|
+
return response.text
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SlideCold(_BaseClient, SyncResources):
|
|
104
|
+
"""Synchronous SlideCold client.
|
|
105
|
+
|
|
106
|
+
>>> with SlideCold() as client:
|
|
107
|
+
... campaigns = client.campaigns.list()
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
api_key: Optional[str] = None,
|
|
113
|
+
base_url: Optional[str] = None,
|
|
114
|
+
timeout: float = 30,
|
|
115
|
+
max_retries: int = 2,
|
|
116
|
+
) -> None:
|
|
117
|
+
super().__init__(api_key, base_url, timeout, max_retries)
|
|
118
|
+
self._http = httpx.Client(headers=self._headers, timeout=timeout)
|
|
119
|
+
self._init_resources()
|
|
120
|
+
|
|
121
|
+
def request(
|
|
122
|
+
self,
|
|
123
|
+
operation_id: str,
|
|
124
|
+
*,
|
|
125
|
+
path: Optional[Dict[str, Any]] = None,
|
|
126
|
+
query: Optional[Dict[str, Any]] = None,
|
|
127
|
+
body: Optional[Any] = None,
|
|
128
|
+
file: Optional[FileInput] = None,
|
|
129
|
+
) -> Any:
|
|
130
|
+
"""Call any operation by its OpenAPI operationId. Only GETs are retried."""
|
|
131
|
+
req = self._build(operation_id, path, query, body, file)
|
|
132
|
+
retries = self.max_retries if req["method"] == "GET" else 0
|
|
133
|
+
attempt = 0
|
|
134
|
+
while True:
|
|
135
|
+
try:
|
|
136
|
+
response = self._http.request(**req)
|
|
137
|
+
except httpx.TransportError as e:
|
|
138
|
+
if attempt >= retries:
|
|
139
|
+
raise APIConnectionError(None, f"Could not reach {self.base_url}: {e}") from e
|
|
140
|
+
time.sleep(self._backoff(attempt, None))
|
|
141
|
+
else:
|
|
142
|
+
if response.status_code not in RETRY_STATUSES or attempt >= retries:
|
|
143
|
+
return self._parse(response)
|
|
144
|
+
time.sleep(self._backoff(attempt, response))
|
|
145
|
+
attempt += 1
|
|
146
|
+
|
|
147
|
+
def wait_for_login_job(self, job_id: str, interval: float = 2, timeout: float = 120) -> Dict[str, Any]:
|
|
148
|
+
"""Poll `accounts.login_status` until the job is completed, failed or unknown; return the last status.
|
|
149
|
+
|
|
150
|
+
Raises TimeoutError if the job is still running after `timeout` seconds.
|
|
151
|
+
"""
|
|
152
|
+
deadline = time.monotonic() + timeout
|
|
153
|
+
while True:
|
|
154
|
+
status: Dict[str, Any] = self.accounts.login_status(job_id)
|
|
155
|
+
if status.get("state") in TERMINAL_JOB_STATES:
|
|
156
|
+
return status
|
|
157
|
+
if time.monotonic() + interval > deadline:
|
|
158
|
+
raise TimeoutError(f"Login job {job_id} still {status.get('state')!r} after {timeout}s")
|
|
159
|
+
time.sleep(interval)
|
|
160
|
+
|
|
161
|
+
def close(self) -> None:
|
|
162
|
+
self._http.close()
|
|
163
|
+
|
|
164
|
+
def __enter__(self) -> "SlideCold":
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
def __exit__(self, *exc: object) -> None:
|
|
168
|
+
self.close()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class AsyncSlideCold(_BaseClient, AsyncResources):
|
|
172
|
+
"""Asynchronous SlideCold client (asyncio).
|
|
173
|
+
|
|
174
|
+
>>> async with AsyncSlideCold() as client:
|
|
175
|
+
... campaigns = await client.campaigns.list()
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
api_key: Optional[str] = None,
|
|
181
|
+
base_url: Optional[str] = None,
|
|
182
|
+
timeout: float = 30,
|
|
183
|
+
max_retries: int = 2,
|
|
184
|
+
) -> None:
|
|
185
|
+
super().__init__(api_key, base_url, timeout, max_retries)
|
|
186
|
+
self._http = httpx.AsyncClient(headers=self._headers, timeout=timeout)
|
|
187
|
+
self._init_resources()
|
|
188
|
+
|
|
189
|
+
async def request(
|
|
190
|
+
self,
|
|
191
|
+
operation_id: str,
|
|
192
|
+
*,
|
|
193
|
+
path: Optional[Dict[str, Any]] = None,
|
|
194
|
+
query: Optional[Dict[str, Any]] = None,
|
|
195
|
+
body: Optional[Any] = None,
|
|
196
|
+
file: Optional[FileInput] = None,
|
|
197
|
+
) -> Any:
|
|
198
|
+
"""Call any operation by its OpenAPI operationId. Only GETs are retried."""
|
|
199
|
+
req = self._build(operation_id, path, query, body, file)
|
|
200
|
+
retries = self.max_retries if req["method"] == "GET" else 0
|
|
201
|
+
attempt = 0
|
|
202
|
+
while True:
|
|
203
|
+
try:
|
|
204
|
+
response = await self._http.request(**req)
|
|
205
|
+
except httpx.TransportError as e:
|
|
206
|
+
if attempt >= retries:
|
|
207
|
+
raise APIConnectionError(None, f"Could not reach {self.base_url}: {e}") from e
|
|
208
|
+
await asyncio.sleep(self._backoff(attempt, None))
|
|
209
|
+
else:
|
|
210
|
+
if response.status_code not in RETRY_STATUSES or attempt >= retries:
|
|
211
|
+
return self._parse(response)
|
|
212
|
+
await asyncio.sleep(self._backoff(attempt, response))
|
|
213
|
+
attempt += 1
|
|
214
|
+
|
|
215
|
+
async def wait_for_login_job(
|
|
216
|
+
self, job_id: str, interval: float = 2, timeout: float = 120
|
|
217
|
+
) -> Dict[str, Any]:
|
|
218
|
+
"""Async version of `SlideCold.wait_for_login_job`."""
|
|
219
|
+
deadline = time.monotonic() + timeout
|
|
220
|
+
while True:
|
|
221
|
+
status: Dict[str, Any] = await self.accounts.login_status(job_id)
|
|
222
|
+
if status.get("state") in TERMINAL_JOB_STATES:
|
|
223
|
+
return status
|
|
224
|
+
if time.monotonic() + interval > deadline:
|
|
225
|
+
raise TimeoutError(f"Login job {job_id} still {status.get('state')!r} after {timeout}s")
|
|
226
|
+
await asyncio.sleep(interval)
|
|
227
|
+
|
|
228
|
+
async def close(self) -> None:
|
|
229
|
+
await self._http.aclose()
|
|
230
|
+
|
|
231
|
+
async def __aenter__(self) -> "AsyncSlideCold":
|
|
232
|
+
return self
|
|
233
|
+
|
|
234
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
235
|
+
await self.close()
|
slidecold/_config.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""API key and base URL resolution, shared with the Node and Go CLIs (`config.json`: {"apiKey", "baseUrl"})."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
DEFAULT_BASE_URL = "https://slidecold.com/api/v1"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def config_path() -> Path:
|
|
12
|
+
if os.name == "nt":
|
|
13
|
+
base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
14
|
+
else:
|
|
15
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
16
|
+
return Path(base) / "slidecold" / "config.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_config() -> Dict[str, Any]:
|
|
20
|
+
try:
|
|
21
|
+
data = json.loads(config_path().read_text(encoding="utf-8"))
|
|
22
|
+
except (OSError, ValueError):
|
|
23
|
+
return {}
|
|
24
|
+
return data if isinstance(data, dict) else {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def save_config(data: Dict[str, Any]) -> Path:
|
|
28
|
+
"""Write the config file readable only by the current user (0600)."""
|
|
29
|
+
path = config_path()
|
|
30
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
32
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
33
|
+
json.dump(data, f, indent=2)
|
|
34
|
+
f.write("\n")
|
|
35
|
+
os.chmod(path, 0o600) # the file may have existed with wider permissions
|
|
36
|
+
return path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve(api_key: Optional[str] = None, base_url: Optional[str] = None) -> Tuple[Optional[str], str]:
|
|
40
|
+
"""Explicit argument, then SLIDECOLD_API_KEY / SLIDECOLD_BASE_URL, then the config file, then prod."""
|
|
41
|
+
config = load_config()
|
|
42
|
+
key = api_key or os.environ.get("SLIDECOLD_API_KEY") or config.get("apiKey") or None
|
|
43
|
+
url = base_url or os.environ.get("SLIDECOLD_BASE_URL") or config.get("baseUrl") or DEFAULT_BASE_URL
|
|
44
|
+
return key, str(url).rstrip("/")
|
slidecold/_exceptions.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
|
|
3
|
+
KEYS_URL = "https://slidecold.com/settings"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SlideColdError(Exception):
|
|
7
|
+
"""Base error. `status` is the HTTP status (None for connection errors), `body` the parsed response."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, status: Optional[int], message: str, body: Any = None) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.status = status
|
|
12
|
+
self.message = message
|
|
13
|
+
self.body = body
|
|
14
|
+
|
|
15
|
+
def __str__(self) -> str:
|
|
16
|
+
return f"{self.message} (HTTP {self.status})" if self.status else self.message
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class APIConnectionError(SlideColdError):
|
|
20
|
+
"""The request never got a response (DNS, refused connection, timeout)."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class APIError(SlideColdError):
|
|
24
|
+
"""The API returned an error status. Specific statuses use the subclasses below."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthenticationError(APIError):
|
|
28
|
+
"""401: the API key is missing or invalid."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, status: Optional[int], message: str, body: Any = None) -> None:
|
|
31
|
+
super().__init__(status, f"{message}. Create an API key at {KEYS_URL}", body)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PermissionDeniedError(APIError):
|
|
35
|
+
"""403: the key is missing a scope (e.g. `campaigns:write`) or a plan limit was hit."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NotFoundError(APIError):
|
|
39
|
+
"""404."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RateLimitError(APIError):
|
|
43
|
+
"""429."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def error_for_status(status: int, message: str, body: Any) -> APIError:
|
|
47
|
+
cls = {401: AuthenticationError, 403: PermissionDeniedError, 404: NotFoundError, 429: RateLimitError}
|
|
48
|
+
return cls.get(status, APIError)(status, message, body)
|
slidecold/_files.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import mimetypes
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import IO, Tuple, Union
|
|
5
|
+
|
|
6
|
+
FileInput = Union[str, "os.PathLike[str]", bytes, IO[bytes], Tuple[str, bytes]]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def to_upload(file: FileInput, default_name: str = "upload") -> Tuple[str, bytes, str]:
|
|
10
|
+
"""Normalize a file argument into an httpx multipart tuple (filename, content, content_type)."""
|
|
11
|
+
# shortcut: reads the whole file into memory (fine for photos/CSVs); stream it if large uploads appear.
|
|
12
|
+
if isinstance(file, tuple):
|
|
13
|
+
name, content = file
|
|
14
|
+
elif isinstance(file, bytes):
|
|
15
|
+
name, content = default_name, file
|
|
16
|
+
elif isinstance(file, (str, os.PathLike)):
|
|
17
|
+
path = Path(file)
|
|
18
|
+
name, content = path.name, path.read_bytes()
|
|
19
|
+
else:
|
|
20
|
+
name, content = Path(getattr(file, "name", default_name)).name, file.read()
|
|
21
|
+
return name, content, mimetypes.guess_type(name)[0] or "application/octet-stream"
|
slidecold/_mcp.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""MCP server exposing one tool per SlideCold API operation (requires the `mcp` extra)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from mcp import types
|
|
8
|
+
from mcp.server import Server
|
|
9
|
+
from mcp.server.stdio import stdio_server
|
|
10
|
+
|
|
11
|
+
from ._client import AsyncSlideCold
|
|
12
|
+
from ._exceptions import SlideColdError
|
|
13
|
+
from ._operations import OPERATIONS, Operation
|
|
14
|
+
from ._version import __version__
|
|
15
|
+
|
|
16
|
+
INSTRUCTIONS = (
|
|
17
|
+
"Tools for the SlideCold API (Instagram cold-DM outreach). Each tool needs the API key scope named in its "
|
|
18
|
+
"description; a 403 means the key lacks that scope or a plan limit was hit. Login jobs (create_account, "
|
|
19
|
+
"relogin_account, check_account_session) return a jobId to poll with get_login_status."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def tool_schema(op: Operation) -> Dict[str, Any]:
|
|
24
|
+
props: Dict[str, Any] = {}
|
|
25
|
+
required: List[str] = []
|
|
26
|
+
for p in (*op.path_params, *op.query_params):
|
|
27
|
+
props[p.name] = {**p.schema, **({"description": p.description} if p.description else {})}
|
|
28
|
+
if p.required:
|
|
29
|
+
required.append(p.name)
|
|
30
|
+
if op.body_schema is not None:
|
|
31
|
+
if op.body_flat:
|
|
32
|
+
props.update(op.body_schema.get("properties", {}))
|
|
33
|
+
if op.body_required:
|
|
34
|
+
required += op.body_schema.get("required", [])
|
|
35
|
+
else:
|
|
36
|
+
props["body"] = op.body_schema
|
|
37
|
+
if op.body_required and not op.file_field:
|
|
38
|
+
required.append("body")
|
|
39
|
+
if op.file_field:
|
|
40
|
+
props["file_path"] = {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"description": f"Path to a local file to upload as `{op.file_field}`.",
|
|
43
|
+
}
|
|
44
|
+
if op.body == "multipart":
|
|
45
|
+
required.append("file_path")
|
|
46
|
+
schema: Dict[str, Any] = {"type": "object", "properties": props}
|
|
47
|
+
if required:
|
|
48
|
+
schema["required"] = required
|
|
49
|
+
return schema
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def make_tool(op: Operation) -> types.Tool:
|
|
53
|
+
description = op.summary + "."
|
|
54
|
+
if op.description:
|
|
55
|
+
description += "\n\n" + op.description
|
|
56
|
+
description += f"\n\nRequired API key scope: {op.scope}"
|
|
57
|
+
read_only = op.method == "GET"
|
|
58
|
+
return types.Tool(
|
|
59
|
+
name=op.tool,
|
|
60
|
+
description=description,
|
|
61
|
+
input_schema=tool_schema(op),
|
|
62
|
+
annotations=types.ToolAnnotations(
|
|
63
|
+
title=op.summary,
|
|
64
|
+
read_only_hint=read_only,
|
|
65
|
+
destructive_hint=None if read_only else op.destructive,
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
TOOLS = {op.tool: (op, make_tool(op)) for op in OPERATIONS.values()}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _text(text: str, error: bool = False) -> types.CallToolResult:
|
|
74
|
+
return types.CallToolResult(content=[types.TextContent(type="text", text=text)], is_error=error)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_server(client: Optional[AsyncSlideCold] = None) -> Server:
|
|
78
|
+
"""Build the server. Without `client`, one is created on the first tool call from env/config."""
|
|
79
|
+
state: Dict[str, AsyncSlideCold] = {"client": client} if client else {}
|
|
80
|
+
|
|
81
|
+
async def list_tools(ctx: Any, params: Any) -> types.ListToolsResult:
|
|
82
|
+
return types.ListToolsResult(tools=[tool for _, tool in TOOLS.values()])
|
|
83
|
+
|
|
84
|
+
async def call_tool(ctx: Any, params: types.CallToolRequestParams) -> types.CallToolResult:
|
|
85
|
+
if params.name not in TOOLS:
|
|
86
|
+
return _text(f"Unknown tool: {params.name}", error=True)
|
|
87
|
+
op = TOOLS[params.name][0]
|
|
88
|
+
args = dict(params.arguments or {})
|
|
89
|
+
try:
|
|
90
|
+
if "client" not in state:
|
|
91
|
+
state["client"] = AsyncSlideCold()
|
|
92
|
+
file_path = args.pop("file_path", None)
|
|
93
|
+
path = {p.name: args.pop(p.name, None) for p in op.path_params}
|
|
94
|
+
query = {q.name: args.pop(q.name, None) for q in op.query_params}
|
|
95
|
+
body = (args or None) if op.body_flat else args.pop("body", None)
|
|
96
|
+
result = await state["client"].request(
|
|
97
|
+
op.id,
|
|
98
|
+
path=path,
|
|
99
|
+
query=query,
|
|
100
|
+
body=body,
|
|
101
|
+
file=os.path.expanduser(file_path) if file_path else None,
|
|
102
|
+
)
|
|
103
|
+
except (SlideColdError, ValueError, OSError) as e:
|
|
104
|
+
return _text(str(e), error=True)
|
|
105
|
+
if result is None:
|
|
106
|
+
return _text("OK")
|
|
107
|
+
return _text(result if isinstance(result, str) else json.dumps(result, indent=2, ensure_ascii=False))
|
|
108
|
+
|
|
109
|
+
return Server(
|
|
110
|
+
"slidecold",
|
|
111
|
+
version=__version__,
|
|
112
|
+
instructions=INSTRUCTIONS,
|
|
113
|
+
on_list_tools=list_tools,
|
|
114
|
+
on_call_tool=call_tool,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def serve_stdio() -> None:
|
|
119
|
+
server = build_server()
|
|
120
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
121
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|