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.
- moostack_mcp-0.1.0/PKG-INFO +111 -0
- moostack_mcp-0.1.0/README.md +95 -0
- moostack_mcp-0.1.0/pyproject.toml +41 -0
- moostack_mcp-0.1.0/pyproject.toml.orig +42 -0
- moostack_mcp-0.1.0/src/moostack_mcp/__init__.py +1 -0
- moostack_mcp-0.1.0/src/moostack_mcp/cli.py +98 -0
- moostack_mcp-0.1.0/src/moostack_mcp/client.py +156 -0
- moostack_mcp-0.1.0/src/moostack_mcp/config.py +49 -0
- moostack_mcp-0.1.0/src/moostack_mcp/server.py +502 -0
- moostack_mcp-0.1.0/src/moostack_mcp/tests/__init__.py +0 -0
- moostack_mcp-0.1.0/src/moostack_mcp/tests/test_cli.py +64 -0
- moostack_mcp-0.1.0/src/moostack_mcp/tests/test_server.py +310 -0
|
@@ -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)
|