speechwire-mcp 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.
@@ -0,0 +1,37 @@
1
+ """speechwire-mcp: Model Context Protocol server for SpeechWire tournament management."""
2
+
3
+ from speechwire_mcp.client import SpeechWireClient, SpeechWireAuthError
4
+ from speechwire_mcp.judges import (
5
+ get_judge_list,
6
+ get_judge_contact,
7
+ get_judge_availability,
8
+ get_judge_school,
9
+ )
10
+ from speechwire_mcp.login import get_accounts, get_tournaments
11
+ from speechwire_mcp.rooms import get_room_counts, get_room_list, get_room_usage
12
+ from speechwire_mcp.schematics import get_schematic_events, get_round_schematic
13
+ from speechwire_mcp.structure import get_groupings, get_timeslots
14
+ from speechwire_mcp.teams import get_team_list, get_team_entries, get_hybrid_entries
15
+ from speechwire_mcp.results import get_tab_sheet
16
+
17
+ __all__ = [
18
+ "SpeechWireClient",
19
+ "SpeechWireAuthError",
20
+ "get_judge_list",
21
+ "get_judge_contact",
22
+ "get_judge_availability",
23
+ "get_judge_school",
24
+ "get_accounts",
25
+ "get_tournaments",
26
+ "get_room_counts",
27
+ "get_room_list",
28
+ "get_room_usage",
29
+ "get_schematic_events",
30
+ "get_round_schematic",
31
+ "get_groupings",
32
+ "get_timeslots",
33
+ "get_team_list",
34
+ "get_team_entries",
35
+ "get_hybrid_entries",
36
+ "get_tab_sheet",
37
+ ]
@@ -0,0 +1,5 @@
1
+ """Run the SpeechWire MCP server: python -m speechwire_mcp"""
2
+
3
+ from speechwire_mcp.server import main
4
+
5
+ main()
@@ -0,0 +1,411 @@
1
+ from enum import Enum
2
+ from typing import TypeVar, Callable
3
+ import logging
4
+ import requests
5
+ from bs4 import BeautifulSoup
6
+ from requests.exceptions import RequestException
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ class ClientState(Enum):
14
+ """Authentication state machine for the SpeechWire client."""
15
+
16
+ UNAUTHENTICATED = "unauthenticated"
17
+ LOGGED_IN = "logged_in"
18
+ ACCOUNT_SELECTED = "account_selected"
19
+ TOURNAMENT_ACTIVE = "tournament_active"
20
+
21
+
22
+ class SpeechWireAuthError(Exception):
23
+ """Raised when authentication fails (bad credentials, missing pages, etc.)."""
24
+
25
+ pass
26
+
27
+
28
+ class SpeechWireSelectionRequired(Exception):
29
+ """Raised when multiple options are available and user must choose.
30
+
31
+ Parameters
32
+ ----------
33
+ message : str
34
+ Human-readable explanation.
35
+ options : list[dict]
36
+ Available choices the caller should present to the user.
37
+ """
38
+
39
+ def __init__(self, message: str, options: list[dict]):
40
+ super().__init__(message)
41
+ self.options = options
42
+
43
+
44
+ class SpeechWireClient:
45
+ """Authenticated HTTP client for the SpeechWire tournament management system.
46
+
47
+ Handles a phased authentication flow (login → account → tournament) with
48
+ automatic session renewal on expiry. Account, circuit, and tournament IDs
49
+ are optional — when omitted the client enters discovery mode and can
50
+ auto-select when exactly one option exists.
51
+
52
+ Parameters
53
+ ----------
54
+ email : str
55
+ SpeechWire account email.
56
+ password : str
57
+ SpeechWire account password.
58
+ account_id : str | None
59
+ Numeric account identifier. Discovered if omitted.
60
+ circuit_id : str | None
61
+ Numeric circuit identifier. Discovered if omitted.
62
+ tournament_id : str | None
63
+ Numeric tournament identifier. Discovered if omitted.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ email: str,
69
+ password: str,
70
+ account_id: str | None = None,
71
+ circuit_id: str | None = None,
72
+ tournament_id: str | None = None,
73
+ ):
74
+ self.email = email
75
+ self.password = password
76
+ self.account_id = account_id
77
+ self.circuit_id = circuit_id
78
+ self.tournament_id = tournament_id
79
+ self.session = requests.Session()
80
+ self.state = ClientState.UNAUTHENTICATED
81
+ self._reauthenticating = False
82
+
83
+ # ------------------------------------------------------------------
84
+ # Phased auth methods
85
+ # ------------------------------------------------------------------
86
+
87
+ def login(self) -> None:
88
+ """Phase A — POST credentials and establish a logged-in session.
89
+
90
+ Transitions state to ``LOGGED_IN``.
91
+ """
92
+ try:
93
+ self.session.cookies.clear()
94
+ self.state = ClientState.UNAUTHENTICATED
95
+
96
+ resp = self.session.post(
97
+ "https://www.speechwire.com/c-login.php",
98
+ data={
99
+ "teamemail": self.email,
100
+ "password": self.password,
101
+ "mode": "account",
102
+ "tournid": "",
103
+ "Submit": "Log in",
104
+ },
105
+ )
106
+ resp.raise_for_status()
107
+ body_lower = resp.text.lower()
108
+ if "incorrect" in body_lower or "invalid" in body_lower:
109
+ raise SpeechWireAuthError(
110
+ "Login rejected — SpeechWire reported invalid credentials"
111
+ )
112
+ self.state = ClientState.LOGGED_IN
113
+ logger.debug("Logged in as %s", self.email)
114
+ except RequestException as e:
115
+ raise SpeechWireAuthError(f"Login failed: {e}") from e
116
+
117
+ def select_account(self, account_id: str | int) -> None:
118
+ """Phase B — select an account by its numeric ID.
119
+
120
+ Parameters
121
+ ----------
122
+ account_id : str | int
123
+ The ``selectaccountid`` value from the account-select page.
124
+
125
+ Transitions state to ``ACCOUNT_SELECTED`` and stores the chosen ID.
126
+ """
127
+ try:
128
+ resp = self.session.get(
129
+ "https://www.speechwire.com/c-account-select.php",
130
+ params={"selectaccountid": str(account_id)},
131
+ )
132
+ resp.raise_for_status()
133
+ self.account_id = str(account_id)
134
+ self.state = ClientState.ACCOUNT_SELECTED
135
+ logger.info("Selected account %s", account_id)
136
+ except RequestException as e:
137
+ raise SpeechWireAuthError(f"Account selection failed: {e}") from e
138
+
139
+ def select_tournament(self, tournament_id: str | int, circuit_id: str | int) -> None:
140
+ """Phase C — select a tournament and activate the session.
141
+
142
+ Parameters
143
+ ----------
144
+ tournament_id : str | int
145
+ Numeric tournament identifier.
146
+ circuit_id : str | int
147
+ Numeric circuit identifier.
148
+
149
+ Transitions state to ``TOURNAMENT_ACTIVE`` and stores the chosen IDs.
150
+ """
151
+ try:
152
+ resp = self.session.post(
153
+ "https://www.speechwire.com/c-circuit-tournaments.php",
154
+ data={
155
+ "tournid": str(tournament_id),
156
+ "Submit": "Log in",
157
+ "mode": "tournjump",
158
+ "circuitid": str(circuit_id),
159
+ },
160
+ )
161
+
162
+ # Activate tournament session via the redirect link
163
+ soup = BeautifulSoup(resp.text, "html.parser")
164
+ link = soup.find(
165
+ "a",
166
+ href=lambda x: x and "njc=" in x and f"tournid={tournament_id}" in x,
167
+ )
168
+ if not link:
169
+ raise SpeechWireAuthError("Could not find tournament activation link")
170
+
171
+ self.session.get(
172
+ link["href"],
173
+ headers={"Referer": "https://www.speechwire.com/"},
174
+ )
175
+ self.tournament_id = str(tournament_id)
176
+ self.circuit_id = str(circuit_id)
177
+ self.state = ClientState.TOURNAMENT_ACTIVE
178
+ logger.info(
179
+ "Activated tournament %s (circuit %s)",
180
+ tournament_id,
181
+ circuit_id,
182
+ )
183
+ except RequestException as e:
184
+ raise SpeechWireAuthError(f"Tournament selection failed: {e}") from e
185
+
186
+ # ------------------------------------------------------------------
187
+ # Discovery helpers
188
+ # ------------------------------------------------------------------
189
+
190
+ def discover_accounts(self) -> list[dict]:
191
+ """Fetch the list of accounts available after login.
192
+
193
+ Returns
194
+ -------
195
+ list[dict]
196
+ Each dict has ``account_id`` (int) and ``name`` (str).
197
+ """
198
+ from speechwire_mcp.login.parsers import parse_account_list_html
199
+
200
+ resp = self.get("https://www.speechwire.com/c-account-select.php")
201
+ return parse_account_list_html(resp.text)
202
+
203
+ def discover_tournaments(self) -> list[dict]:
204
+ """Fetch the list of tournaments for the selected account.
205
+
206
+ Returns
207
+ -------
208
+ list[dict]
209
+ Each dict has ``tournament_id``, ``circuit_id``, ``name``,
210
+ and optionally ``date``.
211
+ """
212
+ from speechwire_mcp.login.parsers import parse_tournament_list_html
213
+
214
+ resp = self.get("https://www.speechwire.com/c-circuit-tournaments.php")
215
+ return parse_tournament_list_html(resp.text)
216
+
217
+ # ------------------------------------------------------------------
218
+ # Auto-select private helpers
219
+ # ------------------------------------------------------------------
220
+
221
+ def _auto_select_account(self) -> None:
222
+ """Discover accounts and auto-select if exactly one exists."""
223
+ accounts = self.discover_accounts()
224
+ if len(accounts) == 1:
225
+ self.select_account(accounts[0]["account_id"])
226
+ logger.info("Auto-selected sole account: %s", accounts[0].get("name"))
227
+ elif len(accounts) == 0:
228
+ raise SpeechWireAuthError("No accounts found for this login. Verify your credentials.")
229
+ else:
230
+ raise SpeechWireSelectionRequired(
231
+ "Multiple accounts available — please select one.",
232
+ options=accounts,
233
+ )
234
+
235
+
236
+ # ------------------------------------------------------------------
237
+ # Convenience wrappers
238
+ # ------------------------------------------------------------------
239
+
240
+ def ensure_tournament_session(self) -> None:
241
+ """Reach ``TOURNAMENT_ACTIVE`` state, auto-discovering where possible.
242
+
243
+ If IDs were provided up front this behaves like the original
244
+ ``_authenticate()``. Otherwise it discovers and auto-selects when
245
+ a single option exists, raising ``SpeechWireSelectionRequired``
246
+ when the caller must choose.
247
+ """
248
+ if self.state == ClientState.TOURNAMENT_ACTIVE:
249
+ return
250
+ self._authenticate()
251
+
252
+ def _authenticate(self) -> None:
253
+ """Full authentication convenience wrapper.
254
+
255
+ Logs in, selects account (by ID or auto-discover), then selects
256
+ tournament (by ID or auto-discover).
257
+ """
258
+ self.login()
259
+
260
+ if self.account_id:
261
+ self.select_account(self.account_id)
262
+ else:
263
+ self._auto_select_account()
264
+
265
+ if self.tournament_id and self.circuit_id:
266
+ self.select_tournament(self.tournament_id, self.circuit_id)
267
+
268
+ # ------------------------------------------------------------------
269
+ # HTTP helpers
270
+ # ------------------------------------------------------------------
271
+
272
+ def _looks_like_expired_session(self, resp: requests.Response) -> bool:
273
+ """Detect whether a response indicates the session has expired.
274
+
275
+ Checks for two indicators:
276
+ - Password input field (login page marker)
277
+ - "Select the tournament" text, but only when the URL is NOT the
278
+ tournament discovery page where that text appears legitimately
279
+ """
280
+ if 'type="password"' in resp.text or "type='password'" in resp.text:
281
+ return True
282
+ if "Select the tournament" in resp.text and "c-circuit-tournaments.php" not in resp.url:
283
+ return True
284
+ return False
285
+
286
+ def get(self, url: str, params: dict | None = None) -> requests.Response:
287
+ """GET a manage.speechwire.com page with session-expiry handling.
288
+
289
+ If the response looks like an expired session (login page redirect
290
+ or unexpected tournament-selection text), the client re-authenticates
291
+ and retries the request once. A guard prevents infinite recursion
292
+ when ``_authenticate`` itself issues GETs (e.g. account discovery).
293
+ """
294
+ resp = self.session.get(url, params=params)
295
+ if self._looks_like_expired_session(resp) and not self._reauthenticating:
296
+ self._reauthenticating = True
297
+ try:
298
+ self._authenticate()
299
+ finally:
300
+ self._reauthenticating = False
301
+ resp = self.session.get(url, params=params)
302
+ return resp
303
+
304
+ def post(self, url: str, data: dict) -> requests.Response:
305
+ """POST form data with session-expiry handling.
306
+
307
+ If the response looks like an expired session, re-authenticates
308
+ and retries the POST once.
309
+
310
+ Parameters
311
+ ----------
312
+ url : str
313
+ Target URL on manage.speechwire.com.
314
+ data : dict
315
+ Form data to submit.
316
+ """
317
+ resp = self.session.post(url, data=data)
318
+ if self._looks_like_expired_session(resp) and not self._reauthenticating:
319
+ self._reauthenticating = True
320
+ try:
321
+ self._authenticate()
322
+ finally:
323
+ self._reauthenticating = False
324
+ resp = self.session.post(url, data=data)
325
+ return resp
326
+
327
+
328
+ def _fetch_and_parse(
329
+ client: SpeechWireClient | None,
330
+ url: str,
331
+ parser: Callable[[str], T],
332
+ default: T,
333
+ context: str = "",
334
+ params: dict | None = None,
335
+ ) -> T:
336
+ """Fetch a page and parse it, returning *default* on any failure.
337
+
338
+ Parameters
339
+ ----------
340
+ client : SpeechWireClient | None
341
+ The authenticated client instance.
342
+ url : str
343
+ URL to fetch.
344
+ parser : Callable[[str], T]
345
+ Pure function that converts HTML text into the desired data shape.
346
+ default : T
347
+ Value returned when fetching or parsing fails.
348
+ context : str
349
+ Human-readable label for log messages.
350
+ params : dict | None
351
+ Optional query parameters passed to ``requests.Session.get``.
352
+ """
353
+ if not client:
354
+ logger.error("No SpeechWire client provided")
355
+ return default
356
+
357
+ try:
358
+ resp = client.get(url, params=params)
359
+ resp.raise_for_status()
360
+ except Exception:
361
+ logger.error("Failed to fetch %s", context)
362
+ return default
363
+
364
+ try:
365
+ return parser(resp.text)
366
+ except Exception:
367
+ logger.error("Failed to parse %s", context)
368
+ return default
369
+
370
+
371
+ def _post_and_parse(
372
+ client: SpeechWireClient | None,
373
+ url: str,
374
+ data: dict,
375
+ parser: Callable[[str], T],
376
+ default: T,
377
+ context: str = "",
378
+ ) -> T:
379
+ """POST form data and parse the response, returning *default* on failure.
380
+
381
+ Parameters
382
+ ----------
383
+ client : SpeechWireClient | None
384
+ The authenticated client instance.
385
+ url : str
386
+ URL to POST to.
387
+ data : dict
388
+ Form data to submit.
389
+ parser : Callable[[str], T]
390
+ Pure function that converts HTML text into the desired data shape.
391
+ default : T
392
+ Value returned when posting or parsing fails.
393
+ context : str
394
+ Human-readable label for log messages.
395
+ """
396
+ if not client:
397
+ logger.error("No SpeechWire client provided")
398
+ return default
399
+
400
+ try:
401
+ resp = client.post(url, data=data)
402
+ resp.raise_for_status()
403
+ except Exception:
404
+ logger.error("Failed to post %s", context)
405
+ return default
406
+
407
+ try:
408
+ return parser(resp.text)
409
+ except Exception:
410
+ logger.error("Failed to parse %s response", context)
411
+ return default
@@ -0,0 +1,23 @@
1
+ from speechwire_mcp.judges.client import (
2
+ get_judge_list,
3
+ get_judge_contact,
4
+ get_judge_availability,
5
+ get_judge_school,
6
+ add_judge,
7
+ get_judge_types,
8
+ update_judge_email,
9
+ update_judge_availability,
10
+ update_judge_school,
11
+ )
12
+
13
+ __all__ = [
14
+ "get_judge_list",
15
+ "get_judge_contact",
16
+ "get_judge_availability",
17
+ "get_judge_school",
18
+ "add_judge",
19
+ "get_judge_types",
20
+ "update_judge_email",
21
+ "update_judge_availability",
22
+ "update_judge_school",
23
+ ]