moostack-mcp 0.1.0__tar.gz

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,111 @@
1
+ Metadata-Version: 2.3
2
+ Name: moostack-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for MooStack. Manage your studio conversationally via Claude
5
+ Author: Kaushik LS
6
+ Author-email: Kaushik LS <50703773+thekaushikls@users.noreply.github.com>
7
+ License: MIT
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Requires-Dist: click>=8.5.0
10
+ Requires-Dist: fastmcp>=4.0.5
11
+ Requires-Dist: httpx>=0.28.1
12
+ Requires-Dist: typing-extensions>=4.12.0
13
+ Requires-Python: >=3.11
14
+ Project-URL: Repository, https://github.com/thekaushikls/moostack-mcp
15
+ Description-Content-Type: text/markdown
16
+
17
+ # moostack-mcp
18
+
19
+ An MCP server that lets a MooStack studio owner manage their studio conversationally through Claude Desktop or Claude Code — no dashboard required.
20
+
21
+ It's a thin client: every tool call is proxied to your studio's MooStack backend over HTTPS.
22
+
23
+ ## Requirements
24
+
25
+ - Python 3.11+
26
+ - A MooStack account (email + password) that owns or belongs to at least one studio
27
+
28
+ ## Install
29
+
30
+ With [uv](https://docs.astral.sh/uv/) (recommended):
31
+
32
+ ```sh
33
+ uv tool install git+https://github.com/thekaushikls/moostack-mcp
34
+ ```
35
+
36
+ Or with pip:
37
+
38
+ ```sh
39
+ pip install git+https://github.com/thekaushikls/moostack-mcp
40
+ ```
41
+
42
+ ## Set up
43
+
44
+ ```sh
45
+ moostack-mcp setup
46
+ ```
47
+
48
+ This will:
49
+ 1. Ask for your MooStack email, password, and backend URL (press Enter to accept the default).
50
+ 2. Validate the credentials against the backend.
51
+ 3. Save them to `~/.moostack/config.json` (created with `0600` permissions — readable only by you).
52
+ 4. Offer to auto-configure Claude Desktop by adding this server to `claude_desktop_config.json`.
53
+
54
+ Restart Claude Desktop afterwards and it will pick up the new server.
55
+
56
+ ### Manual Claude Desktop setup
57
+
58
+ If you skip auto-configure, or you're on an OS it doesn't recognize, add this to your `claude_desktop_config.json` yourself (merge it into the `mcpServers` object if one already exists):
59
+
60
+ ```json
61
+ {
62
+ "mcpServers": {
63
+ "moostack": {
64
+ "command": "moostack-mcp",
65
+ "args": ["serve"]
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ Config file locations:
72
+ - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
73
+ - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
74
+ - Linux: `~/.config/Claude/claude_desktop_config.json`
75
+
76
+ ## What you can do with it
77
+
78
+ **Your account & studios**
79
+ - `list_my_studios` — the studios you own or belong to, and your role in each
80
+ - `get_my_profile` / `update_my_profile` — view or edit your account details
81
+ - `list_members` — your studio's member roster
82
+
83
+ **Classes & pricing**
84
+ - `list_offerings` — every class you've set up, with its pricing tiers
85
+ - `create_offering` / `update_offering` / `delete_offering` — create a class with pricing in one step, edit it, or remove it
86
+
87
+ **Sessions & attendance**
88
+ - `list_sessions` — occurrences of a class, past and upcoming
89
+ - `create_session` — add a one-off extra session
90
+ - `view_attendance` — see who's booked into a session
91
+ - `mark_attendance` — mark students present/absent
92
+
93
+ **Student enrollments**
94
+ - `grant_sessions` — add bonus sessions to a student's enrollment
95
+ - `adjust_enrollment` — manually change a student's session count or expiry
96
+ - `override_attendance` — correct a single attendance record after the fact
97
+
98
+ **Zoom**
99
+ - `get_zoom_status` / `disconnect_zoom` — check or disconnect your studio's linked Zoom account (connecting Zoom itself is done from the MooStack web app, since it requires a browser OAuth flow)
100
+
101
+ ## Run it manually
102
+
103
+ ```sh
104
+ moostack-mcp serve
105
+ ```
106
+
107
+ Runs the server over stdio — this is what Claude Desktop launches automatically once configured; you shouldn't normally need to run it yourself.
108
+
109
+ ## License
110
+
111
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,95 @@
1
+ # moostack-mcp
2
+
3
+ An MCP server that lets a MooStack studio owner manage their studio conversationally through Claude Desktop or Claude Code — no dashboard required.
4
+
5
+ It's a thin client: every tool call is proxied to your studio's MooStack backend over HTTPS.
6
+
7
+ ## Requirements
8
+
9
+ - Python 3.11+
10
+ - A MooStack account (email + password) that owns or belongs to at least one studio
11
+
12
+ ## Install
13
+
14
+ With [uv](https://docs.astral.sh/uv/) (recommended):
15
+
16
+ ```sh
17
+ uv tool install git+https://github.com/thekaushikls/moostack-mcp
18
+ ```
19
+
20
+ Or with pip:
21
+
22
+ ```sh
23
+ pip install git+https://github.com/thekaushikls/moostack-mcp
24
+ ```
25
+
26
+ ## Set up
27
+
28
+ ```sh
29
+ moostack-mcp setup
30
+ ```
31
+
32
+ This will:
33
+ 1. Ask for your MooStack email, password, and backend URL (press Enter to accept the default).
34
+ 2. Validate the credentials against the backend.
35
+ 3. Save them to `~/.moostack/config.json` (created with `0600` permissions — readable only by you).
36
+ 4. Offer to auto-configure Claude Desktop by adding this server to `claude_desktop_config.json`.
37
+
38
+ Restart Claude Desktop afterwards and it will pick up the new server.
39
+
40
+ ### Manual Claude Desktop setup
41
+
42
+ If you skip auto-configure, or you're on an OS it doesn't recognize, add this to your `claude_desktop_config.json` yourself (merge it into the `mcpServers` object if one already exists):
43
+
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "moostack": {
48
+ "command": "moostack-mcp",
49
+ "args": ["serve"]
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ Config file locations:
56
+ - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
57
+ - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
58
+ - Linux: `~/.config/Claude/claude_desktop_config.json`
59
+
60
+ ## What you can do with it
61
+
62
+ **Your account & studios**
63
+ - `list_my_studios` — the studios you own or belong to, and your role in each
64
+ - `get_my_profile` / `update_my_profile` — view or edit your account details
65
+ - `list_members` — your studio's member roster
66
+
67
+ **Classes & pricing**
68
+ - `list_offerings` — every class you've set up, with its pricing tiers
69
+ - `create_offering` / `update_offering` / `delete_offering` — create a class with pricing in one step, edit it, or remove it
70
+
71
+ **Sessions & attendance**
72
+ - `list_sessions` — occurrences of a class, past and upcoming
73
+ - `create_session` — add a one-off extra session
74
+ - `view_attendance` — see who's booked into a session
75
+ - `mark_attendance` — mark students present/absent
76
+
77
+ **Student enrollments**
78
+ - `grant_sessions` — add bonus sessions to a student's enrollment
79
+ - `adjust_enrollment` — manually change a student's session count or expiry
80
+ - `override_attendance` — correct a single attendance record after the fact
81
+
82
+ **Zoom**
83
+ - `get_zoom_status` / `disconnect_zoom` — check or disconnect your studio's linked Zoom account (connecting Zoom itself is done from the MooStack web app, since it requires a browser OAuth flow)
84
+
85
+ ## Run it manually
86
+
87
+ ```sh
88
+ moostack-mcp serve
89
+ ```
90
+
91
+ Runs the server over stdio — this is what Claude Desktop launches automatically once configured; you shouldn't normally need to run it yourself.
92
+
93
+ ## License
94
+
95
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "moostack-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server for MooStack. Manage your studio conversationally via Claude"
5
+ readme = "README.md"
6
+ classifiers = ["License :: OSI Approved :: MIT License"]
7
+ requires-python = ">=3.11"
8
+ dependencies = [
9
+ "click>=8.5.0",
10
+ "fastmcp>=4.0.5",
11
+ "httpx>=0.28.1",
12
+ "typing-extensions>=4.12.0",
13
+ ]
14
+
15
+ [project.license]
16
+ text = "MIT"
17
+
18
+ [[project.authors]]
19
+ name = "Kaushik LS"
20
+ email = "50703773+thekaushikls@users.noreply.github.com"
21
+
22
+ [project.urls]
23
+ Repository = "https://github.com/thekaushikls/moostack-mcp"
24
+
25
+ [project.scripts]
26
+ moostack-mcp = "moostack_mcp.cli:main"
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.10.9,<0.11.0"]
30
+ build-backend = "uv_build"
31
+
32
+ [dependency-groups]
33
+ dev = [
34
+ "pytest>=9.1.1",
35
+ "pytest-asyncio>=1.4.0",
36
+ "ruff>=0.16.8",
37
+ ]
38
+
39
+ [tool.pytest.ini_options]
40
+ asyncio_mode = "auto"
41
+ testpaths = ["src"]
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "moostack-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server for MooStack. Manage your studio conversationally via Claude"
5
+
6
+ readme = "README.md"
7
+ license = { text = "MIT" }
8
+ authors = [
9
+ { name = "Kaushik LS", email = "50703773+thekaushikls@users.noreply.github.com" }
10
+ ]
11
+ classifiers = [
12
+ "License :: OSI Approved :: MIT License",
13
+ ]
14
+ requires-python = ">=3.11"
15
+ dependencies = [
16
+ "click>=8.5.0",
17
+ "fastmcp>=4.0.5",
18
+ "httpx>=0.28.1",
19
+ "typing-extensions>=4.12.0",
20
+ ]
21
+
22
+ [project.urls]
23
+ Repository = "https://github.com/thekaushikls/moostack-mcp"
24
+
25
+ [project.scripts]
26
+ moostack-mcp = "moostack_mcp.cli:main"
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.10.9,<0.11.0"]
30
+ build-backend = "uv_build"
31
+
32
+ [dependency-groups]
33
+ dev = [
34
+ "pytest>=9.1.1",
35
+ "pytest-asyncio>=1.4.0",
36
+ "ruff>=0.16.8",
37
+ ]
38
+
39
+ [tool.pytest.ini_options]
40
+ asyncio_mode = "auto"
41
+ testpaths = ["src"]
42
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import platform
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import click
11
+
12
+ from moostack_mcp.client import MooStackClient, MooStackClientError
13
+ from moostack_mcp.config import DEFAULT_BASE_URL, Config, save_config
14
+
15
+ MCP_SERVER_NAME = "moostack"
16
+
17
+
18
+ @click.group()
19
+ def main() -> None:
20
+ """MooStack MCP server - manage your studio conversationally via Claude."""
21
+
22
+
23
+ @main.command()
24
+ @click.option("--email", prompt="MooStack email")
25
+ @click.option("--password", prompt="MooStack password", hide_input=True)
26
+ @click.option("--base-url", prompt="Backend URL", default=DEFAULT_BASE_URL)
27
+ def setup(email: str, password: str, base_url: str) -> None:
28
+ """Save your MooStack credentials, after validating them against the backend."""
29
+ click.echo("Checking your credentials...")
30
+ try:
31
+ asyncio.run(_validate_credentials(email, password, base_url))
32
+ except MooStackClientError as exc:
33
+ raise click.ClickException(str(exc)) from exc
34
+
35
+ save_config(email=email, password=password, base_url=base_url)
36
+ click.echo("Saved.")
37
+
38
+ if click.confirm("Auto-configure Claude Desktop?", default=True):
39
+ _configure_claude_desktop()
40
+ else:
41
+ click.echo("Skipped. Run `moostack-mcp serve` to start the MCP server.")
42
+
43
+
44
+ async def _validate_credentials(email: str, password: str, base_url: str) -> None:
45
+ client = MooStackClient(Config(email=email, password=password, base_url=base_url))
46
+ try:
47
+ await client.get("/users/me")
48
+ finally:
49
+ await client.aclose()
50
+
51
+
52
+ def _claude_desktop_config_path() -> Path:
53
+ system = platform.system()
54
+ if system == "Darwin":
55
+ return (
56
+ Path.home()
57
+ / "Library"
58
+ / "Application Support"
59
+ / "Claude"
60
+ / "claude_desktop_config.json"
61
+ )
62
+ if system == "Windows":
63
+ appdata = os.environ.get("APPDATA", str(Path.home()))
64
+ return Path(appdata) / "Claude" / "claude_desktop_config.json"
65
+ return Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
66
+
67
+
68
+ def _merge_mcp_server_config(existing: dict[str, Any]) -> dict[str, Any]:
69
+ """Add/overwrite this server's entry under 'mcpServers', preserving everything else."""
70
+ merged = dict(existing)
71
+ servers = dict(merged.get("mcpServers", {}))
72
+ servers[MCP_SERVER_NAME] = {"command": "moostack-mcp", "args": ["serve"]}
73
+ merged["mcpServers"] = servers
74
+ return merged
75
+
76
+
77
+ def _configure_claude_desktop() -> None:
78
+ path = _claude_desktop_config_path()
79
+ path.parent.mkdir(parents=True, exist_ok=True)
80
+
81
+ existing: dict[str, Any] = {}
82
+ if path.exists():
83
+ try:
84
+ existing = json.loads(path.read_text())
85
+ except json.JSONDecodeError:
86
+ click.echo(f"Warning: {path} has invalid JSON, replacing it.")
87
+
88
+ path.write_text(json.dumps(_merge_mcp_server_config(existing), indent=2))
89
+ click.echo(f"Updated {path}.")
90
+ click.echo("Restart Claude Desktop to pick it up.")
91
+
92
+
93
+ @main.command()
94
+ def serve() -> None:
95
+ """Run the MCP server over stdio (used by Claude Desktop / Claude Code)."""
96
+ from moostack_mcp.server import mcp
97
+
98
+ mcp.run(transport="stdio")
@@ -0,0 +1,156 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from moostack_mcp.config import Config
9
+
10
+
11
+ class MooStackClientError(Exception):
12
+ """Base exception for all MooStack API client errors."""
13
+
14
+
15
+ class AuthenticationError(MooStackClientError):
16
+ """Raised when login/refresh fails and credentials need to be re-entered."""
17
+
18
+
19
+ class PermissionDeniedError(MooStackClientError):
20
+ """Raised on 403 — the authenticated user lacks permission for the action."""
21
+
22
+
23
+ class NotFoundError(MooStackClientError):
24
+ """Raised on 404 — the requested resource doesn't exist."""
25
+
26
+
27
+ class ValidationError(MooStackClientError):
28
+ """Raised on 422 — the request failed backend validation."""
29
+
30
+
31
+ class ServerError(MooStackClientError):
32
+ """Raised on 5xx — the MooStack backend is unavailable."""
33
+
34
+
35
+ def _extract_detail(response: httpx.Response, fallback: str) -> str:
36
+ try:
37
+ body = response.json()
38
+ except ValueError:
39
+ return fallback
40
+ detail = body.get("detail") if isinstance(body, dict) else None # type: ignore
41
+ if detail is None:
42
+ return fallback
43
+ if isinstance(detail, str):
44
+ return detail
45
+ if isinstance(detail, list):
46
+ # FastAPI 422 shape: [{"loc": [...], "msg": "...", "type": "..."}, ...]
47
+ messages = []
48
+ for item in detail:
49
+ loc = ".".join(str(part) for part in item.get("loc", []) if part != "body")
50
+ msg = item.get("msg", "")
51
+ messages.append(f"{loc}: {msg}" if loc else msg)
52
+ return "; ".join(messages) or fallback
53
+ return fallback
54
+
55
+
56
+ class MooStackClient:
57
+ """Auth-aware async HTTP client for the MooStack API.
58
+
59
+ Handles login, access-token attachment, and transparent refresh/retry on 401.
60
+ One instance is meant to live for the lifetime of the MCP server process, so
61
+ its underlying httpx.AsyncClient keeps a single cookie jar — that's what lets
62
+ the refresh_token cookie set by /auth/login/email survive to /auth/refresh.
63
+ """
64
+
65
+ def __init__(self, config: Config) -> None:
66
+ self._config = config
67
+ self._http = httpx.AsyncClient(base_url=config.base_url, timeout=15.0)
68
+ self._access_token: str | None = None
69
+ # Serializes the whole request lifecycle (not just login/refresh) so two
70
+ # concurrent tool calls never race to refresh/re-login at the same time.
71
+ # This means API calls run one at a time — fine for a single interactive
72
+ # session; narrow this to just the token-mutation section if that changes.
73
+ self._lock = asyncio.Lock()
74
+
75
+ async def aclose(self) -> None:
76
+ await self._http.aclose()
77
+
78
+ async def _login(self) -> None:
79
+ response = await self._http.post(
80
+ "/auth/login/email",
81
+ json={"email": self._config.email, "password": self._config.password},
82
+ )
83
+ data = self._parse(response)
84
+ self._access_token = data["access_token"]
85
+
86
+ async def _refresh(self) -> bool:
87
+ response = await self._http.post("/auth/refresh")
88
+ if response.status_code != 200:
89
+ return False
90
+ self._access_token = response.json()["access_token"]
91
+ return True
92
+
93
+ async def _send(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
94
+ headers = kwargs.pop("headers", {})
95
+ headers["Authorization"] = f"Bearer {self._access_token}"
96
+ return await self._http.request(method, path, headers=headers, **kwargs)
97
+
98
+ async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
99
+ async with self._lock:
100
+ if self._access_token is None:
101
+ await self._login()
102
+
103
+ response = await self._send(method, path, **kwargs)
104
+
105
+ if response.status_code == 401:
106
+ if await self._refresh():
107
+ response = await self._send(method, path, **kwargs)
108
+
109
+ if response.status_code == 401:
110
+ await self._login()
111
+ response = await self._send(method, path, **kwargs)
112
+
113
+ return self._parse(response)
114
+
115
+ def _parse(self, response: httpx.Response) -> Any:
116
+ if response.status_code == 401:
117
+ raise AuthenticationError(
118
+ "Authentication failed. Please run `moostack-mcp setup` to update "
119
+ "your credentials."
120
+ )
121
+ if response.status_code == 403:
122
+ raise PermissionDeniedError(
123
+ _extract_detail(
124
+ response,
125
+ "You don't have permission for this action. You must be the "
126
+ "studio owner.",
127
+ )
128
+ )
129
+ if response.status_code == 404:
130
+ raise NotFoundError(
131
+ _extract_detail(
132
+ response, "Not found. Check the org_slug and try again."
133
+ )
134
+ )
135
+ if response.status_code == 422:
136
+ raise ValidationError(_extract_detail(response, "Invalid request."))
137
+ if response.status_code >= 500:
138
+ raise ServerError(
139
+ "The MooStack server is temporarily unavailable. Try again shortly."
140
+ )
141
+ response.raise_for_status()
142
+ if response.status_code == 204 or not response.content:
143
+ return None
144
+ return response.json()
145
+
146
+ async def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
147
+ return await self._request("GET", path, params=params)
148
+
149
+ async def post(self, path: str, json: dict[str, Any] | None = None) -> Any:
150
+ return await self._request("POST", path, json=json)
151
+
152
+ async def patch(self, path: str, json: dict[str, Any] | None = None) -> Any:
153
+ return await self._request("PATCH", path, json=json)
154
+
155
+ async def delete(self, path: str) -> Any:
156
+ return await self._request("DELETE", path)
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ CONFIG_DIR = Path.home() / ".moostack"
9
+ CONFIG_PATH = CONFIG_DIR / "config.json"
10
+ DEFAULT_BASE_URL = "https://api.moostack.in"
11
+
12
+
13
+ class ConfigNotFound(Exception):
14
+ """Raised when ~/.moostack/config.json is missing or unreadable."""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Config:
19
+ email: str
20
+ password: str
21
+ base_url: str = DEFAULT_BASE_URL
22
+
23
+
24
+ def load_config() -> Config:
25
+ if not CONFIG_PATH.exists():
26
+ raise ConfigNotFound(
27
+ f"No config found at {CONFIG_PATH}. Run 'moostack-mcp setup' first."
28
+ )
29
+ try:
30
+ data = json.loads(CONFIG_PATH.read_text())
31
+ return Config(
32
+ email=data["email"],
33
+ password=data["password"],
34
+ base_url=data.get("base_url", DEFAULT_BASE_URL),
35
+ )
36
+ except (json.JSONDecodeError, KeyError) as ex:
37
+ raise ConfigNotFound(
38
+ f"Config at {CONFIG_PATH} is invalid ({ex}). Run 'moostack-mcp setup' to fix it"
39
+ ) from ex
40
+
41
+
42
+ def save_config(email: str, password: str, base_url: str = DEFAULT_BASE_URL) -> None:
43
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True) # Create folder path if non-existent
44
+ payload = json.dumps(
45
+ {"email": email, "password": password, "base_url": base_url},
46
+ indent=2, # Human readable
47
+ )
48
+ CONFIG_PATH.write_text(payload)
49
+ os.chmod(CONFIG_PATH, 0o600)