qrforge-mcp 0.1.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,12 @@
1
+ # Local (stdio) configuration for qrforge-mcp.
2
+ # Copy to .env or set in your MCP client config.
3
+
4
+ # Required for local use: your QR Forge API token (https://qrforge.work/api/keys).
5
+ QRFORGE_API_TOKEN=
6
+
7
+ # Optional: override the API base URL (defaults to https://qrforge.work).
8
+ # QRFORGE_API_URL=https://qrforge.work
9
+
10
+ # Hosted server only (Docker): which API to forward to. No token here — the
11
+ # hosted server reads each request's Authorization: Bearer <token> header.
12
+ # QRFORGE_API_URL=https://qrforge.work
@@ -0,0 +1,20 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+ .env
11
+
12
+ # Test / tooling
13
+ .pytest_cache/
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ .coverage
17
+ htmlcov/
18
+
19
+ # CI workflow withheld until a workflow-scoped token is available
20
+ .github/workflows/
@@ -0,0 +1,23 @@
1
+ # QR Forge MCP server — hosted (streamable-HTTP) image.
2
+ FROM python:3.11-slim
3
+
4
+ ENV PYTHONDONTWRITEBYTECODE=1 \
5
+ PYTHONUNBUFFERED=1 \
6
+ MCP_HOST=0.0.0.0 \
7
+ MCP_PORT=8001
8
+
9
+ WORKDIR /app
10
+
11
+ # Install the package (and its deps) from the build context.
12
+ COPY pyproject.toml README.md ./
13
+ COPY src ./src
14
+ RUN pip install --no-cache-dir .
15
+
16
+ # Drop privileges.
17
+ RUN useradd --create-home appuser
18
+ USER appuser
19
+
20
+ EXPOSE 8001
21
+
22
+ # Hosted mode: each request carries its own Authorization: Bearer <token>.
23
+ CMD ["qrforge-mcp", "--http", "--host", "0.0.0.0", "--port", "8001"]
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Georgi Kolarov
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,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: qrforge-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for the QR Forge API — create and manage trackable QR codes.
5
+ Project-URL: Homepage, https://qrforge.work
6
+ Project-URL: Repository, https://github.com/jkolarov/qrforge-mcp
7
+ Project-URL: Issues, https://github.com/jkolarov/qrforge-mcp/issues
8
+ Author: Georgi Kolarov
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: mcp,model-context-protocol,qr,qrcode,qrforge
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: fastmcp>=2.3.0
17
+ Requires-Dist: httpx>=0.27
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
21
+ Requires-Dist: respx>=0.21; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # QR Forge MCP server
25
+
26
+ An [MCP](https://modelcontextprotocol.io) server for [QR Forge](https://qrforge.work) —
27
+ create and manage trackable QR codes (links, files, Wi-Fi, WhatsApp, phone, email)
28
+ straight from an AI agent. It's a thin client over the public QR Forge REST API; it
29
+ stores nothing and authenticates with **your** QR Forge API token.
30
+
31
+ Get a token at **https://qrforge.work/api/keys**.
32
+
33
+ ## Two ways to use it
34
+
35
+ ### 1. Local (stdio) — install and run on your machine
36
+
37
+ ```bash
38
+ uvx qrforge-mcp # or: pipx install qrforge-mcp
39
+ ```
40
+
41
+ Add it to your MCP client (Claude Desktop / Claude Code) — `claude_desktop_config.json`
42
+ or `.mcp.json`:
43
+
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "qrforge": {
48
+ "command": "qrforge-mcp",
49
+ "env": { "QRFORGE_API_TOKEN": "your-token-here" }
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ Or with Claude Code's CLI:
56
+
57
+ ```bash
58
+ claude mcp add qrforge --env QRFORGE_API_TOKEN=your-token-here -- qrforge-mcp
59
+ ```
60
+
61
+ Environment variables:
62
+
63
+ | Var | Required | Default | Purpose |
64
+ |-----|----------|---------|---------|
65
+ | `QRFORGE_API_TOKEN` | yes (local) | — | Your QR Forge API token |
66
+ | `QRFORGE_API_URL` | no | `https://qrforge.work` | API base URL |
67
+
68
+ ### 2. Hosted — connect to our server, no install
69
+
70
+ The hosted server runs at **`https://mcp.qrforge.work/mcp/`**. Pass your token in the
71
+ `X-QRForge-Token` header:
72
+
73
+ ```bash
74
+ claude mcp add --transport http qrforge https://mcp.qrforge.work/mcp/ \
75
+ --header "X-QRForge-Token: your-token-here"
76
+ ```
77
+
78
+ > The hosted server sits behind Cloudflare, which strips the `Authorization` header
79
+ > on streaming requests — so use **`X-QRForge-Token`** for the hosted endpoint.
80
+ > (`Authorization: Bearer <token>` still works for local/self-hosted instances.)
81
+
82
+ The hosted server is stateless and multi-user: it forwards each request's token to
83
+ the API and never stores it.
84
+
85
+ ## Tools
86
+
87
+ - **Identity/tokens:** `whoami`, `list_api_tokens`, `create_api_token`, `revoke_api_token`
88
+ - **Discovery:** `list_qr_types`, `list_qr_styles`
89
+ - **Render (no save):** `render_qr`
90
+ - **Create:** `create_url_qrcode`, `create_whatsapp_qrcode`, `create_wifi_qrcode`,
91
+ `create_phone_qrcode`, `create_email_qrcode`, `create_file_qrcode`
92
+ - **Manage:** `list_qrcodes`, `get_qrcode`, `update_qrcode`, `delete_qrcode`
93
+ - **Images:** `get_qrcode_png`, `get_qrcode_svg`
94
+ - **Files/logo:** `download_qrcode_file`, `replace_qrcode_file`, `set_qrcode_logo`,
95
+ `remove_qrcode_logo`
96
+ - **Analytics:** `get_qrcode_history`, `get_qrcode_scans`
97
+
98
+ `style` (optional, on create/render/update) is an object with keys `module_drawer`
99
+ (`square|rounded|circle|gapped|vertical_bars|horizontal_bars`), `fill_type`
100
+ (`solid|radial|horizontal|vertical`), `fill_color`, `fill_color_2`, `back_color` (hex).
101
+
102
+ File tools accept the file via `file_path` (local), `file_url` (server downloads it),
103
+ or `file_base64` — up to 50 MB.
104
+
105
+ ## Develop
106
+
107
+ ```bash
108
+ pip install -e ".[dev]"
109
+ pytest -q
110
+ ```
111
+
112
+ Run the hosted server locally:
113
+
114
+ ```bash
115
+ qrforge-mcp --http --host 0.0.0.0 --port 8001 # streamable-HTTP at /mcp/
116
+ # or stdio:
117
+ QRFORGE_API_TOKEN=... qrforge-mcp
118
+ ```
119
+
120
+ ## License
121
+
122
+ MIT
@@ -0,0 +1,99 @@
1
+ # QR Forge MCP server
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server for [QR Forge](https://qrforge.work) —
4
+ create and manage trackable QR codes (links, files, Wi-Fi, WhatsApp, phone, email)
5
+ straight from an AI agent. It's a thin client over the public QR Forge REST API; it
6
+ stores nothing and authenticates with **your** QR Forge API token.
7
+
8
+ Get a token at **https://qrforge.work/api/keys**.
9
+
10
+ ## Two ways to use it
11
+
12
+ ### 1. Local (stdio) — install and run on your machine
13
+
14
+ ```bash
15
+ uvx qrforge-mcp # or: pipx install qrforge-mcp
16
+ ```
17
+
18
+ Add it to your MCP client (Claude Desktop / Claude Code) — `claude_desktop_config.json`
19
+ or `.mcp.json`:
20
+
21
+ ```json
22
+ {
23
+ "mcpServers": {
24
+ "qrforge": {
25
+ "command": "qrforge-mcp",
26
+ "env": { "QRFORGE_API_TOKEN": "your-token-here" }
27
+ }
28
+ }
29
+ }
30
+ ```
31
+
32
+ Or with Claude Code's CLI:
33
+
34
+ ```bash
35
+ claude mcp add qrforge --env QRFORGE_API_TOKEN=your-token-here -- qrforge-mcp
36
+ ```
37
+
38
+ Environment variables:
39
+
40
+ | Var | Required | Default | Purpose |
41
+ |-----|----------|---------|---------|
42
+ | `QRFORGE_API_TOKEN` | yes (local) | — | Your QR Forge API token |
43
+ | `QRFORGE_API_URL` | no | `https://qrforge.work` | API base URL |
44
+
45
+ ### 2. Hosted — connect to our server, no install
46
+
47
+ The hosted server runs at **`https://mcp.qrforge.work/mcp/`**. Pass your token in the
48
+ `X-QRForge-Token` header:
49
+
50
+ ```bash
51
+ claude mcp add --transport http qrforge https://mcp.qrforge.work/mcp/ \
52
+ --header "X-QRForge-Token: your-token-here"
53
+ ```
54
+
55
+ > The hosted server sits behind Cloudflare, which strips the `Authorization` header
56
+ > on streaming requests — so use **`X-QRForge-Token`** for the hosted endpoint.
57
+ > (`Authorization: Bearer <token>` still works for local/self-hosted instances.)
58
+
59
+ The hosted server is stateless and multi-user: it forwards each request's token to
60
+ the API and never stores it.
61
+
62
+ ## Tools
63
+
64
+ - **Identity/tokens:** `whoami`, `list_api_tokens`, `create_api_token`, `revoke_api_token`
65
+ - **Discovery:** `list_qr_types`, `list_qr_styles`
66
+ - **Render (no save):** `render_qr`
67
+ - **Create:** `create_url_qrcode`, `create_whatsapp_qrcode`, `create_wifi_qrcode`,
68
+ `create_phone_qrcode`, `create_email_qrcode`, `create_file_qrcode`
69
+ - **Manage:** `list_qrcodes`, `get_qrcode`, `update_qrcode`, `delete_qrcode`
70
+ - **Images:** `get_qrcode_png`, `get_qrcode_svg`
71
+ - **Files/logo:** `download_qrcode_file`, `replace_qrcode_file`, `set_qrcode_logo`,
72
+ `remove_qrcode_logo`
73
+ - **Analytics:** `get_qrcode_history`, `get_qrcode_scans`
74
+
75
+ `style` (optional, on create/render/update) is an object with keys `module_drawer`
76
+ (`square|rounded|circle|gapped|vertical_bars|horizontal_bars`), `fill_type`
77
+ (`solid|radial|horizontal|vertical`), `fill_color`, `fill_color_2`, `back_color` (hex).
78
+
79
+ File tools accept the file via `file_path` (local), `file_url` (server downloads it),
80
+ or `file_base64` — up to 50 MB.
81
+
82
+ ## Develop
83
+
84
+ ```bash
85
+ pip install -e ".[dev]"
86
+ pytest -q
87
+ ```
88
+
89
+ Run the hosted server locally:
90
+
91
+ ```bash
92
+ qrforge-mcp --http --host 0.0.0.0 --port 8001 # streamable-HTTP at /mcp/
93
+ # or stdio:
94
+ QRFORGE_API_TOKEN=... qrforge-mcp
95
+ ```
96
+
97
+ ## License
98
+
99
+ MIT
@@ -0,0 +1,25 @@
1
+ # Hosted QR Forge MCP server. Public traffic reaches it via the Cloudflare
2
+ # tunnel ingress mcp.qrforge.work -> http://localhost:8001.
3
+ services:
4
+ mcp-server:
5
+ build:
6
+ context: .
7
+ dockerfile: Dockerfile
8
+ container_name: qr-mcp-server
9
+ ports:
10
+ - "8001:8001"
11
+ environment:
12
+ # The QR Forge API this server talks to. No token is stored here — each
13
+ # MCP request must carry its own Authorization: Bearer <token>.
14
+ - QRFORGE_API_URL=${QRFORGE_API_URL:-https://qrforge.work}
15
+ - MCP_HOST=0.0.0.0
16
+ - MCP_PORT=8001
17
+ restart: unless-stopped
18
+ healthcheck:
19
+ # Prove the server is listening (TCP connect). Avoids HTTP-status quirks of
20
+ # the MCP endpoint.
21
+ test: ["CMD", "python", "-c", "import socket; socket.create_connection(('localhost', 8001), 3).close()"]
22
+ interval: 30s
23
+ timeout: 10s
24
+ retries: 3
25
+ start_period: 20s
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "qrforge-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server for the QR Forge API — create and manage trackable QR codes."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Georgi Kolarov" }]
13
+ keywords = ["mcp", "qr", "qrcode", "qrforge", "model-context-protocol"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ dependencies = [
20
+ "fastmcp>=2.3.0",
21
+ "httpx>=0.27",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.21"]
26
+
27
+ [project.scripts]
28
+ qrforge-mcp = "qrforge_mcp.__main__:main"
29
+
30
+ [project.urls]
31
+ Homepage = "https://qrforge.work"
32
+ Repository = "https://github.com/jkolarov/qrforge-mcp"
33
+ Issues = "https://github.com/jkolarov/qrforge-mcp/issues"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/qrforge_mcp"]
37
+
38
+ [tool.pytest.ini_options]
39
+ asyncio_mode = "auto"
40
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ """QR Forge MCP server — expose the QR Forge REST API as MCP tools."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,31 @@
1
+ """CLI entrypoint. Default transport is stdio (local); ``--http`` runs the
2
+ hosted streamable-HTTP server."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+
8
+ from .server import mcp
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser(
13
+ prog="qrforge-mcp",
14
+ description="MCP server for the QR Forge API (https://qrforge.work).",
15
+ )
16
+ parser.add_argument(
17
+ "--http", action="store_true",
18
+ help="Run as a hosted HTTP (streamable-http) server instead of stdio.",
19
+ )
20
+ parser.add_argument("--host", default=os.environ.get("MCP_HOST", "127.0.0.1"))
21
+ parser.add_argument("--port", type=int, default=int(os.environ.get("MCP_PORT", "8001")))
22
+ args = parser.parse_args()
23
+
24
+ if args.http:
25
+ mcp.run(transport="http", host=args.host, port=args.port)
26
+ else:
27
+ mcp.run()
28
+
29
+
30
+ if __name__ == "__main__":
31
+ main()
@@ -0,0 +1,176 @@
1
+ """Thin REST client for the QR Forge API, shared by all MCP tools.
2
+
3
+ The MCP server never touches QR Forge's database — it only calls the public REST
4
+ API (``/api/v1``) with the user's API token. This module resolves the token and
5
+ base URL, performs requests, and normalises the API's error envelope into MCP
6
+ ``ToolError``s.
7
+
8
+ Token resolution (in order):
9
+ 1. Hosted (HTTP) mode: the caller's ``Authorization: Bearer <token>`` header,
10
+ read via ``fastmcp.server.dependencies.get_http_headers`` and forwarded.
11
+ 2. Local (stdio) mode: the ``QRFORGE_API_TOKEN`` environment variable.
12
+
13
+ Base URL: ``QRFORGE_API_URL`` (default ``https://qrforge.work``).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import os
19
+ from pathlib import Path
20
+ from typing import Any
21
+ from urllib.parse import urlparse
22
+
23
+ import httpx
24
+ from fastmcp.exceptions import ToolError
25
+ from fastmcp.server.dependencies import get_http_headers
26
+
27
+ DEFAULT_BASE_URL = "https://qrforge.work"
28
+ MAX_FILE_BYTES = 50 * 1024 * 1024 # mirrors the API's 50 MB upload limit
29
+ TIMEOUT = httpx.Timeout(120.0, connect=15.0)
30
+
31
+
32
+ def api_base() -> str:
33
+ base = os.environ.get("QRFORGE_API_URL", DEFAULT_BASE_URL).rstrip("/")
34
+ return f"{base}/api/v1"
35
+
36
+
37
+ def resolve_token() -> str:
38
+ """Return the API token from the request header (hosted) or env (local)."""
39
+ headers: dict[str, str] = {}
40
+ try:
41
+ # `authorization` is excluded from get_http_headers() by default; opt it in.
42
+ # Also read X-QRForge-Token: some proxies (notably Cloudflare) strip the
43
+ # Authorization header, so a custom header is the reliable hosted path.
44
+ headers = get_http_headers(include={"authorization", "x-qrforge-token"}) or {}
45
+ except Exception:
46
+ headers = {}
47
+
48
+ # Custom token header (survives proxies that strip Authorization).
49
+ custom = (headers.get("x-qrforge-token") or "").strip()
50
+ if custom:
51
+ return custom
52
+
53
+ # Standard Authorization: Bearer <token> (local/direct or proxies that keep it).
54
+ auth = headers.get("authorization") or headers.get("Authorization") or ""
55
+ if auth.lower().startswith("bearer "):
56
+ token = auth[7:].strip()
57
+ if token:
58
+ return token
59
+
60
+ # Local (stdio) mode: environment variable.
61
+ token = (os.environ.get("QRFORGE_API_TOKEN") or "").strip()
62
+ if token:
63
+ return token
64
+
65
+ raise ToolError(
66
+ "No QR Forge API token found. Local: set QRFORGE_API_TOKEN. Hosted: send "
67
+ "'X-QRForge-Token: <token>' (or 'Authorization: Bearer <token>'). Create a "
68
+ "token at https://qrforge.work/api/keys."
69
+ )
70
+
71
+
72
+ def request(
73
+ method: str,
74
+ path: str,
75
+ *,
76
+ params: dict | None = None,
77
+ json: dict | None = None,
78
+ data: dict | None = None,
79
+ files: dict | None = None,
80
+ expect: str = "json",
81
+ ) -> Any:
82
+ """Call the QR Forge API. ``expect`` is 'json', 'bytes', or 'text'.
83
+
84
+ Raises ToolError on transport failures and on any 4xx/5xx, surfacing the
85
+ API's ``{"error": ..., "code": ...}`` envelope.
86
+ """
87
+ url = f"{api_base()}{path}"
88
+ headers = {"Authorization": f"Bearer {resolve_token()}"}
89
+ # Strip None values so optional fields don't get sent as literal nulls.
90
+ if json is not None:
91
+ json = {k: v for k, v in json.items() if v is not None}
92
+ if data is not None:
93
+ data = {k: v for k, v in data.items() if v is not None}
94
+
95
+ try:
96
+ resp = httpx.request(
97
+ method, url, params=params, json=json, data=data, files=files,
98
+ headers=headers, timeout=TIMEOUT,
99
+ )
100
+ except httpx.HTTPError as exc:
101
+ raise ToolError(f"Could not reach the QR Forge API at {url}: {exc}") from exc
102
+
103
+ if resp.status_code >= 400:
104
+ code = msg = None
105
+ try:
106
+ body = resp.json()
107
+ code, msg = body.get("code"), body.get("error")
108
+ except Exception:
109
+ msg = (resp.text or "")[:300]
110
+ suffix = f"/{code}" if code else ""
111
+ raise ToolError(
112
+ f"QR Forge API error ({resp.status_code}{suffix}): {msg or 'request failed'}"
113
+ )
114
+
115
+ if expect == "bytes":
116
+ return resp.content
117
+ if expect == "text":
118
+ return resp.text
119
+ if not resp.content:
120
+ return {}
121
+ try:
122
+ return resp.json()
123
+ except Exception:
124
+ return {"raw": resp.text}
125
+
126
+
127
+ def load_file(
128
+ *,
129
+ file_path: str | None = None,
130
+ file_url: str | None = None,
131
+ file_base64: str | None = None,
132
+ filename: str | None = None,
133
+ ) -> tuple[str, bytes]:
134
+ """Resolve file content from a local path, a URL, or base64 (in that order).
135
+
136
+ Returns ``(filename, content_bytes)``. Enforces the 50 MB API limit.
137
+ """
138
+ if file_path:
139
+ path = Path(file_path).expanduser()
140
+ if not path.is_file():
141
+ raise ToolError(f"File not found: {file_path}")
142
+ content = path.read_bytes()
143
+ name = filename or path.name
144
+ elif file_url:
145
+ try:
146
+ resp = httpx.get(file_url, timeout=TIMEOUT, follow_redirects=True)
147
+ resp.raise_for_status()
148
+ except httpx.HTTPError as exc:
149
+ raise ToolError(f"Could not download file_url: {exc}") from exc
150
+ content = resp.content
151
+ name = filename or os.path.basename(urlparse(file_url).path) or "upload"
152
+ elif file_base64:
153
+ try:
154
+ content = base64.b64decode(file_base64, validate=True)
155
+ except Exception as exc:
156
+ raise ToolError(f"Invalid base64 in file_base64: {exc}") from exc
157
+ name = filename or "upload"
158
+ else:
159
+ raise ToolError("Provide one of: file_path, file_url, or file_base64.")
160
+
161
+ if len(content) > MAX_FILE_BYTES:
162
+ raise ToolError(
163
+ f"File is {len(content) // (1024 * 1024)} MB; the QR Forge limit is 50 MB."
164
+ )
165
+ return name, content
166
+
167
+
168
+ # Style fields accepted by the API (see _style_from / _sanitise_style server-side).
169
+ STYLE_KEYS = ("module_drawer", "fill_color", "back_color", "fill_type", "fill_color_2")
170
+
171
+
172
+ def clean_style(style: dict | None) -> dict:
173
+ """Keep only recognised, non-null style keys."""
174
+ if not style:
175
+ return {}
176
+ return {k: v for k, v in style.items() if k in STYLE_KEYS and v is not None}
@@ -0,0 +1,400 @@
1
+ """QR Forge MCP server: every QR Forge REST API capability as an MCP tool.
2
+
3
+ Tools are a thin mirror of ``/api/v1``. Authentication is the caller's QR Forge
4
+ API token (forwarded header when hosted, ``QRFORGE_API_TOKEN`` env when local).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Annotated, Any
9
+
10
+ from fastmcp import FastMCP
11
+ from fastmcp.exceptions import ToolError
12
+ from fastmcp.tools.tool import ToolResult
13
+ from fastmcp.utilities.types import Image
14
+ from mcp.types import TextContent
15
+ from pydantic import Field
16
+ from starlette.requests import Request
17
+ from starlette.responses import JSONResponse
18
+
19
+ from . import client
20
+
21
+ mcp = FastMCP(
22
+ name="QR Forge",
23
+ instructions=(
24
+ "Tools to create and manage trackable QR codes via the QR Forge API "
25
+ "(https://qrforge.work). Authenticate with a QR Forge API token. Use "
26
+ "list_qr_types and list_qr_styles to discover options. 'style' is an "
27
+ "optional object with keys: module_drawer (square|rounded|circle|gapped|"
28
+ "vertical_bars|horizontal_bars), fill_type (solid|radial|horizontal|"
29
+ "vertical), fill_color, fill_color_2, back_color (hex like #000000)."
30
+ ),
31
+ )
32
+
33
+
34
+ @mcp.custom_route("/health", methods=["GET"])
35
+ async def health(_request: Request) -> JSONResponse:
36
+ """Unauthenticated liveness probe for uptime monitors (HTTP transport only)."""
37
+ return JSONResponse({"status": "ok"})
38
+
39
+ Style = Annotated[dict | None, Field(
40
+ default=None,
41
+ description="Optional QR style: module_drawer, fill_type, fill_color, "
42
+ "fill_color_2, back_color.",
43
+ )]
44
+
45
+ # Fields surfaced to the user/model. We drop the raw API `links` and the
46
+ # `download_url` (auth-gated /api/v1 paths) so the model never hands the user a
47
+ # URL that 401s — image/file access goes through the dedicated tools instead.
48
+ _PRESENT_FIELDS = (
49
+ "id", "name", "type", "public_id", "public_url", "is_active", "is_expired",
50
+ "expires_at", "total_scans", "has_logo", "style", "target",
51
+ )
52
+
53
+
54
+ def present(doc: dict) -> dict:
55
+ """Trim a QR document to user-facing fields (no internal API links)."""
56
+ out = {k: doc.get(k) for k in _PRESENT_FIELDS if doc.get(k) is not None}
57
+ target = out.get("target")
58
+ if isinstance(target, dict):
59
+ out["target"] = {k: v for k, v in target.items() if k != "download_url"}
60
+ return out
61
+
62
+
63
+ def _render_png(doc_id: int) -> bytes | None:
64
+ """Best-effort: render the saved QR as PNG. Never fails the calling tool."""
65
+ try:
66
+ return client.request("GET", f"/qrcodes/{doc_id}/qr.png",
67
+ params={"size": 10}, expect="bytes")
68
+ except Exception:
69
+ return None
70
+
71
+
72
+ def _qr_result(doc: dict, note: str) -> ToolResult:
73
+ """Return the QR image inline (for the client to display) plus clean metadata
74
+ the model can reason over. The image is also re-fetchable via get_qrcode_png."""
75
+ summary = present(doc)
76
+ blocks: list = []
77
+ png = _render_png(doc["id"])
78
+ if png is not None:
79
+ blocks.append(Image(data=png, format="png").to_image_content())
80
+ public = doc.get("public_url")
81
+ text = (f"{note} QR code #{doc['id']} (type={doc.get('type')})."
82
+ + (f" Encoded/public link: {public}." if public else "")
83
+ + " The QR image is attached above; call get_qrcode_png(id="
84
+ f"{doc['id']}) to fetch it again.")
85
+ blocks.append(TextContent(type="text", text=text))
86
+ return ToolResult(content=blocks, structured_content=summary)
87
+
88
+
89
+ # ── Identity & tokens ─────────────────────────────────────────────────────────
90
+
91
+ @mcp.tool
92
+ def whoami() -> dict:
93
+ """Return the authenticated account's identity and QR-code count (GET /me)."""
94
+ return client.request("GET", "/me")
95
+
96
+
97
+ @mcp.tool
98
+ def list_api_tokens() -> dict:
99
+ """List the account's API tokens (secrets are never returned)."""
100
+ return client.request("GET", "/tokens")
101
+
102
+
103
+ @mcp.tool
104
+ def create_api_token(name: str = "API Token") -> dict:
105
+ """Create a new API token. The full secret is returned exactly once."""
106
+ return client.request("POST", "/tokens", json={"name": name})
107
+
108
+
109
+ @mcp.tool
110
+ def revoke_api_token(token_id: int) -> dict:
111
+ """Revoke (delete) an API token by id."""
112
+ return client.request("DELETE", f"/tokens/{token_id}")
113
+
114
+
115
+ # ── Discovery ─────────────────────────────────────────────────────────────────
116
+
117
+ @mcp.tool
118
+ def list_qr_types() -> dict:
119
+ """List available QR code types and their required fields (GET /qr/types)."""
120
+ return client.request("GET", "/qr/types")
121
+
122
+
123
+ @mcp.tool
124
+ def list_qr_styles() -> dict:
125
+ """List QR style presets and defaults (GET /qr/styles)."""
126
+ return client.request("GET", "/qr/styles")
127
+
128
+
129
+ # ── Stateless render ──────────────────────────────────────────────────────────
130
+
131
+ @mcp.tool
132
+ def render_qr(url: str, format: str = "png", size: int = 12, style: Style = None) -> Any:
133
+ """Render a styled QR code for an arbitrary URL without saving it.
134
+
135
+ Returns a PNG image (format='png') or the SVG markup as text (format='svg').
136
+ """
137
+ body: dict[str, Any] = {"url": url, "format": format, "size": size}
138
+ cleaned = client.clean_style(style)
139
+ if cleaned:
140
+ body["style"] = cleaned # render nests style under "style"
141
+ if format == "svg":
142
+ return client.request("POST", "/qr/render", json=body, expect="text")
143
+ png = client.request("POST", "/qr/render", json=body, expect="bytes")
144
+ return Image(data=png, format="png")
145
+
146
+
147
+ # ── Create QR codes (per type) ────────────────────────────────────────────────
148
+
149
+ def _create(type_: str, fields: dict, style: dict | None) -> ToolResult:
150
+ # POST /qrcodes reads style fields at the TOP LEVEL of the body.
151
+ body = {"type": type_, **{k: v for k, v in fields.items() if v is not None}}
152
+ body.update(client.clean_style(style))
153
+ doc = client.request("POST", "/qrcodes", json=body)
154
+ return _qr_result(doc, "Created")
155
+
156
+
157
+ @mcp.tool
158
+ def create_url_qrcode(
159
+ target_url: str,
160
+ name: str | None = None,
161
+ static: bool = False,
162
+ expires_in_days: int | None = None,
163
+ style: Style = None,
164
+ ) -> ToolResult:
165
+ """Create a Website-link QR code. static=True encodes the URL directly
166
+ (no redirect, no scan tracking, not editable); default is a tracked, editable
167
+ dynamic link."""
168
+ return _create("url", {
169
+ "target_url": target_url, "name": name,
170
+ "static": static, "expires_in_days": expires_in_days,
171
+ }, style)
172
+
173
+
174
+ @mcp.tool
175
+ def create_whatsapp_qrcode(
176
+ target_url: str,
177
+ name: str | None = None,
178
+ expires_in_days: int | None = None,
179
+ style: Style = None,
180
+ ) -> ToolResult:
181
+ """Create a WhatsApp QR code from a wa.me link (tracked dynamic link)."""
182
+ return _create("whatsapp", {
183
+ "target_url": target_url, "name": name, "expires_in_days": expires_in_days,
184
+ }, style)
185
+
186
+
187
+ @mcp.tool
188
+ def create_wifi_qrcode(
189
+ ssid: str,
190
+ auth: str = "WPA",
191
+ password: str | None = None,
192
+ hidden: bool = False,
193
+ name: str | None = None,
194
+ style: Style = None,
195
+ ) -> ToolResult:
196
+ """Create a Wi-Fi QR code (static; encodes credentials directly).
197
+ auth is one of WPA, WPA3, WEP, nopass."""
198
+ return _create("wifi", {
199
+ "ssid": ssid, "auth": auth, "password": password,
200
+ "hidden": hidden, "name": name,
201
+ }, style)
202
+
203
+
204
+ @mcp.tool
205
+ def create_phone_qrcode(phone: str, name: str | None = None, style: Style = None) -> ToolResult:
206
+ """Create a phone-number QR code (static; dials when scanned)."""
207
+ return _create("phone", {"phone": phone, "name": name}, style)
208
+
209
+
210
+ @mcp.tool
211
+ def create_email_qrcode(
212
+ email: str,
213
+ subject: str | None = None,
214
+ body: str | None = None,
215
+ name: str | None = None,
216
+ style: Style = None,
217
+ ) -> ToolResult:
218
+ """Create an email QR code (static; opens a prefilled email when scanned)."""
219
+ return _create("email", {
220
+ "email": email, "subject": subject, "body": body, "name": name,
221
+ }, style)
222
+
223
+
224
+ @mcp.tool
225
+ def create_file_qrcode(
226
+ file_path: str | None = None,
227
+ file_url: str | None = None,
228
+ file_base64: str | None = None,
229
+ filename: str | None = None,
230
+ name: str | None = None,
231
+ expires_in_days: int | None = None,
232
+ style: Style = None,
233
+ ) -> ToolResult:
234
+ """Create a file/document QR code. Provide the file via file_path (local),
235
+ file_url (downloaded by the server), or file_base64. Accepts images, PDF,
236
+ Office, OpenDocument and text files up to 50 MB."""
237
+ fname, content = client.load_file(
238
+ file_path=file_path, file_url=file_url, file_base64=file_base64, filename=filename)
239
+ data = {"type": "pdf", "name": name, "expires_in_days": expires_in_days}
240
+ data.update(client.clean_style(style))
241
+ files = {"pdf_file": (fname, content, "application/octet-stream")}
242
+ doc = client.request("POST", "/qrcodes", data=data, files=files)
243
+ return _qr_result(doc, "Created")
244
+
245
+
246
+ # ── Read / update / delete ────────────────────────────────────────────────────
247
+
248
+ @mcp.tool
249
+ def list_qrcodes(
250
+ q: str | None = None,
251
+ active: bool | None = None,
252
+ page: int = 1,
253
+ limit: int = 25,
254
+ ) -> dict:
255
+ """List the account's QR codes with optional search (q matches name/filename),
256
+ active filter, and pagination (limit max 100). Returns data + pagination."""
257
+ params: dict[str, Any] = {"page": page, "limit": limit}
258
+ if q:
259
+ params["q"] = q
260
+ if active is not None:
261
+ params["active"] = "true" if active else "false"
262
+ result = client.request("GET", "/qrcodes", params=params)
263
+ result["data"] = [present(d) for d in result.get("data", [])]
264
+ return result
265
+
266
+
267
+ @mcp.tool
268
+ def get_qrcode(id: int) -> dict:
269
+ """Get a single QR code's details by id. Use get_qrcode_png to view the image."""
270
+ return present(client.request("GET", f"/qrcodes/{id}"))
271
+
272
+
273
+ @mcp.tool
274
+ def update_qrcode(
275
+ id: int,
276
+ name: str | None = None,
277
+ is_active: bool | None = None,
278
+ target_url: str | None = None,
279
+ ssid: str | None = None,
280
+ auth: str | None = None,
281
+ password: str | None = None,
282
+ hidden: bool | None = None,
283
+ phone: str | None = None,
284
+ email: str | None = None,
285
+ subject: str | None = None,
286
+ body: str | None = None,
287
+ expires_in_days: int | None = None,
288
+ style: Style = None,
289
+ ) -> dict:
290
+ """Update a QR code's mutable fields. Only fields valid for the QR's type
291
+ apply (e.g. target_url for url; ssid/auth/password/hidden for wifi). Set
292
+ expires_in_days to 0/null to clear expiry."""
293
+ payload: dict[str, Any] = {
294
+ "name": name, "is_active": is_active, "target_url": target_url,
295
+ "ssid": ssid, "auth": auth, "password": password, "hidden": hidden,
296
+ "phone": phone, "email": email, "subject": subject, "body": body,
297
+ "expires_in_days": expires_in_days,
298
+ }
299
+ payload = {k: v for k, v in payload.items() if v is not None}
300
+ cleaned = client.clean_style(style)
301
+ if cleaned:
302
+ payload["style"] = cleaned # PATCH nests style under "style"
303
+ return present(client.request("PATCH", f"/qrcodes/{id}", json=payload))
304
+
305
+
306
+ @mcp.tool
307
+ def delete_qrcode(id: int) -> dict:
308
+ """Delete a QR code (and its underlying file, if any)."""
309
+ return client.request("DELETE", f"/qrcodes/{id}")
310
+
311
+
312
+ # ── Rendered images ───────────────────────────────────────────────────────────
313
+
314
+ @mcp.tool
315
+ def get_qrcode_png(id: int, size: int = 30, no_logo: bool = False) -> Image:
316
+ """Render a saved QR code as a PNG image using its stored style/logo."""
317
+ params: dict[str, Any] = {"size": size}
318
+ if no_logo:
319
+ params["no_logo"] = "1"
320
+ png = client.request("GET", f"/qrcodes/{id}/qr.png", params=params, expect="bytes")
321
+ return Image(data=png, format="png")
322
+
323
+
324
+ @mcp.tool
325
+ def get_qrcode_svg(id: int) -> str:
326
+ """Return a saved QR code as SVG markup (text)."""
327
+ return client.request("GET", f"/qrcodes/{id}/qr.svg", expect="text")
328
+
329
+
330
+ # ── Underlying file & logo ────────────────────────────────────────────────────
331
+
332
+ @mcp.tool
333
+ def download_qrcode_file(id: int, save_path: str | None = None) -> dict:
334
+ """Download a file-backed QR code's current file. With save_path, writes it
335
+ there and returns the path; otherwise returns base64 (small files only)."""
336
+ import base64 as _b64
337
+ from pathlib import Path as _Path
338
+
339
+ content = client.request("GET", f"/qrcodes/{id}/file", expect="bytes")
340
+ if save_path:
341
+ p = _Path(save_path).expanduser()
342
+ p.parent.mkdir(parents=True, exist_ok=True)
343
+ p.write_bytes(content)
344
+ return {"saved_to": str(p), "bytes": len(content)}
345
+ if len(content) > 8 * 1024 * 1024:
346
+ raise ToolError(
347
+ f"File is {len(content) // (1024 * 1024)} MB — too large to inline. "
348
+ "Pass save_path to write it to disk instead."
349
+ )
350
+ return {"bytes": len(content), "base64": _b64.b64encode(content).decode()}
351
+
352
+
353
+ @mcp.tool
354
+ def replace_qrcode_file(
355
+ id: int,
356
+ file_path: str | None = None,
357
+ file_url: str | None = None,
358
+ file_base64: str | None = None,
359
+ filename: str | None = None,
360
+ ) -> dict:
361
+ """Replace a file-backed QR code's file while keeping its QR/link unchanged."""
362
+ fname, content = client.load_file(
363
+ file_path=file_path, file_url=file_url, file_base64=file_base64, filename=filename)
364
+ files = {"pdf_file": (fname, content, "application/octet-stream")}
365
+ return present(client.request("PUT", f"/qrcodes/{id}/file", files=files))
366
+
367
+
368
+ @mcp.tool
369
+ def set_qrcode_logo(
370
+ id: int,
371
+ file_path: str | None = None,
372
+ file_url: str | None = None,
373
+ file_base64: str | None = None,
374
+ filename: str | None = None,
375
+ ) -> dict:
376
+ """Set/replace a QR code's center logo (PNG, JPG, or WebP)."""
377
+ fname, content = client.load_file(
378
+ file_path=file_path, file_url=file_url, file_base64=file_base64, filename=filename)
379
+ files = {"logo": (fname, content, "application/octet-stream")}
380
+ return present(client.request("PUT", f"/qrcodes/{id}/logo", files=files))
381
+
382
+
383
+ @mcp.tool
384
+ def remove_qrcode_logo(id: int) -> dict:
385
+ """Remove a QR code's center logo."""
386
+ return present(client.request("DELETE", f"/qrcodes/{id}/logo"))
387
+
388
+
389
+ # ── Analytics & history ───────────────────────────────────────────────────────
390
+
391
+ @mcp.tool
392
+ def get_qrcode_history(id: int) -> dict:
393
+ """File-replacement history for a file-backed QR code (newest first)."""
394
+ return client.request("GET", f"/qrcodes/{id}/history")
395
+
396
+
397
+ @mcp.tool
398
+ def get_qrcode_scans(id: int, days: int = 30) -> dict:
399
+ """Scan analytics for a dynamic QR code: daily series + totals (days 1-365)."""
400
+ return client.request("GET", f"/qrcodes/{id}/scans", params={"days": days})
@@ -0,0 +1,62 @@
1
+ """Unit tests for the REST client helpers (token resolution, style, file load)."""
2
+ import base64
3
+
4
+ import pytest
5
+ from fastmcp.exceptions import ToolError
6
+
7
+ from qrforge_mcp import client
8
+
9
+
10
+ def test_api_base_default(monkeypatch):
11
+ monkeypatch.delenv("QRFORGE_API_URL", raising=False)
12
+ assert client.api_base() == "https://qrforge.work/api/v1"
13
+
14
+
15
+ def test_api_base_override(monkeypatch):
16
+ monkeypatch.setenv("QRFORGE_API_URL", "http://localhost:5000/")
17
+ assert client.api_base() == "http://localhost:5000/api/v1"
18
+
19
+
20
+ def test_resolve_token_prefers_header(monkeypatch):
21
+ monkeypatch.setenv("QRFORGE_API_TOKEN", "env-token")
22
+ monkeypatch.setattr(client, "get_http_headers",
23
+ lambda **_: {"authorization": "Bearer hdr-token"})
24
+ assert client.resolve_token() == "hdr-token"
25
+
26
+
27
+ def test_resolve_token_custom_header_wins(monkeypatch):
28
+ # X-QRForge-Token survives proxies that strip Authorization (e.g. Cloudflare).
29
+ monkeypatch.delenv("QRFORGE_API_TOKEN", raising=False)
30
+ monkeypatch.setattr(client, "get_http_headers",
31
+ lambda **_: {"x-qrforge-token": "custom-token"})
32
+ assert client.resolve_token() == "custom-token"
33
+
34
+
35
+ def test_resolve_token_falls_back_to_env(monkeypatch):
36
+ monkeypatch.setenv("QRFORGE_API_TOKEN", "env-token")
37
+ monkeypatch.setattr(client, "get_http_headers", lambda **_: {})
38
+ assert client.resolve_token() == "env-token"
39
+
40
+
41
+ def test_resolve_token_missing_raises(monkeypatch):
42
+ monkeypatch.delenv("QRFORGE_API_TOKEN", raising=False)
43
+ monkeypatch.setattr(client, "get_http_headers", lambda **_: {})
44
+ with pytest.raises(ToolError):
45
+ client.resolve_token()
46
+
47
+
48
+ def test_clean_style_filters_unknown_and_null():
49
+ style = {"module_drawer": "circle", "fill_color": None, "bogus": "x"}
50
+ assert client.clean_style(style) == {"module_drawer": "circle"}
51
+ assert client.clean_style(None) == {}
52
+
53
+
54
+ def test_load_file_base64_and_limit():
55
+ name, content = client.load_file(file_base64=base64.b64encode(b"hello").decode(),
56
+ filename="a.txt")
57
+ assert name == "a.txt" and content == b"hello"
58
+
59
+
60
+ def test_load_file_requires_a_source():
61
+ with pytest.raises(ToolError):
62
+ client.load_file()
@@ -0,0 +1,143 @@
1
+ """Tool-level tests: drive the real MCP tools via an in-memory FastMCP client,
2
+ with the QR Forge REST API mocked by respx. Verifies request shaping (incl. the
3
+ top-level-vs-nested style quirk), auth header forwarding, and error surfacing.
4
+ """
5
+ import base64
6
+
7
+ import httpx
8
+ import pytest
9
+ import respx
10
+ from fastmcp import Client
11
+
12
+ from qrforge_mcp.server import mcp
13
+
14
+ API = "https://qrforge.work/api/v1"
15
+
16
+
17
+ @pytest.fixture(autouse=True)
18
+ def _env(monkeypatch):
19
+ monkeypatch.setenv("QRFORGE_API_TOKEN", "test-token")
20
+ monkeypatch.setenv("QRFORGE_API_URL", "https://qrforge.work")
21
+
22
+
23
+ async def _call(name, args=None):
24
+ async with Client(mcp) as c:
25
+ return await c.call_tool(name, args or {})
26
+
27
+
28
+ @respx.mock
29
+ async def test_whoami_forwards_bearer():
30
+ route = respx.get(f"{API}/me").mock(
31
+ return_value=httpx.Response(200, json={"email": "a@b.com", "qrcode_count": 3}))
32
+ res = await _call("whoami")
33
+ assert route.called
34
+ assert route.calls[0].request.headers["authorization"] == "Bearer test-token"
35
+ assert res.data["email"] == "a@b.com"
36
+
37
+
38
+ @respx.mock
39
+ async def test_list_qrcodes_sends_filters():
40
+ route = respx.get(f"{API}/qrcodes").mock(
41
+ return_value=httpx.Response(200, json={"data": [], "pagination": {"total": 0}}))
42
+ await _call("list_qrcodes", {"q": "menu", "active": True, "page": 2, "limit": 50})
43
+ req = route.calls[0].request
44
+ assert dict(req.url.params) == {"page": "2", "limit": "50", "q": "menu", "active": "true"}
45
+
46
+
47
+ @respx.mock
48
+ async def test_create_url_puts_style_at_top_level():
49
+ route = respx.post(f"{API}/qrcodes").mock(
50
+ return_value=httpx.Response(201, json={"id": 1, "type": "url"}))
51
+ respx.get(url__regex=r".+/qr\.png").mock(return_value=httpx.Response(200, content=b"\x89PNG\r\n"))
52
+ await _call("create_url_qrcode", {
53
+ "target_url": "https://example.com", "name": "X", "static": True,
54
+ "style": {"module_drawer": "circle", "fill_color": "#ff0000"},
55
+ })
56
+ import json as _json
57
+ sent = _json.loads(route.calls[0].request.content)
58
+ assert sent["type"] == "url"
59
+ assert sent["target_url"] == "https://example.com"
60
+ assert sent["static"] is True
61
+ # style fields are TOP-LEVEL on create
62
+ assert sent["module_drawer"] == "circle"
63
+ assert sent["fill_color"] == "#ff0000"
64
+ assert "style" not in sent
65
+
66
+
67
+ @respx.mock
68
+ async def test_update_nests_style():
69
+ route = respx.patch(f"{API}/qrcodes/7").mock(
70
+ return_value=httpx.Response(200, json={"id": 7}))
71
+ await _call("update_qrcode", {"id": 7, "name": "New",
72
+ "style": {"fill_color": "#00ff00"}})
73
+ import json as _json
74
+ sent = _json.loads(route.calls[0].request.content)
75
+ assert sent["name"] == "New"
76
+ # style is NESTED on PATCH
77
+ assert sent["style"] == {"fill_color": "#00ff00"}
78
+ assert "fill_color" not in sent
79
+
80
+
81
+ @respx.mock
82
+ async def test_create_wifi_body():
83
+ route = respx.post(f"{API}/qrcodes").mock(
84
+ return_value=httpx.Response(201, json={"id": 2, "type": "wifi"}))
85
+ respx.get(url__regex=r".+/qr\.png").mock(return_value=httpx.Response(200, content=b"\x89PNG\r\n"))
86
+ await _call("create_wifi_qrcode", {"ssid": "Net", "auth": "WPA", "password": "p"})
87
+ import json as _json
88
+ sent = _json.loads(route.calls[0].request.content)
89
+ assert sent == {"type": "wifi", "ssid": "Net", "auth": "WPA",
90
+ "password": "p", "hidden": False}
91
+
92
+
93
+ @respx.mock
94
+ async def test_create_file_qrcode_multipart():
95
+ route = respx.post(f"{API}/qrcodes").mock(
96
+ return_value=httpx.Response(201, json={"id": 3, "type": "pdf"}))
97
+ respx.get(url__regex=r".+/qr\.png").mock(return_value=httpx.Response(200, content=b"\x89PNG\r\n"))
98
+ await _call("create_file_qrcode", {
99
+ "file_base64": base64.b64encode(b"PK\x03\x04").decode(),
100
+ "filename": "sheet.xlsx", "name": "Sheet",
101
+ })
102
+ req = route.calls[0].request
103
+ assert req.headers["content-type"].startswith("multipart/form-data")
104
+ body = req.content
105
+ assert b"sheet.xlsx" in body and b'name="pdf_file"' in body and b"pdf" in body
106
+
107
+
108
+ @respx.mock
109
+ async def test_create_returns_inline_image_and_clean_metadata():
110
+ respx.post(f"{API}/qrcodes").mock(return_value=httpx.Response(201, json={
111
+ "id": 9, "type": "url", "public_url": "https://qrforge.work/d/abc",
112
+ "links": {"qr_png": "/api/v1/qrcodes/9/qr.png"},
113
+ "target": {"kind": "url", "url": "https://x.io", "download_url": "/api/v1/qrcodes/9/file"},
114
+ }))
115
+ respx.get(url__regex=r".+/qr\.png").mock(
116
+ return_value=httpx.Response(200, content=b"\x89PNG\r\nDATA"))
117
+ res = await _call("create_url_qrcode", {"target_url": "https://x.io"})
118
+ # an image content block is returned for the client to display
119
+ assert any(getattr(b, "type", "") == "image" for b in res.content)
120
+ # structured metadata is clean: no auth-gated api links surfaced
121
+ assert res.data["id"] == 9 and res.data["public_url"] == "https://qrforge.work/d/abc"
122
+ assert "links" not in res.data
123
+ assert "download_url" not in res.data["target"]
124
+
125
+
126
+ @respx.mock
127
+ async def test_render_svg_returns_text():
128
+ respx.post(f"{API}/qr/render").mock(
129
+ return_value=httpx.Response(200, text="<svg>ok</svg>",
130
+ headers={"content-type": "image/svg+xml"}))
131
+ res = await _call("render_qr", {"url": "https://x.io", "format": "svg"})
132
+ text = res.data if isinstance(res.data, str) else res.content[0].text
133
+ assert "<svg>" in text
134
+
135
+
136
+ @respx.mock
137
+ async def test_api_error_becomes_tool_error():
138
+ respx.post(f"{API}/qrcodes").mock(return_value=httpx.Response(
139
+ 400, json={"error": "Missing or invalid required field: target_url",
140
+ "code": "invalid_target_url"}))
141
+ with pytest.raises(Exception) as exc:
142
+ await _call("create_url_qrcode", {"target_url": "not a url"})
143
+ assert "invalid_target_url" in str(exc.value)