diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 69b5ab1..2aa3360 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,3 +23,24 @@ jobs:
- name: Run validate
run: task validate
+
+ cross-platform:
+ strategy:
+ matrix:
+ os: [ubuntu-latest, windows-latest, macos-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+
+ - name: Build
+ run: go build ./...
+
+ - name: Vet
+ run: go vet ./...
+
+ - name: Test
+ run: go test ./...
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..5e910c5
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,49 @@
+name: Deploy docs
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'docs/**'
+ - 'Taskfile.yml'
+ - '.github/workflows/docs.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ docs:
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deploy.outputs.page_url }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+
+ - name: Install task
+ run: go install github.com/go-task/task/v3/cmd/task@latest
+
+ - name: Build docs
+ run: task docs-build
+
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: site/
+
+ - uses: actions/deploy-pages@v4
+ id: deploy
diff --git a/.gitignore b/.gitignore
index bafcd24..0eb9add 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
# Binaries
bin/
dist/
+site/
*.exe
*.exe~
*.dll
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 4410ed2..88d901d 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -16,6 +16,7 @@ builds:
goos:
- darwin
- linux
+ - windows
goarch:
- amd64
- arm64
diff --git a/AGENTS.md b/AGENTS.md
index 0e489df..9ab62fd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,9 +4,10 @@ AI agent context for the mcp-sim Go project (mobile emulator lifecycle MCP serve
## Project Overview
-mcp-sim is an MCP server for managing iOS Simulator and Android Emulator lifecycle
-on a remote macOS host. Built-in adapters for iOS (xcrun simctl), Android (emulator + adb),
-and agent-device controller.
+mcp-sim is an MCP server for managing iOS Simulator and Android Emulator lifecycle,
+runnable on macOS, Linux, or Windows. Built-in adapters for iOS (xcrun simctl, macOS-only —
+requires Xcode), Android (emulator + adb, cross-platform), and agent-device controller
+(cross-platform).
## Backlog
@@ -16,7 +17,7 @@ Issues #1–#21 cover M1 milestone tasks. M2 hardening tracked separately.
## Current release
-v0.1.1 — patch release with bug fixes. Branch `main` holds released commits. Use `develop` for
+v0.2.0 — cross-platform support (Windows/Linux), native service install, hosted doc site. Branch `main` holds released commits. Use `develop` for
new work. Versions live in `internal/version/version.go` and are injected at
build time via `-ldflags "-X .../internal/version.Version=..."`.
@@ -26,7 +27,7 @@ build time via `-ldflags "-X .../internal/version.Version=..."`.
|----------|---------|
| `docs/architecture.md` | Adapter model + separation of concerns |
| `docs/tailscale.md` | Network deployment over Tailscale |
-| `docs/launchd.md` | macOS service management |
+| `docs/service.md` | Running mcp-sim as a native OS service (launchd/systemd/Windows Service) |
| `docs/adding-platform.md` | Implementing new Platform adapters |
| `CONTRIBUTING.md` | PR process + load-bearing separation rule |
| `pkg/contract/platform.go` | Platform interface |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f3213e1..5f5b48a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,26 @@ Nothing yet.
### Security
-->
+## [0.2.0] - 2026-07-05
+
+### Added
+
+- `mcp-sim service` subcommand (install/uninstall/start/stop/restart/status/run) via `github.com/kardianos/service` — installs mcp-sim as a native background service (launchd/systemd/Windows Service), with a `--user` mode for non-root installs on macOS/Linux.
+- Windows binaries in GoReleaser cross-compilation matrix (darwin/linux/windows × amd64/arm64).
+- Hosted doc site at https://espetro.github.io/mcp-sim/, built from `docs/` via docmd and deployed to GitHub Pages on push to `main`. `task docs` / `task docs-build` for local preview/build.
+
+### Changed
+
+- Android adapter and agent-device controller now run correctly on Windows and Linux (previously darwin/linux-only): `setProcAttr` split into `procattr_unix.go`/`procattr_windows.go` (Setpgid vs `CREATE_NEW_PROCESS_GROUP`); Android SDK home-dir probing covers macOS/Linux/Windows defaults.
+- `agent-device` controller stop now uses `cmd.Process.Kill()` instead of Unix-only `pkill -f`.
+- Default config path now resolved via `os.UserHomeDir()` instead of raw `$HOME` lookup, fixing Windows.
+- CI (`ci.yml`) now runs a cross-platform matrix (ubuntu/windows/macos) for build+vet+test.
+- `docs/launchd.md` renamed to `docs/service.md`; README/AGENTS.md wording corrected — only iOS Simulator tools require macOS, the rest of the server runs on all three OSes.
+
+### Removed
+
+- `docs/releases/` — release notes duplicated root `CHANGELOG.md`; dropped in favor of the single changelog.
+
## [0.1.1] - 2026-07-05
### Fixed
@@ -59,6 +79,7 @@ Nothing yet.
- Cross-compiled releases via GoReleaser (darwin/linux/windows × amd64/arm64)
- Homebrew tap `espetro/homebrew-mcp-sim` (when published)
-[Unreleased]: https://github.com/espetro/mcp-sim/compare/v0.1.1...HEAD
+[Unreleased]: https://github.com/espetro/mcp-sim/compare/v0.2.0...HEAD
+[0.2.0]: https://github.com/espetro/mcp-sim/compare/v0.1.1...v0.2.0
[0.1.1]: https://github.com/espetro/mcp-sim/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/espetro/mcp-sim/releases/tag/v0.1.0
diff --git a/README.md b/README.md
index 04c5916..f4c1beb 100644
--- a/README.md
+++ b/README.md
@@ -8,13 +8,13 @@
mcp-sim is resilient: each platform adapter is auto-detected at startup, and a missing tool just means that platform's tools are skipped. You can run the server with **only iOS**, **only Android**, or **both**.
-For full functionality on macOS, install:
+The server itself runs on macOS, Linux, and Windows. iOS support requires macOS + Xcode (Simulator is Apple-only tooling); Android and the agent-device controller work on all three OSes.
-| Tool | Required for | Install |
-|------|---|---|
-| Xcode + iOS Simulators | iOS tools | `xcode-select --install` (or App Store → Xcode) |
-| Android SDK + `emulator` + `adb` | Android tools | Install [Android Studio](https://developer.android.com/studio) or `brew install --cask android-commandlinetools` |
-| `agent-device` | verification controller | `brew install agent-device` (or see [agent-device docs](https://github.com/espetro/agent-device)) |
+| Tool | Required for | OS | Install |
+|------|---|---|---|
+| Xcode + iOS Simulators | iOS tools | macOS only | `xcode-select --install` (or App Store → Xcode) |
+| Android SDK + `emulator` + `adb` | Android tools | macOS, Linux, Windows | Install [Android Studio](https://developer.android.com/studio) or `brew install --cask android-commandlinetools` |
+| `agent-device` | verification controller | macOS, Linux, Windows | `brew install agent-device` (or see [agent-device docs](https://github.com/espetro/agent-device)) |
Each tool is checked via PATH probing at server startup. If `xcode-select -p` succeeds the iOS adapter registers; if `emulator` or `adb` is on PATH, the Android adapter registers; if `agent-device` resolves, the controller registers. Otherwise the relevant MCP tools are simply absent — the server still starts.
@@ -34,12 +34,15 @@ MCPSIM_AGENT_DEVICE_ENABLED=false mcp-sim serve
brew install espetro/mcp-sim/mcp-sim
```
-### go install
+
+go install (build from source)
```bash
go install github.com/espetro/mcp-sim/cmd/mcp-sim@latest
```
+
+
### GitHub Releases
Download pre-built binaries from [github.com/espetro/mcp-sim/releases](https://github.com/espetro/mcp-sim/releases).
@@ -63,7 +66,7 @@ Show version:
```bash
mcp-sim version
-# mcp-sim 0.1.1 (commit, date)
+# mcp-sim 0.2.0 (commit, date)
```
Configure your MCP client (Claude Code, Cursor, etc.):
@@ -98,11 +101,19 @@ For Tailscale-based remote access, see [docs/tailscale.md](docs/tailscale.md).
## Docs
+Browse the hosted docs site: **https://espetro.github.io/mcp-sim/**
+
- [Architecture](docs/architecture.md) — adapter model and separation of concerns
- [Tailscale setup](docs/tailscale.md) — running over Tailscale
-- [launchd](docs/launchd.md) — macOS service management
+- [Running as a service](docs/service.md) — install as a native OS service (launchd/systemd/Windows Service)
- [Adding a platform](docs/adding-platform.md) — implementing the Platform interface
+Run the docs site locally:
+
+```bash
+task docs
+```
+
## License
Apache 2.0
diff --git a/Taskfile.yml b/Taskfile.yml
index 8050a7c..3608dfe 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -39,6 +39,16 @@ tasks:
cmds:
- go mod tidy
+ docs:
+ desc: Run docmd dev server for docs/ (live preview)
+ cmds:
+ - npx @docmd/core dev
+
+ docs-build:
+ desc: Build static docs site from docs/ (outputs to site/)
+ cmds:
+ - npx @docmd/core build
+
validate:
desc: Typecheck + lint + test (AI-agent parseable)
run: when_changed
diff --git a/cmd/mcp-sim/main.go b/cmd/mcp-sim/main.go
index 21bf0a3..f0822d9 100644
--- a/cmd/mcp-sim/main.go
+++ b/cmd/mcp-sim/main.go
@@ -5,22 +5,23 @@ import (
"flag"
"fmt"
"io"
- "net/http"
"os"
"os/signal"
"sort"
"syscall"
"github.com/espetro/mcp-sim/controllers/agentdevice"
+ "github.com/espetro/mcp-sim/internal/bootstrap"
"github.com/espetro/mcp-sim/internal/config"
"github.com/espetro/mcp-sim/internal/core"
- srv "github.com/espetro/mcp-sim/internal/http"
applog "github.com/espetro/mcp-sim/internal/log"
+ svc "github.com/espetro/mcp-sim/internal/service"
"github.com/espetro/mcp-sim/internal/version"
"github.com/espetro/mcp-sim/pkg/mcp"
"github.com/espetro/mcp-sim/platforms/android"
"github.com/espetro/mcp-sim/platforms/ios"
+ kservice "github.com/kardianos/service"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
)
@@ -29,6 +30,7 @@ const (
cmdHelp = "help"
cmdServe = "serve"
cmdMCP = "mcp"
+ cmdService = "service"
cmdVersion = "version"
)
@@ -40,6 +42,7 @@ Usage:
Commands:
serve Start the HTTP/SSE server (long-lived, default for service mode)
mcp Run over stdio (spawnable per agent session)
+ service Install/manage mcp-sim as a native OS service
version Print version information
help Print this message
@@ -73,6 +76,32 @@ Flags:
Reads JSON-RPC from stdin, writes to stdout.
`
+const usageService = `mcp-sim service — install/manage mcp-sim as a native OS service
+
+Installs mcp-sim as a launchd service (macOS), systemd service (Linux), or
+Windows Service (Windows), managed by the OS the same way as any other
+background service.
+
+Usage:
+ mcp-sim service [flags]
+
+Actions:
+ install Register mcp-sim with the OS service manager
+ uninstall Remove mcp-sim from the OS service manager
+ start Start the installed service
+ stop Stop the installed service
+ restart Restart the installed service
+ status Print the installed service's status
+ run Hidden entry point invoked by the OS service manager itself
+
+Flags (only meaningful with "install"):
+ -listen, --listen Address to bind (overrides config; carried to "run")
+ -config, --config Path to YAML config file (carried to "run")
+ -user, --user Install as a per-user service, no root required
+ (unsupported on Windows)
+ -h, --help Show this help
+`
+
func main() {
if err := run(os.Args[0], os.Args[1:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
@@ -95,6 +124,8 @@ func run(prog string, args []string, stdout io.Writer) error {
return runServe(prog, args[1:], stdout)
case cmdMCP:
return runMCP(prog, args[1:], stdout)
+ case cmdService:
+ return runService(prog, args[1:], stdout)
case "-v", "--version":
return printVersion(stdout)
default:
@@ -166,60 +197,112 @@ func runMCP(prog string, args []string, stdout io.Writer) error {
return mcpImpl(prog, *configPath)
}
-// serveImpl and mcpImpl are the actual implementation of each subcommand.
-// Extracted so run() stays small and command flags parse first.
-func serveImpl(prog, listenAddr, configPath string) error {
- _ = configPath // TODO: pass through to config.Load; env override preserved
+// runService parses the service action and flags, then dispatches to
+// kardianos/service's control functions or runs as the installed service.
+func runService(prog string, args []string, stdout io.Writer) error {
+ if len(args) == 0 || isHelpFlag(args[0]) || args[0] == cmdHelp {
+ fmt.Fprint(stdout, usageService)
+ return nil
+ }
+ action := args[0]
+
+ fs := flag.NewFlagSet(cmdService, flag.ContinueOnError)
+ fs.SetOutput(stdout)
+ listenAddr := fs.String("listen", "", "Address to bind (overrides config)")
+ configPath := fs.String("config", "", "Path to YAML config file")
+ userService := fs.Bool("user", false, "Install as a per-user service (no root required; unsupported on Windows)")
+ fs.Usage = func() { fmt.Fprint(stdout, usageService) }
+ if err := fs.Parse(args[1:]); err != nil {
+ if err == flag.ErrHelp {
+ return nil
+ }
+ fmt.Fprint(stdout, usageService)
+ return err
+ }
+
+ var svcArgs []string
+ if *listenAddr != "" {
+ svcArgs = append(svcArgs, "--listen", *listenAddr)
+ }
+ if *configPath != "" {
+ svcArgs = append(svcArgs, "--config", *configPath)
+ }
+
+ svcConfig, err := svc.BuildConfig(svcArgs, *userService)
+ if err != nil {
+ return fmt.Errorf("building service config: %w", err)
+ }
+
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
- if listenAddr != "" {
- cfg.Server.Listen = listenAddr
+ if *listenAddr != "" {
+ cfg.Server.Listen = *listenAddr
}
logger := applog.New(cfg.Server.LogLevel, cfg.Server.LogFormat)
- ctx := applog.WithContext(context.Background(), logger)
+ program := svc.NewProgram(cfg, logger)
- registry := core.NewRegistry(logger)
- lifecycle := core.NewLifecycle(registry)
-
- if cfg.Platforms.IOS.Enabled {
- iosPlatform, err := ios.New(ctx, cfg.Platforms.IOS)
- if err != nil {
- return fmt.Errorf("ios platform: %w", err)
- }
- if iosPlatform == nil {
- logger.Warn("ios platform disabled — Xcode/xcrun not detected, skipping iOS tools")
- } else {
- registry.RegisterPlatform(iosPlatform)
- }
+ s, err := kservice.New(program, svcConfig)
+ if err != nil {
+ return fmt.Errorf("creating service: %w", err)
}
- if cfg.Platforms.Android.Enabled {
- androidPlatform, err := android.New(cfg.Platforms.Android)
+
+ switch action {
+ case "run":
+ return s.Run()
+ case "status":
+ status, err := s.Status()
if err != nil {
- return fmt.Errorf("android platform: %w", err)
+ return err
}
- if androidPlatform == nil {
- logger.Warn("android platform disabled — emulator/adb not detected, skipping Android tools")
- } else {
- registry.RegisterPlatform(androidPlatform)
+ fmt.Fprintln(stdout, serviceStatusString(status))
+ return nil
+ case "install", "uninstall", "start", "stop", "restart":
+ if err := kservice.Control(s, action); err != nil {
+ return err
}
+ fmt.Fprintf(stdout, "mcp-sim service: %s ok\n", action)
+ return nil
+ default:
+ fmt.Fprintf(os.Stderr, "%s: unknown service action %q\n\n", prog, action)
+ fmt.Fprint(stdout, usageService)
+ os.Exit(2)
+ return nil // unreachable
}
- if cfg.Controllers.AgentDevice.Enabled {
- registry.RegisterController(agentdevice.New(cfg.Controllers.AgentDevice))
+}
+
+func serviceStatusString(status kservice.Status) string {
+ switch status {
+ case kservice.StatusRunning:
+ return "running"
+ case kservice.StatusStopped:
+ return "stopped"
+ default:
+ return "unknown"
}
+}
- mcpServer := mcp.NewServer(registry, lifecycle, logger)
+// serveImpl and mcpImpl are the actual implementation of each subcommand.
+// Extracted so run() stays small and command flags parse first.
+func serveImpl(prog, listenAddr, configPath string) error {
+ _ = configPath // TODO: pass through to config.Load; env override preserved
+ cfg, err := config.Load()
+ if err != nil {
+ return fmt.Errorf("loading config: %w", err)
+ }
+ if listenAddr != "" {
+ cfg.Server.Listen = listenAddr
+ }
- mux := http.NewServeMux()
- mux.Handle("/mcp", mcpServer.StreamableHTTPHandler())
- mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- fmt.Fprintln(w, "ok")
- })
+ logger := applog.New(cfg.Server.LogLevel, cfg.Server.LogFormat)
+ ctx := applog.WithContext(context.Background(), logger)
- httpServer := srv.New(cfg.Server.Listen, http.HandlerFunc(mux.ServeHTTP))
+ registry, httpServer, err := bootstrap.BuildHTTPServer(ctx, cfg, logger)
+ if err != nil {
+ return err
+ }
logger.Info("mcp-sim starting",
"version", version.Version,
diff --git a/controllers/agentdevice/agentdevice.go b/controllers/agentdevice/agentdevice.go
index 9add1c6..271eb5a 100644
--- a/controllers/agentdevice/agentdevice.go
+++ b/controllers/agentdevice/agentdevice.go
@@ -19,6 +19,7 @@ type Controller struct {
running bool
mu sync.Mutex
stopCh chan struct{}
+ cmd *exec.Cmd
}
// New creates a new agent-device controller adapter.
@@ -57,6 +58,7 @@ func (c *Controller) Start(ctx context.Context, cfg contract.StartConfig) (contr
c.running = true
c.stopCh = make(chan struct{})
+ c.cmd = cmd
return contract.ProxyInfo{
Name: c.Name(),
@@ -74,9 +76,9 @@ func (c *Controller) Stop(ctx context.Context) error {
return nil
}
- // Find and kill the process.
- cmd := exec.CommandContext(ctx, "pkill", "-f", "agent-device proxy")
- _ = cmd.Run()
+ if c.cmd != nil && c.cmd.Process != nil {
+ _ = c.cmd.Process.Kill()
+ }
c.running = false
close(c.stopCh)
diff --git a/docs/adding-platform.md b/docs/adding-platform.md
index cb3bc7c..939cdee 100644
--- a/docs/adding-platform.md
+++ b/docs/adding-platform.md
@@ -44,7 +44,7 @@ In `internal/config/config.go`, add `MyPlatformConfig` and wire it up.
- Do NOT add verification tools (tap, screenshot, getTree) — those belong in Controllers
- Implement `AwaitReady` for a good developer experience
-- Use `SysProcAttr{Setpgid:true}` when spawning long-lived processes
+- For long-lived spawned processes, set process attrs via a package-private `setProcAttr(cmd *exec.Cmd)` helper split across `procattr_unix.go` (`//go:build !windows`, `Setpgid:true`) and `procattr_windows.go` (`//go:build windows`, `CreationFlags: CREATE_NEW_PROCESS_GROUP`) — see `platforms/android/procattr_*.go`. `Setpgid` is Unix-only; any platform adapter targeting Windows needs this split.
- Add integration tests gated on `MCPSIM_INTEGRATION=1`
## Testing
diff --git a/docs/launchd.md b/docs/launchd.md
deleted file mode 100644
index cc2a027..0000000
--- a/docs/launchd.md
+++ /dev/null
@@ -1,50 +0,0 @@
-# macOS launchd service
-
-## Using launchd
-
-Create `~/Library/LaunchAgents/com.espetro.mcp-sim.plist`:
-
-```xml
-
-
-
-
- Label
- com.espetro.mcp-sim
- ProgramArguments
-
- /usr/local/bin/mcp-sim
- serve
- --listen
- :9090
-
- RunAtLoad
-
- KeepAlive
-
- StandardOutPath
- /usr/local/var/log/mcp-sim.log
- StandardErrorPath
- /usr/local/var/log/mcp-sim.log
-
-
-```
-
-Load the service:
-
-```bash
-mkdir -p /usr/local/var/log
-launchctl load ~/Library/LaunchAgents/com.espetro.mcp-sim.plist
-```
-
-## Using Homebrew services
-
-If installed via Homebrew:
-
-```bash
-brew services start mcp-sim
-brew services stop mcp-sim
-brew services restart mcp-sim
-```
-
-Logs: `brew services log mcp-sim`
diff --git a/docs/releases/v0.1.0.md b/docs/releases/v0.1.0.md
deleted file mode 100644
index 3bd3cb7..0000000
--- a/docs/releases/v0.1.0.md
+++ /dev/null
@@ -1,50 +0,0 @@
-# v0.1.0 — initial MVP release
-
-**Released:** 2026-07-05
-**Tag:** `v0.1.0` on `main` (commit `85e6f1e`)
-
-## Highlights
-
-- MCP server for managing iOS Simulator and Android Emulator lifecycle on a remote macOS host.
-- 10 MCP tools: `list_devices`, `boot_device`, `stop_device`, `get_state`, `await_ready`, `wipe_device`, `open_url`, `start_controller`, `stop_controller`, `controller_status`.
-- Streamable HTTP transport at `/mcp` plus stdio transport via the `mcp` subcommand.
-- Lazy platform registration: iOS, Android, and agent-device auto-detect their dependencies at startup and skip gracefully when missing. Same binary serves Swift-only, Android-only, or full-stack users.
-- Cross-compiled binaries for darwin/{amd64,arm64}, linux/{amd64,arm64}, windows/amd64 via GoReleaser.
-- Homebrew tap at `espetro/homebrew-mcp-sim` (planned, not yet published in v0.1.0).
-
-## Install
-
-```bash
-brew install espetro/mcp-sim/mcp-sim
-# or
-go install github.com/espetro/mcp-sim/cmd/mcp-sim@latest
-# or download from https://github.com/espetro/mcp-sim/releases/tag/v0.1.0
-```
-
-## Prerequisites
-
-mcp-sim auto-detects prerequisites. If any are missing, that platform's tools are simply absent from the MCP server.
-
-- **iOS Simulator**: Xcode + Command Line Tools (`xcode-select --install`)
-- **Android Emulator**: Android SDK with `emulator` and `adb` on PATH
-- **agent-device**: installed separately (`brew install agent-device`); invoked by the agent client, not by mcp-sim
-
-To force-disable a platform:
-
-```bash
-MCPSIM_IOS_ENABLED=false mcp-sim serve
-MCPSIM_ANDROID_ENABLED=false mcp-sim serve
-MCPSIM_AGENT_DEVICE_ENABLED=false mcp-sim serve
-```
-
-## Known issues (fixed in v0.1.1)
-
-- `await_ready` default timeout too short for iOS Simulator boot on slower Macs.
-- `open_url https://...` fails on Android AVDs without a browser app — surfaces as a clear error but is unexpected.
-- `stop_device` did not always terminate Android emulators; fixed via PID tracking + SIGTERM to process group.
-
-See [v0.1.1 release notes](v0.1.1.md) for fixes.
-
-## Full changelog
-
-See [CHANGELOG.md](../../CHANGELOG.md).
\ No newline at end of file
diff --git a/docs/releases/v0.1.1.md b/docs/releases/v0.1.1.md
deleted file mode 100644
index f90197e..0000000
--- a/docs/releases/v0.1.1.md
+++ /dev/null
@@ -1,48 +0,0 @@
-# v0.1.1 — integration-test hardening
-
-**Released:** 2026-07-05 (planned)
-**Tag:** `v0.1.1` on `main` (planned)
-
-Patch release. Fixes the bugs surfaced by the v0.1.0 integration test.
-
-## Fixed
-
-- **Android emulator killed by request ctx** — `boot_device` returned success but the emulator died ~10s later when adb showed `offline`. Cause: `exec.CommandContext(reqCtx)` propagated SIGKILL when the SDK cancelled the request. Emulator now spawned with `context.Background()` so it survives the request lifecycle.
-- **Stdio inherited from server** — emulator/agent-device stdio redirected to `io.Discard` to avoid SIGPIPE on backgrounded servers.
-- **`open_url` errors had no context** — both iOS and Android now use `CombinedOutput()` and include stderr so callers see the actual reason an intent failed (e.g. "Activity not started, unable to resolve Intent" on AVDs without a browser).
-- **`stop_device` didn't reliably terminate Android emulator** — now tracks spawned PIDs and SIGTERMs the process group directly via `syscall.Kill(-pid, SIGTERM)`. `adb emu kill` becomes a fallback.
-- **`await_ready` default timeout too short** — bumped from 60s to 180s. iOS Simulator boot on slower Macs takes ~90s.
-- **`boot_device` default timeout too short** — bumped from 60s to 120s in `lifecycle.BootDevice`.
-
-## Changed
-
-- **`controllers/agentdevice`** — properly invokes `agent-device proxy` (the proxy subcommand shipped in agent-device v0.18+). The initial v0.1.0 implementation was based on testing with an older v0.14 binary that didn't have `proxy`; v0.1.1 restores the proxy-spawning behavior with correct flags (`--port`, `--host`, `--daemon-auth-token`). The controller now:
- - `Start()`: spawns `agent-device proxy --port N` as a detached child
- - `Stop()`: SIGTERMs the process group (PID tracked)
- - `Status()`: probes `/health` on the proxy
-- **AGENTS.md** — removed the `## Current release` section (went stale at every release). Release docs now live in `docs/releases/` and AGENTS.md references them via progressive disclosure.
-- **README prerequisites** — agent-device entry now specifies "v0.18+" since the controller adapter requires the `proxy` subcommand.
-
-## Added
-
-- `docs/releases/v0.1.0.md` — release notes for the v0.1.0 tag.
-- `docs/releases/v0.1.1.md` — this file.
-- `.agents/plans/2026-07-05-v0.1.0-integration-test.md` — integration test report (gitignored).
-- `.agents/plans/2026-07-05-v0.1.1-release.md` — release plan (gitignored).
-- **Homebrew tap live** — `espetro/homebrew-mcp-sim` created on GitHub. GoReleaser's `brews` block now ships a `service` stanza (`run`, `keep_alive`, `log_path`), so `brew services start/stop/restart mcp-sim` (documented in `docs/launchd.md`) actually works once the formula publishes.
-- **`.github/workflows/release.yml`** — new GitHub Actions workflow triggers GoReleaser on `v*` tag push, replacing the manual-only `task release` flow. Requires `TAP_GITHUB_TOKEN` repo secret (scoped to `homebrew-mcp-sim`) alongside the default `GITHUB_TOKEN`.
-
-## How to upgrade
-
-```bash
-brew upgrade mcp-sim
-# or
-go install github.com/espetro/mcp-sim/cmd/mcp-sim@v0.1.1
-```
-
-No config migration required.
-
-## Known caveats (still)
-
-- **Android AVDs without a browser app** still reject `open_url https://...`. We surface this as a clear "Activity not started" error instead of a generic exit code, but the user must install Chrome or another browser on the AVD. Tracked for v0.1.2.
-- **agent-device v0.14 and earlier** register the controller adapter but `start_controller` fails at spawn time (no `proxy` subcommand). Upgrade to v0.18+ for full controller support.
\ No newline at end of file
diff --git a/docs/service.md b/docs/service.md
new file mode 100644
index 0000000..f5e5226
--- /dev/null
+++ b/docs/service.md
@@ -0,0 +1,86 @@
+# Running mcp-sim as a service
+
+`mcp-sim service` installs mcp-sim as a native background service, managed by
+whichever OS it runs on:
+
+- macOS → `launchd`
+- Linux → `systemd` (or SysV/Upstart as fallback)
+- Windows → Windows Service
+
+## Install and manage
+
+```bash
+mcp-sim service install --listen :9090 --config ~/.config/mcp-sim/config.yaml
+mcp-sim service start
+mcp-sim service status
+mcp-sim service stop
+mcp-sim service restart
+mcp-sim service uninstall
+```
+
+`--listen` and `--config` are only read at `install` time — they're recorded
+as the arguments the OS service manager passes to the hidden
+`mcp-sim service run` entry point it invokes on every start.
+
+Installing system-wide requires elevated privileges (`sudo` on macOS/Linux, an
+elevated shell on Windows). To skip that on macOS/Linux, install a per-user
+service instead (LaunchAgent / `systemd --user`, no root needed):
+
+```bash
+mcp-sim service install --user --listen :9090
+mcp-sim service start
+```
+
+Windows Service install always needs elevation — there's no per-user
+equivalent on that OS.
+
+## Manual launchd customization (macOS)
+
+If you'd rather manage the launchd plist yourself instead of using
+`mcp-sim service install`, create
+`~/Library/LaunchAgents/com.espetro.mcp-sim.plist`:
+
+```xml
+
+
+
+
+ Label
+ com.espetro.mcp-sim
+ ProgramArguments
+
+ /usr/local/bin/mcp-sim
+ serve
+ --listen
+ :9090
+
+ RunAtLoad
+
+ KeepAlive
+
+ StandardOutPath
+ /usr/local/var/log/mcp-sim.log
+ StandardErrorPath
+ /usr/local/var/log/mcp-sim.log
+
+
+```
+
+Load the service:
+
+```bash
+mkdir -p /usr/local/var/log
+launchctl load ~/Library/LaunchAgents/com.espetro.mcp-sim.plist
+```
+
+### Using Homebrew services
+
+If installed via Homebrew:
+
+```bash
+brew services start mcp-sim
+brew services stop mcp-sim
+brew services restart mcp-sim
+```
+
+Logs: `brew services log mcp-sim`
diff --git a/go.mod b/go.mod
index 0353f49..5e23a0b 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/espetro/mcp-sim
go 1.25.0
require (
+ github.com/kardianos/service v1.2.4
github.com/modelcontextprotocol/go-sdk v1.6.1
gopkg.in/yaml.v3 v3.0.1
)
diff --git a/go.sum b/go.sum
index e90e507..bcfbb19 100644
--- a/go.sum
+++ b/go.sum
@@ -4,6 +4,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
+github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk=
+github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go
new file mode 100644
index 0000000..2f27ad8
--- /dev/null
+++ b/internal/bootstrap/bootstrap.go
@@ -0,0 +1,63 @@
+// Package bootstrap wires the registry, MCP server, and HTTP handler shared
+// by the "serve" subcommand and the OS-native service program.
+package bootstrap
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net/http"
+
+ "github.com/espetro/mcp-sim/controllers/agentdevice"
+ "github.com/espetro/mcp-sim/internal/config"
+ "github.com/espetro/mcp-sim/internal/core"
+ srv "github.com/espetro/mcp-sim/internal/http"
+ "github.com/espetro/mcp-sim/pkg/mcp"
+ "github.com/espetro/mcp-sim/platforms/android"
+ "github.com/espetro/mcp-sim/platforms/ios"
+)
+
+// BuildHTTPServer constructs the registry and HTTP server for serve/service modes.
+// It registers whichever platform/controller adapters are enabled and detected.
+func BuildHTTPServer(ctx context.Context, cfg config.Config, logger *slog.Logger) (*core.Registry, *srv.Server, error) {
+ registry := core.NewRegistry(logger)
+ lifecycle := core.NewLifecycle(registry)
+
+ if cfg.Platforms.IOS.Enabled {
+ iosPlatform, err := ios.New(ctx, cfg.Platforms.IOS)
+ if err != nil {
+ return nil, nil, fmt.Errorf("ios platform: %w", err)
+ }
+ if iosPlatform == nil {
+ logger.Warn("ios platform disabled — Xcode/xcrun not detected, skipping iOS tools")
+ } else {
+ registry.RegisterPlatform(iosPlatform)
+ }
+ }
+ if cfg.Platforms.Android.Enabled {
+ androidPlatform, err := android.New(cfg.Platforms.Android)
+ if err != nil {
+ return nil, nil, fmt.Errorf("android platform: %w", err)
+ }
+ if androidPlatform == nil {
+ logger.Warn("android platform disabled — emulator/adb not detected, skipping Android tools")
+ } else {
+ registry.RegisterPlatform(androidPlatform)
+ }
+ }
+ if cfg.Controllers.AgentDevice.Enabled {
+ registry.RegisterController(agentdevice.New(cfg.Controllers.AgentDevice))
+ }
+
+ mcpServer := mcp.NewServer(registry, lifecycle, logger)
+
+ mux := http.NewServeMux()
+ mux.Handle("/mcp", mcpServer.StreamableHTTPHandler())
+ mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintln(w, "ok")
+ })
+
+ httpServer := srv.New(cfg.Server.Listen, http.HandlerFunc(mux.ServeHTTP))
+ return registry, httpServer, nil
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index cec7f8b..3d73c1c 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"os"
+ "path/filepath"
"strconv"
"gopkg.in/yaml.v3"
@@ -88,8 +89,8 @@ func Load() (Config, error) {
if err := loadFile(path, &cfg); err != nil {
return Config{}, fmt.Errorf("loading config from %s: %w", path, err)
}
- } else if home := os.Getenv("HOME"); home != "" {
- defaultPath := home + "/.config/mcp-sim/config.yaml"
+ } else if home, err := os.UserHomeDir(); err == nil && home != "" {
+ defaultPath := filepath.Join(home, ".config", "mcp-sim", "config.yaml")
_ = loadFile(defaultPath, &cfg) // ignore error if missing
}
diff --git a/internal/service/service.go b/internal/service/service.go
new file mode 100644
index 0000000..240af99
--- /dev/null
+++ b/internal/service/service.go
@@ -0,0 +1,92 @@
+// Package service wires mcp-sim into a native OS-managed background service
+// (launchd on macOS, systemd on Linux, Windows Service on Windows) via
+// github.com/kardianos/service.
+package service
+
+import (
+ "context"
+ "log/slog"
+ "os"
+
+ "github.com/espetro/mcp-sim/internal/bootstrap"
+ "github.com/espetro/mcp-sim/internal/config"
+ "github.com/espetro/mcp-sim/internal/core"
+
+ kservice "github.com/kardianos/service"
+)
+
+// Program implements kservice.Interface, wrapping the same HTTP server
+// construction used by the "serve" subcommand.
+type Program struct {
+ cfg config.Config
+ logger *slog.Logger
+
+ registry *core.Registry
+ cancel context.CancelFunc
+ done chan struct{}
+}
+
+// NewProgram creates a service program for the given config.
+func NewProgram(cfg config.Config, logger *slog.Logger) *Program {
+ return &Program{cfg: cfg, logger: logger}
+}
+
+// Start is invoked by the OS service manager. It must return quickly, so the
+// HTTP server runs in a background goroutine.
+func (p *Program) Start(s kservice.Service) error {
+ ctx, cancel := context.WithCancel(context.Background())
+ p.cancel = cancel
+
+ registry, httpServer, err := bootstrap.BuildHTTPServer(ctx, p.cfg, p.logger)
+ if err != nil {
+ cancel()
+ return err
+ }
+ p.registry = registry
+ p.done = make(chan struct{})
+
+ go func() {
+ defer close(p.done)
+ if err := httpServer.ListenAndServe(ctx); err != nil {
+ p.logger.Error("http server stopped with error", "error", err)
+ }
+ }()
+ return nil
+}
+
+// Stop is invoked by the OS service manager for graceful shutdown.
+func (p *Program) Stop(s kservice.Service) error {
+ if p.cancel != nil {
+ p.cancel()
+ }
+ if p.done != nil {
+ <-p.done
+ }
+ if p.registry != nil {
+ p.registry.ShutdownAll()
+ }
+ return nil
+}
+
+// BuildConfig builds the kardianos/service Config used to install mcp-sim as
+// a native OS service. args are extra CLI flags carried through to the
+// hidden "service run" entry point the installed service actually invokes.
+// When user is true, installs a per-user service (LaunchAgent / systemd
+// --user) that doesn't require root — unsupported on Windows, where a
+// Windows Service always needs an elevated install regardless.
+func BuildConfig(args []string, user bool) (*kservice.Config, error) {
+ exe, err := os.Executable()
+ if err != nil {
+ return nil, err
+ }
+ return &kservice.Config{
+ Name: "mcp-sim",
+ DisplayName: "mcp-sim",
+ Description: "MCP server for mobile emulator lifecycle",
+ Executable: exe,
+ Arguments: append([]string{"service", "run"}, args...),
+ Option: kservice.KeyValue{
+ "UserService": user,
+ },
+ }, nil
+}
diff --git a/internal/version/version.go b/internal/version/version.go
index 6592208..8008dbd 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -2,13 +2,13 @@
//
// All variables are ldflags-overridable:
//
-// go build -ldflags "-X github.com/espetro/mcp-sim/internal/version.Version=v0.1.1 \
+// go build -ldflags "-X github.com/espetro/mcp-sim/internal/version.Version=v0.2.0 \
// -X github.com/espetro/mcp-sim/internal/version.Commit=$(git rev-parse HEAD) \
// -X github.com/espetro/mcp-sim/internal/version.Date=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
package version
-// Version is the semantic version of mcp-sim (e.g. "0.1.1").
-var Version = "0.1.1"
+// Version is the semantic version of mcp-sim (e.g. "0.2.0").
+var Version = "0.2.0"
// Commit is the git commit SHA the binary was built from.
var Commit = "none"
diff --git a/platforms/android/android.go b/platforms/android/android.go
index a76ae86..8787e1c 100644
--- a/platforms/android/android.go
+++ b/platforms/android/android.go
@@ -7,9 +7,10 @@ import (
"fmt"
"os"
"os/exec"
+ "path/filepath"
+ "runtime"
"strconv"
"strings"
- "syscall"
"time"
"github.com/espetro/mcp-sim/internal/config"
@@ -46,9 +47,17 @@ func New(cfg config.AndroidConfig) (*Platform, error) {
androidHome = os.Getenv("ANDROID_HOME")
if androidHome == "" {
if home, _ := os.UserHomeDir(); home != "" {
- androidHome = home + "/Library/Android/sdk"
- if _, err := os.Stat(androidHome); os.IsNotExist(err) {
- androidHome = home + "/Android/Sdk"
+ switch runtime.GOOS {
+ case "darwin":
+ androidHome = filepath.Join(home, "Library", "Android", "sdk")
+ case "windows":
+ if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
+ androidHome = filepath.Join(localAppData, "Android", "Sdk")
+ } else {
+ androidHome = filepath.Join(home, "AppData", "Local", "Android", "Sdk")
+ }
+ default:
+ androidHome = filepath.Join(home, "Android", "Sdk")
}
}
}
@@ -176,7 +185,7 @@ func (p *Platform) Start(ctx context.Context, target string, opts contract.Start
}
cmd := p.emulatorCmd(ctx, args...)
- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ setProcAttr(cmd)
if err := cmd.Start(); err != nil {
return contract.Device{}, fmt.Errorf("emulator start: %w", err)
}
@@ -289,7 +298,7 @@ func (p *Platform) Wipe(ctx context.Context, target string) error {
_ = p.Stop(ctx, target)
// Restart with -wipe-data.
cmd := p.emulatorCmd(ctx, "-avd", target, "-wipe-data")
- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ setProcAttr(cmd)
_ = cmd.Start()
return nil
}
diff --git a/platforms/android/procattr_unix.go b/platforms/android/procattr_unix.go
new file mode 100644
index 0000000..5e209c1
--- /dev/null
+++ b/platforms/android/procattr_unix.go
@@ -0,0 +1,13 @@
+//go:build !windows
+
+package android
+
+import (
+ "os/exec"
+ "syscall"
+)
+
+// setProcAttr configures cmd to survive parent death by starting it in its own process group.
+func setProcAttr(cmd *exec.Cmd) {
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+}
diff --git a/platforms/android/procattr_windows.go b/platforms/android/procattr_windows.go
new file mode 100644
index 0000000..15380d4
--- /dev/null
+++ b/platforms/android/procattr_windows.go
@@ -0,0 +1,13 @@
+//go:build windows
+
+package android
+
+import (
+ "os/exec"
+ "syscall"
+)
+
+// setProcAttr configures cmd to survive parent death by placing it in a new process group.
+func setProcAttr(cmd *exec.Cmd) {
+ cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP}
+}