docauto-mcp 0.2.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.
- docauto_mcp-0.2.0/.gitignore +66 -0
- docauto_mcp-0.2.0/Dockerfile +24 -0
- docauto_mcp-0.2.0/PKG-INFO +161 -0
- docauto_mcp-0.2.0/README.md +133 -0
- docauto_mcp-0.2.0/docauto_mcp/__init__.py +10 -0
- docauto_mcp-0.2.0/docauto_mcp/auth.py +288 -0
- docauto_mcp-0.2.0/docauto_mcp/client.py +351 -0
- docauto_mcp-0.2.0/docauto_mcp/config.py +103 -0
- docauto_mcp-0.2.0/docauto_mcp/errors.py +25 -0
- docauto_mcp-0.2.0/docauto_mcp/install.py +162 -0
- docauto_mcp-0.2.0/docauto_mcp/server.py +273 -0
- docauto_mcp-0.2.0/docauto_mcp/server_http.py +184 -0
- docauto_mcp-0.2.0/docauto_mcp/token_verifier.py +72 -0
- docauto_mcp-0.2.0/docauto_mcp/tools.py +374 -0
- docauto_mcp-0.2.0/pyproject.toml +54 -0
- docauto_mcp-0.2.0/tests/conftest.py +8 -0
- docauto_mcp-0.2.0/tests/test_auth.py +328 -0
- docauto_mcp-0.2.0/tests/test_client.py +272 -0
- docauto_mcp-0.2.0/tests/test_install.py +102 -0
- docauto_mcp-0.2.0/tests/test_server.py +28 -0
- docauto_mcp-0.2.0/tests/test_server_http.py +64 -0
- docauto_mcp-0.2.0/tests/test_token_verifier.py +84 -0
- docauto_mcp-0.2.0/tests/test_tools.py +250 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Kubernetes secrets e valores Helm com credenciais — NUNCA commitar
|
|
2
|
+
k8s/secrets.yaml
|
|
3
|
+
k8s/keycloak/secrets.yaml
|
|
4
|
+
k8s/prometheus-values.yaml
|
|
5
|
+
|
|
6
|
+
# Python
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.py[cod]
|
|
9
|
+
*.egg-info/
|
|
10
|
+
.venv/
|
|
11
|
+
dist/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
htmlcov/
|
|
16
|
+
.coverage
|
|
17
|
+
|
|
18
|
+
# Env
|
|
19
|
+
.env
|
|
20
|
+
.env.local
|
|
21
|
+
|
|
22
|
+
# Node
|
|
23
|
+
node_modules/
|
|
24
|
+
.next/
|
|
25
|
+
out/
|
|
26
|
+
|
|
27
|
+
# OS
|
|
28
|
+
.DS_Store
|
|
29
|
+
Thumbs.db
|
|
30
|
+
|
|
31
|
+
# Docker volumes
|
|
32
|
+
postgres_data/
|
|
33
|
+
minio_data/
|
|
34
|
+
|
|
35
|
+
# Use-case test outputs (keep script, template, csv and results.md)
|
|
36
|
+
use-case-tests/output/
|
|
37
|
+
|
|
38
|
+
# References mvp_docauto.md and guide_claude_code_docauto.pdf
|
|
39
|
+
references/
|
|
40
|
+
|
|
41
|
+
#Visual Studio Code
|
|
42
|
+
.vscode/
|
|
43
|
+
|
|
44
|
+
#Claude Settings
|
|
45
|
+
.claude/settings.local.json
|
|
46
|
+
|
|
47
|
+
# AI agent local tooling configs (not project instructions)
|
|
48
|
+
.playwright-mcp/
|
|
49
|
+
.mcp.json
|
|
50
|
+
|
|
51
|
+
# Claude Code skill run artifacts (ephemeral results — not project state)
|
|
52
|
+
.claude/skills/**/*_RESULTS.md
|
|
53
|
+
.claude/skills/**/RESULTS/
|
|
54
|
+
|
|
55
|
+
# Frontend test artifacts
|
|
56
|
+
frontend/test-results/
|
|
57
|
+
frontend/playwright-report/
|
|
58
|
+
|
|
59
|
+
# Git worktrees
|
|
60
|
+
.worktrees/
|
|
61
|
+
|
|
62
|
+
# Secrets / environment mapping — never commit
|
|
63
|
+
ENVIRONMENTS.md
|
|
64
|
+
|
|
65
|
+
# Local planning and roadmap notes — never commit
|
|
66
|
+
FEATURES.md
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Hosted DocAuto MCP server (Streamable HTTP). Built for linux/arm64 (Oracle ARM).
|
|
2
|
+
# Build context is this directory (mcp/).
|
|
3
|
+
FROM python:3.13-slim
|
|
4
|
+
|
|
5
|
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
6
|
+
PYTHONUNBUFFERED=1
|
|
7
|
+
|
|
8
|
+
WORKDIR /app
|
|
9
|
+
|
|
10
|
+
# Install the package (and its deps: mcp, httpx, uvicorn) from its own metadata.
|
|
11
|
+
COPY pyproject.toml README.md ./
|
|
12
|
+
COPY docauto_mcp/ docauto_mcp/
|
|
13
|
+
RUN pip install --no-cache-dir .
|
|
14
|
+
|
|
15
|
+
RUN adduser --disabled-password --gecos "" appuser
|
|
16
|
+
USER appuser
|
|
17
|
+
|
|
18
|
+
EXPOSE 8000
|
|
19
|
+
|
|
20
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
21
|
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')" || exit 1
|
|
22
|
+
|
|
23
|
+
CMD ["uvicorn", "docauto_mcp.server_http:http_app", "--factory", \
|
|
24
|
+
"--host", "0.0.0.0", "--port", "8000"]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: docauto-mcp
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Model Context Protocol server for the DocAuto document-generation API.
|
|
5
|
+
Project-URL: Homepage, https://docauto.com.br
|
|
6
|
+
Project-URL: Documentation, https://docauto.com.br/docs/mcp
|
|
7
|
+
Project-URL: Repository, https://github.com/gzucob/docauto
|
|
8
|
+
Author: DocAuto
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: ai,docauto,documents,mcp,model-context-protocol
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Office/Business
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Requires-Dist: mcp<2,>=1.12
|
|
23
|
+
Requires-Dist: uvicorn>=0.30
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# DocAuto MCP server
|
|
30
|
+
|
|
31
|
+
A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes
|
|
32
|
+
the DocAuto document-generation workflow to AI assistants. It is a thin client
|
|
33
|
+
over the public `/api/v1` surface — it holds no secrets and enforces no
|
|
34
|
+
business logic; multi-tenancy and validation live in the backend.
|
|
35
|
+
|
|
36
|
+
- **Transport:** stdio (local) and a hosted Streamable-HTTP server at
|
|
37
|
+
`https://mcp.docauto.com.br/mcp` (add it as a remote connector — no install).
|
|
38
|
+
- **Auth (local):** OAuth 2.0 Device Authorization Grant against the Keycloak
|
|
39
|
+
`docauto-cli` public client. You sign in once in your browser; tokens are
|
|
40
|
+
cached at `~/.docauto/mcp-tokens.json` (0600) and refreshed automatically.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
The server is a standalone Python package (it is **not** part of the backend).
|
|
45
|
+
Requires Python ≥ 3.11.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# run on demand, no install:
|
|
49
|
+
uvx docauto-mcp
|
|
50
|
+
# or install it:
|
|
51
|
+
pipx install docauto-mcp
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
From a checkout (dev): `pip install ./mcp`.
|
|
55
|
+
|
|
56
|
+
## Configure your MCP client
|
|
57
|
+
|
|
58
|
+
### Claude Desktop — automatic
|
|
59
|
+
|
|
60
|
+
The package ships a setup helper that writes `claude_desktop_config.json` for
|
|
61
|
+
you (handling the Windows Store/MSIX config-path quirk):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
docauto-mcp-install # register the installed `docauto-mcp` command
|
|
65
|
+
docauto-mcp-install --command uvx # zero-install: run via `uvx docauto-mcp`
|
|
66
|
+
docauto-mcp-install --print # print the JSON block instead of writing it
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Claude Desktop — manual
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"mcpServers": {
|
|
74
|
+
"docauto": {
|
|
75
|
+
"command": "docauto-mcp"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
If `docauto-mcp` is not on your PATH, use the `uvx` form
|
|
82
|
+
(`"command": "uvx", "args": ["docauto-mcp"]`) or point `command` at the script
|
|
83
|
+
inside your virtualenv.
|
|
84
|
+
|
|
85
|
+
### Claude Code (CLI)
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
claude mcp add docauto -- uvx docauto-mcp
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Cursor / VS Code / other clients
|
|
92
|
+
|
|
93
|
+
These accept a remote MCP server by URL — see the **hosted** server below
|
|
94
|
+
(`https://mcp.docauto.com.br/mcp`), which needs no local install.
|
|
95
|
+
|
|
96
|
+
### Environment overrides (optional)
|
|
97
|
+
|
|
98
|
+
Defaults target production; override only for local dev:
|
|
99
|
+
|
|
100
|
+
| Variable | Default |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `DOCAUTO_MCP_ISSUER` | `https://auth.docauto.com.br/realms/docauto` |
|
|
103
|
+
| `DOCAUTO_MCP_API_BASE` | `https://api.docauto.com.br` (server-to-server; set to the internal service in-cluster) |
|
|
104
|
+
| `DOCAUTO_MCP_PUBLIC_API_BASE` | `https://api.docauto.com.br` (public base for user-facing links, e.g. signed download URLs) |
|
|
105
|
+
| `DOCAUTO_MCP_PUBLIC_APP_BASE` | `https://docauto.com.br` (public frontend base for the upload page link) |
|
|
106
|
+
| `DOCAUTO_MCP_CLIENT_ID` | `docauto-cli` |
|
|
107
|
+
| `DOCAUTO_MCP_HOME` | `~/.docauto` (token cache location) |
|
|
108
|
+
|
|
109
|
+
## Logging in
|
|
110
|
+
|
|
111
|
+
You sign in with your normal DocAuto account (the MCP server does not create a
|
|
112
|
+
different kind of account):
|
|
113
|
+
|
|
114
|
+
1. Call **`login_start`** — it returns a verification URL and a short code.
|
|
115
|
+
2. Open the URL, sign in (email/password or Google), and approve.
|
|
116
|
+
3. Call **`login_finish`** — it completes the login and caches your tokens.
|
|
117
|
+
|
|
118
|
+
After that the assistant can use the tools below; tokens refresh silently until
|
|
119
|
+
the session expires.
|
|
120
|
+
|
|
121
|
+
## Prompts
|
|
122
|
+
|
|
123
|
+
- **`generate_documents`** (argument: `output_format` = `pdf` | `docx`) — a
|
|
124
|
+
guided template that drives the whole batch-generation flow. Available in any
|
|
125
|
+
client that surfaces MCP prompts, over both transports.
|
|
126
|
+
|
|
127
|
+
## Tools
|
|
128
|
+
|
|
129
|
+
- **Session:** `login_start`, `login_finish`, `logout`, `whoami`, `doctor`,
|
|
130
|
+
`workflow_guide` — `doctor` reports the version, the configured endpoints, API
|
|
131
|
+
reachability, and sign-in state (no secrets)
|
|
132
|
+
- **Templates:** `list_templates`, `get_template`, `upload_template`, `delete_template`
|
|
133
|
+
- **Datasets:** `list_datasets`, `get_dataset`, `preview_dataset`, `upload_dataset`, `delete_dataset`
|
|
134
|
+
- **Generation:** `validate_mapping`, `create_generation_job`, `get_job`, `list_jobs`, `cancel_job`, `delete_job`, `list_job_documents`
|
|
135
|
+
- **Downloads:** `download_document`, `download_job_zip` — over stdio these save
|
|
136
|
+
to the user's local Downloads folder; over the hosted HTTP transport
|
|
137
|
+
`download_job_zip` returns a short-lived signed URL the user opens in a browser
|
|
138
|
+
(the ZIP is never streamed through the model, so there is no size cap)
|
|
139
|
+
- **Uploads (hosted HTTP only):** over stdio `upload_template`/`upload_dataset`
|
|
140
|
+
read a local path; over the hosted transport they take only a `filename` and
|
|
141
|
+
return an `upload_url` the user opens to send the file (the bytes never pass
|
|
142
|
+
through the model), plus an `upload_ref` to poll with `check_upload` until the
|
|
143
|
+
result is `ready`
|
|
144
|
+
|
|
145
|
+
## Typical flow
|
|
146
|
+
|
|
147
|
+
`login_start` → `login_finish` → `whoami` → `upload_template(path)` →
|
|
148
|
+
`upload_dataset(path)` → `validate_mapping(...)` →
|
|
149
|
+
`create_generation_job(...)` → poll `get_job(job_id)` →
|
|
150
|
+
`download_job_zip(job_id, dest)`.
|
|
151
|
+
|
|
152
|
+
The `field_mapping` maps each template variable to a dataset column, e.g.
|
|
153
|
+
`{"nome_cliente": "nome_cliente", "cpf": "cpf"}`. `output_format` is `pdf`
|
|
154
|
+
(default) or `docx`.
|
|
155
|
+
|
|
156
|
+
## Development
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
# unit tests (no MCP SDK needed — auth/client/tools are isolated)
|
|
160
|
+
python -m pytest tests
|
|
161
|
+
```
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# DocAuto MCP server
|
|
2
|
+
|
|
3
|
+
A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes
|
|
4
|
+
the DocAuto document-generation workflow to AI assistants. It is a thin client
|
|
5
|
+
over the public `/api/v1` surface — it holds no secrets and enforces no
|
|
6
|
+
business logic; multi-tenancy and validation live in the backend.
|
|
7
|
+
|
|
8
|
+
- **Transport:** stdio (local) and a hosted Streamable-HTTP server at
|
|
9
|
+
`https://mcp.docauto.com.br/mcp` (add it as a remote connector — no install).
|
|
10
|
+
- **Auth (local):** OAuth 2.0 Device Authorization Grant against the Keycloak
|
|
11
|
+
`docauto-cli` public client. You sign in once in your browser; tokens are
|
|
12
|
+
cached at `~/.docauto/mcp-tokens.json` (0600) and refreshed automatically.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
The server is a standalone Python package (it is **not** part of the backend).
|
|
17
|
+
Requires Python ≥ 3.11.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# run on demand, no install:
|
|
21
|
+
uvx docauto-mcp
|
|
22
|
+
# or install it:
|
|
23
|
+
pipx install docauto-mcp
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
From a checkout (dev): `pip install ./mcp`.
|
|
27
|
+
|
|
28
|
+
## Configure your MCP client
|
|
29
|
+
|
|
30
|
+
### Claude Desktop — automatic
|
|
31
|
+
|
|
32
|
+
The package ships a setup helper that writes `claude_desktop_config.json` for
|
|
33
|
+
you (handling the Windows Store/MSIX config-path quirk):
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
docauto-mcp-install # register the installed `docauto-mcp` command
|
|
37
|
+
docauto-mcp-install --command uvx # zero-install: run via `uvx docauto-mcp`
|
|
38
|
+
docauto-mcp-install --print # print the JSON block instead of writing it
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Claude Desktop — manual
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"docauto": {
|
|
47
|
+
"command": "docauto-mcp"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
If `docauto-mcp` is not on your PATH, use the `uvx` form
|
|
54
|
+
(`"command": "uvx", "args": ["docauto-mcp"]`) or point `command` at the script
|
|
55
|
+
inside your virtualenv.
|
|
56
|
+
|
|
57
|
+
### Claude Code (CLI)
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
claude mcp add docauto -- uvx docauto-mcp
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Cursor / VS Code / other clients
|
|
64
|
+
|
|
65
|
+
These accept a remote MCP server by URL — see the **hosted** server below
|
|
66
|
+
(`https://mcp.docauto.com.br/mcp`), which needs no local install.
|
|
67
|
+
|
|
68
|
+
### Environment overrides (optional)
|
|
69
|
+
|
|
70
|
+
Defaults target production; override only for local dev:
|
|
71
|
+
|
|
72
|
+
| Variable | Default |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `DOCAUTO_MCP_ISSUER` | `https://auth.docauto.com.br/realms/docauto` |
|
|
75
|
+
| `DOCAUTO_MCP_API_BASE` | `https://api.docauto.com.br` (server-to-server; set to the internal service in-cluster) |
|
|
76
|
+
| `DOCAUTO_MCP_PUBLIC_API_BASE` | `https://api.docauto.com.br` (public base for user-facing links, e.g. signed download URLs) |
|
|
77
|
+
| `DOCAUTO_MCP_PUBLIC_APP_BASE` | `https://docauto.com.br` (public frontend base for the upload page link) |
|
|
78
|
+
| `DOCAUTO_MCP_CLIENT_ID` | `docauto-cli` |
|
|
79
|
+
| `DOCAUTO_MCP_HOME` | `~/.docauto` (token cache location) |
|
|
80
|
+
|
|
81
|
+
## Logging in
|
|
82
|
+
|
|
83
|
+
You sign in with your normal DocAuto account (the MCP server does not create a
|
|
84
|
+
different kind of account):
|
|
85
|
+
|
|
86
|
+
1. Call **`login_start`** — it returns a verification URL and a short code.
|
|
87
|
+
2. Open the URL, sign in (email/password or Google), and approve.
|
|
88
|
+
3. Call **`login_finish`** — it completes the login and caches your tokens.
|
|
89
|
+
|
|
90
|
+
After that the assistant can use the tools below; tokens refresh silently until
|
|
91
|
+
the session expires.
|
|
92
|
+
|
|
93
|
+
## Prompts
|
|
94
|
+
|
|
95
|
+
- **`generate_documents`** (argument: `output_format` = `pdf` | `docx`) — a
|
|
96
|
+
guided template that drives the whole batch-generation flow. Available in any
|
|
97
|
+
client that surfaces MCP prompts, over both transports.
|
|
98
|
+
|
|
99
|
+
## Tools
|
|
100
|
+
|
|
101
|
+
- **Session:** `login_start`, `login_finish`, `logout`, `whoami`, `doctor`,
|
|
102
|
+
`workflow_guide` — `doctor` reports the version, the configured endpoints, API
|
|
103
|
+
reachability, and sign-in state (no secrets)
|
|
104
|
+
- **Templates:** `list_templates`, `get_template`, `upload_template`, `delete_template`
|
|
105
|
+
- **Datasets:** `list_datasets`, `get_dataset`, `preview_dataset`, `upload_dataset`, `delete_dataset`
|
|
106
|
+
- **Generation:** `validate_mapping`, `create_generation_job`, `get_job`, `list_jobs`, `cancel_job`, `delete_job`, `list_job_documents`
|
|
107
|
+
- **Downloads:** `download_document`, `download_job_zip` — over stdio these save
|
|
108
|
+
to the user's local Downloads folder; over the hosted HTTP transport
|
|
109
|
+
`download_job_zip` returns a short-lived signed URL the user opens in a browser
|
|
110
|
+
(the ZIP is never streamed through the model, so there is no size cap)
|
|
111
|
+
- **Uploads (hosted HTTP only):** over stdio `upload_template`/`upload_dataset`
|
|
112
|
+
read a local path; over the hosted transport they take only a `filename` and
|
|
113
|
+
return an `upload_url` the user opens to send the file (the bytes never pass
|
|
114
|
+
through the model), plus an `upload_ref` to poll with `check_upload` until the
|
|
115
|
+
result is `ready`
|
|
116
|
+
|
|
117
|
+
## Typical flow
|
|
118
|
+
|
|
119
|
+
`login_start` → `login_finish` → `whoami` → `upload_template(path)` →
|
|
120
|
+
`upload_dataset(path)` → `validate_mapping(...)` →
|
|
121
|
+
`create_generation_job(...)` → poll `get_job(job_id)` →
|
|
122
|
+
`download_job_zip(job_id, dest)`.
|
|
123
|
+
|
|
124
|
+
The `field_mapping` maps each template variable to a dataset column, e.g.
|
|
125
|
+
`{"nome_cliente": "nome_cliente", "cpf": "cpf"}`. `output_format` is `pdf`
|
|
126
|
+
(default) or `docx`.
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
# unit tests (no MCP SDK needed — auth/client/tools are isolated)
|
|
132
|
+
python -m pytest tests
|
|
133
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""DocAuto MCP server — a thin Model Context Protocol server over the DocAuto API.
|
|
2
|
+
|
|
3
|
+
The package is a standalone deployable (not part of the FastAPI monolith): it
|
|
4
|
+
talks to the public ``/api/v1`` surface over HTTP and authenticates as the user
|
|
5
|
+
via the Keycloak ``docauto-cli`` public client (OAuth 2.0 Device Authorization
|
|
6
|
+
Grant). Multi-tenancy is enforced server-side, so the MCP server holds no
|
|
7
|
+
secrets and never sees another tenant's data.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""OAuth 2.0 Device Authorization Grant for the DocAuto MCP server.
|
|
2
|
+
|
|
3
|
+
The MCP server authenticates the user as the Keycloak ``docauto-cli`` public
|
|
4
|
+
client using the device flow (RFC 8628): no redirect URI, no local web server —
|
|
5
|
+
the user opens a verification URL, approves, and the server polls for the token.
|
|
6
|
+
Tokens are cached on disk and silently refreshed; only the first login (or an
|
|
7
|
+
expired refresh token) requires the interactive step.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import secrets
|
|
17
|
+
import stat
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from .config import Settings
|
|
27
|
+
from .errors import AuthError, NotAuthenticatedError
|
|
28
|
+
|
|
29
|
+
_EXPIRY_SKEW_SECONDS = 30
|
|
30
|
+
_DEFAULT_INTERVAL_SECONDS = 5
|
|
31
|
+
_DEFAULT_DEVICE_TIMEOUT_SECONDS = 600
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _generate_pkce_pair() -> tuple[str, str]:
|
|
35
|
+
"""Return an (S256) ``(code_verifier, code_challenge)`` PKCE pair.
|
|
36
|
+
|
|
37
|
+
The ``docauto-cli`` client enforces PKCE, and Keycloak applies it to the
|
|
38
|
+
device flow too: the device authorization request must carry the challenge
|
|
39
|
+
and the token poll must carry the verifier.
|
|
40
|
+
"""
|
|
41
|
+
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
|
|
42
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
43
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
|
44
|
+
return verifier, challenge
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class TokenSet:
|
|
49
|
+
"""A cached set of OAuth tokens."""
|
|
50
|
+
|
|
51
|
+
access_token: str
|
|
52
|
+
refresh_token: str | None
|
|
53
|
+
expires_at: float
|
|
54
|
+
|
|
55
|
+
def is_expired(self, now: float) -> bool:
|
|
56
|
+
"""Return whether the access token is expired (with a safety skew)."""
|
|
57
|
+
return now >= self.expires_at - _EXPIRY_SKEW_SECONDS
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TokenStore:
|
|
61
|
+
"""Persists a :class:`TokenSet` to a 0600 file on disk."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, path: Path) -> None:
|
|
64
|
+
"""Store the on-disk path for the token cache."""
|
|
65
|
+
self._path = path
|
|
66
|
+
|
|
67
|
+
def load(self) -> TokenSet | None:
|
|
68
|
+
"""Return the cached token set, or ``None`` if absent or unreadable."""
|
|
69
|
+
try:
|
|
70
|
+
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
71
|
+
except (OSError, ValueError):
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
return TokenSet(
|
|
75
|
+
access_token=str(data["access_token"]),
|
|
76
|
+
refresh_token=(
|
|
77
|
+
str(data["refresh_token"]) if data.get("refresh_token") else None
|
|
78
|
+
),
|
|
79
|
+
expires_at=float(data["expires_at"]),
|
|
80
|
+
)
|
|
81
|
+
except (KeyError, TypeError, ValueError):
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
def save(self, tokens: TokenSet) -> None:
|
|
85
|
+
"""Write the token set to disk with owner-only permissions."""
|
|
86
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
self._path.write_text(
|
|
88
|
+
json.dumps(
|
|
89
|
+
{
|
|
90
|
+
"access_token": tokens.access_token,
|
|
91
|
+
"refresh_token": tokens.refresh_token,
|
|
92
|
+
"expires_at": tokens.expires_at,
|
|
93
|
+
}
|
|
94
|
+
),
|
|
95
|
+
encoding="utf-8",
|
|
96
|
+
)
|
|
97
|
+
try:
|
|
98
|
+
os.chmod(self._path, stat.S_IRUSR | stat.S_IWUSR)
|
|
99
|
+
except OSError:
|
|
100
|
+
# Best-effort on platforms without POSIX permissions (e.g. Windows).
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
def clear(self) -> None:
|
|
104
|
+
"""Delete the cached tokens, if any."""
|
|
105
|
+
try:
|
|
106
|
+
self._path.unlink()
|
|
107
|
+
except FileNotFoundError:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class DeviceFlowAuthenticator:
|
|
112
|
+
"""Obtains and refreshes access tokens via the OAuth device flow."""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
settings: Settings,
|
|
117
|
+
store: TokenStore,
|
|
118
|
+
*,
|
|
119
|
+
http: httpx.Client | None = None,
|
|
120
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
121
|
+
now: Callable[[], float] = time.time,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Wire the authenticator to its settings, token store, and clock."""
|
|
124
|
+
self._settings = settings
|
|
125
|
+
self._store = store
|
|
126
|
+
self._http = http or httpx.Client(timeout=30.0)
|
|
127
|
+
self._sleep = sleep
|
|
128
|
+
self._now = now
|
|
129
|
+
self._pending: dict[str, Any] | None = None
|
|
130
|
+
|
|
131
|
+
# -- non-interactive ----------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def get_access_token(self) -> str:
|
|
134
|
+
"""Return a valid access token without prompting.
|
|
135
|
+
|
|
136
|
+
Uses the cached token, transparently refreshing with the refresh token
|
|
137
|
+
when expired.
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
NotAuthenticatedError: If there is no usable token — the caller
|
|
141
|
+
should ask the user to run the login tool.
|
|
142
|
+
"""
|
|
143
|
+
tokens = self._store.load()
|
|
144
|
+
if tokens and not tokens.is_expired(self._now()):
|
|
145
|
+
return tokens.access_token
|
|
146
|
+
if tokens and tokens.refresh_token:
|
|
147
|
+
refreshed = self._refresh(tokens.refresh_token)
|
|
148
|
+
if refreshed is not None:
|
|
149
|
+
self._store.save(refreshed)
|
|
150
|
+
return refreshed.access_token
|
|
151
|
+
raise NotAuthenticatedError(
|
|
152
|
+
"Not authenticated. Run the 'login_start' tool, approve in the "
|
|
153
|
+
"browser, then run 'login_finish'."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def is_authenticated(self) -> bool:
|
|
157
|
+
"""Return whether a valid (or refreshable) token is available."""
|
|
158
|
+
try:
|
|
159
|
+
self.get_access_token()
|
|
160
|
+
except NotAuthenticatedError:
|
|
161
|
+
return False
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
def logout(self) -> None:
|
|
165
|
+
"""Clear cached tokens and any pending device authorization."""
|
|
166
|
+
self._pending = None
|
|
167
|
+
self._store.clear()
|
|
168
|
+
|
|
169
|
+
# -- interactive device flow -------------------------------------------
|
|
170
|
+
|
|
171
|
+
def start_device_flow(self) -> dict[str, Any]:
|
|
172
|
+
"""Begin device authorization and return the user-facing instructions.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
A mapping with ``verification_uri``, ``verification_uri_complete``
|
|
176
|
+
(when provided), ``user_code``, and ``expires_in``.
|
|
177
|
+
"""
|
|
178
|
+
verifier, challenge = _generate_pkce_pair()
|
|
179
|
+
resp = self._http.post(
|
|
180
|
+
self._settings.device_auth_url,
|
|
181
|
+
data={
|
|
182
|
+
"client_id": self._settings.client_id,
|
|
183
|
+
"scope": self._settings.scope,
|
|
184
|
+
"code_challenge": challenge,
|
|
185
|
+
"code_challenge_method": "S256",
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
if resp.status_code != 200:
|
|
189
|
+
raise AuthError(
|
|
190
|
+
f"Device authorization request failed ({resp.status_code})."
|
|
191
|
+
)
|
|
192
|
+
data = resp.json()
|
|
193
|
+
interval = int(data.get("interval", _DEFAULT_INTERVAL_SECONDS))
|
|
194
|
+
expires_in = int(data.get("expires_in", _DEFAULT_DEVICE_TIMEOUT_SECONDS))
|
|
195
|
+
self._pending = {
|
|
196
|
+
"device_code": data["device_code"],
|
|
197
|
+
"interval": interval,
|
|
198
|
+
"deadline": self._now() + expires_in,
|
|
199
|
+
"code_verifier": verifier,
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
"verification_uri": data.get("verification_uri"),
|
|
203
|
+
"verification_uri_complete": data.get("verification_uri_complete"),
|
|
204
|
+
"user_code": data.get("user_code"),
|
|
205
|
+
"expires_in": expires_in,
|
|
206
|
+
"message": (
|
|
207
|
+
"Open the verification URL, sign in, and approve. Then run "
|
|
208
|
+
"'login_finish' to complete."
|
|
209
|
+
),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
def finish_device_flow(self) -> str:
|
|
213
|
+
"""Poll the token endpoint until the user approves (or it times out).
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
A short success message.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
AuthError: If no device flow is pending, or it is denied/expired.
|
|
220
|
+
"""
|
|
221
|
+
if self._pending is None:
|
|
222
|
+
raise AuthError("No login in progress. Run 'login_start' first.")
|
|
223
|
+
device_code = str(self._pending["device_code"])
|
|
224
|
+
interval = int(self._pending["interval"])
|
|
225
|
+
deadline = float(self._pending["deadline"])
|
|
226
|
+
code_verifier = str(self._pending["code_verifier"])
|
|
227
|
+
|
|
228
|
+
while self._now() < deadline:
|
|
229
|
+
self._sleep(interval)
|
|
230
|
+
resp = self._http.post(
|
|
231
|
+
self._settings.token_url,
|
|
232
|
+
data={
|
|
233
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
234
|
+
"client_id": self._settings.client_id,
|
|
235
|
+
"device_code": device_code,
|
|
236
|
+
"code_verifier": code_verifier,
|
|
237
|
+
},
|
|
238
|
+
)
|
|
239
|
+
if resp.status_code == 200:
|
|
240
|
+
self._store.save(self._token_set_from_response(resp.json()))
|
|
241
|
+
self._pending = None
|
|
242
|
+
return "Login successful."
|
|
243
|
+
error = self._error_code(resp)
|
|
244
|
+
if error == "authorization_pending":
|
|
245
|
+
continue
|
|
246
|
+
if error == "slow_down":
|
|
247
|
+
interval += 5
|
|
248
|
+
continue
|
|
249
|
+
self._pending = None
|
|
250
|
+
raise AuthError(f"Device authorization failed: {error}.")
|
|
251
|
+
|
|
252
|
+
self._pending = None
|
|
253
|
+
raise AuthError("Device authorization timed out before approval.")
|
|
254
|
+
|
|
255
|
+
# -- internals ----------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
def _refresh(self, refresh_token: str) -> TokenSet | None:
|
|
258
|
+
"""Exchange a refresh token for a new token set, or ``None`` on failure."""
|
|
259
|
+
resp = self._http.post(
|
|
260
|
+
self._settings.token_url,
|
|
261
|
+
data={
|
|
262
|
+
"grant_type": "refresh_token",
|
|
263
|
+
"client_id": self._settings.client_id,
|
|
264
|
+
"refresh_token": refresh_token,
|
|
265
|
+
},
|
|
266
|
+
)
|
|
267
|
+
if resp.status_code != 200:
|
|
268
|
+
return None
|
|
269
|
+
return self._token_set_from_response(resp.json())
|
|
270
|
+
|
|
271
|
+
def _token_set_from_response(self, data: dict[str, Any]) -> TokenSet:
|
|
272
|
+
"""Build a :class:`TokenSet` from a token-endpoint JSON response."""
|
|
273
|
+
expires_in = float(data.get("expires_in", 300))
|
|
274
|
+
return TokenSet(
|
|
275
|
+
access_token=str(data["access_token"]),
|
|
276
|
+
refresh_token=(
|
|
277
|
+
str(data["refresh_token"]) if data.get("refresh_token") else None
|
|
278
|
+
),
|
|
279
|
+
expires_at=self._now() + expires_in,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _error_code(resp: httpx.Response) -> str:
|
|
284
|
+
"""Extract the OAuth ``error`` code from a non-200 token response."""
|
|
285
|
+
try:
|
|
286
|
+
return str(resp.json().get("error", "unknown_error"))
|
|
287
|
+
except ValueError:
|
|
288
|
+
return "unknown_error"
|