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 + mypy
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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file arcade_mcp_host_config-0.1.1.tar.gz.
File metadata
- Download URL: arcade_mcp_host_config-0.1.1.tar.gz
- Upload date:
- Size: 225.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7bf3a361a7ca3d69d0775f086511742d7b4b4544b0d32647e4127397c13d6ca8
|
|
| MD5 |
c9b655ca4f514de0373a5fb668b168f0
|
|
| BLAKE2b-256 |
46be1817a046c0a4154eb0139d8e35d2fb6dcb9c76217a7a7f038f946625a60f
|
Provenance
The following attestation bundles were made for arcade_mcp_host_config-0.1.1.tar.gz:
Publisher:
toolkit-publish-to-pypi.yml on ArcadeAI/monorepo
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
arcade_mcp_host_config-0.1.1.tar.gz -
Subject digest:
7bf3a361a7ca3d69d0775f086511742d7b4b4544b0d32647e4127397c13d6ca8 - Sigstore transparency entry: 2582614675
- Sigstore integration time:
-
Permalink:
ArcadeAI/monorepo@7398d64653852afe839e0ab7225fa8c0e04a4c04 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/ArcadeAI
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
toolkit-publish-to-pypi.yml@7398d64653852afe839e0ab7225fa8c0e04a4c04 -
Trigger Event:
push
-
Statement type:
File details
Details for the file arcade_mcp_host_config-0.1.1-py3-none-any.whl.
File metadata
- Download URL: arcade_mcp_host_config-0.1.1-py3-none-any.whl
- Upload date:
- Size: 26.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1b3742221e9c4e9778dca19a7f8a55a952026d363486b6aa0577b8a8cfaaccc7
|
|
| MD5 |
a0aff51207be5de847e981d6caf6a6ea
|
|
| BLAKE2b-256 |
c124ca76c22ca797dde53157e77ced14f357736d568df3caa43b3ab7a8251e0a
|
Provenance
The following attestation bundles were made for arcade_mcp_host_config-0.1.1-py3-none-any.whl:
Publisher:
toolkit-publish-to-pypi.yml on ArcadeAI/monorepo
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
arcade_mcp_host_config-0.1.1-py3-none-any.whl -
Subject digest:
1b3742221e9c4e9778dca19a7f8a55a952026d363486b6aa0577b8a8cfaaccc7 - Sigstore transparency entry: 2582614683
- Sigstore integration time:
-
Permalink:
ArcadeAI/monorepo@7398d64653852afe839e0ab7225fa8c0e04a4c04 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/ArcadeAI
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
toolkit-publish-to-pypi.yml@7398d64653852afe839e0ab7225fa8c0e04a4c04 -
Trigger Event:
push
-
Statement type: