justfill-mcp 0.4.0__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,3 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maciej Śnieżyński
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: justfill-mcp
3
+ Version: 0.4.0
4
+ Summary: JustFill MCP server — let AI agents detect, review and fill PDF form fields via justfill.app
5
+ Project-URL: Homepage, https://justfill.app
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: ai-agents,claude,form-filling,forms,mcp,model-context-protocol,pdf
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Office/Business
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: httpx>=0.24.0
15
+ Requires-Dist: mcp>=1.2.0
16
+ Requires-Dist: pillow>=10.0.0
17
+ Requires-Dist: pypdf>=4.0.0
18
+ Description-Content-Type: text/markdown
19
+
20
+ # JustFill MCP Server
21
+
22
+ Let AI agents (Claude, ChatGPT, n8n — any MCP client) detect, review and fill
23
+ PDF form fields through [justfill.app](https://justfill.app).
24
+
25
+ ## Why agents can trust it
26
+
27
+ | Source | Confidence | What it means |
28
+ |---|---|---|
29
+ | **Saved template** | 1.0 | This exact PDF was filled before; geometry is human/agent-verified. No ML runs at all. |
30
+ | **AcroForm** | 1.0 | The PDF has embedded form fields — read from the file, filled natively. |
31
+ | **ML detection** | 0.0–0.95 | An honest draft. Review it visually (`render_preview`), fix it, then `save_template` to lock it in. |
32
+
33
+ ML confidence is *calibrated*: the detector's raw scores are not
34
+ probabilities (its server-side filter accepts boxes from raw ~0.02 and
35
+ auto-accepts at raw 0.15), so they are mapped onto 0–1 to mean what you'd
36
+ expect — ≥0.75 "detector is sure", 0.4–0.75 "probably right, glance at the
37
+ preview", <0.4 "borderline accept, verify". The raw detector score is kept
38
+ on each field as `raw_score`.
39
+
40
+ The correction loop (`render_preview` → `add/update/remove_field`) exists
41
+ precisely because ML detection has false positives and negatives. A false
42
+ positive costs nothing (leave it unfilled or remove it); a false negative is
43
+ visible on the preview and fixable with one `add_field` call. Once reviewed,
44
+ `save_template` makes every future fill of that form deterministic.
45
+
46
+ ## Setup
47
+
48
+ ```bash
49
+ uv tool install ./mcp-server # or: pip install ./mcp-server
50
+ ```
51
+
52
+ Authorize once (opens the browser, one click while logged in to justfill.app):
53
+
54
+ ```bash
55
+ justfill-mcp login
56
+ ```
57
+
58
+ Then the config needs no credentials at all:
59
+
60
+ ```json
61
+ {
62
+ "mcpServers": {
63
+ "justfill": { "command": "justfill-mcp" }
64
+ }
65
+ }
66
+ ```
67
+
68
+ Alternatives, in the order the server checks them:
69
+
70
+ 1. `JUSTFILL_API_KEY` env — create a key at justfill.app → Account → API Keys
71
+ and put `"env": {"JUSTFILL_API_KEY": "jf_live_…"}` in the config.
72
+ 2. The key saved by `justfill-mcp login` (`~/.config/justfill/credentials.json`).
73
+ 3. `JUSTFILL_EMAIL` + `JUSTFILL_PASSWORD` — legacy fallback; an API key is
74
+ better (no password in config files, revocable per client, never expires
75
+ mid-session).
76
+
77
+ ## Tools
78
+
79
+ - `open_pdf(path, min_confidence=0.0, max_pages=10, force_detect=False)` —
80
+ template → AcroForm → ML resolution order. Accepts scanned images too
81
+ (jpg/png/tiff → converted to PDF, deterministically, so templates still
82
+ match). `force_detect=True` ignores a saved template and re-runs ML.
83
+ - `render_preview(page_index)` — page image with labeled field boxes (blue = deterministic, green/orange/red = ML confidence)
84
+ - `render_filled_preview(values, page_index)` — the same page with your values
85
+ drawn in place (checkboxes get an X). Costs no fills — check before you fill.
86
+ - `list_fields(page_index?)`
87
+ - `add_field(x, y, w, h, name, page_index, field_type, align?, vertical_align?)` — coords in % of page, top-left origin
88
+ - `update_field(field_id, …)` / `remove_field(field_id)`
89
+ - `update_fields([{field_id, …}, …])` / `remove_fields([ids])` — batch versions
90
+ - `prune_fields(field_type?, confidence_below?, width_below?, height_below?, page_index?, exclude_ids?)` —
91
+ bulk-delete detection noise in one call (criteria AND-ed, removed ids returned)
92
+ - `fill_pdf(values, output_path, flatten=True)` — `values` = `{field_id: text}`;
93
+ responds with `warnings` for values that will be shrunk/truncated to fit
94
+ - `save_template(name)` — persist the reviewed layout for deterministic repeat fills
95
+ - `list_templates()`
96
+
97
+ Text alignment: `align` = `left|center|right`, `vertical_align` =
98
+ `top|middle|bottom` — set per field (e.g. `right` for RTL forms, `center` for
99
+ boxed digits). Persisted in templates.
100
+
101
+ ## Example agent flow
102
+
103
+ ```
104
+ open_pdf("~/forms/w-9.pdf") → acroform, 27 fields, confidence 1.0
105
+ fill_pdf({"f1": "Jane Doe", …}, "~/out/w-9-filled.pdf")
106
+ ```
107
+
108
+ ```
109
+ open_pdf("~/forms/scan.jpg") → converted to PDF; ml, 34 fields
110
+ render_preview(0) → agent sees noise + one missed line
111
+ prune_fields(field_type="cell", width_below=3) → 16 removed in one call
112
+ add_field(x=18, y=62.5, w=40, h=3, name="Phone")
113
+ render_filled_preview({…}) → values sit right, no overflow
114
+ fill_pdf({…}, "~/out/filled.pdf")
115
+ save_template("Client intake form") → next time: deterministic
116
+ ```
117
+
118
+ ## Notes
119
+
120
+ - Auth is a regular justfill.app account; tokens auto-refresh on expiry.
121
+ - Detection is free; downloads consume the account's fill allowance/credits
122
+ (same rules as the web app).
123
+ - One PDF open at a time per server session (by design — keeps ids stable).
@@ -0,0 +1,104 @@
1
+ # JustFill MCP Server
2
+
3
+ Let AI agents (Claude, ChatGPT, n8n — any MCP client) detect, review and fill
4
+ PDF form fields through [justfill.app](https://justfill.app).
5
+
6
+ ## Why agents can trust it
7
+
8
+ | Source | Confidence | What it means |
9
+ |---|---|---|
10
+ | **Saved template** | 1.0 | This exact PDF was filled before; geometry is human/agent-verified. No ML runs at all. |
11
+ | **AcroForm** | 1.0 | The PDF has embedded form fields — read from the file, filled natively. |
12
+ | **ML detection** | 0.0–0.95 | An honest draft. Review it visually (`render_preview`), fix it, then `save_template` to lock it in. |
13
+
14
+ ML confidence is *calibrated*: the detector's raw scores are not
15
+ probabilities (its server-side filter accepts boxes from raw ~0.02 and
16
+ auto-accepts at raw 0.15), so they are mapped onto 0–1 to mean what you'd
17
+ expect — ≥0.75 "detector is sure", 0.4–0.75 "probably right, glance at the
18
+ preview", <0.4 "borderline accept, verify". The raw detector score is kept
19
+ on each field as `raw_score`.
20
+
21
+ The correction loop (`render_preview` → `add/update/remove_field`) exists
22
+ precisely because ML detection has false positives and negatives. A false
23
+ positive costs nothing (leave it unfilled or remove it); a false negative is
24
+ visible on the preview and fixable with one `add_field` call. Once reviewed,
25
+ `save_template` makes every future fill of that form deterministic.
26
+
27
+ ## Setup
28
+
29
+ ```bash
30
+ uv tool install ./mcp-server # or: pip install ./mcp-server
31
+ ```
32
+
33
+ Authorize once (opens the browser, one click while logged in to justfill.app):
34
+
35
+ ```bash
36
+ justfill-mcp login
37
+ ```
38
+
39
+ Then the config needs no credentials at all:
40
+
41
+ ```json
42
+ {
43
+ "mcpServers": {
44
+ "justfill": { "command": "justfill-mcp" }
45
+ }
46
+ }
47
+ ```
48
+
49
+ Alternatives, in the order the server checks them:
50
+
51
+ 1. `JUSTFILL_API_KEY` env — create a key at justfill.app → Account → API Keys
52
+ and put `"env": {"JUSTFILL_API_KEY": "jf_live_…"}` in the config.
53
+ 2. The key saved by `justfill-mcp login` (`~/.config/justfill/credentials.json`).
54
+ 3. `JUSTFILL_EMAIL` + `JUSTFILL_PASSWORD` — legacy fallback; an API key is
55
+ better (no password in config files, revocable per client, never expires
56
+ mid-session).
57
+
58
+ ## Tools
59
+
60
+ - `open_pdf(path, min_confidence=0.0, max_pages=10, force_detect=False)` —
61
+ template → AcroForm → ML resolution order. Accepts scanned images too
62
+ (jpg/png/tiff → converted to PDF, deterministically, so templates still
63
+ match). `force_detect=True` ignores a saved template and re-runs ML.
64
+ - `render_preview(page_index)` — page image with labeled field boxes (blue = deterministic, green/orange/red = ML confidence)
65
+ - `render_filled_preview(values, page_index)` — the same page with your values
66
+ drawn in place (checkboxes get an X). Costs no fills — check before you fill.
67
+ - `list_fields(page_index?)`
68
+ - `add_field(x, y, w, h, name, page_index, field_type, align?, vertical_align?)` — coords in % of page, top-left origin
69
+ - `update_field(field_id, …)` / `remove_field(field_id)`
70
+ - `update_fields([{field_id, …}, …])` / `remove_fields([ids])` — batch versions
71
+ - `prune_fields(field_type?, confidence_below?, width_below?, height_below?, page_index?, exclude_ids?)` —
72
+ bulk-delete detection noise in one call (criteria AND-ed, removed ids returned)
73
+ - `fill_pdf(values, output_path, flatten=True)` — `values` = `{field_id: text}`;
74
+ responds with `warnings` for values that will be shrunk/truncated to fit
75
+ - `save_template(name)` — persist the reviewed layout for deterministic repeat fills
76
+ - `list_templates()`
77
+
78
+ Text alignment: `align` = `left|center|right`, `vertical_align` =
79
+ `top|middle|bottom` — set per field (e.g. `right` for RTL forms, `center` for
80
+ boxed digits). Persisted in templates.
81
+
82
+ ## Example agent flow
83
+
84
+ ```
85
+ open_pdf("~/forms/w-9.pdf") → acroform, 27 fields, confidence 1.0
86
+ fill_pdf({"f1": "Jane Doe", …}, "~/out/w-9-filled.pdf")
87
+ ```
88
+
89
+ ```
90
+ open_pdf("~/forms/scan.jpg") → converted to PDF; ml, 34 fields
91
+ render_preview(0) → agent sees noise + one missed line
92
+ prune_fields(field_type="cell", width_below=3) → 16 removed in one call
93
+ add_field(x=18, y=62.5, w=40, h=3, name="Phone")
94
+ render_filled_preview({…}) → values sit right, no overflow
95
+ fill_pdf({…}, "~/out/filled.pdf")
96
+ save_template("Client intake form") → next time: deterministic
97
+ ```
98
+
99
+ ## Notes
100
+
101
+ - Auth is a regular justfill.app account; tokens auto-refresh on expiry.
102
+ - Detection is free; downloads consume the account's fill allowance/credits
103
+ (same rules as the web app).
104
+ - One PDF open at a time per server session (by design — keeps ids stable).
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "justfill-mcp"
3
+ version = "0.4.0"
4
+ description = "JustFill MCP server — let AI agents detect, review and fill PDF form fields via justfill.app"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ keywords = ["mcp", "pdf", "forms", "form-filling", "ai-agents", "claude", "model-context-protocol"]
9
+ classifiers = [
10
+ "Development Status :: 4 - Beta",
11
+ "Intended Audience :: Developers",
12
+ "Programming Language :: Python :: 3",
13
+ "Topic :: Office/Business",
14
+ ]
15
+ dependencies = [
16
+ "mcp>=1.2.0",
17
+ "httpx>=0.24.0",
18
+ "pillow>=10.0.0",
19
+ "pypdf>=4.0.0",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://justfill.app"
24
+
25
+ [project.scripts]
26
+ justfill-mcp = "justfill_mcp.server:main"
27
+
28
+ [build-system]
29
+ requires = ["hatchling"]
30
+ build-backend = "hatchling.build"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/justfill_mcp"]
@@ -0,0 +1,3 @@
1
+ """JustFill MCP server package."""
2
+
3
+ __version__ = "0.4.0"
@@ -0,0 +1,181 @@
1
+ """HTTP client for the JustFill API.
2
+
3
+ Auth, preferred: a per-user API key (`JUSTFILL_API_KEY`, format `jf_live_…`,
4
+ created at justfill.app → Account → API keys) sent as `Authorization: Bearer`.
5
+ Keys don't expire, so no refresh logic is needed.
6
+
7
+ Auth, fallback (email+password): POST /api/auth/token sets an httpOnly
8
+ `access_token` cookie whose value is the JWT itself. We lift it out of the
9
+ cookie jar and send it as a Bearer header instead — cookie-less requests
10
+ bypass the CSRF origin check (which only guards cookie-authenticated
11
+ mutations) and work from any non-browser client. Tokens are short-lived
12
+ (~30 min), so any 401 triggers a single transparent re-login + retry.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from typing import Any
19
+
20
+ import httpx
21
+
22
+
23
+ class JustFillAuthError(RuntimeError):
24
+ pass
25
+
26
+
27
+ class JustFillApiError(RuntimeError):
28
+ def __init__(self, status: int, detail: str):
29
+ self.status = status
30
+ super().__init__(f"JustFill API error {status}: {detail}")
31
+
32
+
33
+ class JustFillClient:
34
+ def __init__(
35
+ self,
36
+ base_url: str | None = None,
37
+ email: str | None = None,
38
+ password: str | None = None,
39
+ api_key: str | None = None,
40
+ timeout: float = 180.0,
41
+ ):
42
+ self.base_url = (base_url or os.getenv("JUSTFILL_API_URL", "https://justfill.app")).rstrip("/")
43
+ self.email = email or os.getenv("JUSTFILL_EMAIL", "")
44
+ self.password = password or os.getenv("JUSTFILL_PASSWORD", "")
45
+ self.api_key = api_key or os.getenv("JUSTFILL_API_KEY", "")
46
+ if not self.api_key and not (self.email and self.password):
47
+ # `justfill-mcp login` stores a key in ~/.config/justfill/
48
+ from justfill_mcp.login import load_stored_api_key
49
+ self.api_key = load_stored_api_key() or ""
50
+ self._token: str | None = self.api_key or None
51
+ self._http = httpx.Client(timeout=timeout, follow_redirects=True)
52
+
53
+ # ---------- auth ----------
54
+
55
+ def _login(self) -> None:
56
+ if self.api_key:
57
+ # API keys don't expire — a 401 with one means it was revoked.
58
+ raise JustFillAuthError(
59
+ "JUSTFILL_API_KEY was rejected (revoked or invalid). "
60
+ "Create a new key at justfill.app."
61
+ )
62
+ if not self.email or not self.password:
63
+ raise JustFillAuthError(
64
+ "Not authorized. Run `justfill-mcp login` (opens the browser), "
65
+ "or set JUSTFILL_API_KEY, or JUSTFILL_EMAIL and JUSTFILL_PASSWORD."
66
+ )
67
+ resp = self._http.post(
68
+ f"{self.base_url}/api/auth/token",
69
+ data={"username": self.email, "password": self.password},
70
+ )
71
+ if resp.status_code != 200:
72
+ raise JustFillAuthError(f"Login failed ({resp.status_code}): {resp.text[:200]}")
73
+ token = resp.cookies.get("access_token") or self._http.cookies.get("access_token")
74
+ if not token:
75
+ raise JustFillAuthError("Login succeeded but no access_token cookie was returned.")
76
+ self._token = token
77
+ # Bearer-only from here on: cookies must NOT ride along or the CSRF
78
+ # origin guard will 403 mutating requests from non-browser clients.
79
+ self._http.cookies.clear()
80
+
81
+ def _request(self, method: str, path: str, *, _retried: bool = False, **kwargs) -> httpx.Response:
82
+ if self._token is None:
83
+ self._login()
84
+ headers = kwargs.pop("headers", {})
85
+ headers["Authorization"] = f"Bearer {self._token}"
86
+ resp = self._http.request(method, f"{self.base_url}{path}", headers=headers, **kwargs)
87
+ if resp.status_code == 401 and not _retried:
88
+ self._token = None
89
+ return self._request(method, path, _retried=True, headers=headers, **kwargs)
90
+ if resp.status_code >= 400:
91
+ raise JustFillApiError(resp.status_code, resp.text[:500])
92
+ return resp
93
+
94
+ # ---------- endpoints ----------
95
+
96
+ def render_page(self, pdf_bytes: bytes, page: int = 0, dpi: int = 150) -> dict[str, Any]:
97
+ """POST /api/pdfs/render -> {imageBase64, width, height, pageCount}"""
98
+ resp = self._request(
99
+ "POST",
100
+ f"/api/pdfs/render?page={page}&dpi={dpi}",
101
+ files={"file": ("document.pdf", pdf_bytes, "application/pdf")},
102
+ )
103
+ return resp.json()
104
+
105
+ def detect_fillable(self, pdf_bytes: bytes) -> dict[str, Any]:
106
+ """POST /api/analyze/detect-fillable -> {isFillable, fields, pageCount, warnings}"""
107
+ resp = self._request(
108
+ "POST",
109
+ "/api/analyze/detect-fillable",
110
+ files={"pdf_file": ("document.pdf", pdf_bytes, "application/pdf")},
111
+ )
112
+ return resp.json()
113
+
114
+ # 200 DPI matches the detector's training resolution — same default the web
115
+ # UI uses. Measured recall 0.944 @200 vs 0.917 @300 (test_dpi_recall.py);
116
+ # rendering above the training resolution hurts.
117
+ def detect_fields_batch(self, pdf_b64: str, pages: list[int], dpi: int = 200) -> dict[str, Any]:
118
+ """POST /api/detect-fields/batch -> {results: [{pageIndex, imageWidth, imageHeight, fields}], creditsCharged}"""
119
+ resp = self._request(
120
+ "POST",
121
+ "/api/detect-fields/batch",
122
+ json={"pdfBase64": pdf_b64, "pages": pages, "dpi": dpi},
123
+ )
124
+ return resp.json()
125
+
126
+ def calibrations_by_hash(self, content_hash: str, include_others: bool = True) -> list[dict[str, Any]]:
127
+ """GET /api/calibrations/by-hash/{hash} -> own calibrations + published templates."""
128
+ resp = self._request(
129
+ "GET",
130
+ f"/api/calibrations/by-hash/{content_hash}?include_others={'true' if include_others else 'false'}",
131
+ )
132
+ data = resp.json()
133
+ return data.get("items", data) if isinstance(data, dict) else data
134
+
135
+ def save_calibration(
136
+ self,
137
+ document_id: str,
138
+ content_hash: str,
139
+ name: str,
140
+ fields: list[dict[str, Any]],
141
+ pdf_bytes: bytes | None,
142
+ ) -> dict[str, Any]:
143
+ """PUT /api/calibrations/{document_id} (multipart upsert)."""
144
+ import json as _json
145
+
146
+ calibration = {
147
+ "documentId": document_id,
148
+ "contentHash": content_hash,
149
+ "documentName": name,
150
+ "displayName": name,
151
+ "fields": fields,
152
+ }
153
+ files: dict[str, Any] = {"calibration_data": (None, _json.dumps(calibration))}
154
+ if pdf_bytes is not None:
155
+ files["pdf_file"] = ("document.pdf", pdf_bytes, "application/pdf")
156
+ resp = self._request("PUT", f"/api/calibrations/{document_id}", files=files)
157
+ return resp.json()
158
+
159
+ def list_calibrations(self, limit: int = 50) -> dict[str, Any]:
160
+ resp = self._request("GET", f"/api/calibrations?limit={limit}")
161
+ return resp.json()
162
+
163
+ def generate_pdf(
164
+ self,
165
+ pdf_bytes: bytes,
166
+ fields_json: str,
167
+ flatten: bool = True,
168
+ ) -> tuple[bytes, str]:
169
+ """POST /api/generate/pdf -> (filled PDF bytes, output_mode).
170
+
171
+ output_mode mirrors the X-Output-Mode header: "clean", or
172
+ "watermarked" when the account's free fills are used up — the agent
173
+ must be told, or it silently delivers a watermarked document.
174
+ """
175
+ resp = self._request(
176
+ "POST",
177
+ "/api/generate/pdf",
178
+ files={"pdf_file": ("document.pdf", pdf_bytes, "application/pdf")},
179
+ data={"fields_json": fields_json, "flatten": "true" if flatten else "false"},
180
+ )
181
+ return resp.content, resp.headers.get("X-Output-Mode", "clean")
@@ -0,0 +1,105 @@
1
+ """`justfill-mcp login` — browser-based authorization (no password in config).
2
+
3
+ Starts a one-shot loopback HTTP listener, opens https://justfill.app/authorize
4
+ in the browser, the logged-in user clicks Authorize, and the freshly minted
5
+ API key arrives at http://127.0.0.1:PORT/callback?key=... The key is stored in
6
+ ~/.config/justfill/credentials.json (0600); the API client picks it up
7
+ automatically when JUSTFILL_API_KEY is not set.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import socket
15
+ import sys
16
+ import threading
17
+ import webbrowser
18
+ from http.server import BaseHTTPRequestHandler, HTTPServer
19
+ from pathlib import Path
20
+ from urllib.parse import parse_qs, quote, urlparse
21
+
22
+ LOGIN_TIMEOUT_S = 300
23
+
24
+ _DONE_HTML = """<!doctype html><meta charset="utf-8">
25
+ <title>JustFill — authorized</title>
26
+ <body style="font-family:system-ui;display:grid;place-items:center;height:90vh">
27
+ <div style="text-align:center">
28
+ <h2>&#9989; JustFill MCP is authorized</h2>
29
+ <p>You can close this tab and return to your terminal.</p>
30
+ </div></body>""".encode()
31
+
32
+
33
+ def credentials_path() -> Path:
34
+ base = os.getenv("XDG_CONFIG_HOME") or str(Path.home() / ".config")
35
+ return Path(base) / "justfill" / "credentials.json"
36
+
37
+
38
+ def load_stored_api_key() -> str | None:
39
+ """Read the API key saved by `justfill-mcp login`, if any."""
40
+ try:
41
+ data = json.loads(credentials_path().read_text())
42
+ return data.get("api_key") or None
43
+ except Exception:
44
+ return None
45
+
46
+
47
+ def _save(key: str, app_url: str) -> Path:
48
+ path = credentials_path()
49
+ path.parent.mkdir(parents=True, exist_ok=True)
50
+ path.write_text(json.dumps({"api_key": key, "app_url": app_url}, indent=1))
51
+ path.chmod(0o600)
52
+ return path
53
+
54
+
55
+ def main() -> int:
56
+ app_url = (os.getenv("JUSTFILL_APP_URL") or "https://justfill.app").rstrip("/")
57
+ result: dict = {}
58
+ ready = threading.Event()
59
+
60
+ class Handler(BaseHTTPRequestHandler):
61
+ def do_GET(self): # noqa: N802
62
+ parsed = urlparse(self.path)
63
+ if parsed.path != "/callback":
64
+ self.send_response(404); self.end_headers()
65
+ return
66
+ key = (parse_qs(parsed.query).get("key") or [""])[0]
67
+ if key.startswith("jf_"):
68
+ result["key"] = key
69
+ self.send_response(200)
70
+ self.send_header("Content-Type", "text/html; charset=utf-8")
71
+ self.end_headers()
72
+ self.wfile.write(_DONE_HTML)
73
+ ready.set()
74
+
75
+ def log_message(self, *args): # silence request logging
76
+ pass
77
+
78
+ # OS-assigned free loopback port
79
+ with socket.socket() as probe:
80
+ probe.bind(("127.0.0.1", 0))
81
+ port = probe.getsockname()[1]
82
+ server = HTTPServer(("127.0.0.1", port), Handler)
83
+ threading.Thread(target=server.serve_forever, daemon=True).start()
84
+
85
+ url = f"{app_url}/authorize?port={port}&name={quote('MCP on ' + (os.uname().nodename if hasattr(os, 'uname') else 'this computer'))}"
86
+ print("Opening your browser to authorize JustFill MCP…")
87
+ print(f"If it does not open, visit:\n {url}\n")
88
+ webbrowser.open(url)
89
+
90
+ if not ready.wait(timeout=LOGIN_TIMEOUT_S) or "key" not in result:
91
+ server.shutdown()
92
+ print("Authorization timed out or no key was received.", file=sys.stderr)
93
+ print("You can also create a key manually at "
94
+ f"{app_url}/account and set JUSTFILL_API_KEY.", file=sys.stderr)
95
+ return 1
96
+ server.shutdown()
97
+
98
+ path = _save(result["key"], app_url)
99
+ print(f"Authorized. API key saved to {path}")
100
+ print("justfill-mcp will use it automatically — no env vars needed.")
101
+ return 0
102
+
103
+
104
+ if __name__ == "__main__":
105
+ sys.exit(main())