API reference
The server exposes three tools. Their names and response shapes are part
of the utility client's contract.
Generate a TOTP code
generate_totp(secret, digits=6, period=30, algorithm="sha1")
| Argument |
Accepted values |
secret |
Base32 secret; spaces, dashes and mixed case are normalized |
digits |
6, 7 or 8 |
period |
Positive integer seconds |
algorithm |
sha1, sha256 or sha512 |
A successful response contains a string code and the seconds left in the period:
{"status": "ok", "data": {"code": "123456", "expires_in": 18}}
Invalid inputs return an INVALID: error string. The example code above is
illustrative; the actual code depends on the secret and current time.
Decode a QR code
decode_qr(image_path) reads a local image accessible to the server process.
Pillow opens the image and zxing-cpp decodes exactly one QR code. The tool accepts
a file path; it has no base64 argument.
{"status": "ok", "data": {"payload": "https://example.com/login"}}
Empty or missing paths return INVALID:. Invalid images, images without a QR,
and images containing multiple QRs return DECODE_FAILED:.
Pillow and zxing-cpp are required runtime dependencies and must be installed
before starting the server.
Render a Burp-style evidence screenshot
render_burp_screenshot(request, response, response_time_ms, output_path, annotations=None, theme="light", layout="stack", ratio="1:1")
renders an HTTP request/response pair as a Burp Suite Repeater style PNG and writes it
inside the workspace directory (see server configuration). The image is
not returned; the agent imports the file with the knowledgebase's own tools.
| Argument |
Accepted values |
request |
Raw request text, up to 1 MiB |
response |
Raw response text, up to 1 MiB |
response_time_ms |
Integer from 0 to 3,600,000, shown in the footer |
output_path |
Workspace-relative path of a new .png file, such as findings/login.png |
annotations |
Up to 10 marks (box, caption or redaction), each with exactly one selector |
theme |
light or dark |
layout |
stack (request above response) or side |
ratio |
1:1, 4:3, 3:4 or 16:9; the width is fixed |
Missing directories in output_path are created. Hidden components such as .cache/
are rejected, and the file name must end in .png.
Each annotation names a target (request or response) and exactly one of match
(optionally regex, occurrence), line (optionally start_col, end_col), lines
or element. It can add a label or note, set box and color, and redact the
matched value. Unknown annotation fields are rejected.
A literal match is copied exactly from the message. A regex match runs on the message
after line endings are normalized to LF and each tab is expanded to four spaces.
line, start_col and end_col count from 1. end_col without start_col boxes the
line from column 1.
Redaction keeps keep characters readable at each end of the blurred value: 3 by
default, so a 13-character secret shows 6 of its characters. Use keep: 0 for
passwords, tokens and cookies.
{"status": "ok", "data": {"path": "findings/login.png"}}
Existing files are never overwritten; use a new path for each call.
Malformed or wrongly typed arguments are rejected by MCP schema validation as a protocol
error, not with the status/error envelope. These include an unknown annotation field,
a bad color, ratio, theme or layout, a line or column below 1, a negative
occurrence or keep, and a response_time_ms outside its range. Other errors use
the envelope:
| Prefix |
Meaning |
INVALID: |
Bad annotation (named as annotations[i]), too many annotations, oversized or non-Unicode input, or marks that do not fit on one screen |
CONFIGURATION: |
The workspace directory is not set or does not exist |
PATH_DENIED: |
output_path escapes the workspace or uses a hidden component or symlink |
FILE_EXISTS: |
output_path already exists |
IO_ERROR: |
The file could not be written |
RENDER_FAILED: |
Unexpected rendering failure |
Response helpers
Success uses status: "ok"; failure uses status: "error" and one error string:
{"status": "error", "error": "INVALID: image_path is required"}
Response builders for utility MCP tools.
error_response(error)
Build an error response containing the supplied message.
Source code in src/justpen_utility_mcp/responses.py
| def error_response(error: str) -> dict[str, Any]:
"""Build an error response containing the supplied message."""
return {"status": "error", "error": error}
|
ok_response(data=None)
Build an ok response, including data only when provided.
Source code in src/justpen_utility_mcp/responses.py
| def ok_response(data: dict[str, Any] | list[Any] | None = None) -> dict[str, Any]:
"""Build an ok response, including data only when provided."""
r: dict[str, Any] = {"status": "ok"}
if data is not None:
r["data"] = data
return r
|
The registration functions below attach the documented tools to FastMCP.
The Python source is shown without changing the existing runtime implementation.
Burp-style evidence screenshot tool.
Bases: BaseModel
One mark (box, note or redaction) on the request or the response.
Source code in src/justpen_utility_mcp/tools/burp.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96 | class BurpAnnotation(BaseModel):
"""One mark (box, note or redaction) on the request or the response."""
# A misspelled field such as "redacted" must fail instead of drawing a secret in clear text.
model_config = ConfigDict(extra="forbid")
target: MessageKind = Field(description="Which panel the mark goes on: request or response")
match: str | None = Field(
default=None,
max_length=200,
description="Text to box, copied exactly from the message. A regular expression when regex is true",
)
regex: bool = Field(
default=False,
description=(
"Interpret match as a regular expression. It runs on the message with LF line endings and "
"each tab expanded to four spaces"
),
)
occurrence: int = Field(
default=1,
ge=0,
description="Which match to mark, counting from 1. 0 marks every match",
)
line: int | None = Field(default=None, ge=1, description="Box a whole line (line number counting from 1)")
start_col: int | None = Field(
default=None, ge=1, description="With line: first column, counting from 1. Default 1 when end_col is given"
)
end_col: int | None = Field(default=None, ge=1, description="With line: last column, inclusive")
lines: list[int] | None = Field(default=None, description="[first, last] line range to box as one block")
element: str | None = Field(
default=None,
description=(
"Target a panel element instead of text: "
+ ", ".join(UI_ELEMENT_NAMES)
+ " (bytes, millis, status and tab:render exist on the response only). "
"For example element='millis' marks the response time and element='bytes' the size"
),
)
label: str | None = Field(
default=None, max_length=120, description="Short caption written next to the box and joined by an arrow"
)
note: str | None = Field(default=None, max_length=120, description="Caption with an arrow but no box")
box: bool = Field(default=True, description="false draws no box")
color: ColorName | HexColor = Field(
default="red", description="red, orange, yellow, green, blue, black, white, purple or #rrggbb"
)
redact: bool = Field(
default=False,
description=(
"Blur the matched value. Give match only the value to hide, not the whole line. "
"Only the first match is redacted unless occurrence is 0. "
"keep characters stay readable at each end; use keep=0 for passwords, tokens and cookies"
),
)
keep: KeepCount | list[KeepCount] | None = Field(
default=None,
description=(
"With redact: characters left readable at each end of the blur, one number for both ends or "
"[start, end]. Default 3, which leaves 6 characters of a secret visible; use 0 for passwords, "
"tokens and cookies"
),
)
|
Register the evidence screenshot tool on the MCP server.
Source code in src/justpen_utility_mcp/tools/burp.py
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293 | def register(mcp: FastMCP, *, workspace_dir: Path | None = None) -> None:
"""Register the evidence screenshot tool on the MCP server."""
@mcp.tool(name="render_burp_screenshot")
def render_screenshot(
request: str,
response: str,
response_time_ms: Annotated[int, Field(ge=0, le=MAX_RESPONSE_TIME_MS)],
output_path: str,
annotations: list[BurpAnnotation] | None = None,
theme: ThemeName = "light",
layout: LayoutName = "stack",
ratio: Ratio = "1:1",
) -> dict[str, Any]:
"""Render an HTTP request/response pair as a Burp Suite Repeater style PNG in the workspace.
Marks important places with boxes, captions and redactions. The PNG is written to
output_path inside the workspace; no image is returned. Missing directories in
output_path are created; hidden components are rejected and the name must end in .png.
The footer shows the real response size and response_time_ms. Existing files are never
overwritten, so use a new path for each call. Mark only places that matter: at most 10
annotations, each with exactly one selector (match, line, lines or element). Copy match
text exactly from the message; a regex match runs on the message with LF line endings
and tabs expanded to four spaces. Redaction covers only the first match unless
occurrence is 0, and keep characters (default 3) stay readable at each end; use keep=0
for passwords, tokens and cookies.
Args:
request: The raw HTTP request text, up to 1 MiB.
response: The raw HTTP response text, up to 1 MiB.
response_time_ms: The real response time in milliseconds, shown in the footer.
output_path: Workspace-relative path of the new PNG, such as findings/login.png.
Missing directories are created; hidden components are rejected; the name
must end in .png.
annotations: Marks to draw on the request and response, at most 10.
theme: light or dark.
layout: stack puts request above response; side puts them next to each other.
ratio: Image shape: 1:1, 4:3, 3:4 or 16:9. Width is fixed.
Returns on success:
data: {path: str} — the written path relative to the workspace
Errors:
Malformed or wrongly typed arguments (an unknown annotation field, a bad color,
ratio, theme or layout, a line or column below 1, a negative occurrence or keep) are
rejected by MCP schema validation as a protocol error, not with the status/error
envelope below.
INVALID — bad annotation (named as annotations[i]), too many annotations,
oversized or non-Unicode input, or marks that do not fit on one screen
CONFIGURATION — the workspace directory is not set up
PATH_DENIED — output_path escapes the workspace or uses a hidden component or symlink
FILE_EXISTS — output_path already exists
IO_ERROR — the file could not be written
RENDER_FAILED — unexpected rendering failure
"""
specs = annotations or []
if (problem := _check_inputs(request, response, specs)) is not None:
return error_response(problem)
if workspace_dir is None or not workspace_dir.is_dir():
return error_response("CONFIGURATION: the workspace directory is not set or does not exist")
try:
parts = validate_output_path(output_path, workspace_dir)
settings = RenderSettings(millis=response_time_ms, theme=theme, layout=layout, ratio=ratio)
png = render_burp_screenshot(request, response, [_to_renderer(a) for a in specs], settings)
written = write_png(workspace_dir, parts, png)
except WorkspaceError as exc:
return error_response(f"{exc.kind}: {exc}")
except RenderError as exc:
return error_response(_render_error_message(exc, specs))
except Exception as exc: # noqa: BLE001 - any renderer failure maps to RENDER_FAILED without echoing content
logger.error("render failed: %s", type(exc).__name__) # noqa: TRY400 - a traceback could carry request text
return error_response("RENDER_FAILED: the screenshot could not be rendered; try different annotations")
return ok_response(data={"path": written})
|
QR code decoding tool.
Register the local QR decoding tool on the MCP server.
Source code in src/justpen_utility_mcp/tools/qr.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88 | def register(mcp: FastMCP) -> None:
"""Register the local QR decoding tool on the MCP server."""
@mcp.tool
async def decode_qr(image_path: str) -> dict[str, Any]:
"""Decode a single QR code from a local image file.
Returns on success:
data: {payload: str} — the raw payload string
Errors:
INVALID — empty path or file not found
DECODE_FAILED — corrupt image, no QR found, or multiple QRs
DEPENDENCY_MISSING — zxingcpp or Pillow not installed
"""
if not image_path or not image_path.strip():
return error_response("INVALID: image_path is required")
try:
payload = _decode_qr_from_image(Path(image_path))
except ImportError as e:
install_name = {"zxingcpp": "zxing-cpp", "PIL": "Pillow"}.get(e.name or "", e.name or "required dependency")
return error_response(f"DEPENDENCY_MISSING: {install_name} not installed")
except FileNotFoundError:
return error_response(f"INVALID: image not found: {image_path}")
except ValueError as e:
kind = str(e)
messages = {
"invalid_image": "failed to read image: not a valid image file",
"no_qr_found": "no QR code found in image",
"multiple_qrs_found": "multiple QR codes found (expected 1)",
}
msg = messages.get(kind, f"unexpected decode error: {kind}")
return error_response(f"DECODE_FAILED: {msg}")
return ok_response(data={"payload": payload})
|
TOTP code generation tool.
Register the Base32 TOTP generation tool on the MCP server.
Source code in src/justpen_utility_mcp/tools/totp.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102 | def register(mcp: FastMCP) -> None:
"""Register the Base32 TOTP generation tool on the MCP server."""
@mcp.tool
async def generate_totp(
secret: str,
digits: int = 6,
period: int = 30,
algorithm: str = "sha1",
) -> dict[str, Any]:
"""Generate a TOTP code from a Base32 secret.
Args:
secret: Base32 encoded TOTP secret (spaces/dashes/mixed case OK)
digits: Code length — 6, 7, or 8 (default: 6)
period: Time period in seconds (default: 30)
algorithm: Hash algorithm — sha1, sha256, sha512 (default: sha1)
Returns on success:
data: {code: str, expires_in: int}
Errors:
INVALID — bad secret, unsupported digits/period/algorithm
"""
if not secret or not secret.strip():
return error_response("INVALID: secret is required")
if digits not in _VALID_DIGITS:
return error_response(f"INVALID: digits must be 6, 7, or 8 (got {digits})")
if period <= 0:
return error_response(f"INVALID: period must be positive (got {period})")
if algorithm not in _HASH_FUNCS:
return error_response(f"INVALID: algorithm must be sha1, sha256, or sha512 (got {algorithm})")
try:
secret_bytes = _normalize_secret(secret)
except ValueError:
return error_response("INVALID: secret is not valid Base32")
code, remaining = _generate_code(secret_bytes, digits, period, algorithm)
return ok_response(data={"code": code, "expires_in": remaining})
|