fronyboard 0.28.1__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.
- fronyboard-0.28.1/.gitignore +10 -0
- fronyboard-0.28.1/PKG-INFO +46 -0
- fronyboard-0.28.1/README.md +24 -0
- fronyboard-0.28.1/pyproject.toml +45 -0
- fronyboard-0.28.1/scripts/migrate_v020.py +51 -0
- fronyboard-0.28.1/src/fronyboard/__init__.py +3 -0
- fronyboard-0.28.1/src/fronyboard/auth.py +121 -0
- fronyboard-0.28.1/src/fronyboard/fauth.py +129 -0
- fronyboard-0.28.1/src/fronyboard/log.py +222 -0
- fronyboard-0.28.1/src/fronyboard/server.py +463 -0
- fronyboard-0.28.1/src/fronyboard/service.py +745 -0
- fronyboard-0.28.1/src/fronyboard/store.py +228 -0
- fronyboard-0.28.1/src/fronyboard/validation.py +261 -0
- fronyboard-0.28.1/src/fronyboard/web.py +267 -0
- fronyboard-0.28.1/test/conftest.py +122 -0
- fronyboard-0.28.1/test/integration/test_auth.py +134 -0
- fronyboard-0.28.1/test/integration/test_overview.py +86 -0
- fronyboard-0.28.1/test/integration/test_web.py +207 -0
- fronyboard-0.28.1/test/unit/test_concurrency.py +35 -0
- fronyboard-0.28.1/test/unit/test_log.py +72 -0
- fronyboard-0.28.1/test/unit/test_reads.py +189 -0
- fronyboard-0.28.1/test/unit/test_server.py +34 -0
- fronyboard-0.28.1/test/unit/test_service.py +306 -0
- fronyboard-0.28.1/test/unit/test_store.py +57 -0
- fronyboard-0.28.1/test/unit/test_validation.py +124 -0
- fronyboard-0.28.1/uv.lock +1174 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
backend/dist/
|
|
6
|
+
node_modules/
|
|
7
|
+
*.tsbuildinfo
|
|
8
|
+
# frontend/dist is committed on purpose: the home server deploys by git pull alone (no node).
|
|
9
|
+
# launchers carry FRONY_SERVICE_KEY; commit only the .cmd.example
|
|
10
|
+
*.cmd
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fronyboard
|
|
3
|
+
Version: 0.28.1
|
|
4
|
+
Summary: FronyBoard — an MCP server that gives AI agents a first-class project tracker
|
|
5
|
+
Project-URL: Homepage, https://github.com/Cafelatte1/FronyBoard
|
|
6
|
+
Project-URL: Repository, https://github.com/Cafelatte1/FronyBoard
|
|
7
|
+
Project-URL: Issues, https://github.com/Cafelatte1/FronyBoard/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: ai-agents,claude-code,mcp,mcp-server,project-tracker
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Bug Tracking
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: httpx>=0.27
|
|
17
|
+
Requires-Dist: loguru>=0.7
|
|
18
|
+
Requires-Dist: mcp>=1.2.0
|
|
19
|
+
Requires-Dist: pyyaml>=6.0
|
|
20
|
+
Requires-Dist: tzdata>=2024.1
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# FronyBoard
|
|
24
|
+
|
|
25
|
+
An MCP server that gives AI agents (Claude Code and friends) a first-class project
|
|
26
|
+
tracker: roadmap → quarterly periods → months → tasks in one SQLite file, a schema +
|
|
27
|
+
rule validation gate before every write, retrospectives that close a period, and a
|
|
28
|
+
read-only web dashboard for humans.
|
|
29
|
+
|
|
30
|
+
<!-- mcp-name: io.github.cafelatte1/fronyboard -->
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
Requires [uv](https://docs.astral.sh/uv/).
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
claude mcp add FronyBoard -- uvx fronyboard
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Any MCP client that can launch a stdio command works the same way: the command is
|
|
41
|
+
`uvx fronyboard`. Data is written to `%LOCALAPPDATA%\Frony\FronyBoard\data`
|
|
42
|
+
(`~/.Frony/FronyBoard/data` where `LOCALAPPDATA` is unset); set `AIRA_DATA_DIR` to
|
|
43
|
+
relocate it.
|
|
44
|
+
|
|
45
|
+
Full documentation, the data model, the 20 tools and the shared-server mode live in
|
|
46
|
+
the repository: https://github.com/Cafelatte1/FronyBoard
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# FronyBoard
|
|
2
|
+
|
|
3
|
+
An MCP server that gives AI agents (Claude Code and friends) a first-class project
|
|
4
|
+
tracker: roadmap → quarterly periods → months → tasks in one SQLite file, a schema +
|
|
5
|
+
rule validation gate before every write, retrospectives that close a period, and a
|
|
6
|
+
read-only web dashboard for humans.
|
|
7
|
+
|
|
8
|
+
<!-- mcp-name: io.github.cafelatte1/fronyboard -->
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
Requires [uv](https://docs.astral.sh/uv/).
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
claude mcp add FronyBoard -- uvx fronyboard
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Any MCP client that can launch a stdio command works the same way: the command is
|
|
19
|
+
`uvx fronyboard`. Data is written to `%LOCALAPPDATA%\Frony\FronyBoard\data`
|
|
20
|
+
(`~/.Frony/FronyBoard/data` where `LOCALAPPDATA` is unset); set `AIRA_DATA_DIR` to
|
|
21
|
+
relocate it.
|
|
22
|
+
|
|
23
|
+
Full documentation, the data model, the 20 tools and the shared-server mode live in
|
|
24
|
+
the repository: https://github.com/Cafelatte1/FronyBoard
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fronyboard"
|
|
3
|
+
version = "0.28.1"
|
|
4
|
+
description = "FronyBoard — an MCP server that gives AI agents a first-class project tracker"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = {text = "MIT"}
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
keywords = ["mcp", "mcp-server", "project-tracker", "ai-agents", "claude-code"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Topic :: Software Development :: Bug Tracking",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"httpx>=0.27",
|
|
18
|
+
"loguru>=0.7",
|
|
19
|
+
"mcp>=1.2.0",
|
|
20
|
+
"pyyaml>=6.0",
|
|
21
|
+
"tzdata>=2024.1",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/Cafelatte1/FronyBoard"
|
|
26
|
+
Repository = "https://github.com/Cafelatte1/FronyBoard"
|
|
27
|
+
Issues = "https://github.com/Cafelatte1/FronyBoard/issues"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
fronyboard = "fronyboard.server:main"
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=8.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["hatchling"]
|
|
39
|
+
build-backend = "hatchling.build"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/fronyboard"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["test"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""One-off migration to the v0.2.0 layout: period folders -> single period files.
|
|
2
|
+
|
|
3
|
+
For every `projects/{KEY}/{YYYYQ#}/` folder: merge objective.yaml (months) and
|
|
4
|
+
tasks.yaml (tasks, minus their `epic` field — epics are gone in v0.2.0) into
|
|
5
|
+
`projects/{KEY}/{YYYYQ#}.yaml`, move result.md into the `result` field, then
|
|
6
|
+
remove the folder. Idempotent: a project with no period folders is left as is.
|
|
7
|
+
|
|
8
|
+
Run from backend/ against the live data root (stop the server first):
|
|
9
|
+
|
|
10
|
+
uv run python scripts/migrate_v020.py [data-root] # default: ~/.aira
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
18
|
+
from fronyboard import store # noqa: E402
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def migrate(root: Path) -> None:
|
|
22
|
+
projects = root / "projects"
|
|
23
|
+
if not projects.is_dir():
|
|
24
|
+
raise SystemExit(f"no projects directory under {root}")
|
|
25
|
+
for pdir in sorted(projects.iterdir()):
|
|
26
|
+
if not pdir.is_dir():
|
|
27
|
+
continue
|
|
28
|
+
for period_dir in sorted(p for p in pdir.iterdir() if p.is_dir()):
|
|
29
|
+
objective = period_dir / "objective.yaml"
|
|
30
|
+
tasks_file = period_dir / "tasks.yaml"
|
|
31
|
+
if not objective.exists() and not tasks_file.exists():
|
|
32
|
+
print(f"skip {period_dir} — not a period folder")
|
|
33
|
+
continue
|
|
34
|
+
obj = store.load_yaml(objective) or {} if objective.exists() else {}
|
|
35
|
+
tsk = store.load_yaml(tasks_file) or {} if tasks_file.exists() else {}
|
|
36
|
+
data = {"months": obj.get("months") or [], "tasks": []}
|
|
37
|
+
for t in tsk.get("tasks") or []:
|
|
38
|
+
t.pop("epic", None)
|
|
39
|
+
data["tasks"].append(t)
|
|
40
|
+
result_md = period_dir / "result.md"
|
|
41
|
+
if result_md.exists():
|
|
42
|
+
data["result"] = result_md.read_text(encoding="utf-8")
|
|
43
|
+
out = pdir / f"{period_dir.name}.yaml"
|
|
44
|
+
store.save_yaml(out, data)
|
|
45
|
+
shutil.rmtree(period_dir)
|
|
46
|
+
print(f"migrated {period_dir} -> {out.name} "
|
|
47
|
+
f"({len(data['months'])} months, {len(data['tasks'])} tasks)")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
migrate(Path(sys.argv[1]).expanduser() if len(sys.argv) > 1 else Path.home() / ".aira")
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""HTTP bearer auth for the FronyBoard server — delegation to FronyAuth since AIR-056.
|
|
2
|
+
|
|
3
|
+
API keys and OAuth tokens are issued and judged by FronyAuth (see fauth.py for
|
|
4
|
+
the client and its configuration); FronyBoard no longer reads auth.yaml/oauth.yaml.
|
|
5
|
+
The only credential that stays local is the dashboard session token, which
|
|
6
|
+
lives in this process's memory and never leaves the machine.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import secrets
|
|
13
|
+
|
|
14
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
15
|
+
|
|
16
|
+
from . import fauth, log
|
|
17
|
+
|
|
18
|
+
# Dashboard sessions are held in memory only — a server restart signs everyone out.
|
|
19
|
+
_sessions: dict[str, str] = {} # token -> username
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_session(username: str = "admin") -> str:
|
|
23
|
+
token = "fbsession_" + secrets.token_hex(24)
|
|
24
|
+
_sessions[token] = username
|
|
25
|
+
return token
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def session_user(token: str | None) -> str | None:
|
|
29
|
+
return _sessions.get(token or "")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def verify_session(token: str | None) -> bool:
|
|
33
|
+
return bool(token) and token in _sessions
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def drop_session(token: str | None) -> None:
|
|
37
|
+
_sessions.pop(token or "", None)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def with_mcp_cors(app):
|
|
41
|
+
"""Answer browser CORS on /mcp only. claude.ai's web app probes a connector
|
|
42
|
+
from the browser itself, so the preflight must pass without a credential and
|
|
43
|
+
the 401 must be readable (it carries the WWW-Authenticate pointer)."""
|
|
44
|
+
cors = CORSMiddleware(app, allow_origins=["*"], allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
|
45
|
+
allow_headers=["*"], expose_headers=["Mcp-Session-Id", "WWW-Authenticate"])
|
|
46
|
+
|
|
47
|
+
async def wrapped(scope, receive, send):
|
|
48
|
+
if scope["type"] == "http" and scope.get("path", "").startswith("/mcp"):
|
|
49
|
+
await cors(scope, receive, send)
|
|
50
|
+
else:
|
|
51
|
+
await app(scope, receive, send)
|
|
52
|
+
return wrapped
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class BearerAuthMiddleware:
|
|
56
|
+
"""Pure ASGI middleware: reject HTTP requests without a valid credential.
|
|
57
|
+
|
|
58
|
+
Only paths starting with one of `protected` require a credential (default:
|
|
59
|
+
all) — the FronyBoard static files stay open while /mcp and /api stay keyed.
|
|
60
|
+
`open_paths` are exact-match exceptions inside protected space (the login
|
|
61
|
+
endpoint). A bearer token may be a dashboard session token (checked locally)
|
|
62
|
+
or an API key / OAuth access token (judged by FronyAuth). When FronyAuth is
|
|
63
|
+
unreachable the answer is 503, never 401 — a client must not conclude its
|
|
64
|
+
key was revoked because the auth server blinked.
|
|
65
|
+
|
|
66
|
+
`resource_metadata_url` is what a 401 on /mcp advertises via
|
|
67
|
+
WWW-Authenticate (RFC 9728) so OAuth-capable clients know where to start.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, app, protected: tuple[str, ...] = ("/",),
|
|
71
|
+
open_paths: tuple[str, ...] = (), resource_metadata_url: str | None = None):
|
|
72
|
+
self.app = app
|
|
73
|
+
self.protected = protected
|
|
74
|
+
self.open_paths = open_paths
|
|
75
|
+
self.resource_metadata_url = resource_metadata_url
|
|
76
|
+
|
|
77
|
+
async def __call__(self, scope, receive, send):
|
|
78
|
+
path = scope.get("path", "")
|
|
79
|
+
if scope["type"] != "http" or path in self.open_paths or \
|
|
80
|
+
not any(path.startswith(p) for p in self.protected):
|
|
81
|
+
await self.app(scope, receive, send)
|
|
82
|
+
return
|
|
83
|
+
auth_header = ""
|
|
84
|
+
for name, value in scope.get("headers") or []:
|
|
85
|
+
if name == b"authorization":
|
|
86
|
+
auth_header = value.decode("latin-1")
|
|
87
|
+
break
|
|
88
|
+
token = auth_header[7:] if auth_header.lower().startswith("bearer ") else None
|
|
89
|
+
client = scope.get("client") or ("?", 0)
|
|
90
|
+
if verify_session(token):
|
|
91
|
+
caller = f"session:{session_user(token)}"
|
|
92
|
+
else:
|
|
93
|
+
try:
|
|
94
|
+
caller = await fauth.verify(token)
|
|
95
|
+
except fauth.Unavailable as e:
|
|
96
|
+
log.event("ERROR", "auth", "fauth_unavailable", ip=str(client[0]), path=path,
|
|
97
|
+
error=str(e))
|
|
98
|
+
await self._reject(send, 503, "auth service unavailable — try again shortly")
|
|
99
|
+
return
|
|
100
|
+
if caller is None:
|
|
101
|
+
log.event("WARNING", "auth", "key_rejected", ip=str(client[0]), path=path,
|
|
102
|
+
prefix=(token or "")[:9] or None)
|
|
103
|
+
headers = []
|
|
104
|
+
if self.resource_metadata_url is not None and path.startswith("/mcp"):
|
|
105
|
+
# RFC 9728 discovery: tells an OAuth-capable client where to start.
|
|
106
|
+
headers.append((b"www-authenticate",
|
|
107
|
+
f'Bearer resource_metadata="{self.resource_metadata_url}"'.encode()))
|
|
108
|
+
await self._reject(send, 401, "unauthorized — send 'Authorization: Bearer <api key>'",
|
|
109
|
+
headers)
|
|
110
|
+
return
|
|
111
|
+
# Tag the request so the MCP tool log can name its caller.
|
|
112
|
+
scope.setdefault("state", {})["caller"] = caller
|
|
113
|
+
await self.app(scope, receive, send)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
async def _reject(send, status: int, message: str, extra_headers: list | None = None) -> None:
|
|
117
|
+
body = json.dumps({"error": message}).encode()
|
|
118
|
+
headers = [(b"content-type", b"application/json"),
|
|
119
|
+
(b"content-length", str(len(body)).encode())] + (extra_headers or [])
|
|
120
|
+
await send({"type": "http.response.start", "status": status, "headers": headers})
|
|
121
|
+
await send({"type": "http.response.body", "body": body})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Client for FronyAuth — the central auth server every Frony service delegates to.
|
|
2
|
+
|
|
3
|
+
Since AIR-056 FronyBoard no longer reads auth.yaml/oauth.yaml itself: every bearer
|
|
4
|
+
credential (API key or OAuth access token) is judged by FronyAuth's
|
|
5
|
+
POST /introspect, the dashboard login by POST /admin/verify, and the Settings
|
|
6
|
+
key management by its /keys API. The contract is FronyAuth's
|
|
7
|
+
docs/introspection.md (project-auth repo).
|
|
8
|
+
|
|
9
|
+
Configuration (set in the launcher next to AIRA_DATA_DIR):
|
|
10
|
+
|
|
11
|
+
FRONY_AUTH_URL FronyAuth base URL (default http://127.0.0.1:8640)
|
|
12
|
+
FRONY_SERVICE_KEY this service's own frony_ API key, used to call FronyAuth
|
|
13
|
+
|
|
14
|
+
Verdicts are cached in memory by sha256(token) — positives for 60s (or until
|
|
15
|
+
the token's own expiry), negatives for 5s — so a FronyAuth blip does not drop
|
|
16
|
+
every request. When FronyAuth cannot be reached and no cached verdict exists,
|
|
17
|
+
`Unavailable` is raised and the middleware answers 503 (fail closed, never 401:
|
|
18
|
+
a client must not conclude its key was revoked).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import hashlib
|
|
25
|
+
import os
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
POSITIVE_TTL = 60
|
|
31
|
+
NEGATIVE_TTL = 5
|
|
32
|
+
TIMEOUT = 2.0
|
|
33
|
+
|
|
34
|
+
_cache: dict[str, tuple[float, str | None]] = {} # sha256(token) -> (expires, caller|None)
|
|
35
|
+
_http: tuple[asyncio.AbstractEventLoop, httpx.AsyncClient] | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Unavailable(Exception):
|
|
39
|
+
"""FronyAuth could not be reached and no cached verdict exists."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def base_url() -> str:
|
|
43
|
+
return (os.environ.get("FRONY_AUTH_URL") or "http://127.0.0.1:8640").rstrip("/")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _headers() -> dict:
|
|
47
|
+
return {"Authorization": f"Bearer {os.environ.get('FRONY_SERVICE_KEY', '')}"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _client() -> httpx.AsyncClient:
|
|
51
|
+
"""One client per event loop, reused across calls: building an AsyncClient costs
|
|
52
|
+
~200 ms on the home server (SSL context + CA bundle) while the request itself takes
|
|
53
|
+
~30 ms, and a per-call client made every introspect and /keys round trip pay it (AIR-072).
|
|
54
|
+
Keyed by loop so tests, which run one loop per test, never reuse a closed one."""
|
|
55
|
+
global _http
|
|
56
|
+
loop = asyncio.get_running_loop()
|
|
57
|
+
if _http is None or _http[0] is not loop:
|
|
58
|
+
_http = (loop, httpx.AsyncClient(timeout=TIMEOUT))
|
|
59
|
+
return _http[1]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _post(path: str, payload: dict) -> httpx.Response:
|
|
63
|
+
last_error: Exception | None = None
|
|
64
|
+
for _ in range(2): # one retry, per the contract
|
|
65
|
+
try:
|
|
66
|
+
return await _client().post(base_url() + path, json=payload, headers=_headers())
|
|
67
|
+
except httpx.HTTPError as e:
|
|
68
|
+
last_error = e
|
|
69
|
+
raise Unavailable(str(last_error))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def verify(token: str | None) -> str | None:
|
|
73
|
+
"""The caller label for a bearer token (`key:…` / `oauth:…`), or None."""
|
|
74
|
+
if not token:
|
|
75
|
+
return None
|
|
76
|
+
digest = hashlib.sha256(token.encode()).hexdigest()
|
|
77
|
+
now = time.time()
|
|
78
|
+
cached = _cache.get(digest)
|
|
79
|
+
if cached and cached[0] > now:
|
|
80
|
+
return cached[1]
|
|
81
|
+
try:
|
|
82
|
+
response = await _post("/introspect", {"token": token})
|
|
83
|
+
except Unavailable:
|
|
84
|
+
if cached: # expired entry beats an outage — the contract's cache rule
|
|
85
|
+
return cached[1]
|
|
86
|
+
raise
|
|
87
|
+
if response.status_code != 200:
|
|
88
|
+
raise Unavailable(f"introspect answered {response.status_code}")
|
|
89
|
+
result = response.json()
|
|
90
|
+
if result.get("active"):
|
|
91
|
+
caller, ttl = str(result.get("caller")), POSITIVE_TTL
|
|
92
|
+
else:
|
|
93
|
+
caller, ttl = None, NEGATIVE_TTL
|
|
94
|
+
_cache[digest] = (now + ttl, caller)
|
|
95
|
+
return caller
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def admin_verify(username: str, password: str, client_addr: str) -> tuple[int, dict]:
|
|
99
|
+
"""Delegate a dashboard login; returns FronyAuth's (status, body) as-is."""
|
|
100
|
+
response = await _post("/admin/verify",
|
|
101
|
+
{"username": username, "password": password, "client_addr": client_addr})
|
|
102
|
+
return response.status_code, response.json()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def keys() -> list[dict]:
|
|
106
|
+
try:
|
|
107
|
+
response = await _client().get(base_url() + "/keys", headers=_headers())
|
|
108
|
+
except httpx.HTTPError as e:
|
|
109
|
+
raise Unavailable(str(e))
|
|
110
|
+
if response.status_code != 200:
|
|
111
|
+
raise Unavailable(f"/keys answered {response.status_code}")
|
|
112
|
+
return response.json()["keys"]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def create_key(name: str) -> tuple[int, dict]:
|
|
116
|
+
response = await _post("/keys", {"name": name})
|
|
117
|
+
return response.status_code, response.json()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def delete_key(name: str) -> tuple[int, dict]:
|
|
121
|
+
try:
|
|
122
|
+
response = await _client().delete(base_url() + f"/keys/{name}", headers=_headers())
|
|
123
|
+
except httpx.HTTPError as e:
|
|
124
|
+
raise Unavailable(str(e))
|
|
125
|
+
return response.status_code, response.json()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def clear_cache() -> None:
|
|
129
|
+
_cache.clear()
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""JSON Lines event logs, written for agents rather than people.
|
|
2
|
+
|
|
3
|
+
Two files under the log root (`FRONYBOARD_LOG_DIR`, default `<data root>/../logs`):
|
|
4
|
+
|
|
5
|
+
tools.jsonl one line per MCP tool call — who called what, on which record,
|
|
6
|
+
how long it took, and whether it was accepted (kept 180 days)
|
|
7
|
+
server.jsonl boot/shutdown, auth events, HTTP 4xx/5xx, rejected tool calls
|
|
8
|
+
and unhandled exceptions with tracebacks (kept 30 days)
|
|
9
|
+
|
|
10
|
+
Every line is a flat JSON object with a fixed field set (see docs/logging.md).
|
|
11
|
+
Nothing is emitted until `setup()` runs, so importing this module (tests, CLI
|
|
12
|
+
subcommands) stays silent. stdout is never a sink — in stdio mode it carries MCP.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import datetime
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import secrets
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
import traceback
|
|
25
|
+
import zoneinfo
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from loguru import logger
|
|
30
|
+
|
|
31
|
+
from . import store
|
|
32
|
+
|
|
33
|
+
logger.remove()
|
|
34
|
+
|
|
35
|
+
# Free-text fields are logged as their length only: they are long, and they are
|
|
36
|
+
# the user's planning prose, not telemetry.
|
|
37
|
+
TEXT_FIELDS = {"content", "prd", "goal", "now", "next", "later", "result_markdown", "description"}
|
|
38
|
+
|
|
39
|
+
_tz: datetime.tzinfo | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def log_dir() -> Path:
|
|
43
|
+
override = os.environ.get("FRONYBOARD_LOG_DIR")
|
|
44
|
+
return Path(override) if override else store.data_root().parent / "logs"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def setup(*, stderr: bool = False) -> Path:
|
|
48
|
+
"""Install the two file sinks (and optionally a WARNING+ stderr echo); returns the log root."""
|
|
49
|
+
global _tz
|
|
50
|
+
name = os.environ.get("FRONYBOARD_TZ")
|
|
51
|
+
try:
|
|
52
|
+
_tz = zoneinfo.ZoneInfo(name) if name else None
|
|
53
|
+
except (zoneinfo.ZoneInfoNotFoundError, ValueError):
|
|
54
|
+
_tz = None
|
|
55
|
+
root = log_dir()
|
|
56
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
logger.remove()
|
|
58
|
+
common = {"format": "{message}", "level": "INFO", "rotation": "00:00",
|
|
59
|
+
"compression": "gz", "encoding": "utf-8"}
|
|
60
|
+
logger.add(root / "server.jsonl", retention="30 days",
|
|
61
|
+
filter=lambda r: r["extra"].get("stream") != "tools", **common)
|
|
62
|
+
logger.add(root / "tools.jsonl", retention="180 days",
|
|
63
|
+
filter=lambda r: r["extra"].get("stream") == "tools", **common)
|
|
64
|
+
if stderr:
|
|
65
|
+
logger.add(sys.stderr, format="{message}", level="WARNING",
|
|
66
|
+
filter=lambda r: r["extra"].get("stream") != "tools")
|
|
67
|
+
# Route the stdlib loggers (uvicorn, mcp) through the same sinks.
|
|
68
|
+
logging.basicConfig(handlers=[InterceptHandler()], level=logging.INFO, force=True)
|
|
69
|
+
for lg_name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
|
70
|
+
lg = logging.getLogger(lg_name)
|
|
71
|
+
lg.handlers = [InterceptHandler()]
|
|
72
|
+
lg.propagate = False
|
|
73
|
+
return root
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def shutdown() -> None:
|
|
77
|
+
logger.remove()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _ts() -> str:
|
|
81
|
+
"""ISO 8601 with offset, in FRONYBOARD_TZ or the process-local zone."""
|
|
82
|
+
return datetime.datetime.now(_tz).astimezone(_tz).isoformat(timespec="milliseconds")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _dump(payload: dict[str, Any]) -> str:
|
|
86
|
+
return json.dumps(payload, ensure_ascii=False, default=str)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def event(level: str, scope: str, evt: str, **fields: Any) -> None:
|
|
90
|
+
"""One server.jsonl line: {ts, level, scope, event, ...fields}."""
|
|
91
|
+
payload = {"ts": _ts(), "level": level, "scope": scope, "event": evt, **fields}
|
|
92
|
+
logger.bind(stream="server").log(level, _dump(payload))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def exception(scope: str, evt: str, **fields: Any) -> None:
|
|
96
|
+
"""`event` at ERROR with the current exception's traceback attached."""
|
|
97
|
+
event("ERROR", scope, evt, trace=traceback.format_exc(), **fields)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def tool_call(**fields: Any) -> None:
|
|
101
|
+
"""One tools.jsonl line."""
|
|
102
|
+
logger.bind(stream="tools").info(_dump({"ts": _ts(), **fields}))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def new_req() -> str:
|
|
106
|
+
"""Short correlation id shared by a tools.jsonl line and its server.jsonl follow-ups."""
|
|
107
|
+
return secrets.token_hex(3)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def summarize_args(args: dict[str, Any]) -> dict[str, Any]:
|
|
111
|
+
out: dict[str, Any] = {}
|
|
112
|
+
for k, v in args.items():
|
|
113
|
+
if v is None:
|
|
114
|
+
continue
|
|
115
|
+
if k in TEXT_FIELDS:
|
|
116
|
+
out[f"{k}_len"] = len(str(v))
|
|
117
|
+
else:
|
|
118
|
+
out[k] = v
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class InterceptHandler(logging.Handler):
|
|
123
|
+
"""stdlib logging → server.jsonl. Access lines below 400 are dropped (the
|
|
124
|
+
dashboard polls every minute); everything else keeps its logger name."""
|
|
125
|
+
|
|
126
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
127
|
+
if record.name == "uvicorn.access":
|
|
128
|
+
try:
|
|
129
|
+
addr, method, path, _http, status = record.args # type: ignore[misc]
|
|
130
|
+
status = int(status)
|
|
131
|
+
except (TypeError, ValueError):
|
|
132
|
+
return
|
|
133
|
+
if status < 400:
|
|
134
|
+
return
|
|
135
|
+
if method == "GET" and path == "/mcp" and status in (404, 405):
|
|
136
|
+
return # a client opening the optional SSE listen stream — protocol chatter, not a fault
|
|
137
|
+
event("WARNING" if status < 500 else "ERROR", "http", "response",
|
|
138
|
+
status=status, method=method, path=path, ip=str(addr).rsplit(":", 1)[0])
|
|
139
|
+
return
|
|
140
|
+
if record.levelno < logging.WARNING:
|
|
141
|
+
return # startup chatter
|
|
142
|
+
level = "ERROR" if record.levelno >= logging.ERROR else "WARNING"
|
|
143
|
+
fields: dict[str, Any] = {"logger": record.name, "msg": record.getMessage()}
|
|
144
|
+
if record.exc_info:
|
|
145
|
+
fields["trace"] = "".join(traceback.format_exception(*record.exc_info))
|
|
146
|
+
event(level, "py", "log", **fields)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ToolLogMiddleware:
|
|
150
|
+
"""MCP server middleware: one tools.jsonl line per tools/call.
|
|
151
|
+
|
|
152
|
+
The caller comes from `scope["state"]["caller"]`, which the bearer-auth ASGI
|
|
153
|
+
middleware fills in for HTTP transports; stdio has no request and logs as `stdio`.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
async def __call__(self, ctx, call_next):
|
|
157
|
+
if ctx.method != "tools/call":
|
|
158
|
+
return await call_next(ctx)
|
|
159
|
+
params = ctx.params or {}
|
|
160
|
+
name = params.get("name")
|
|
161
|
+
args = dict(params.get("arguments") or {})
|
|
162
|
+
req = new_req()
|
|
163
|
+
caller = "stdio"
|
|
164
|
+
request = getattr(ctx, "request", None)
|
|
165
|
+
if request is not None:
|
|
166
|
+
state = (getattr(request, "scope", None) or {}).get("state") or {}
|
|
167
|
+
caller = state.get("caller") or "unknown"
|
|
168
|
+
line: dict[str, Any] = {"req": req, "tool": name, "caller": caller}
|
|
169
|
+
key = args.pop("key", None)
|
|
170
|
+
task = args.pop("task_id", None)
|
|
171
|
+
period = args.pop("period", None)
|
|
172
|
+
project = key or (task.split("-", 1)[0] if isinstance(task, str) and "-" in task else None)
|
|
173
|
+
if project:
|
|
174
|
+
line["project"] = project
|
|
175
|
+
if period:
|
|
176
|
+
line["period"] = period
|
|
177
|
+
if task:
|
|
178
|
+
line["task"] = task
|
|
179
|
+
line["args"] = summarize_args(args)
|
|
180
|
+
|
|
181
|
+
t0 = time.perf_counter()
|
|
182
|
+
try:
|
|
183
|
+
result = await call_next(ctx)
|
|
184
|
+
except Exception as e:
|
|
185
|
+
ms = round((time.perf_counter() - t0) * 1000, 1)
|
|
186
|
+
tool_call(**line, ms=ms, ok=False, error=type(e).__name__, msg=str(e))
|
|
187
|
+
exception("tool", "exception", req=req, tool=name, error=type(e).__name__, msg=str(e))
|
|
188
|
+
raise
|
|
189
|
+
ms = round((time.perf_counter() - t0) * 1000, 1)
|
|
190
|
+
is_error, content, structured = _result_parts(result)
|
|
191
|
+
if is_error:
|
|
192
|
+
# The SDK turns any exception raised by a tool into an is_error result;
|
|
193
|
+
# for this server that is a validation/argument rejection (FronyBoardError).
|
|
194
|
+
msg = "; ".join(_text(c) for c in content)
|
|
195
|
+
msg = msg.removeprefix(f"Error executing tool {name}: ")[:500] # SDK boilerplate
|
|
196
|
+
tool_call(**line, ms=ms, ok=False, error="rejected", msg=msg)
|
|
197
|
+
event("WARNING", "tool", "rejected", req=req, tool=name, msg=msg)
|
|
198
|
+
else:
|
|
199
|
+
sc = structured
|
|
200
|
+
if isinstance(sc, dict) and "warnings" not in sc and isinstance(sc.get("result"), dict):
|
|
201
|
+
sc = sc["result"]
|
|
202
|
+
warnings = len(sc.get("warnings") or []) if isinstance(sc, dict) else 0
|
|
203
|
+
tool_call(**line, ms=ms, ok=True, warnings=warnings)
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _result_parts(result: Any) -> tuple[bool, list[Any], Any]:
|
|
208
|
+
"""(is_error, content, structured_content) from a CallToolResult, whether the
|
|
209
|
+
middleware sees the model or its wire-format dict."""
|
|
210
|
+
if isinstance(result, dict):
|
|
211
|
+
return (bool(result.get("isError") or result.get("is_error")),
|
|
212
|
+
list(result.get("content") or []),
|
|
213
|
+
result.get("structuredContent") or result.get("structured_content") or {})
|
|
214
|
+
return (bool(getattr(result, "is_error", False)),
|
|
215
|
+
list(getattr(result, "content", None) or []),
|
|
216
|
+
getattr(result, "structured_content", None) or {})
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _text(part: Any) -> str:
|
|
220
|
+
if isinstance(part, dict):
|
|
221
|
+
return str(part.get("text", ""))
|
|
222
|
+
return str(getattr(part, "text", ""))
|