#!/usr/bin/env python3 """ Plan Budowy MCP Server — wersja kompatybilna z Claude Code ========================================================= Lokalny serwer MCP (Model Context Protocol) dla aplikacji Plan Budowy. Komunikuje się z backendem przez REST API i udostępnia narzędzia AI (Claude Code, Claude Desktop, Cursor, inne). Wymagania: pip install requests Użycie (autoryzacja w przeglądarce przy pierwszym uruchomieniu): export PLAN_BUDOWY_BASE_URL="https://planbudowy.com.pl" python3 mcp_server.py Przy pierwszym uruchomieniu skrypt otworzy przeglądarkę, poprosi o zalogowanie się i potwierdzenie dostępu (ekran consent), a otrzymany token zapisze lokalnie w ~/.config/plan-budowy/mcp-token.json. Kolejne uruchomienia używają zapisanego tokenu. Opcjonalnie: export PLAN_BUDOWY_MCP_LABEL="Claude Desktop" (sugerowana nazwa tokenu na ekranie consent; user może ją edytować). Więcej informacji: https://planbudowy.com.pl/mcp_info """ import json import os import sys import time import webbrowser from pathlib import Path from typing import Any from urllib.parse import urlencode, urljoin try: import requests except ImportError as exc: # pragma: no cover print( "Błąd: brak biblioteki 'requests'. Zainstaluj ją:\n pip install requests", file=sys.stderr, ) raise SystemExit(1) from exc DEFAULT_LABEL = "Claude Desktop" TOKEN_FILE_ENV = "PLAN_BUDOWY_MCP_TOKEN_FILE" def _token_file_path() -> Path: override = os.environ.get(TOKEN_FILE_ENV, "").strip() if override: return Path(override) return Path.home() / ".config" / "plan-budowy" / "mcp-token.json" def _load_token_file() -> dict[str, Any] | None: path = _token_file_path() if not path.is_file(): return None try: return json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return None def _save_token_file(data: dict[str, Any]) -> None: path = _token_file_path() path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") try: os.chmod(path, 0o600) except OSError: pass def _token_is_valid(base_url: str, token: str) -> bool: try: resp = requests.get( urljoin(base_url + "/", "api/accounts"), headers={"X-API-Token": token}, timeout=10, ) except requests.RequestException: # Chwilowy błąd sieci — nie odrzucaj tokenu. return True return resp.status_code == 200 def _authorize(base_url: str, label: str) -> str: """First-run device flow (RFC 8628): poproś o kod, otwórz przeglądarkę, polluj /mcp/token. Bez localhost/loopback — przeglądarka otwiera stronę zatwierdzania na serwerze Plan Budowy, a skrypt odpytuje /mcp/token aż user zatwierdzi. """ init_url = urljoin(base_url + "/", "mcp/device/code") init_data: dict[str, Any] = {} if label: init_data["label"] = label init_resp = requests.post(init_url, data=init_data, timeout=30) try: init = init_resp.json() except json.JSONDecodeError: init = {} if init_resp.status_code != 200 or not isinstance(init, dict) or "device_code" not in init: raise SystemExit(f"Nie udało się uzyskać kodu urządzenia (HTTP {init_resp.status_code}): {init}") device_code = str(init["device_code"]) user_code = str(init.get("user_code", "")) verification_uri = str(init.get("verification_uri", urljoin(base_url + "/", "mcp/device"))) verification_uri_complete = str(init.get("verification_uri_complete", verification_uri)) expires_in = int(init.get("expires_in", 900)) interval = int(init.get("interval", 5)) print( "\n[Plan Budowy MCP] Wymagana autoryzacja.\n" f" Otwieram: {verification_uri_complete}\n" f" Jeśli przeglądarka się nie otworzy, wejdź na {verification_uri}\n" f" i wpisz kod: {user_code}\n" f" Czas na zatwierdzenie: {expires_in}s. Oczekiwanie…\n", file=sys.stderr, ) try: webbrowser.open(verification_uri_complete) except webbrowser.Error: pass token_url = urljoin(base_url + "/", "mcp/token") deadline = time.time() + expires_in while time.time() < deadline: time.sleep(interval) resp = requests.post( token_url, data={ "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": device_code, }, timeout=30, ) try: data = resp.json() except json.JSONDecodeError: data = {} if resp.status_code == 200 and isinstance(data, dict) and "access_token" in data: return str(data["access_token"]) err = data.get("error") if isinstance(data, dict) else None if err == "authorization_pending": continue if err == "slow_down": time.sleep(interval) continue if err == "access_denied": raise SystemExit("Odmówiono dostępu w przeglądarce.") if err == "expired_token": raise SystemExit("Kod urządzenia wygasł. Uruchom skrypt ponownie.") raise SystemExit(f"Błąd autoryzacji (HTTP {resp.status_code}): {data}") raise SystemExit("Przekroczono czas oczekiwania na autoryzację. Uruchom skrypt ponownie.") def _resolve_token(base_url: str, label: str) -> tuple[str, Path | None]: """Zwraca (token, ścieżka_pliku_lub_None_gdy_z_env).""" env_token = os.environ.get("PLAN_BUDOWY_API_TOKEN", "").strip() if env_token: return env_token, None path = _token_file_path() data = _load_token_file() if data and data.get("access_token") and data.get("base_url") == base_url: return str(data["access_token"]), path # Brak tokenu. Flow autoryzacji (przeglądarka + polling) odpalamy TYLKO interaktywnie # (stdin to TTY). Gdy skrypt jest spawnowany przez `claude mcp list` / health-check # (stdin = pipe), nie blokuj i nie otwieraj przeglądarki — fail fast. if not sys.stdin.isatty(): raise SystemExit( "Brak tokenu MCP. Uruchom `python3 mcp_server.py` w terminalu, aby się autoryzować " "(otworzy przeglądarkę). Po jednorazowej autoryzacji Claude Code połączy się automatycznie." ) token = _authorize(base_url, label) _save_token_file({"access_token": token, "base_url": base_url, "label": label}) return token, path class PlanBudowyMcpServer: """Prosty serwer MCP stdio proxy do Plan Budowy REST API. Obsługuje dwa formaty transportu stdio: - NDJSON (Claude Code): każda linia to osobny JSON-RPC. - Content-Length + CRLF (oryginalny MCP/LSP). """ NAME = "plan-budowy-mcp" VERSION = "1.0.0" SUPPORTED_PROTOCOL_VERSIONS = ["2024-11-05", "2025-03-26", "2025-11-25"] def __init__(self, base_url: str, api_token: str) -> None: self.base_url = base_url.rstrip("/") self.api_token = api_token self.initialized = False self._client_protocol_version: str | None = None def run(self) -> None: """Główna pętla stdio.""" while True: request = self._read_request() if request is None: break response = self._handle(request) if response is not None: self._send(response) def _read_request(self) -> dict[str, Any] | None: line = sys.stdin.readline() if not line: return None line = line.rstrip("\n") if not line: return self._read_request() # NDJSON: cała wiadomość w jednej linii if line.startswith("{"): try: return json.loads(line) except json.JSONDecodeError: return None # Content-Length + CRLF format headers: dict[str, str] = {} current = line while current.strip() != "": if ":" in current: key, value = current.split(":", 1) headers[key.strip()] = value.strip() next_line = sys.stdin.readline() if not next_line: return None current = next_line.rstrip("\n") length = int(headers.get("Content-Length", "0")) if length <= 0: return None payload = sys.stdin.read(length) try: return json.loads(payload) except json.JSONDecodeError: return None def _handle(self, request: dict[str, Any]) -> dict[str, Any] | None: method = request.get("method", "") req_id = request.get("id") if not self.initialized and method != "initialize": return self._error(req_id, -32000, "Server not initialized") if method == "initialize": self.initialized = True params = request.get("params", {}) client_version = params.get("protocolVersion") self._client_protocol_version = self._negotiate_protocol(client_version) return self._result(req_id, { "protocolVersion": self._client_protocol_version, "capabilities": {"tools": {}}, "serverInfo": {"name": self.NAME, "version": self.VERSION}, }) if method == "notifications/initialized": return None if method == "tools/list": return self._result(req_id, {"tools": self._tools()}) if method == "tools/call": return self._call_tool(req_id, request.get("params", {})) return self._error(req_id, -32601, f"Method not found: {method}") def _negotiate_protocol(self, client_version: Any) -> str: if isinstance(client_version, str) and client_version in self.SUPPORTED_PROTOCOL_VERSIONS: return client_version # Fallback do najstarszej wspieranej wersji return self.SUPPORTED_PROTOCOL_VERSIONS[0] def _call_tool(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]: name = params.get("name", "") args = params.get("arguments", {}) try: result = self._dispatch(name, args) except Exception as exc: # pragma: no cover result = {"error": True, "message": str(exc)} return self._result(req_id, { "content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}] }) def _dispatch(self, name: str, args: dict[str, Any]) -> dict[str, Any]: if name == "search_accounts": return self._api_get("/api/accounts/search", {"q": args.get("query", "")}) if name == "create_account": payload: dict[str, Any] = { "code": args.get("code", ""), "name": args.get("name", ""), "type": args.get("type", "cost"), } parent_code = args.get("parent_code") or args.get("parentCode") if parent_code: payload["parentCode"] = parent_code return self._api_post("/api/accounts", payload) if name == "list_projects": return self._api_get("/api/projects") if name == "list_tasks": project_id = args.get("project_id") return self._api_get(f"/api/projects/{project_id}/tasks") if name in ("get_account", "get_balance"): code = args.get("code", "") return self._api_get(f"/api/accounts/{code}") if name == "add_journal_entry": payload = self._build_journal_entry_payload(args) return self._api_post("/api/journal-entries", payload) if name == "get_journal_entry": entry_id = args.get("id") return self._api_get(f"/api/journal-entries/{entry_id}") if name == "allocate_cost": entry_id = args.get("entry_id", args.get("entryId")) line_id = args.get("line_id", args.get("lineId")) payload: dict[str, Any] = {"taskId": args.get("task_id", args.get("taskId"))} if "amount" in args: payload["amount"] = args["amount"] return self._api_post( f"/api/journal-entries/{entry_id}/lines/{line_id}/allocate", payload, ) return {"error": True, "message": f"Unknown tool: {name}"} def _build_journal_entry_payload(self, args: dict[str, Any]) -> dict[str, Any]: lines: list[dict[str, Any]] = [] for line in args.get("lines", []): lines.append({ "accountCode": line.get("account_code", ""), "debit": line.get("debit", "0"), "credit": line.get("credit", "0"), "description": line.get("description", ""), }) allocations: list[dict[str, Any]] = [] for alloc in args.get("allocations", []): allocations.append({ "lineIndex": alloc.get("line_index", alloc.get("lineIndex", 0)), "taskId": alloc.get("task_id", alloc.get("taskId")), "amount": alloc.get("amount", ""), }) payload: dict[str, Any] = { "date": args.get("date", ""), "description": args.get("description", ""), "documentNo": args.get("document_no", args.get("documentNo")), "lines": lines, } if allocations: payload["allocations"] = allocations return payload def _api_get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: url = urljoin(self.base_url + "/", path.lstrip("/")) if params: url += "?" + urlencode(params) response = requests.get(url, headers={"X-API-Token": self.api_token}, timeout=30) return self._decode(response) def _api_post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: url = urljoin(self.base_url + "/", path.lstrip("/")) response = requests.post( url, headers={"X-API-Token": self.api_token, "Content-Type": "application/json"}, json=payload, timeout=30, ) return self._decode(response) @staticmethod def _decode(response: requests.Response) -> dict[str, Any]: try: data = response.json() except json.JSONDecodeError: data = None if response.status_code == 401: return { "error": True, "status": 401, "message": "Token API został unieważniony lub jest nieprawidłowy. " "Uruchom skrypt ponownie — usunie stary token i poprosi o ponowną autoryzację.", } if response.status_code >= 400: return { "error": True, "status": response.status_code, "message": data.get("message", response.text[:200]) if isinstance(data, dict) else response.text[:200], } return data if data is not None else {"error": True, "message": f"HTTP {response.status_code}: empty body"} def _tools(self) -> list[dict[str, Any]]: return [ { "name": "search_accounts", "description": "Wyszukuje konta księgowe po kodzie lub nazwie.", "inputSchema": { "type": "object", "properties": { "query": {"type": "string", "description": "Fragment kodu lub nazwy konta"}, }, "required": ["query"], }, }, { "name": "create_account", "description": ( "Tworzy konto w planie kont (np. 500 Koszty materiałów). Wymagane do księgowania — " "accountCode w zapisie musi istnieć. Typy: asset, liability, equity, income, cost." ), "inputSchema": { "type": "object", "properties": { "code": {"type": "string", "description": "Kod konta, np. 500"}, "name": {"type": "string", "description": "Nazwa konta, np. Koszty materiałów"}, "type": { "type": "string", "description": "Typ konta (domyślnie cost)", "enum": ["asset", "liability", "equity", "income", "cost"], }, "parent_code": {"type": "string", "description": "Kod konta nadrzędnego (opcjonalnie)"}, }, "required": ["code", "name"], }, }, { "name": "list_projects", "description": "Lista projektów budowlanych.", "inputSchema": {"type": "object", "properties": {}}, }, { "name": "list_tasks", "description": "Lista zadań w projekcie.", "inputSchema": { "type": "object", "properties": { "project_id": {"type": "integer", "description": "ID projektu"}, }, "required": ["project_id"], }, }, { "name": "get_account", "description": "Zwraca szczegóły konta wraz z obrotami i saldem.", "inputSchema": { "type": "object", "properties": {"code": {"type": "string", "description": "Kod konta"}}, "required": ["code"], }, }, { "name": "get_balance", "description": "Alias dla get_account — zwraca saldo konta.", "inputSchema": { "type": "object", "properties": {"code": {"type": "string", "description": "Kod konta"}}, "required": ["code"], }, }, { "name": "add_journal_entry", "description": "Tworzy zapis księgowy (podwójny zapis WN/MA) z opcjonalnymi alokacjami do zadań.", "inputSchema": { "type": "object", "properties": { "date": {"type": "string", "description": "Data zapisu YYYY-MM-DD"}, "description": {"type": "string", "description": "Opis zapisu"}, "document_no": {"type": "string", "description": "Numer dokumentu"}, "lines": { "type": "array", "items": { "type": "object", "properties": { "account_code": {"type": "string"}, "debit": {"type": "string", "description": "Kwota WN"}, "credit": {"type": "string", "description": "Kwota MA"}, "description": {"type": "string"}, }, "required": ["account_code"], }, }, "allocations": { "type": "array", "items": { "type": "object", "properties": { "line_index": {"type": "integer", "description": "Indeks linii od 0"}, "task_id": {"type": "integer"}, "amount": {"type": "string", "description": "Kwota alokacji (opcjonalnie)"}, }, "required": ["line_index", "task_id"], }, }, }, "required": ["description", "lines"], }, }, { "name": "get_journal_entry", "description": "Zwraca szczegóły zapisu księgowego.", "inputSchema": { "type": "object", "properties": {"id": {"type": "integer", "description": "ID zapisu"}}, "required": ["id"], }, }, { "name": "allocate_cost", "description": "Przypisuje linię zapisu księgowego do konkretnego zadania budowy.", "inputSchema": { "type": "object", "properties": { "entry_id": {"type": "integer", "description": "ID zapisu"}, "line_id": {"type": "integer", "description": "ID linii"}, "task_id": {"type": "integer", "description": "ID zadania"}, "amount": {"type": "string", "description": "Kwota alokacji (opcjonalnie)"}, }, "required": ["entry_id", "line_id", "task_id"], }, }, ] @staticmethod def _result(req_id: Any, result: dict[str, Any]) -> dict[str, Any]: return {"jsonrpc": "2.0", "id": req_id, "result": result} @staticmethod def _error(req_id: Any, code: int, message: str) -> dict[str, Any]: return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}} def _send(self, response: dict[str, Any]) -> None: text = json.dumps(response, ensure_ascii=False) sys.stdout.write(text + "\n") sys.stdout.flush() def main() -> None: base_url = os.environ.get("PLAN_BUDOWY_BASE_URL", "http://localhost:5502").rstrip("/") label = os.environ.get("PLAN_BUDOWY_MCP_LABEL", DEFAULT_LABEL).strip() or DEFAULT_LABEL token, token_path = _resolve_token(base_url, label) # Jeśli token z pliku okazał się unieważniony — usuń go i autoryzuj ponownie. if token_path is not None and not _token_is_valid(base_url, token): print("[Plan Budowy MCP] Token unieważniony — ponowna autoryzacja.", file=sys.stderr) try: token_path.unlink() except OSError: pass token, token_path = _resolve_token(base_url, label) if token_path is not None and not _token_is_valid(base_url, token): raise SystemExit("Autoryzacja zakończona, ale token nadal jest nieważny.") server = PlanBudowyMcpServer(base_url, token) server.run() if __name__ == "__main__": main()