Skip to main content

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 sees file:// → 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>" with filename/mime_type backfilled. 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 / attachments items carrying a source) are ever rewritten; every other key and a top-level source are left untouched.
  • A prompt-injected agent could still aim an attachment source at 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, and VSCODE_MIN_VERSION in _payload.py are 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.py uses from __future__ import annotations to stay runnable on old system python3, 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_url targets 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

arcade_mcp_host_config-0.1.1.tar.gz (225.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

arcade_mcp_host_config-0.1.1-py3-none-any.whl (26.7 kB view details)

Uploaded Python 3

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

Hashes for arcade_mcp_host_config-0.1.1.tar.gz
Algorithm Hash digest
SHA256 7bf3a361a7ca3d69d0775f086511742d7b4b4544b0d32647e4127397c13d6ca8
MD5 c9b655ca4f514de0373a5fb668b168f0
BLAKE2b-256 46be1817a046c0a4154eb0139d8e35d2fb6dcb9c76217a7a7f038f946625a60f

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file arcade_mcp_host_config-0.1.1-py3-none-any.whl.

File metadata

File hashes

Hashes for arcade_mcp_host_config-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 1b3742221e9c4e9778dca19a7f8a55a952026d363486b6aa0577b8a8cfaaccc7
MD5 a0aff51207be5de847e981d6caf6a6ea
BLAKE2b-256 c124ca76c22ca797dde53157e77ced14f357736d568df3caa43b3ab7a8251e0a

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page