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.
@@ -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,3 @@
1
+ """FronyBoard — an MCP server that gives AI agents a first-class project tracker."""
2
+
3
+ __version__ = "0.1.0"
@@ -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", ""))