Metadata-Version: 2.5
Name: arcade-mcp-host-config
Version: 0.1.1
Summary: Client-side MCP host config (hooks) shared by Arcade toolkits
Author-email: Arcade <dev@arcade.dev>
License: Proprietary - Arcade Software License Agreement v1.0
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: arcade-mcp-server<2.0.0,>=1.17.0; extra == 'dev'
Requires-Dist: mypy>=1.5.1; extra == 'dev'
Requires-Dist: pre-commit<3.5.0,>=3.4.0; extra == 'dev'
Requires-Dist: pytest-cov<4.1.0,>=4.0.0; extra == 'dev'
Requires-Dist: pytest>=8.1.2; extra == 'dev'
Requires-Dist: ruff>=0.11.0; extra == 'dev'
Description-Content-Type: text/markdown

# 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.

```python
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

```python
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:

```python
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

```python
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:

```python
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, …}`.

```python
{"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

```sh
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.
