Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Files that get shipped to an AmigaOS machine byte-for-byte must keep
# LF line endings, whatever the checkout platform does by default.
#
# The install scripts and the archive README are uploaded to targets
# exactly as they sit in the working tree. Checked out on Windows with
# the usual autocrlf, they gain CRLF, which puts a stray carriage
# return on the end of every AmigaDOS script line and shows up as ^M
# in Amiga text viewers.
mcpd/install/* text eol=lf

# The daemon sources are cross-compiled from the working tree by the
# Docker toolchain; keep them LF for the same reason a Unix-side build
# would expect.
mcpd/src/** text eol=lf
mcpd/Makefile text eol=lf
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

## Unreleased

### Added

- **`scripts/build_release_lha.py`** — assembles the MCPd release
archive on an AmigaOS target (a QEMU guest will do), so the AmigaOS
protection bits are already correct in the file users download.
Protection bits belong to the file rather than the byte stream, and
no cross-platform container preserves them: a bare ELF moved onto an
Amiga over SMB, a USB stick, or a browser download arrives with the
executable bit protected and refuses to run. The script stages the
daemon, the install scripts and a README, sets the executable and
script bits, runs `LhA`, then extracts the archive again and checks
the flags came back before accepting it. `MCPd-1.3.lha` is attached
to the v1.3 release.

### Changed

- Documentation pass across the README and everything it links to,
Expand Down
1 change: 1 addition & 0 deletions COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ Located in `scripts/`. Run with `python scripts/<name>.py`.
|---|---|
| `run_installer_x5000.py` | Drive an end-to-end X5000 install via `installer_run` (defaults to dry-run). |
| `run_installer_stage.py` | Drive `installer_stage` to upload the ISO + Update LHAs + Enhancer + extras + MCPd to a target. |
| `build_release_lha.py --target <name> [--version V] [--out DIR]` | Assemble the release archive on an AmigaOS target (a QEMU guest is fine) so the AmigaOS protection bits are baked into the file users download. Stages the daemon, the install scripts and a README, sets the executable and script bits, runs `LhA`, extracts the result again to prove the flags survived, then downloads the archive and tidies up after itself. |
| `deploy_mcpd_x5000.py` | One-shot deploy of a freshly built MCPd to a running X5000 (auto-start install + watchdog). |

### Diagnostics and probes
Expand Down
33 changes: 31 additions & 2 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,8 +472,37 @@ eleven seconds of cold boot.

### Manual install (no host helper)

Copy `mcpd/MCPd` and the scripts from `mcpd/install/` onto the
target, then from a Shell on the target:
The simplest route is the release archive. Download
`MCPd-<version>.lha` from the [latest
release](https://github.com/derfsss/MCP-AmigaOS4/releases/latest) onto
the Amiga and, from a Shell:

```
LhA x MCPd-1.3.lha
Execute MCPd/MCPd-Install
```

The archive is assembled on AmigaOS, so every file already carries the
right protection bits — the daemon is runnable the moment it is
extracted.

**Why that matters.** AmigaOS protection bits are a property of the
file, not of its contents, and no cross-platform container preserves
them. A bare `MCPd` ELF fetched from the release page and moved onto
the Amiga over SMB, a USB stick, or a browser download arrives with
the `e` (executable) bit protected, and the daemon then refuses to run
for a reason that looks nothing like the cause. If you do transfer the
bare binary that way, fix it by hand:

```
Protect SYS:System/MCPd/MCPd +rwed
```

`MCPd-Install` and `scripts/install_mcpd_autostart.py` both set the
bits themselves, so only manual copies need this.

Alternatively, copy `mcpd/MCPd` and the scripts from `mcpd/install/`
onto the target yourself, then:

```
CD <directory containing the files>
Expand Down
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,26 @@ uv run amiga-fleet-mcp --init
uv run amiga-fleet-mcp --health-check
```

The Amiga side needs `MCPd` running on each target. Download the
binary from the [latest
release](https://github.com/derfsss/MCP-AmigaOS4/releases/latest) and
either run `MCPd-Install` from a Shell on the machine, or deploy it
The Amiga side needs `MCPd` running on each target. Either deploy it
from the host:

```sh
python scripts/install_mcpd_autostart.py <target-ip>:4322
```

or, on the Amiga itself, grab `MCPd-<version>.lha` from the [latest
release](https://github.com/derfsss/MCP-AmigaOS4/releases/latest):

```
LhA x MCPd-1.3.lha
Execute MCPd/MCPd-Install
```

The archive is assembled on AmigaOS, so the protection bits are
already correct — extract and the daemon runs. (A raw ELF copied over
SMB or a USB stick arrives with the executable bit protected and has
to be `Protect +rwed`-ed by hand.)

Either way MCPd lands in `SYS:System/MCPd/`, gains a watchdog, and
auto-starts on boot. Then register the server with your client — for
Claude Code:
Expand Down
73 changes: 73 additions & 0 deletions mcpd/install/Archive-README
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
MCPd 1.3 - the AmigaOS 4 daemon for MCP-AmigaOS4
================================================

MCPd lets an MCP-aware client on another machine drive this Amiga:
browse and edit files, run AmigaDOS commands, read live system state,
capture the screen, and more. It listens on TCP port 4322, with a UDP
discovery responder on 4323.

This archive was assembled on AmigaOS, so every file already carries
the right protection bits. Unpack it with LhA and the binary is
runnable as-is - no Protect needed:

LhA x MCPd-1.3.lha


Installing
----------

From a Shell, in the directory this archive unpacked into:

Execute MCPd/MCPd-Install

That copies MCPd to SYS:System/MCPd/, installs the watchdog wrapper,
backs up S:Network-Startup, and adds a launch line so the daemon
starts on boot. Reboot, or start it straight away with:

Run >NIL: <NIL: SYS:System/MCPd/MCPd

To remove it again:

Execute SYS:System/MCPd/MCPd-Uninstall


What is in here
---------------

MCPd the daemon (PowerPC ELF)
MCPd-Install install + register for auto-start
MCPd-Uninstall undo the above
MCPd-Watchdog relaunch wrapper, used by the auto-start
MCPd-Enable-Input allow keyboard / mouse injection
MCPd-Disable-Input disallow it again


Before you enable input injection
---------------------------------

MCPd has NO authentication on its listener. Anyone who can reach port
4322 can read and write any file this machine can see, and run
AmigaDOS commands. Run it on a trusted, private network only.

Keyboard and mouse injection is the one part that is off until you
switch it on, because it types and clicks as the logged-in user in
whatever window has focus. MCPd-Enable-Input opens that gate and
prints a warning first; the setting is read once at daemon startup,
so restart MCPd afterwards.


Requirements
------------

AmigaOS 4.1 Final Edition
bsdsocket.library (Roadshow)
z.library 53+ (ships with Update 2 and later) - used for
compressed uploads and for screen capture


Documentation and source
------------------------

https://github.com/derfsss/MCP-AmigaOS4

BSD 3-Clause licensed. Copyright (c) 2026 Richard Gibbs.
4 changes: 3 additions & 1 deletion mcpd/install/MCPd-Install
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ Echo ""
Echo "MCPd-Install: done. Reboot to auto-launch MCPd on :4322."
Echo " Or launch immediately: Run >NIL: <NIL: SYS:System/MCPd/MCPd"
Echo ""
Echo "Note: keyboard/mouse injection (input.*) is DISABLED. Clients"
; '**' prints one literal asterisk: '*' is the AmigaDOS Echo escape
; character, so "input.*)" would print as "input.)".
Echo "Note: keyboard/mouse injection (input.**) is DISABLED. Clients"
Echo " get -32003 until you run MCPd-Enable-Input and restart"
Echo " MCPd. Read the warning in that script before you do."
Quit 0
194 changes: 194 additions & 0 deletions scripts/build_release_lha.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"""Assemble the MCPd release archive *on AmigaOS*, so the protection
bits are right in the file the user downloads.

Why not zip it on the host? Because AmigaOS protection bits are part
of the file, not the byte stream. A raw ELF fetched from a release
page and copied onto an Amiga over SMB, a USB stick, or a browser
download arrives with the `e` (executable) bit protected, and the
daemon then refuses to run for a reason that looks nothing like the
cause. Zip and tar have no concept of those bits either.

LhA does. Building the archive on a real AmigaOS filesystem, with the
bits already set, means `LhA x` restores them on the far side and the
binary is runnable straight out of the archive. The script bit on the
install scripts proves it survived: `s` is never a filesystem
default, so seeing `-s--rwed` after extraction means the flags came
out of the archive rather than from the volume.

The assembly happens on whichever target you point this at — a QEMU
guest is the obvious choice, since it needs no hardware and leaves no
trace.

Usage:
python scripts/build_release_lha.py --target qemu-pegasos2-sm501
python scripts/build_release_lha.py --target qemu-pegasos2-sm501 \\
--version 1.4 --out dist/
"""

from __future__ import annotations

import argparse
import asyncio
import re
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from _paths import repo_root

sys.path.insert(0, str(repo_root() / "host" / "src"))

from amiga_fleet_mcp.config import load_config
from amiga_fleet_mcp.fleet import Fleet
from amiga_fleet_mcp.tools import exec as exec_tool
from amiga_fleet_mcp.tools import fs as fs_tool

# Everything the archive ships, in the order it is staged.
SCRIPTS = (
"MCPd-Install",
"MCPd-Uninstall",
"MCPd-Watchdog",
"MCPd-Enable-Input",
"MCPd-Disable-Input",
)

STAGE = "RAM:mcpd-dist"
DRAWER = "MCPd"


def daemon_version(root: Path) -> str:
"""Read the version out of rpc.h -- the single source of truth."""
text = (root / "mcpd" / "src" / "rpc.h").read_text(encoding="utf-8")
m = re.search(r'#define\s+MCPD_VERSION_STR\s+"([^"]+)"', text)
if not m:
raise SystemExit("could not find MCPD_VERSION_STR in mcpd/src/rpc.h")
return m.group(1)


async def sh(fleet: Fleet, target: str, cmd: str, *,
cwd: str | None = None, timeout: float = 120.0,
allow_fail: bool = False) -> str:
r = await exec_tool.exec_cmd(fleet, target, cmd, cwd=cwd,
timeout_s=timeout)
if r.exit_code != 0 and not allow_fail:
raise SystemExit(f"{cmd!r} failed rc={r.exit_code}: {r.output[:400]}")
return r.output


async def rm(fleet: Fleet, target: str, path: str, *,
all_: bool = False) -> None:
"""Delete something that may or may not be there.

AmigaDOS `Delete` returns a warning (5) for a missing object even
with QUIET, so the tidy-up calls must not treat that as failure.
"""
await sh(fleet, target,
f'Delete >NIL: {path}{" ALL" if all_ else ""} QUIET',
allow_fail=True)


async def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--target", required=True,
help="a reachable AmigaOS target to build on")
ap.add_argument("--version", help="override the version string "
"(default: read from rpc.h)")
ap.add_argument("--out", default="dist",
help="host directory for the finished archive")
ap.add_argument("--readme",
help="README to include in the archive (default: "
"mcpd/install/Archive-README)")
args = ap.parse_args()

root = repo_root()
version = args.version or daemon_version(root)
archive = f"MCPd-{version}.lha"
binary = root / "mcpd" / "MCPd"
if not binary.is_file():
raise SystemExit(f"{binary} not built -- run `make docker-build` "
"in mcpd/ first")

fleet = Fleet(load_config())
t = args.target
print(f"building {archive} on {t}", flush=True)

# Fresh staging tree every run: a leftover file from a previous
# build would be archived silently.
await rm(fleet, t, STAGE, all_=True)
await fs_tool.fs_makedir(fleet, t, STAGE)
await fs_tool.fs_makedir(fleet, t, f"{STAGE}/{DRAWER}")

up = await fs_tool.fs_upload(
fleet, t, local_path=str(binary),
remote_path=f"{STAGE}/{DRAWER}/MCPd", verify=True)
print(f" MCPd {up.bytes_total} bytes, sha256 verified", flush=True)

for name in SCRIPTS:
await fs_tool.fs_upload(
fleet, t, local_path=str(root / "mcpd" / "install" / name),
remote_path=f"{STAGE}/{DRAWER}/{name}", verify=True)
print(f" {len(SCRIPTS)} install scripts", flush=True)

readme = Path(args.readme) if args.readme else (
root / "mcpd" / "install" / "Archive-README")
if readme.is_file():
await fs_tool.fs_upload(
fleet, t, local_path=str(readme),
remote_path=f"{STAGE}/{DRAWER}/README", verify=True)
print(f" README (from {readme.name})", flush=True)
else:
print(f" no README at {readme} -- archive will omit it",
flush=True)

# The bits that have to survive the trip. The uploads already land
# as ----rwed; `s` marks the AmigaDOS scripts executable by name.
await sh(fleet, t, f'Protect {STAGE}/{DRAWER}/MCPd +rwed')
await sh(fleet, t, f'Protect {STAGE}/{DRAWER}/#?-#? +rwed')
await sh(fleet, t, f'Protect {STAGE}/{DRAWER}/#?-#? +s')

await rm(fleet, t, f"RAM:{archive}")
await sh(fleet, t, f'C:LhA -r -e a RAM:{archive} {DRAWER}', cwd=STAGE)

# Prove the flags round-trip rather than assuming it: extract into
# a clean directory and look at what comes out. `s` is never a
# filesystem default, so it can only have come from the archive.
await rm(fleet, t, "RAM:mcpd-verify", all_=True)
await fs_tool.fs_makedir(fleet, t, "RAM:mcpd-verify")
await sh(fleet, t, f'C:LhA x RAM:{archive} RAM:mcpd-verify/')
listing = await sh(fleet, t, f'List RAM:mcpd-verify/{DRAWER}')
print("\n extracted flags:", flush=True)
ok = True
for line in listing.splitlines():
parts = line.split()
if len(parts) < 3 or not parts[1].isdigit():
continue
name, _size, flags = parts[0], parts[1], parts[2]
print(f" {name:22s} {flags}", flush=True)
if "e" not in flags.split("-")[-1]:
ok = False
print(" ^ NOT executable", flush=True)
if name.startswith("MCPd-") and "s" not in flags:
ok = False
print(" ^ script bit missing", flush=True)
if not ok:
raise SystemExit("protection bits did not survive the archive")

out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
local = out_dir / archive
dl = await fs_tool.fs_download(
fleet, t, remote_path=f"RAM:{archive}",
local_path=str(local), verify=True)

# Leave the target as we found it.
await rm(fleet, t, STAGE, all_=True)
await rm(fleet, t, "RAM:mcpd-verify", all_=True)
await rm(fleet, t, f"RAM:{archive}")

print(f"\n {local} ({dl.bytes_total} bytes)", flush=True)
print(f" sha256 {dl.sha256}", flush=True)
return 0


if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
Loading