arcade-mcp-host-config
Client-side MCP host config shared by Arcade toolkits. The first — and today the only —
piece is attachment substitution: the preToolUse hook that turns a local file path
into the file's bytes on the user's machine, before the tool call leaves the client.
This package is the single source of truth for that script and the types around it. A toolkit that accepts file attachments imports it instead of carrying its own copy.
from arcade_mcp_host_config.attachments import (
HOOK_SCRIPT,
build_missing_hook_payload,
classify_attachment_source,
)
No runtime dependencies. It describes the error a toolkit raises; it never constructs one,
so it does not import arcade-core.
Why it exists
Two toolkits each grew a private copy of "the attachment hook." The copies did not merely duplicate each other — they diverged: different filenames, different scoping (one by tool name, one by structure), different input shapes, different deny payloads. Neither could handle the other's calls. A user who installed one and then used the other toolkit got a hook that silently did nothing.
One package, one script, one filename fixes that by construction.
The hook is three things
A toolkit imports all three; only the middle one is parameterized per toolkit.
1. The substitution script
from arcade_mcp_host_config.attachments import HOOK_SCRIPT, HOOK_SCRIPT_FILENAME, MAX_BYTES
# HOOK_SCRIPT -> the full preToolUse script, as inspectable text
# HOOK_SCRIPT_FILENAME -> "arcade_attachment_substitution.py" (stable, unversioned)
# MAX_BYTES -> 25 * 1024 * 1024
The user saves HOOK_SCRIPT under HOOK_SCRIPT_FILENAME and registers it once per client.
It is registered once and shared verbatim by every toolkit.
HOOK_SCRIPT is the text of this package's attachments/_hook_source.py, read at import.
The shipped string and the module CI type-checks and unit-tests are therefore one source —
they cannot drift.
2. The missing-hook install/upgrade payload
When a file:// source reaches the server, the hook never ran — it is not installed, or
it is older than the current protocol and did not understand the input shape. Both land on
the same response:
from arcade_mcp_server.exceptions import ToolExecutionError
from arcade_mcp_host_config.attachments import build_missing_hook_payload
raise ToolExecutionError(
**build_missing_hook_payload(
docs_url="https://docs.arcade.dev/.../linear",
example_tool_name="Linear_UpsertAttachment",
display_name="Linear",
)
)
It returns exactly {message, developer_message, extra} — the ToolExecutionError
constructor's keyword arguments and nothing else. message is self-sufficient JSON: the
script text (hook_script), every client's registration block (setup_by_host), the
required protocol version, and what to ask the user. extra is deliberately lean —
hook_filename, min_version, docs_url only — and does NOT repeat hook_script/
setup_by_host: ToolRuntimeError.to_payload() spreads extra onto the same top-level
object as message, so a second copy would double the bytes an MCP client transmits and
an agent reads for zero benefit. A programmatic consumer reads the script/snippets via
json.loads(message)["hook_script"] / ["setup_by_host"].
Supported clients today: Cursor, Claude Code, Codex CLI, VS Code chat.
3. The shared schema and classifier
from arcade_mcp_host_config.attachments import (
Attachment,
AttachmentSourceScheme,
classify_attachment_source,
)
class LinearAttachment(Attachment, total=False):
title: str # Linear's field, not the package's
Attachment is the minimal common shape: source (required by convention), optional
filename and mime_type. Toolkit-specific fields are declared by the toolkit, never here.
classify_attachment_source is truthful, not a policy: http(s) classifies as URL
even for a toolkit that cannot fetch remote URLs. Accept/reject is a server-side decision
per toolkit, and it is exactly one if branch:
scheme = classify_attachment_source(attachment["source"])
if scheme is AttachmentSourceScheme.FILE:
raise ToolExecutionError(**build_missing_hook_payload(...)) # hook missing or stale
if scheme is AttachmentSourceScheme.URL:
... # Linear attaches the link; Gmail rejects it here
if scheme is AttachmentSourceScheme.DATA:
... # the normal path: split the data: URI, re-check size, upload
A toolkit migrating off a private classifier that folded http(s) into UNSUPPORTED must
add an explicit URL branch, or a remote URL will fall through to its data: handling.
Scoping contract
Two constraints, stated loudly because they are the whole safety model.
1. Structural, not tool-name. A source is rewritten only inside an attachment
(object) or attachments (list) key whose item is a dict shaped {source, …}.
{"attachment": {"source": "file:///x"}} # -> rewritten to data:
{"attachments": [{"source": "file:///x"}]} # -> rewritten to data:
{"attachments": [{"source": "https://x"}]} # -> passed through
{"source": "file:///x"} # -> UNTOUCHED (top-level)
{"attachment": {"url": "file:///x"}} # -> UNTOUCHED (no `source` key)
A hook is registered once per client and cannot tell one toolkit's call from another's, so
structure is the only signal it has. Unknown keys on an attachment item (Linear's title)
are carried across untouched.
2. file:// only. Only file:// sources are rewritten. data: and http(s):// pass
through verbatim. A file:// value in filename or mime_type is denied, as is a file
over MAX_BYTES, a missing file, or a file:// URL with a host.
Versioning and upgrades
HOOK_PROTOCOL_VERSION is stamped in the script's header comment (protocol=N, the line a
user can eyeball) and echoed in the payload as min_version.
- Install and upgrade are one flow. A stale hook that does not understand the current
input shape leaves
file://untouched → the server seesfile://→ the same missing-hook payload is served, worded "install or upgrade." - The filename is stable and unversioned, so a reinstall overwrites in place: no orphaned files, no re-registration. (Codex re-prompts for trust when the script's hash changes; that is noted in its block.)
- The substituted output is a backward-compatible contract. It stays
source: "data:<mime>;base64,<bytes>"withfilename/mime_typebackfilled. Only input shape changes may degrade to the reinstall path — an output change would silently corrupt calls made by hooks already installed on users' machines. A golden test pins it. - There is no hook-version echo in v1: the only channel is the tool input, and injecting
a reserved key risks schema validation. The
file://signal is sufficient.
Testing
make test # uv run pytest tests/ -v
make check # ruff check + ruff format --check + ty
The unit tier is pure — local files only, no client, no network. The end-to-end "install the hook, attach a real file, the toolkit substitutes it" acceptance runs through a consuming toolkit's gateway, not from this package alone.
Security model
The hook runs on the user's machine and reads any absolute local file named in an
attachment source (file://), up to MAX_BYTES, then inlines its bytes into the outbound
tool call. It is registered once per client and — being structure-scoped — runs on every MCP
tool call. This is deliberate, and the accepted risk is stated here so it is a decision, not
an oversight:
- The user installs the hook knowingly and it reads files with their own permissions — it grants an agent no access the user does not already have.
- Only attachment-shaped inputs (
attachment/attachmentsitems carrying asource) are ever rewritten; every other key and a top-levelsourceare left untouched. - A prompt-injected agent could still aim an attachment
sourceat a sensitive path (~/.ssh/id_rsa,.env) on a toolkit that accepts attachments, and the bytes would be sent to that toolkit's server. There is no directory allowlist today — the trust boundary is "the user chose to install this hook," not "these paths are safe."
If a deployment needs a tighter boundary, add an opt-in directory allowlist to the hook (see below); it is intentionally out of scope for v1.
Known gaps / future work
- Directory allowlist for the hook. An opt-in list of readable roots would narrow the file-exfiltration surface described above. Deferred; the v1 boundary is user-install trust.
- Client minimum versions are approximate.
CLAUDE_CODE_MIN_VERSION,CODEX_MIN_VERSION, andVSCODE_MIN_VERSIONin_payload.pyare hand-recorded from each client's changelog and nothing detects when a client changes its hook contract. Re-verify when bumping the protocol. - The hook's Python 3.8 runtime claim is untested.
_hook_source.pyusesfrom __future__ import annotationsto stay runnable on old systempython3, but the tests exercise it only under the dev interpreter. A syntax check against the lowest supported grammar would catch a 3.10+ construct slipping in. docs_urltargets must exist. The missing-hook payload links each consuming toolkit's public per-client install docs; a toolkit must ship those pages or the payload links a 404.- No consumer yet (intentional). The package is published and self-contained; a toolkit adopts it by adding a versioned dependency and importing from here.
Metadata
Release files for arcade-mcp-host-config 1.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| arcade_mcp_host_config-1.1.0.tar.gz | 202.7 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| arcade_mcp_host_config-1.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 229.4 kB
Release files / arcade_mcp_host_config-1.1.0.tar.gz
| Download URL | arcade_mcp_host_config-1.1.0.tar.gz |
|---|---|
| Size | 202.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ae16ea78d6f6aa4d99cc22ca8ec733080d6e3512d45eafe6b282c90f3e86bc62
|
|
BLAKE2b-256 checksum How to use checksums |
e3a229b25431bf65b48f1daeb140556d00484cb5d0355ab75581d91082222c83
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logRelease files / arcade_mcp_host_config-1.1.0-py3-none-any.whl
| Download URL | arcade_mcp_host_config-1.1.0-py3-none-any.whl |
|---|---|
| Size | 26.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4605d09868db36d6763c7e3fa02d8e7b660b303cd3cab3f7cc61677209add84e
|
|
BLAKE2b-256 checksum How to use checksums |
f69e4be6c620b507affd8ed7aea27be104d3cee2491c73763d60abda52d3135d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logSecurity for arcade-mcp-host-config 1.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for arcade_mcp_host_config-1.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.