madcat-os 0.1.2__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,87 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+ tags: ["v*"]
7
+ pull_request:
8
+ branches: ["main"]
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ concurrency:
14
+ group: ci-${{ github.ref }}
15
+ cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
16
+
17
+ jobs:
18
+ check:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ - uses: astral-sh/setup-uv@v6
24
+ with:
25
+ enable-cache: true
26
+ python-version-file: .python-version
27
+
28
+ - name: Install
29
+ run: uv sync --locked
30
+
31
+ - name: Lint
32
+ run: uv run ruff check .
33
+
34
+ - name: Format
35
+ run: uv run ruff format --check .
36
+
37
+ - name: Test
38
+ run: uv run pytest -q
39
+
40
+ - name: Smoke test
41
+ run: uv run madcat mcp list > /dev/null
42
+
43
+ build:
44
+ name: Build distributions
45
+ needs: check
46
+ if: startsWith(github.ref, 'refs/tags/v')
47
+ runs-on: ubuntu-latest
48
+ steps:
49
+ - uses: actions/checkout@v4
50
+
51
+ - uses: astral-sh/setup-uv@v6
52
+ with:
53
+ python-version-file: .python-version
54
+
55
+ - name: Tag must match project version
56
+ run: |
57
+ version="$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
58
+ if [ "${GITHUB_REF_NAME}" != "v${version}" ]; then
59
+ echo "::error::tag ${GITHUB_REF_NAME} does not match pyproject.toml version ${version}"
60
+ exit 1
61
+ fi
62
+
63
+ - name: Build sdist and wheel
64
+ run: uv build
65
+
66
+ - uses: actions/upload-artifact@v4
67
+ with:
68
+ name: dist
69
+ path: dist/
70
+ if-no-files-found: error
71
+
72
+ publish:
73
+ name: Publish to PyPI
74
+ needs: build
75
+ runs-on: ubuntu-latest
76
+ environment:
77
+ name: pypi
78
+ url: https://pypi.org/p/madcat-os
79
+ permissions:
80
+ id-token: write
81
+ steps:
82
+ - uses: actions/download-artifact@v4
83
+ with:
84
+ name: dist
85
+ path: dist/
86
+
87
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .DS_Store
10
+ .env
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.5
2
+ Name: madcat-os
3
+ Version: 0.1.2
4
+ Summary: MADCAT MCP server and CLI — stdio and Streamable HTTP transports, Typer command line
5
+ Author: Adam Ladachowski
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.13
8
+ Requires-Dist: mcp>=1.12
9
+ Requires-Dist: pydantic-settings>=2.8
10
+ Requires-Dist: pydantic>=2.10
11
+ Requires-Dist: rich>=13.9
12
+ Requires-Dist: starlette>=0.46
13
+ Requires-Dist: typer>=0.15
14
+ Requires-Dist: uvicorn>=0.34
15
+ Description-Content-Type: text/markdown
16
+
17
+ # madcat-os (binary: `madcat`)
18
+
19
+ MADCAT MCP server and command line in Python. The installed binary is `madcat`; one package, three faces:
20
+
21
+ - `madcat mcp serve` — MCP over **stdio** (default) or **Streamable HTTP** at `/mcp`
22
+ - `madcat mcp list` / `mcp call <tool> '<json>'` — drive the tool registry from the shell
23
+ - `madcat status` — effective config and tool count
24
+
25
+ Functionality is being moved here from the Rust `madcat` binary; command shapes match it.
26
+
27
+ ## Quick start
28
+
29
+ ```bash
30
+ uv sync
31
+ uv run madcat status
32
+ uv run madcat mcp list
33
+ uv run madcat mcp call server_info
34
+ uv run madcat mcp serve --transport http --token t --bind 127.0.0.1:4197
35
+ ```
36
+
37
+ Then POST an `initialize` to `http://127.0.0.1:4197/mcp` with `Authorization: Bearer t` and
38
+ `Accept: application/json, text/event-stream`; reuse the returned `mcp-session-id`.
39
+
40
+ ## Claude Code
41
+
42
+ stdio:
43
+
44
+ ```json
45
+ { "mcpServers": { "madcat": { "command": "uv", "args": ["run", "--directory", "/path/to/madcat-os", "madcat", "mcp", "serve"] } } }
46
+ ```
47
+
48
+ http:
49
+
50
+ ```json
51
+ { "mcpServers": { "madcat": { "type": "http", "url": "http://127.0.0.1:4097/mcp",
52
+ "headers": { "Authorization": "Bearer ${MADCAT_MCP_TOKEN}" } } } }
53
+ ```
54
+
55
+ ## Configuration
56
+
57
+ Environment, prefix `MADCAT_MCP_` (or a `.env` file): `HOST` (127.0.0.1), `PORT` (4097), `TOKEN`, `LOG_LEVEL`.
58
+ `--bind` and `--token` on `mcp serve` override.
59
+
60
+ ## Adding a tool
61
+
62
+ Create `src/madcat_os/tools/<module>.py` with `register(mcp: MCPServer)` and `@mcp.tool()` handlers
63
+ (docstring per tool, type hints per argument), then add `<module>.register` to `MODULES` in
64
+ `src/madcat_os/tools/__init__.py`. Human output goes to stderr; stdout is the stdio wire.
65
+
66
+ ## Layout
67
+
68
+ ```
69
+ src/madcat_os/
70
+ cli.py Typer app: status, mcp list|call|serve
71
+ server.py build_server(), HTTP app with bearer auth + /health, stdio/http runners
72
+ config.py Settings (pydantic-settings)
73
+ tools/ registry; one module per tool family
74
+ tests/
75
+ justfile
76
+ ```
@@ -0,0 +1,60 @@
1
+ # madcat-os (binary: `madcat`)
2
+
3
+ MADCAT MCP server and command line in Python. The installed binary is `madcat`; one package, three faces:
4
+
5
+ - `madcat mcp serve` — MCP over **stdio** (default) or **Streamable HTTP** at `/mcp`
6
+ - `madcat mcp list` / `mcp call <tool> '<json>'` — drive the tool registry from the shell
7
+ - `madcat status` — effective config and tool count
8
+
9
+ Functionality is being moved here from the Rust `madcat` binary; command shapes match it.
10
+
11
+ ## Quick start
12
+
13
+ ```bash
14
+ uv sync
15
+ uv run madcat status
16
+ uv run madcat mcp list
17
+ uv run madcat mcp call server_info
18
+ uv run madcat mcp serve --transport http --token t --bind 127.0.0.1:4197
19
+ ```
20
+
21
+ Then POST an `initialize` to `http://127.0.0.1:4197/mcp` with `Authorization: Bearer t` and
22
+ `Accept: application/json, text/event-stream`; reuse the returned `mcp-session-id`.
23
+
24
+ ## Claude Code
25
+
26
+ stdio:
27
+
28
+ ```json
29
+ { "mcpServers": { "madcat": { "command": "uv", "args": ["run", "--directory", "/path/to/madcat-os", "madcat", "mcp", "serve"] } } }
30
+ ```
31
+
32
+ http:
33
+
34
+ ```json
35
+ { "mcpServers": { "madcat": { "type": "http", "url": "http://127.0.0.1:4097/mcp",
36
+ "headers": { "Authorization": "Bearer ${MADCAT_MCP_TOKEN}" } } } }
37
+ ```
38
+
39
+ ## Configuration
40
+
41
+ Environment, prefix `MADCAT_MCP_` (or a `.env` file): `HOST` (127.0.0.1), `PORT` (4097), `TOKEN`, `LOG_LEVEL`.
42
+ `--bind` and `--token` on `mcp serve` override.
43
+
44
+ ## Adding a tool
45
+
46
+ Create `src/madcat_os/tools/<module>.py` with `register(mcp: MCPServer)` and `@mcp.tool()` handlers
47
+ (docstring per tool, type hints per argument), then add `<module>.register` to `MODULES` in
48
+ `src/madcat_os/tools/__init__.py`. Human output goes to stderr; stdout is the stdio wire.
49
+
50
+ ## Layout
51
+
52
+ ```
53
+ src/madcat_os/
54
+ cli.py Typer app: status, mcp list|call|serve
55
+ server.py build_server(), HTTP app with bearer auth + /health, stdio/http runners
56
+ config.py Settings (pydantic-settings)
57
+ tools/ registry; one module per tool family
58
+ tests/
59
+ justfile
60
+ ```
@@ -0,0 +1,101 @@
1
+ # madcat — python MCP server and CLI
2
+
3
+ default:
4
+ @just --list
5
+
6
+ # Bump the version everywhere: just bump <major|minor|patch> [dry]
7
+ # Edits files only: no commit, no tag. Every file must match exactly once or
8
+ # nothing is written. `dry` prints the plan without touching anything.
9
+ bump part dry="":
10
+ #!/usr/bin/env python3
11
+ import pathlib
12
+ import re
13
+ import sys
14
+
15
+ part, dry = "{{part}}", "{{dry}}"
16
+ if part not in ("major", "minor", "patch"):
17
+ sys.exit(f"bump: part must be major, minor or patch (got {part!r})")
18
+ if dry not in ("", "dry"):
19
+ sys.exit(f"bump: second argument must be 'dry' or omitted (got {dry!r})")
20
+
21
+ root = pathlib.Path(".")
22
+ m = re.search(r'^version = "(\d+)\.(\d+)\.(\d+)"$', (root / "pyproject.toml").read_text(), re.M)
23
+ if not m:
24
+ sys.exit("bump: no X.Y.Z version found in pyproject.toml")
25
+ major, minor, patch = map(int, m.groups())
26
+ old = f"{major}.{minor}.{patch}"
27
+ if part == "major":
28
+ major, minor, patch = major + 1, 0, 0
29
+ elif part == "minor":
30
+ minor, patch = minor + 1, 0
31
+ else:
32
+ patch += 1
33
+ new = f"{major}.{minor}.{patch}"
34
+ o = re.escape(old)
35
+
36
+ # (file, pattern). Group 1 and 2 are the text around the version.
37
+ # uv.lock is tracked and CI runs `uv sync --locked`, so it must move with pyproject.
38
+ targets = [
39
+ ("pyproject.toml", rf'^(version = "){o}(")$'),
40
+ ("uv.lock", rf'(name = "madcat-os"\nversion = "){o}(")'),
41
+ ]
42
+
43
+ pending = []
44
+ for name, pattern in targets:
45
+ path = root / name
46
+ if not path.exists():
47
+ sys.exit(f"bump: missing {name}")
48
+ out, n = re.subn(pattern, lambda mm: mm.group(1) + new + mm.group(2), path.read_text(), flags=re.M)
49
+ if n != 1:
50
+ sys.exit(f"bump: {name}: expected 1 match for {old}, found {n}; nothing written")
51
+ pending.append((path, out, name))
52
+
53
+ print(f"{old} -> {new} ({part}){' [dry run]' if dry else ''}")
54
+ for path, out, name in pending:
55
+ print(f" update {name}")
56
+ if not dry:
57
+ path.write_text(out)
58
+ if not dry:
59
+ print(f"\nNext: commit, then: git tag -a v{new} -m 'madcat-os {new}' && git push origin main v{new}")
60
+
61
+ # Install the environment (runtime + dev)
62
+ sync:
63
+ uv sync
64
+
65
+ # Lint and format check
66
+ lint:
67
+ uv run ruff check .
68
+ uv run ruff format --check .
69
+
70
+ # Auto-fix lint and format
71
+ fmt:
72
+ uv run ruff check --fix .
73
+ uv run ruff format .
74
+
75
+ # Tests
76
+ test:
77
+ uv run pytest -q
78
+
79
+ # Everything that must pass before a push
80
+ check: lint test
81
+ uv run madcat mcp list >/dev/null
82
+
83
+ # Tool inventory
84
+ tools:
85
+ uv run madcat mcp list
86
+
87
+ # Call one tool locally: just call ping '{}'
88
+ call name args='{}':
89
+ uv run madcat mcp call {{name}} '{{args}}'
90
+
91
+ # Serve over stdio
92
+ stdio:
93
+ uv run madcat mcp serve
94
+
95
+ # Serve over HTTP on 127.0.0.1:4097 (set MADCAT_MCP_TOKEN)
96
+ http:
97
+ uv run madcat mcp serve --transport http
98
+
99
+ # Install as a uv tool (puts madcat on PATH)
100
+ install:
101
+ uv tool install --force --editable .
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "madcat-os"
3
+ version = "0.1.2"
4
+ description = "MADCAT MCP server and CLI — stdio and Streamable HTTP transports, Typer command line"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.13"
8
+ authors = [{ name = "Adam Ladachowski" }]
9
+ dependencies = [
10
+ "mcp>=1.12",
11
+ "typer>=0.15",
12
+ "rich>=13.9",
13
+ "uvicorn>=0.34",
14
+ "starlette>=0.46",
15
+ "pydantic>=2.10",
16
+ "pydantic-settings>=2.8",
17
+ ]
18
+
19
+ [project.scripts]
20
+ madcat = "madcat_os.cli:main"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "pytest>=8.3",
25
+ "pytest-asyncio>=0.25",
26
+ "httpx>=0.28",
27
+ "ruff>=0.9",
28
+ ]
29
+
30
+ [build-system]
31
+ requires = ["hatchling"]
32
+ build-backend = "hatchling.build"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/madcat_os"]
36
+
37
+ [tool.ruff]
38
+ line-length = 110
39
+ target-version = "py313"
40
+
41
+ [tool.ruff.lint]
42
+ select = ["E", "F", "I", "UP", "B"]
43
+
44
+ [tool.pytest.ini_options]
45
+ asyncio_mode = "auto"
46
+ asyncio_default_fixture_loop_scope = "function"
47
+ testpaths = ["tests"]
@@ -0,0 +1,10 @@
1
+ """MADCAT MCP — server and command line."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("madcat-os")
7
+ except PackageNotFoundError: # running from a checkout without install
8
+ __version__ = "0.0.0"
9
+
10
+ __all__ = ["__version__"]
@@ -0,0 +1,189 @@
1
+ """madcat — the MADCAT command line.
2
+
3
+ madcat mcp serve [--transport stdio|http] [--bind HOST:PORT] [--token T]
4
+ madcat mcp list
5
+ madcat mcp call <tool> ['<json>']
6
+ madcat status
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import sys
15
+ from enum import StrEnum
16
+ from typing import Annotated
17
+
18
+ import typer
19
+ from rich import box
20
+ from rich.console import Console
21
+ from rich.json import JSON
22
+ from rich.panel import Panel
23
+ from rich.table import Table
24
+
25
+ from madcat_os import __version__
26
+ from madcat_os.config import SERVER_NAME, Settings, settings
27
+ from madcat_os.server import build_server
28
+
29
+ app = typer.Typer(
30
+ name="madcat",
31
+ help="MADCAT — MCP server and tools, stdio or Streamable HTTP.",
32
+ no_args_is_help=True,
33
+ rich_markup_mode="rich",
34
+ )
35
+ mcp_app = typer.Typer(help="MCP server and tool registry", no_args_is_help=True)
36
+ app.add_typer(mcp_app, name="mcp")
37
+
38
+ # stdout is the MCP wire on stdio; everything human goes to stderr.
39
+ console = Console(stderr=True)
40
+ out = Console()
41
+
42
+
43
+ class Transport(StrEnum):
44
+ stdio = "stdio"
45
+ http = "http"
46
+
47
+
48
+ def _version(value: bool) -> None:
49
+ if value:
50
+ out.print(f"madcat {__version__}")
51
+ raise typer.Exit()
52
+
53
+
54
+ @app.callback()
55
+ def _root(
56
+ version: Annotated[
57
+ bool, typer.Option("--version", "-V", callback=_version, is_eager=True, help="Print version")
58
+ ] = False,
59
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Debug logging")] = False,
60
+ ) -> None:
61
+ logging.basicConfig(
62
+ level=logging.DEBUG if verbose else logging.WARNING,
63
+ stream=sys.stderr,
64
+ format="%(levelname)s %(name)s: %(message)s",
65
+ )
66
+
67
+
68
+ @app.command()
69
+ def status() -> None:
70
+ """Show effective configuration and tool count."""
71
+ cfg = settings()
72
+ mcp = build_server(cfg)
73
+ tools = asyncio.run(mcp.list_tools())
74
+ table = Table(box=box.SIMPLE, show_header=False)
75
+ table.add_column(style="bold cyan")
76
+ table.add_column()
77
+ table.add_row("server", f"{SERVER_NAME} {__version__}")
78
+ table.add_row("python", sys.version.split()[0])
79
+ table.add_row("http bind", f"{cfg.host}:{cfg.port}")
80
+ table.add_row("token", "[green]set[/]" if cfg.token else "[yellow]none[/]")
81
+ table.add_row("tools", str(len(tools)))
82
+ out.print(Panel(table, title="madcat", border_style="cyan"))
83
+
84
+
85
+ @mcp_app.command("list")
86
+ def mcp_list(
87
+ as_json: Annotated[bool, typer.Option("--json", help="Machine-readable output")] = False,
88
+ ) -> None:
89
+ """Tool inventory."""
90
+ mcp = build_server()
91
+ tools = asyncio.run(mcp.list_tools())
92
+ if as_json:
93
+ out.print_json(json.dumps([t.model_dump(mode="json") for t in tools]))
94
+ return
95
+ table = Table(title=f"{SERVER_NAME} tools ({len(tools)})", box=box.SIMPLE_HEAD)
96
+ table.add_column("tool", style="bold cyan", no_wrap=True)
97
+ table.add_column("description")
98
+ for t in tools:
99
+ table.add_row(t.name, (t.description or "").strip().splitlines()[0] if t.description else "")
100
+ out.print(table)
101
+
102
+
103
+ @mcp_app.command("call")
104
+ def mcp_call(
105
+ name: Annotated[str, typer.Argument(help="Tool name")],
106
+ args: Annotated[str, typer.Argument(help="JSON object of arguments")] = "{}",
107
+ ) -> None:
108
+ """Run one tool locally, no MCP client needed."""
109
+ try:
110
+ parsed = json.loads(args)
111
+ except json.JSONDecodeError as e:
112
+ console.print(f"[red]bad JSON arguments:[/] {e}")
113
+ raise typer.Exit(2) from None
114
+ if not isinstance(parsed, dict):
115
+ console.print("[red]arguments must be a JSON object[/]")
116
+ raise typer.Exit(2)
117
+
118
+ mcp = build_server()
119
+ try:
120
+ result = asyncio.run(mcp.call_tool(name, parsed))
121
+ except Exception as e: # noqa: BLE001 — surface the tool error, then exit non-zero
122
+ console.print(f"[red]{name} failed:[/] {e}")
123
+ raise typer.Exit(1) from None
124
+
125
+ if result.structured_content is not None:
126
+ out.print(JSON.from_data(result.structured_content))
127
+ return
128
+ for block in result.content:
129
+ text = getattr(block, "text", None)
130
+ if text is None:
131
+ out.print(block)
132
+ continue
133
+ try:
134
+ out.print(JSON(text))
135
+ except Exception: # noqa: BLE001 — plain text, not JSON
136
+ out.print(text)
137
+
138
+
139
+ @mcp_app.command("serve")
140
+ def mcp_serve(
141
+ transport: Annotated[
142
+ Transport, typer.Option("--transport", "-t", help="stdio or http")
143
+ ] = Transport.stdio,
144
+ bind: Annotated[str | None, typer.Option("--bind", help="HOST:PORT for http (default from env)")] = None,
145
+ token: Annotated[
146
+ str | None, typer.Option("--token", help="Bearer token for http", envvar="MADCAT_MCP_TOKEN")
147
+ ] = None,
148
+ ) -> None:
149
+ """Run the MCP server."""
150
+ overrides: dict = {}
151
+ if bind:
152
+ host, _, port = bind.rpartition(":")
153
+ if not host or not port.isdigit():
154
+ console.print(f"[red]--bind must be HOST:PORT, got {bind!r}[/]")
155
+ raise typer.Exit(2)
156
+ overrides.update(host=host, port=int(port))
157
+ if token:
158
+ overrides["token"] = token
159
+ cfg = Settings(**overrides) if overrides else settings()
160
+ mcp = build_server(cfg)
161
+
162
+ if transport is Transport.stdio:
163
+ from madcat_os.server import serve_stdio
164
+
165
+ console.print(f"[dim]{SERVER_NAME} {__version__} serving on stdio[/]")
166
+ serve_stdio(mcp)
167
+ return
168
+
169
+ from madcat_os.server import serve_http
170
+
171
+ console.print(
172
+ Panel(
173
+ f"[bold]{SERVER_NAME}[/] {__version__}\n"
174
+ f"http://{cfg.host}:{cfg.port}/mcp\n"
175
+ f"token: {'[green]set[/]' if cfg.token else '[yellow]none[/]'}",
176
+ title="madcat serve",
177
+ border_style="cyan",
178
+ expand=False,
179
+ )
180
+ )
181
+ serve_http(mcp, cfg)
182
+
183
+
184
+ def main() -> None:
185
+ app()
186
+
187
+
188
+ if __name__ == "__main__":
189
+ main()
@@ -0,0 +1,32 @@
1
+ """Runtime settings. Everything comes from the environment (prefix MADCAT_MCP_) or a .env file.
2
+
3
+ Tools never read os.environ directly; they take a Settings instance.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from functools import lru_cache
9
+
10
+ from pydantic import Field
11
+ from pydantic_settings import BaseSettings, SettingsConfigDict
12
+
13
+ SERVER_NAME = "madcat"
14
+
15
+
16
+ class Settings(BaseSettings):
17
+ model_config = SettingsConfigDict(env_prefix="MADCAT_MCP_", env_file=".env", extra="ignore")
18
+
19
+ host: str = Field(default="127.0.0.1", description="HTTP bind address")
20
+ port: int = Field(default=4097, description="HTTP bind port")
21
+ token: str | None = Field(default=None, description="Static bearer token for the HTTP transport")
22
+ allowed_hosts: list[str] = Field(
23
+ default_factory=list,
24
+ description="Host headers accepted by the HTTP transport (DNS-rebinding guard). "
25
+ "Empty: only the bind host when binding loopback, any host otherwise.",
26
+ )
27
+ log_level: str = Field(default="info", description="uvicorn / logging level")
28
+
29
+
30
+ @lru_cache(maxsize=1)
31
+ def settings() -> Settings:
32
+ return Settings()