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.
- justfill_mcp-0.4.0/.gitignore +3 -0
- justfill_mcp-0.4.0/LICENSE +21 -0
- justfill_mcp-0.4.0/PKG-INFO +123 -0
- justfill_mcp-0.4.0/README.md +104 -0
- justfill_mcp-0.4.0/pyproject.toml +33 -0
- justfill_mcp-0.4.0/src/justfill_mcp/__init__.py +3 -0
- justfill_mcp-0.4.0/src/justfill_mcp/api_client.py +181 -0
- justfill_mcp-0.4.0/src/justfill_mcp/login.py +105 -0
- justfill_mcp-0.4.0/src/justfill_mcp/server.py +497 -0
- justfill_mcp-0.4.0/src/justfill_mcp/workspace.py +449 -0
- justfill_mcp-0.4.0/tests/test_server.py +188 -0
- justfill_mcp-0.4.0/tests/test_workspace.py +219 -0
- justfill_mcp-0.4.0/uv.lock +1002 -0
|
@@ -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,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>✅ 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())
|