Skip to content

Repository files navigation

dos-mcp

CI Integration License: MIT

A Model Context Protocol server that lets AI agents load, drive, observe, and move files in and out of DOS programs running under js-dos.

Built for reverse-engineering and retro-porting work, where the AI needs to interact with a real DOS binary as the source of truth — sending keystrokes, capturing screens, and copying files between host and the virtual DOS filesystem.

Status

22 tools, covering session control, input, observation, the virtual DOS filesystem, and read-only access to guest memory. Usable today.

read_memory and search_memory landed in 0.2.0, which was the feature that motivated the project: byte-level inspection of a running DOS program. Save-state snapshot and restore is the main thing still missing. Breakpoints and stepping remain speculative.

See CHANGELOG.md for release history.

Install

Needs Node 22.13 or newer. Add it to your MCP client's config and let npx fetch it:

{
  "mcpServers": {
    "dos-mcp": {
      "command": "npx",
      "args": ["-y", "dos-mcp"]
    }
  }
}

That is the whole setup. The DOS emulator arrives as an ordinary dependency, pinned to emulators@8.4.2, so there is nothing to build and no environment variable to set.

One real cost, stated plainly. Puppeteer downloads its own browser on install: a full Chrome plus a headless shell, together about 560 MB on disk, and each Puppeteer version caches another copy rather than replacing the last. This is zero configuration, not zero download.

Behind an HTTP proxy that download needs proxy-agent, which is no longer installed for you:

npm install proxy-agent

Puppeteer 25 made it an optional peer dependency, so HTTP_PROXY and HTTPS_PROXY are ignored until it is present, and the download fails without saying why.

From a clone

git clone https://github.com/abedegno/dos-mcp.git
cd dos-mcp
npm install
npm run build

Use

Install above shows the basic config. For an attended session where you can watch the AI drive:

{
  "mcpServers": {
    "dos-mcp": {
      "command": "npx",
      "args": ["-y", "dos-mcp", "--attended"]
    }
  }
}

In an attended session you can also play. Click the game to capture the mouse; the game then gets raw relative movement, which keeps the cursor in step for games that track the mouse themselves, such as Ultima Underworld. Press Esc to release it.

Restart your client. The AI will see the tools below in its tool list.

Tools

Session control

Tool Description
load_bundle(source, autoexec?, mirror?) Mount a directory / .zip / .jsdos as drive C: and optionally run autoexec commands. Mirror pairs live-sync virtual DOS dirs back to host paths on wait / fs_sync / shutdown.
shutdown() Tear down the emulator.
wait(ms) Let the emulator tick; also flushes pending mirror writes.

Input

Tool Description
send_keys(text, key_delay_ms?) Type literal text. \n is Enter and \t is Tab; there is no token syntax.
send_key_sequence(keys) Named-key sequence with modifier support (e.g. ["Ctrl+F5", "Escape", "ArrowUp"]).
send_click(x, y, button?) Click at canvas-relative coordinates. Moves there first, so it discards a position set by move_mouse_relative.
move_mouse(x, y) Move the cursor without clicking.
move_mouse_relative(dx, dy) Move by a relative delta. Needed for games that track the cursor from INT 33h deltas rather than reading the absolute position, which includes Ultima Underworld.
click_at_cursor(button?, hold_ms?) Press and release where the cursor already is, holding for hold_ms (default 120) so a slow-polling guest sees it.
mouse_button(pressed, button?) Press or release a button and leave it that way, for drags with move_mouse_relative in between. A button left down is released at shutdown.

Observation

Tool Description
screenshot(format?) Capture the current frame (PNG default, JPEG optional).
get_status() Returns { running, dos_time_ms, last_error }.

Virtual DOS filesystem

Tool Description
fs_read(dos_path) Read a file from the virtual DOS FS.
fs_write(dos_path, bytes_base64) Write a file to the virtual DOS FS.
fs_list(dos_path) List a directory (returns { name, size, is_dir }[]).
fs_stat(dos_path) Stat one entry without listing its parent.
fs_delete(dos_path) Delete a file.
fs_push_dir(host_path, dos_path) Recursively copy a host dir into the virtual DOS FS.
fs_pull_dir(dos_path, host_path) Recursively copy a virtual DOS FS subtree to host.
fs_sync() Flush any pending mirrored writes to their host dirs.

Guest memory (read-only)

Tool Description
read_memory(address | segment+offset, length) Read the guest's emulated DOS memory. Returns base64.
search_memory(pattern_base64, max_hits?, start?, end?) Find a byte pattern in guest memory, returning physical addresses.

Addresses are DOS physical. A disassembly's real-mode seg:off can be passed as segment and offset instead, which is segment * 16 + offset.

search_memory exists because a segment base is usually not known in advance: search for content you know is loaded, then read relative to the hit. The scan runs beside the memory in the browser, so nothing large is transferred.

The upper bound on a read is the emulator's wasm heap rather than the guest's configured RAM, which is not discoverable from outside. Reading far above the guest's memory returns emulator internals rather than failing. Conventional memory, which is what real-mode addresses cover, is always well inside the valid range.

Paths accept C:/FOO/BAR.DAT, C:\FOO\BAR.DAT, or /FOO/BAR.DAT (forward-slash unix-style, drive-prefix optional). They're normalised internally.

Example: round-trip a DOS save file

1.  load_bundle(source="/path/to/UW1", autoexec=["UW.EXE"])
2.  fs_push_dir(host_path="/path/to/port-saves/SAVE1", dos_path="C:/SAVE1")
3.  send_key_sequence(["Escape"]) x3, with ~2500ms waits   # title and intro
4.  screenshot()                                  # confirm the main menu
5.  move_mouse_relative(dx=-4000, dy=-4000)       # clamp into the corner
6.  move_mouse_relative(dx=320, dy=322)           # "Journey Onward", observed position
7.  click_at_cursor(hold_ms=200)
8.  wait(2000); screenshot()                      # the save slot list
9.  ... corner-slam again, then click slot 1, observed near (320, 210)
10. wait(2000); screenshot()                      # verify the restore landed
11. fs_pull_dir(dos_path="C:/SAVE2", host_path="/path/to/dos-saves")
12. shutdown()

Then the host process can byte-diff the port-written SAVE1 against the DOS-written SAVE2 to spot format drift.

Three things in that sequence are not obvious, and each one cost real time to find:

  • send_keys types literal text. send_keys("{Enter}") types seven characters. Named keys go through send_key_sequence.
  • Absolute mouse motion cannot position UW's cursor. UW reads INT 33h function 0x0B and accumulates relative mickeys into its own tracker, ignoring the absolute position function 0x03 reports, so send_click and move_mouse cannot place it. Clamp into a corner with a large negative delta, then move by the target offset. The 1:1 correspondence between delta and frame pixel is measured for UW at default sensitivity, not guaranteed in general, so check a screenshot rather than trusting the arithmetic.
  • A click has to be held across real time. click_at_cursor defaults to 120ms because a press and release in the same instant is missed entirely: the guest polls the mouse and the emulator never ticks between two near-identical timestamps.

Judging the result from screenshots needs care too. Frames only push when the buffer changes, so a still frame is not proof of a hung guest. Comparing image hashes does not work either: on UW's menus roughly 2.5% of pixels were observed changing from background palette animation alone, so every pair of frames hashes differently while telling you nothing.

Compare the fraction of changed pixels instead. There is no safe universal threshold, though: a menu highlight, a cursor move or a small dialog can change fewer pixels than the animation does, so a cutoff chosen to ignore the animation will also ignore those. Prefer a reference frame for each outcome you care about and ask which one a capture is closer to.

Attaching to a running session

The emulated DOS filesystem lives inside the browser the server owns. Restarting rebuilds it from the host directory, which destroys anything DOS itself wrote, so reading a save the guest produced means attaching to the live process instead of relaunching. Three scripts do that. They need a session already running, and they leave it running.

node dos-pull.mjs <label> [outDir]   # copy save slots out to <outDir>/<label>/SAVE1/...
node dos-push.mjs <dir> [slot]       # copy a save in, default slot SAVE1
node dos-shot.mjs [out.png]          # screenshot, and report whether the guest is drawing

dos-pull.mjs writes to ./dos-saves unless you pass a directory or set DOS_SAVES_DIR.

Together they make a byte-level bisection loop practical: pull a save the guest wrote, edit it on the host, push it back, reload the slot from the game's own menu, and repeat, without ever restarting the emulator.

Save slots sit at the root of the mount, as SAVE1/LEV.ARK rather than C:\GAME\SAVE1. A push into a slot the game has never created reports success and does nothing, so dos-push.mjs reads each file back and fails if it did not land. Save to a slot once from inside the game before pushing to it.

dos-shot.mjs takes two frames a moment apart. Identical frames mean idle or hung, because DOSBox only pushes a frame when the screen changes. It also asks the emulator for its filesystem, which separates the two cases that matter: a guest ignoring all input while the emulator still answers is hung on its own account, not dead underneath.

Architecture

 MCP client (AI agent)
        │ stdio / MCP protocol
        ▼
 ┌──────────────────────────┐
 │ dos-mcp Node process     │
 │  • MCP tool registry     │
 │  • Puppeteer controller  │
 │  • Mirror-sync tracker   │
 └────────────┬─────────────┘
              │ Chrome DevTools Protocol
              ▼
 ┌──────────────────────────┐
 │ Chromium (headless or    │
 │ --attended)              │
 │  • js-dos v8 + bundle    │
 │  • emulated 16 MB DOS    │
 │    memory (WASM heap)    │
 └──────────────────────────┘

The emulator is emulators@8.4.2, a pinned npm dependency, served to the host page from node_modules. There is no CDN fallback and no directory scanning: one pinned build, or a clear error. Setting DOSMCP_JSDOS_DIR to a built emulators dist overrides it, which is how an unreleased emulator patch gets tested, and a value pointing somewhere unusable is fatal rather than a silent downgrade.

A Backend interface abstracts the emulator so unit tests can use an in-memory FakeBackend without spinning up Chromium.

Develop

npm install
npm run typecheck
npm run lint
npm run test:unit         # fast, no emulator
npm run test:integration  # slow, real Chromium + js-dos
npm run build

Full contribution guidelines: CONTRIBUTING.md.

Licensing

dos-mcp itself is MIT-licensed (see LICENSE). The js-dos runtime it depends on, published as the emulators package, is GPL-2.0. You can use dos-mcp freely, but if you distribute a derivative work, or a product that bundles js-dos or emulators.js, the GPL-2.0 copyleft obligations apply to that distribution. dos-mcp declares emulators as a dependency and npm installs it from the npm registry; the published dos-mcp package does not itself contain emulators.js.

Further reading:

About

MCP server that lets AI agents drive real DOS programs under js-dos: send input, capture the screen, and move files in and out of the virtual DOS filesystem.

Topics

Resources

Contributing

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages