skool-api-mcp 1.0.2__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.
Files changed (96) hide show
  1. skool_api_mcp/__init__.py +28 -0
  2. skool_api_mcp/api.py +213 -0
  3. skool_api_mcp/app.py +29 -0
  4. skool_api_mcp/cli.py +198 -0
  5. skool_api_mcp/core/__init__.py +0 -0
  6. skool_api_mcp/core/account_guard.py +33 -0
  7. skool_api_mcp/core/context.py +175 -0
  8. skool_api_mcp/core/errors.py +78 -0
  9. skool_api_mcp/core/helpers.py +33 -0
  10. skool_api_mcp/core/tools.py +106 -0
  11. skool_api_mcp/mcp_context.py +126 -0
  12. skool_api_mcp/mcp_server.py +67 -0
  13. skool_api_mcp/runner.py +106 -0
  14. skool_api_mcp/scripting.py +73 -0
  15. skool_api_mcp/session.py +197 -0
  16. skool_api_mcp/skool/__init__.py +0 -0
  17. skool_api_mcp/skool/auth.py +24 -0
  18. skool_api_mcp/skool/client.py +243 -0
  19. skool_api_mcp/skool/courses.py +104 -0
  20. skool_api_mcp/skool/events.py +99 -0
  21. skool_api_mcp/skool/settings.py +83 -0
  22. skool_api_mcp/skool/tools/__init__.py +0 -0
  23. skool_api_mcp/skool/tools/add_group_link.py +47 -0
  24. skool_api_mcp/skool/tools/add_group_rule.py +49 -0
  25. skool_api_mcp/skool/tools/add_membership_question.py +62 -0
  26. skool_api_mcp/skool/tools/approve_membership_request.py +50 -0
  27. skool_api_mcp/skool/tools/ban_member.py +58 -0
  28. skool_api_mcp/skool/tools/comment_on_post.py +71 -0
  29. skool_api_mcp/skool/tools/create_category.py +67 -0
  30. skool_api_mcp/skool/tools/create_course.py +89 -0
  31. skool_api_mcp/skool/tools/create_event.py +82 -0
  32. skool_api_mcp/skool/tools/create_lesson.py +79 -0
  33. skool_api_mcp/skool/tools/create_post.py +98 -0
  34. skool_api_mcp/skool/tools/decline_membership_request.py +44 -0
  35. skool_api_mcp/skool/tools/delete_category.py +59 -0
  36. skool_api_mcp/skool/tools/delete_event.py +45 -0
  37. skool_api_mcp/skool/tools/delete_group_link.py +47 -0
  38. skool_api_mcp/skool/tools/delete_group_rule.py +49 -0
  39. skool_api_mcp/skool/tools/delete_membership_question.py +51 -0
  40. skool_api_mcp/skool/tools/delete_post.py +45 -0
  41. skool_api_mcp/skool/tools/discover_groups.py +79 -0
  42. skool_api_mcp/skool/tools/follow_post.py +46 -0
  43. skool_api_mcp/skool/tools/get_auto_dm.py +54 -0
  44. skool_api_mcp/skool/tools/get_conversation.py +71 -0
  45. skool_api_mcp/skool/tools/get_course.py +77 -0
  46. skool_api_mcp/skool/tools/get_event.py +54 -0
  47. skool_api_mcp/skool/tools/get_group.py +81 -0
  48. skool_api_mcp/skool/tools/get_group_stats.py +89 -0
  49. skool_api_mcp/skool/tools/get_leaderboard.py +74 -0
  50. skool_api_mcp/skool/tools/get_lesson.py +74 -0
  51. skool_api_mcp/skool/tools/get_member.py +69 -0
  52. skool_api_mcp/skool/tools/get_poll_results.py +73 -0
  53. skool_api_mcp/skool/tools/get_post.py +95 -0
  54. skool_api_mcp/skool/tools/invite_members.py +62 -0
  55. skool_api_mcp/skool/tools/like_post.py +47 -0
  56. skool_api_mcp/skool/tools/list_categories.py +55 -0
  57. skool_api_mcp/skool/tools/list_conversations.py +75 -0
  58. skool_api_mcp/skool/tools/list_courses.py +91 -0
  59. skool_api_mcp/skool/tools/list_events.py +75 -0
  60. skool_api_mcp/skool/tools/list_group_links.py +46 -0
  61. skool_api_mcp/skool/tools/list_group_rules.py +49 -0
  62. skool_api_mcp/skool/tools/list_groups.py +71 -0
  63. skool_api_mcp/skool/tools/list_levels.py +53 -0
  64. skool_api_mcp/skool/tools/list_members.py +112 -0
  65. skool_api_mcp/skool/tools/list_membership_requests.py +87 -0
  66. skool_api_mcp/skool/tools/list_notifications.py +92 -0
  67. skool_api_mcp/skool/tools/list_post_likes.py +62 -0
  68. skool_api_mcp/skool/tools/list_posts.py +110 -0
  69. skool_api_mcp/skool/tools/list_reported_posts.py +67 -0
  70. skool_api_mcp/skool/tools/mark_lesson_complete.py +45 -0
  71. skool_api_mcp/skool/tools/mark_notifications_read.py +46 -0
  72. skool_api_mcp/skool/tools/pin_comment.py +52 -0
  73. skool_api_mcp/skool/tools/pin_post.py +53 -0
  74. skool_api_mcp/skool/tools/remove_member.py +45 -0
  75. skool_api_mcp/skool/tools/rename_level.py +50 -0
  76. skool_api_mcp/skool/tools/search_group.py +97 -0
  77. skool_api_mcp/skool/tools/search_members.py +54 -0
  78. skool_api_mcp/skool/tools/send_message.py +78 -0
  79. skool_api_mcp/skool/tools/set_auto_dm.py +57 -0
  80. skool_api_mcp/skool/tools/set_course_published.py +56 -0
  81. skool_api_mcp/skool/tools/set_member_role.py +52 -0
  82. skool_api_mcp/skool/tools/set_post_comments.py +46 -0
  83. skool_api_mcp/skool/tools/update_category.py +58 -0
  84. skool_api_mcp/skool/tools/update_event.py +121 -0
  85. skool_api_mcp/skool/tools/update_group_about.py +50 -0
  86. skool_api_mcp/skool/tools/update_group_link.py +54 -0
  87. skool_api_mcp/skool/tools/update_group_rule.py +55 -0
  88. skool_api_mcp/skool/tools/update_lesson.py +67 -0
  89. skool_api_mcp/skool/tools/update_membership_question.py +68 -0
  90. skool_api_mcp/skool/tools/update_post.py +72 -0
  91. skool_api_mcp/skool/tools/vote_on_poll.py +78 -0
  92. skool_api_mcp-1.0.2.dist-info/METADATA +528 -0
  93. skool_api_mcp-1.0.2.dist-info/RECORD +96 -0
  94. skool_api_mcp-1.0.2.dist-info/WHEEL +4 -0
  95. skool_api_mcp-1.0.2.dist-info/entry_points.txt +2 -0
  96. skool_api_mcp-1.0.2.dist-info/licenses/LICENSE +133 -0
@@ -0,0 +1,28 @@
1
+ """Self-hosted API and MCP server for one app (see app.py). Personal use only, see LICENSE.
2
+
3
+ From Python, without a server:
4
+
5
+ from skool_api_mcp import call
6
+ groups = call("list_groups")["groups"]
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from typing import Any
13
+
14
+ __version__ = "1.0.2"
15
+
16
+
17
+ def call(tool: str, args: dict[str, Any] | None = None, **kwargs: Any) -> Any:
18
+ """Run one tool with the saved session and return its answer. Raises ToolError on failure."""
19
+ from skool_api_mcp.runner import Runner
20
+
21
+ async def go() -> Any:
22
+ runner = Runner()
23
+ try:
24
+ return await runner.run(tool, {**(args or {}), **kwargs})
25
+ finally:
26
+ await runner.close()
27
+
28
+ return asyncio.run(go())
skool_api_mcp/api.py ADDED
@@ -0,0 +1,213 @@
1
+ """The REST API and the MCP endpoint on one port.
2
+
3
+ - `GET|POST|PUT|PATCH|DELETE <app.API_PREFIX>/<route>`: each tool at its route, the same paths as the
4
+ hosted API at cotlet.dev. Path parts and the query string are arguments; a JSON body adds the rest.
5
+ - `POST <app.API_PREFIX>/tools/{name}`: any tool by name, the JSON body is its arguments.
6
+ - `GET <app.API_PREFIX>/tools`: every tool with its schema. OpenAPI at `/docs` and `/openapi.json`.
7
+ - `/mcp`: the MCP server over streamable HTTP.
8
+
9
+ With <ENV>_MCP_TOKEN set, every request needs `Authorization: Bearer <token>`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import hmac
16
+ import json
17
+ import os
18
+ import re
19
+ from collections.abc import AsyncIterator, Awaitable, Callable
20
+ from typing import Any
21
+
22
+ from fastapi import FastAPI, Request
23
+ from fastapi.responses import JSONResponse
24
+ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
25
+
26
+ from skool_api_mcp import __version__, app, mcp_server
27
+ from skool_api_mcp.core.errors import HTTP_STATUS, ErrorCode, ToolError
28
+ from skool_api_mcp.core.tools import ToolSpec
29
+ from skool_api_mcp.runner import Runner, description, tools
30
+
31
+ PREFIX = app.API_PREFIX
32
+ ASGI = Callable[[dict[str, Any], Callable[[], Awaitable[Any]], Callable[[Any], Awaitable[None]]], Awaitable[None]]
33
+
34
+
35
+ def _error(code: ErrorCode, message: str) -> JSONResponse:
36
+ return JSONResponse({"error": code.value, "message": message}, status_code=HTTP_STATUS.get(code, 500))
37
+
38
+
39
+ def _typed(value: str, schema: dict[str, Any]) -> Any:
40
+ kind = schema.get("type")
41
+ try:
42
+ if kind == "integer":
43
+ return int(value)
44
+ if kind == "number":
45
+ return float(value)
46
+ except ValueError:
47
+ return value
48
+ if kind == "boolean":
49
+ return value.lower() in ("1", "true", "yes", "on")
50
+ if kind == "array":
51
+ return [_typed(v, schema.get("items") or {}) for v in value.split(",") if v]
52
+ if kind == "object":
53
+ with contextlib.suppress(ValueError):
54
+ return json.loads(value)
55
+ return value
56
+
57
+
58
+ def _route_order(spec: ToolSpec) -> list[int]:
59
+ """Literal segments before {placeholders}, so /groups/discover wins over /groups/{group}."""
60
+ path = (spec.route or "").split(" ", 1)[1]
61
+ return [1 if part.startswith("{") else 0 for part in path.strip("/").split("/")]
62
+
63
+
64
+ def _tag(path: str) -> str:
65
+ """OpenAPI group: the part after /groups/{group}/ (posts, members ...), else the first part."""
66
+ parts = path.strip("/").split("/")
67
+ return parts[2] if parts[:2] == ["groups", "{group}"] and len(parts) > 2 else parts[0]
68
+
69
+
70
+ def _openapi(spec: ToolSpec, method: str, path: str) -> dict[str, Any]:
71
+ props: dict[str, Any] = spec.input_schema.get("properties") or {}
72
+ required = set(spec.input_schema.get("required") or [])
73
+ in_path = set(re.findall(r"\{(\w+)\}", path))
74
+ parameters = [
75
+ {"name": name, "in": "path", "required": True, "schema": props.get(name, {"type": "string"})}
76
+ for name in in_path
77
+ ]
78
+ rest = {k: v for k, v in props.items() if k not in in_path}
79
+ extra: dict[str, Any] = {}
80
+ if method == "GET":
81
+ parameters += [
82
+ {"name": name, "in": "query", "required": name in required, "schema": schema}
83
+ for name, schema in rest.items()
84
+ ]
85
+ elif rest:
86
+ body = {"type": "object", "properties": rest, "required": sorted(required - in_path)}
87
+ extra["requestBody"] = {"required": bool(body["required"]), "content": {"application/json": {"schema": body}}}
88
+ if spec.example_response:
89
+ extra["responses"] = {
90
+ "200": {"description": "OK", "content": {"application/json": {"example": spec.example_response}}}
91
+ }
92
+ return {"parameters": parameters, **extra}
93
+
94
+
95
+ async def _arguments(request: Request, spec: ToolSpec) -> dict[str, Any]:
96
+ props: dict[str, Any] = spec.input_schema.get("properties") or {}
97
+ args: dict[str, Any] = {}
98
+ if request.method != "GET":
99
+ raw = await request.body()
100
+ if raw.strip():
101
+ try:
102
+ body = json.loads(raw)
103
+ except ValueError:
104
+ raise ToolError("The body must be a JSON object.", ErrorCode.INVALID_INPUT) from None
105
+ if not isinstance(body, dict):
106
+ raise ToolError("The body must be a JSON object.", ErrorCode.INVALID_INPUT)
107
+ args.update(body)
108
+ for name in request.query_params:
109
+ values = request.query_params.getlist(name)
110
+ schema = props.get(name) or {}
111
+ args[name] = (
112
+ [_typed(v, schema.get("items") or {}) for v in values]
113
+ if schema.get("type") == "array" and len(values) > 1
114
+ else _typed(values[-1], schema)
115
+ )
116
+ for name, value in request.path_params.items():
117
+ args[name] = _typed(value, props.get(name) or {})
118
+ return args
119
+
120
+
121
+ def create_app(runner: Runner) -> ASGI:
122
+ manager = StreamableHTTPSessionManager(app=mcp_server.build(runner), json_response=True, stateless=True)
123
+ token = os.environ.get(f"{app.ENV}_MCP_TOKEN", "").strip()
124
+
125
+ @contextlib.asynccontextmanager
126
+ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
127
+ async with manager.run():
128
+ yield
129
+ await runner.close()
130
+
131
+ api = FastAPI(
132
+ title=f"{app.NAME} API (self-hosted)",
133
+ version=__version__,
134
+ description=f"Your {app.NAME} account as a REST API, run on your machine with your Chrome's session.",
135
+ lifespan=lifespan,
136
+ )
137
+
138
+ async def call(spec: ToolSpec, args: dict[str, Any]) -> JSONResponse:
139
+ try:
140
+ result = await runner.run(spec.name, args)
141
+ except ToolError as exc:
142
+ return _error(exc.code, str(exc))
143
+ return JSONResponse(result)
144
+
145
+ def endpoint(spec: ToolSpec) -> Callable[[Request], Awaitable[JSONResponse]]:
146
+ async def handle(request: Request) -> JSONResponse:
147
+ try:
148
+ args = await _arguments(request, spec)
149
+ except ToolError as exc:
150
+ return _error(exc.code, str(exc))
151
+ return await call(spec, args)
152
+
153
+ return handle
154
+
155
+ @api.get(f"{PREFIX}/tools", summary="Every tool with its route and input schema")
156
+ async def list_tools() -> list[dict[str, Any]]:
157
+ return [
158
+ {
159
+ "name": spec.name,
160
+ "title": spec.title,
161
+ "access": spec.access,
162
+ "route": spec.route,
163
+ "description": description(spec),
164
+ "input_schema": spec.input_schema,
165
+ "example_response": spec.example_response,
166
+ }
167
+ for spec in tools(runner.read_only)
168
+ ]
169
+
170
+ @api.post(f"{PREFIX}/tools/{{name}}", summary="Call any tool by name; the JSON body is its arguments")
171
+ async def call_by_name(name: str, request: Request) -> JSONResponse:
172
+ raw = await request.body()
173
+ try:
174
+ args = json.loads(raw) if raw.strip() else {}
175
+ except ValueError:
176
+ return _error(ErrorCode.INVALID_INPUT, "The body must be a JSON object.")
177
+ if not isinstance(args, dict):
178
+ return _error(ErrorCode.INVALID_INPUT, "The body must be a JSON object.")
179
+ spec = next((s for s in tools() if s.name == name), None)
180
+ if spec is None:
181
+ return _error(ErrorCode.UNKNOWN_TOOL, f"No tool '{name}'. GET {PREFIX}/tools lists them.")
182
+ return await call(spec, args)
183
+
184
+ for spec in sorted(tools(runner.read_only), key=_route_order):
185
+ method, path = (spec.route or "").split(" ", 1)
186
+ api.add_api_route(
187
+ PREFIX + path,
188
+ endpoint(spec),
189
+ methods=[method],
190
+ name=spec.name,
191
+ operation_id=spec.name,
192
+ summary=spec.title,
193
+ description=description(spec),
194
+ tags=[_tag(path)],
195
+ openapi_extra=_openapi(spec, method, path),
196
+ )
197
+
198
+ async def asgi(
199
+ scope: dict[str, Any], receive: Callable[[], Awaitable[Any]], send: Callable[[Any], Awaitable[None]]
200
+ ) -> None:
201
+ if scope["type"] == "http" and token:
202
+ sent = dict(scope.get("headers") or []).get(b"authorization", b"").decode()
203
+ if not hmac.compare_digest(sent, f"Bearer {token}"):
204
+ await _error(ErrorCode.AUTH_REQUIRED, f"Send Authorization: Bearer <{app.ENV}_MCP_TOKEN>.")(
205
+ scope, receive, send
206
+ )
207
+ return
208
+ if scope["type"] == "http" and (scope["path"] == "/mcp" or scope["path"].startswith("/mcp/")):
209
+ await manager.handle_request(scope, receive, send)
210
+ return
211
+ await api(scope, receive, send)
212
+
213
+ return asgi
skool_api_mcp/app.py ADDED
@@ -0,0 +1,29 @@
1
+ """Everything the server knows about Skool outside `skool/`: names, domains and the session cookie.
2
+
3
+ The rest of the package reads these values and never names the app itself.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ NAME = "Skool"
9
+ SLUG = "skool"
10
+ # The command and the prefix of every environment variable (SKOOL_AUTH_TOKEN, SKOOL_MCP_TOKEN ...).
11
+ CLI = "skool-mcp"
12
+ ENV = "SKOOL"
13
+ # Requests may reach only these hosts and their subdomains. Their cookies are read from Chrome.
14
+ DOMAINS = ("skool.com",)
15
+ # Present only while signed in. The first is the cookie a pasted token sets.
16
+ SESSION_COOKIES = ("auth_token",)
17
+ COOKIE_DOMAIN = ".skool.com"
18
+ LOGIN_URL = "https://www.skool.com/login"
19
+ # A final URL with one of these means the app signed the session out.
20
+ LOGGED_OUT_MARKERS = ("/login",)
21
+ # An answer with one of these asks you to sign in again before a sensitive action. Skool has none.
22
+ RELOGIN_MARKERS: tuple[str, ...] = ()
23
+ # Paths of your Skool account that tools may read but never write. Common password, email and sign-in
24
+ # paths are refused for every app (core/account_guard.py); Skool adds none.
25
+ ACCOUNT_PATHS: tuple[str, ...] = ()
26
+ # REST routes live under this path, the same as on cotlet.dev.
27
+ API_PREFIX = "/api/v1/skool"
28
+ # Prepended to the MCP server's instructions: where an agent starts.
29
+ AGENT_START = "Start with list_groups: its `group` values are what every other tool takes."
skool_api_mcp/cli.py ADDED
@@ -0,0 +1,198 @@
1
+ """<cli>: connect your Chrome, then run the REST API and MCP server, or call one tool.
2
+
3
+ <cli> connect # read your session of the app from Chrome (remote debugging on)
4
+ <cli> status # which account the saved session is
5
+ <cli> tools # every tool, read or write
6
+ <cli> call <tool> k=v # one tool, arguments as JSON or key=value
7
+ <cli> mcp # MCP server over stdio, for Claude Code, Claude Desktop, Cursor
8
+ <cli> serve # REST API + MCP over HTTP on http://127.0.0.1:8787
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import asyncio
15
+ import getpass
16
+ import ipaddress
17
+ import json
18
+ import os
19
+ import sys
20
+ from typing import Any
21
+ from urllib.parse import urlsplit
22
+
23
+ from skool_api_mcp import __version__, app
24
+ from skool_api_mcp import session as sessions
25
+ from skool_api_mcp.core.errors import ToolError
26
+ from skool_api_mcp.runner import Runner, all_tools, tools
27
+
28
+
29
+ def _connect(args: argparse.Namespace) -> None:
30
+ if args.token:
31
+ token = getpass.getpass("auth_token cookie value (input hidden): ")
32
+ session = sessions.from_token(token)
33
+ else:
34
+ if not args.cdp:
35
+ print("Connecting to Chrome. Click Allow if Chrome asks about remote debugging.", file=sys.stderr)
36
+ session = sessions.from_chrome(args.cdp, args.profile_dir)
37
+ path = sessions.save(session)
38
+ who = asyncio.run(_whoami())
39
+ print(f"Saved {len(session.cookies)} {app.NAME} cookies to {path}")
40
+ print(f"Signed in to {app.NAME} as {who}" if who else f"Saved, but {app.NAME} did not confirm the session.")
41
+
42
+
43
+ async def _whoami() -> str | None:
44
+ from skool_api_mcp.core.context import ToolContext
45
+ from skool_api_mcp.skool.auth import whoami
46
+
47
+ ctx = ToolContext(session=sessions.load())
48
+ try:
49
+ return await whoami(ctx)
50
+ except ToolError:
51
+ return None
52
+ finally:
53
+ await ctx.close()
54
+
55
+
56
+ def _status(_: argparse.Namespace) -> None:
57
+ session = sessions.load()
58
+ if not session.signed_in:
59
+ sys.exit(f"No {app.NAME} session. Run: {app.CLI} connect")
60
+ who = asyncio.run(_whoami())
61
+ if not who:
62
+ sys.exit(f"{app.NAME} signed this session out. Sign in to {app.NAME} in Chrome, then run: {app.CLI} connect")
63
+ source = {"auto": "your Chrome", "token": "a pasted token", None: f"{app.ENV}_AUTH_TOKEN"}.get(
64
+ session.source, f"Chrome at {urlsplit(session.source or '').netloc}"
65
+ )
66
+ print(f"Signed in to {app.NAME} as {who} (session from {source}, {sessions.session_path()})")
67
+
68
+
69
+ def _tools(args: argparse.Namespace) -> None:
70
+ specs = tools(args.read_only)
71
+ if args.json:
72
+ rows = [{"name": s.name, "access": s.access, "route": s.route, "input_schema": s.input_schema} for s in specs]
73
+ print(json.dumps(rows, indent=2))
74
+ return
75
+ for spec in specs:
76
+ print(f"{spec.name:<30} {spec.access:<5} {spec.route:<62} {spec.description}")
77
+
78
+
79
+ def _parse_args(tool: str, pairs: list[str]) -> dict[str, Any]:
80
+ """key=value pairs; a value is JSON unless the tool's schema wants a string (IDs like 148213977)."""
81
+ if len(pairs) == 1 and pairs[0].lstrip().startswith("{"):
82
+ return json.loads(pairs[0])
83
+ spec = all_tools().get(tool)
84
+ properties = spec.input_schema.get("properties", {}) if spec else {}
85
+ out: dict[str, Any] = {}
86
+ for pair in pairs:
87
+ key, sep, value = pair.partition("=")
88
+ if not sep:
89
+ sys.exit(f"Arguments are key=value or one JSON object, got {pair!r}")
90
+ if properties.get(key, {}).get("type") == "string":
91
+ out[key] = value
92
+ continue
93
+ try:
94
+ out[key] = json.loads(value)
95
+ except ValueError:
96
+ out[key] = value
97
+ return out
98
+
99
+
100
+ def _call(args: argparse.Namespace) -> None:
101
+ runner = Runner(read_only=args.read_only)
102
+
103
+ async def go() -> Any:
104
+ try:
105
+ return await runner.run(args.tool, _parse_args(args.tool, args.arguments))
106
+ finally:
107
+ await runner.close()
108
+
109
+ try:
110
+ result = asyncio.run(go())
111
+ except ToolError as exc:
112
+ sys.exit(f"{exc.code}: {exc}")
113
+ print(json.dumps(result, indent=2, ensure_ascii=False))
114
+
115
+
116
+ def _mcp(args: argparse.Namespace) -> None:
117
+ from skool_api_mcp.mcp_server import run_stdio
118
+
119
+ asyncio.run(run_stdio(Runner(read_only=args.read_only)))
120
+
121
+
122
+ def _serve(args: argparse.Namespace) -> None:
123
+ import uvicorn
124
+
125
+ from skool_api_mcp.api import create_app
126
+
127
+ try:
128
+ loopback = ipaddress.ip_address(args.host).is_loopback
129
+ except ValueError:
130
+ loopback = args.host == "localhost"
131
+ if not loopback and not os.environ.get(f"{app.ENV}_MCP_TOKEN"):
132
+ sys.exit(
133
+ f"Listening beyond this machine needs {app.ENV}_MCP_TOKEN, or anyone on the network can use your {app.NAME} account."
134
+ )
135
+ print(
136
+ f"REST API http://{args.host}:{args.port}{app.API_PREFIX} docs http://{args.host}:{args.port}/docs",
137
+ file=sys.stderr,
138
+ )
139
+ print(f"MCP http://{args.host}:{args.port}/mcp", file=sys.stderr)
140
+ uvicorn.run(create_app(Runner(read_only=args.read_only)), host=args.host, port=args.port, log_level="warning")
141
+
142
+
143
+ def main() -> None:
144
+ parser = argparse.ArgumentParser(
145
+ prog=app.CLI,
146
+ description=(__doc__ or "").replace("<cli>", app.CLI),
147
+ formatter_class=argparse.RawDescriptionHelpFormatter,
148
+ )
149
+ parser.add_argument("--version", action="version", version=__version__)
150
+ sub = parser.add_subparsers(dest="command", required=True)
151
+
152
+ p = sub.add_parser("connect", help=f"read your {app.NAME} session from Chrome and save it")
153
+ p.add_argument(
154
+ "--cdp", help="DevTools address of a Chrome started with --remote-debugging-port, e.g. http://127.0.0.1:9222"
155
+ )
156
+ p.add_argument("--profile-dir", help="Chrome profile folder holding DevToolsActivePort, when not the default")
157
+ p.add_argument("--token", action="store_true", help="paste the auth_token cookie by hand instead")
158
+ p.set_defaults(func=_connect)
159
+
160
+ p = sub.add_parser("status", help=f"check the saved session with {app.NAME}")
161
+ p.set_defaults(func=_status)
162
+
163
+ for name, func, text in (
164
+ ("tools", _tools, "list the tools"),
165
+ ("mcp", _mcp, "MCP server over stdio"),
166
+ ("serve", _serve, "REST API and MCP over HTTP"),
167
+ ):
168
+ p = sub.add_parser(name, help=text)
169
+ p.add_argument(
170
+ "--read-only",
171
+ action="store_true",
172
+ default=os.environ.get(f"{app.ENV}_MCP_READ_ONLY") == "1",
173
+ help="only tools that read",
174
+ )
175
+ p.set_defaults(func=func)
176
+ if name == "tools":
177
+ p.add_argument("--json", action="store_true", help="names, routes and input schemas as JSON")
178
+ if name == "serve":
179
+ p.add_argument("--host", default=os.environ.get(f"{app.ENV}_MCP_HOST", "127.0.0.1"))
180
+ p.add_argument("--port", type=int, default=int(os.environ.get(f"{app.ENV}_MCP_PORT", "8787")))
181
+
182
+ p = sub.add_parser("call", help="run one tool and print its JSON answer")
183
+ p.add_argument("tool")
184
+ p.add_argument("arguments", nargs="*", help="key=value pairs or one JSON object, e.g. group=yourgroup limit=10")
185
+ p.add_argument("--read-only", action="store_true")
186
+ p.set_defaults(func=_call)
187
+
188
+ args = parser.parse_args()
189
+ try:
190
+ args.func(args)
191
+ except ToolError as exc:
192
+ sys.exit(f"{exc.code}: {exc}")
193
+ except KeyboardInterrupt:
194
+ sys.exit(130)
195
+
196
+
197
+ if __name__ == "__main__":
198
+ main()
File without changes
@@ -0,0 +1,33 @@
1
+ """Tools never change your account in the app: password, email, sign-in, two-factor, profile.
2
+
3
+ Checked on every request a tool makes, before it leaves. Reads stay allowed ("who am I" is a profile GET);
4
+ any other method to an account path is refused. The common paths are matched for every app, the app adds
5
+ its own in `app.ACCOUNT_PATHS`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from urllib.parse import urlsplit
12
+
13
+ from skool_api_mcp import app
14
+ from skool_api_mcp.core.errors import ErrorCode, ToolError
15
+
16
+ _READS = frozenset({"GET", "HEAD", "OPTIONS"})
17
+ _COMMON = re.compile(
18
+ r"passw|two[-_]?factor|2fa|(^|[/_-])(mfa|totp|otp)([/_-]|$)|recovery[-_]?code|"
19
+ r"(change|update|verify)[-_]?email|email[-_]?(change|update)|"
20
+ r"(delete|deactivate|close)[-_]?account|account[-_/](delete|deactivate|close)|"
21
+ r"(^|/)(log[-_]?out|sign[-_]?out|log[-_]?in|sign[-_]?in)([/?]|$)"
22
+ )
23
+
24
+
25
+ def check_account_write(method: str, url: str) -> None:
26
+ if method.upper() in _READS:
27
+ return
28
+ path = urlsplit(url).path.lower()
29
+ if _COMMON.search(path) or any(re.search(pattern, path, re.IGNORECASE) for pattern in app.ACCOUNT_PATHS):
30
+ raise ToolError(
31
+ "This server never changes account settings in the app (password, email, sign-in, profile).",
32
+ ErrorCode.SCOPE_DENIED,
33
+ )
@@ -0,0 +1,175 @@
1
+ """What a tool function gets besides its input: a ready HTTP client with your session of the app.
2
+
3
+ Requests go out with curl_cffi, which copies Chrome's TLS and HTTP/2 fingerprint, so the app sees the same
4
+ kind of client as your browser. Only the app's hosts (`app.DOMAINS`) are reachable, redirects included.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json as jsonlib
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+ from urllib.parse import urljoin, urlsplit
14
+
15
+ from curl_cffi.requests import AsyncSession, BrowserType
16
+
17
+ from skool_api_mcp import app
18
+ from skool_api_mcp.core.account_guard import check_account_write
19
+ from skool_api_mcp.core.errors import AuthExpiredError, CaptchaRequiredError, ErrorCode, ToolError
20
+ from skool_api_mcp.session import Session
21
+
22
+ DOMAINS = app.DOMAINS
23
+ _REDIRECTS = {301, 302, 303, 307, 308}
24
+ _MAX_REDIRECTS = 10
25
+ _TARGETS = {member.value for member in BrowserType}
26
+ _CHALLENGE_MARKERS = ("cf-chl", "challenge-platform", "Just a moment...", "cf_chl_opt")
27
+ _CAPTCHA_MARKERS = ("g-recaptcha", "h-captcha", "cf-turnstile")
28
+ _LOGGED_OUT_MARKERS = app.LOGGED_OUT_MARKERS
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class HttpResponse:
33
+ status: int
34
+ url: str
35
+ headers: dict[str, str]
36
+ text: str
37
+
38
+ def json(self) -> Any:
39
+ try:
40
+ return jsonlib.loads(self.text)
41
+ except ValueError as exc:
42
+ raise ToolError(f"{app.NAME} answered with something that is not JSON.", ErrorCode.TARGET_ERROR) from exc
43
+
44
+
45
+ def allowed_host(url: str) -> bool:
46
+ host = (urlsplit(url).hostname or "").lower()
47
+ return any(host == domain or host.endswith(f".{domain}") for domain in DOMAINS)
48
+
49
+
50
+ def impersonate_target(chrome_major: int | None) -> str:
51
+ """The exact Chrome version when curl_cffi has it, otherwise its newest Chrome profile."""
52
+ target = f"chrome{chrome_major}"
53
+ return target if chrome_major and target in _TARGETS else "chrome"
54
+
55
+
56
+ def _raise_for(response: HttpResponse) -> None:
57
+ final_url = response.url.lower()
58
+ if response.status == 401 or any(marker in final_url for marker in _LOGGED_OUT_MARKERS):
59
+ raise AuthExpiredError(
60
+ f"{app.NAME} signed this session out. Sign in to {app.NAME} in Chrome, then run: {app.CLI} connect"
61
+ )
62
+ if response.status == 429:
63
+ raise ToolError(f"{app.NAME} is rate limiting this account. Try again later.", ErrorCode.RATE_LIMITED)
64
+ head = response.text[:20000]
65
+ if response.status in (401, 403) and any(marker in head for marker in app.RELOGIN_MARKERS):
66
+ raise AuthExpiredError(
67
+ f"{app.NAME} asks you to sign in again before this action. Sign out and in again at "
68
+ f"{app.LOGIN_URL} in Chrome, then run: {app.CLI} connect"
69
+ )
70
+ if response.status in (200, 403) and any(marker in head for marker in _CAPTCHA_MARKERS):
71
+ raise CaptchaRequiredError(
72
+ f"{app.NAME} asks for a CAPTCHA. Open {app.DOMAINS[0]} in Chrome, finish it, then try again."
73
+ )
74
+ if response.status in (403, 503) and (
75
+ response.headers.get("cf-mitigated") == "challenge" or any(marker in head for marker in _CHALLENGE_MARKERS)
76
+ ):
77
+ raise ToolError(
78
+ f"{app.NAME} showed a bot check. Open {app.DOMAINS[0]} in Chrome once, then try again.",
79
+ ErrorCode.TARGET_BLOCKED,
80
+ )
81
+ if response.status == 403:
82
+ raise ToolError(f"{app.NAME} refused the request (403).", ErrorCode.TARGET_BLOCKED)
83
+
84
+
85
+ @dataclass
86
+ class ToolContext:
87
+ session: Session
88
+ timeout_s: float = 30.0
89
+ _cache: dict[str, tuple[float, str]] = field(default_factory=dict, repr=False)
90
+ _http: AsyncSession | None = field(default=None, repr=False)
91
+
92
+ def _client(self) -> AsyncSession:
93
+ if self._http is None:
94
+ headers = {"accept-language": self.session.locale or "en-US,en;q=0.9"}
95
+ target = impersonate_target(self.session.chrome_major)
96
+ # The browser's own user agent only when the TLS fingerprint matches its version.
97
+ if self.session.user_agent and target != "chrome":
98
+ headers["user-agent"] = self.session.user_agent
99
+ self._http = AsyncSession(impersonate=target, proxy=self.session.proxy or None, headers=headers) # pyright: ignore[reportArgumentType] - target is a BrowserType value
100
+ for cookie in self.session.cookies:
101
+ self._http.cookies.set(
102
+ cookie["name"], cookie["value"], domain=cookie["domain"], path=cookie.get("path", "/")
103
+ )
104
+ return self._http
105
+
106
+ async def call(self, method: str, url: str, **kwargs: Any) -> HttpResponse:
107
+ """One request to the app with your cookies. A signed-out answer raises auth_expired.
108
+
109
+ Writes to your account settings in the app are refused before they leave (account_guard).
110
+ """
111
+ if not allowed_host(url):
112
+ raise ToolError(f"Request outside {app.DOMAINS[0]}.", ErrorCode.INTERNAL_ERROR)
113
+ check_account_write(method, url)
114
+ client = self._client()
115
+ method = method.upper()
116
+ params, json, data, headers = (
117
+ kwargs.get("params"),
118
+ kwargs.get("json"),
119
+ kwargs.get("data"),
120
+ kwargs.get("headers"),
121
+ )
122
+ for _ in range(_MAX_REDIRECTS + 1):
123
+ try:
124
+ raw = await client.request(
125
+ method, # pyright: ignore[reportArgumentType] - an HTTP method name
126
+ url,
127
+ params=params,
128
+ json=json,
129
+ data=data,
130
+ headers=headers,
131
+ timeout=self.timeout_s,
132
+ allow_redirects=False,
133
+ )
134
+ except Exception as exc:
135
+ raise ToolError(f"{app.NAME} did not answer: {type(exc).__name__}.", ErrorCode.TARGET_ERROR) from exc
136
+ location = raw.headers.get("location")
137
+ if raw.status_code not in _REDIRECTS or not location:
138
+ break
139
+ url = urljoin(str(raw.url), location)
140
+ if not allowed_host(url):
141
+ raise ToolError(f"{app.NAME} redirected outside {app.DOMAINS[0]}.", ErrorCode.TARGET_ERROR)
142
+ params = None
143
+ if raw.status_code == 303 or (raw.status_code in (301, 302) and method == "POST"):
144
+ method, json, data = "GET", None, None
145
+ else:
146
+ raise ToolError("Too many redirects.", ErrorCode.TARGET_ERROR)
147
+ response = HttpResponse(
148
+ status=raw.status_code,
149
+ url=str(raw.url),
150
+ headers={k.lower(): v or "" for k, v in raw.headers.items()},
151
+ text=raw.text,
152
+ )
153
+ _raise_for(response)
154
+ return response
155
+
156
+ async def get_json(self, url: str, **kwargs: Any) -> Any:
157
+ return (await self.call("GET", url, **kwargs)).json()
158
+
159
+ async def post_json(self, url: str, payload: Any, **kwargs: Any) -> Any:
160
+ return (await self.call("POST", url, json=payload, **kwargs)).json()
161
+
162
+ async def cache_get(self, key: str) -> str | None:
163
+ """Short-lived app data (a build id, a page token), kept in this process."""
164
+ entry = self._cache.get(key)
165
+ if entry is None or entry[0] < time.monotonic():
166
+ return None
167
+ return entry[1]
168
+
169
+ async def cache_set(self, key: str, value: str, ttl_s: int) -> None:
170
+ self._cache[key] = (time.monotonic() + ttl_s, value)
171
+
172
+ async def close(self) -> None:
173
+ if self._http is not None:
174
+ await self._http.close()
175
+ self._http = None