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.
- madcat_os-0.1.2/.github/workflows/ci.yml +87 -0
- madcat_os-0.1.2/.gitignore +10 -0
- madcat_os-0.1.2/.python-version +1 -0
- madcat_os-0.1.2/PKG-INFO +76 -0
- madcat_os-0.1.2/README.md +60 -0
- madcat_os-0.1.2/justfile +101 -0
- madcat_os-0.1.2/pyproject.toml +47 -0
- madcat_os-0.1.2/src/madcat_os/__init__.py +10 -0
- madcat_os-0.1.2/src/madcat_os/cli.py +189 -0
- madcat_os-0.1.2/src/madcat_os/config.py +32 -0
- madcat_os-0.1.2/src/madcat_os/server.py +96 -0
- madcat_os-0.1.2/src/madcat_os/tools/__init__.py +20 -0
- madcat_os-0.1.2/src/madcat_os/tools/system.py +33 -0
- madcat_os-0.1.2/tests/test_cli.py +29 -0
- madcat_os-0.1.2/tests/test_server.py +59 -0
- madcat_os-0.1.2/uv.lock +895 -0
|
@@ -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 @@
|
|
|
1
|
+
3.13
|
madcat_os-0.1.2/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
madcat_os-0.1.2/justfile
ADDED
|
@@ -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()
|