Skip to main content
Glama

home-monitor

Built for dsh 0.2.0-rc.2. Also runs standalone as an MCP stdio server.

Read-only health checks for a small self-hosted setup, packaged two ways:

  1. A DSH plugin — five tools registered straight into the agent (monitor_http, monitor_tcp, monitor_ssl, monitor_docker, monitor_targets).

  2. A standalone MCP stdio server — the same checks behind check_http, check_tcp, check_ssl, check_docker, monitor_status, usable from any MCP client.

Both read the same server/config.json, so the inventory of things to watch lives in one file.

Nothing here writes, restarts, or "fixes" anything. Every check opens a socket, reads, and closes it.

What it looks like

Every image below is a capture of real output, not a mock-up. The capture scripts live in docs/_capture/; they are dev helpers and are not part of the shipped package.

1. The patch row lands in the config the server actually loads. Tail of dsh --profile web --dump-config (1308 lines, rc=0); our row starts at the marked line and is applied verbatim.

Tail of dsh --profile web --dump-config showing the dsh-mcp-client row

2. The model picks the MCP tool by itself. The provider is stubbed so the run is reproducible; the MCP bridge, the five-tool list and the 401 behind TOOLRESULT are real.

Headless turn: TOOLCOUNT, MCP_TOOLS, PICKED and TOOLRESULT

3. One real probe against the live container. 401 on / is the pass, not the failure — that is the auth gate answering.

node probe2.mjs → check_http { target: "dsh" }


Related MCP server: health-monitor-mcp

Layout

home-monitor/
├── package.json            plugin manifest (dsh.bundle.patch → cordis.patch.yml)
├── cordis.patch.yml        patch row that wires the MCP server into DSH
├── lib/index.js            DSH plugin: registers the five tools
└── server/
    ├── package.json        separate package — owns the MCP SDK dependency
    ├── config.json         the whole inventory: http / tcp / ssl / docker
    ├── index.js            MCP stdio server
    └── monitors/
        ├── http.js         status code + latency + body size
        ├── tcp.js          connect / close
        ├── ssl.js          expiry, days left, issuer, chain trust
        └── docker.js       engine ping + per-container state

server/ is its own package on purpose: it has exactly one dependency (@modelcontextprotocol/sdk), and the plugin layer does not depend on it at all — lib/index.js imports the monitor modules directly, so a broken MCP link never takes the DSH tools down with it.


Prerequisites inside the DSH container

Exactly one bare name has to be made resolvable by hand. Everything else ships with the image. Checked against dsh 0.2.0-rc.2.

  • @deepseek-ai/dsh-tools — required. lib/index.js imports defineTool from it by bare name. package.json lists it only as an optional peer dependency, so npm i will not fetch it, and Node's upward walk from /workspace/home-monitor/lib/ cannot reach dsh's nested tree. In the stock image it sits at /usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-tools. Link it next to the plugin:

mkdir -p /workspace/home-monitor/node_modules/@deepseek-ai
ln -sfn /usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-tools \
        /workspace/home-monitor/node_modules/@deepseek-ai/dsh-tools

If you would rather not keep a foreign symlink inside the package, make it a real file: dependency in package.json and reinstall — same effect, more moving parts.

  • @deepseek-ai/dsh-mcp-client — nothing to do. It is a declared dependency of the dsh package itself (one of 82), and the plugin loader resolves patch rows with createRequire(import.meta.url) from .../dsh/node_modules/@deepseek-ai/cordis-plugin-loader/lib/index.js — that is, straight into .../dsh/node_modules/. Verified on a stock image: the row loads with no extra links anywhere, /usr/local/lib/node_modules/@deepseek-ai/ holding only dsh and /data/profiles/web/ having no node_modules at all.

    Linking it beside dsh-tools is harmless and makes ad-hoc resolution checks from the plugin directory pass; it is not required for the row to load.

ln -sfn /usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-mcp-client \
        /workspace/home-monitor/node_modules/@deepseek-ai/dsh-mcp-client   # optional

Pointing the row at the nested directory as file:///usr/local/lib/…/dsh-mcp-client does not work: the loader wants a package entry (exports['.'] → ./lib/index.js), and a nested absolute path is not an install layout it accepts. Name the package and let resolution do the rest.

Check resolution the way the loader does it — from dsh's own package directory. Resolving from /data/profiles/web throws MODULE_NOT_FOUND even on a working install (that directory has no node_modules), so it proves nothing:

docker exec dsh sh -c 'cd /usr/local/lib/node_modules/@deepseek-ai/dsh && node -e "console.log(require.resolve(\"@deepseek-ai/dsh-mcp-client\"))"'

The functional check, after a restart, is the row showing up in the merged config plus the tools appearing:

docker exec dsh dsh --profile web --dump-config | grep -n 'home-monitor\|mcp-client'
# then: the harness should expose mcp__home_monitor__check_http

Quick start (DSH plugin)

Get the code:

git clone https://github.com/lowspecai/dsh-home-monitor.git

The default install path in the patches below assumes the source lives at /workspace/home-monitor inside the DSH container (./workspace on the host).

# 1. dependencies for the MCP server
cd /workspace/home-monitor/server && npm i

# 2. make the plugin resolvable from the profile directory
#    (see "Wiring" below — pick A or C)

# 3. restart and look for the tools
#    DSH's HMR root is empty in the stock config, so a restart is the reload.
docker restart dsh && docker logs --tail 40 dsh

Rebuilding from scratch in a fresh container

# copy the package in (from the host, where ./workspace is mounted)
docker cp home-monitor dsh:/workspace/home-monitor

# MCP server dependency — 95 packages, ~10 s on a domestic mirror
docker exec dsh sh -c 'cd /workspace/home-monitor/server && npm i'

# the one bare-name symlink from "Prerequisites" (dsh-mcp-client needs none)
docker exec dsh sh -c 'mkdir -p /workspace/home-monitor/node_modules/@deepseek-ai && ln -sfn /usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-tools /workspace/home-monitor/node_modules/@deepseek-ai/dsh-tools'

# confirm both halves before touching the live config
docker exec dsh dsh --profile web --dump-config | grep -n 'home-monitor\|mcp-client'

npm i needs a registry that answers from inside the container. The image defaults to npmmirror; npm config get registry tells you what you are on. Set npm_config_registry if you are rebuilding somewhere else.

Verify the plugin before restarting the live container, using a throwaway overlay so a broken row cannot restart-loop production:

# /tmp/ol.yml
- insert:
    - id: home-monitor
      name: '/workspace/home-monitor/lib/index.js'
docker exec dsh sh -c 'cd /workspace && dsh --profile headless --patch /tmp/ol.yml \
  --dump-config' | grep -n home-monitor

--profile is required even when you pass --patch; the overlay is applied after the profile, so it cannot be shadowed by it.

Wiring, option A — install into the profile

docker exec dsh sh -c 'cd /data/profiles/web && corepack enable pnpm && pnpm add /workspace/home-monitor'

Then add one row to /data/profiles/web/cordis.patch.yml:

- insert:
    - id: home-monitor
      name: home-monitor

Wiring, option C — point the profile at the source directory

No pnpm, no node_modules — just an absolute-ish path the profile can resolve:

- insert:
    - id: home-monitor
      name: '/workspace/home-monitor/lib/index.js'

Both rows go in the profile patch (or the home patch, or a --patch overlay — order only matters for conflicts, see below). Back up cordis.patch.yml before editing it; a syntactically valid but schema-invalid row will restart-loop the container.

Verify without booting

docker exec dsh dsh --profile web --dump-config | grep -n 'home-monitor'

Each applied patch file is echoed at the end of the dump under # == <path>, so you can see which layer contributed the row.


server/config.json

JSON on purpose — the server parses it with JSON.parse, so there is no YAML dependency and no extra failure mode. Keys starting with _ are documentation and are ignored by the code.

Key

Meaning

http.timeoutMs

Per-request timeout, default 8000

http.targets[]

{ name, url, expect?, headers? } — expect is a code or an array of codes; [200, 302, 401] means "any of these is healthy"

tcp.timeoutMs

Connect timeout, default 5000

tcp.targets[]

{ name, host, port }

ssl.timeoutMs

Handshake timeout, default 10000

ssl.warnDays

Global expiry warning window, default 21

ssl.targets[]

{ name, host, port?, warnDays?, servername?, allowSelfSigned? }

docker.enabled

Default false. When false the tool returns status: "unavailable" instead of an error

docker.socket

Default /var/run/docker.sock

docker.expectRunning[]

Container names that must be in state running

targets[].name is the handle you pass as { "target": "name" }. Omit the argument to check everything in that group.

Why expect takes an array

Auth-protected endpoints are healthy when they say 401. Pinning 200 alone turns "the service is up and correctly refusing anonymous requests" into a false alarm, which is the fastest way to make a monitor untrustworthy.

Why Docker is off by default

Mounting the Docker socket into an agent is a privilege escalation. It is opt-in so that a copied config never hands out more than it says on the tin.


Standalone MCP server

cd server && npm i
node index.js        # speaks JSON-RPC over stdio, logs to stderr only

Register it in any MCP client:

{
  "mcpServers": {
    "home_monitor": {
      "command": "node",
      "args": ["/workspace/home-monitor/server/index.js"],
      "env": { "PATH": "/usr/local/bin:/usr/bin:/bin" }
    }
  }
}

If you wire it through @deepseek-ai/dsh-mcp-client instead, note that env is scrubbed unless you pass it explicitly — leave it out and node will not be on PATH. serverName must match [A-Za-z0-9_-]{1,32}.

One smoke test, no client needed

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
  | node index.js

Result shape

Every tool returns one JSON object. Failures are values, not exceptions:

{ "ok": false, "checked": 3, "failed": ["hermes-api"], "results": [ ... ] }
  • ok — false if any check in the batch failed

  • failed — names only, so a caller can act without parsing the details

  • results[] — one entry per target, each with name, ok, ms, plus protocol-specific evidence (status code, certificate dates, container states)

  • Unknown target name → ok: false, error: "unknown target …", known: [...]

check_docker / monitor_docker returns a single object rather than a batch, because there is only ever one Docker socket.


Patch layering (for when a row does not take effect)

Later layers win, per row, matched by id:

bundle patches → profile → $DSH_HOME/cordis.patch.yml → --patch overlays

Two consequences worth remembering:

  • config is replaced wholesale. If you override a row to change one field, restate the rest, or schema validation fails and the container restart-loops.

  • Row order carries no loading semantics. Moving a row up or down in the file changes nothing.

Debug ordering with dsh --profile web --dump-config | tail -60: applied patches are listed under # == <path> markers in application order.


Security

  • Read-only by construction: GET requests, TCP connects, TLS handshakes, and Docker GET endpoints. No writes, no restarts, no shell.

  • There is no outbound address filter — the whole point is checking private addresses. Treat config.json as trusted input, and never expose this server or its socket to an untrusted network.

  • ssl checks connect with rejectUnauthorized: false so it can report an untrusted chain instead of crashing on it. Trust is a value in the result (authorized), and allowSelfSigned: true is what decides whether an untrusted chain counts as a problem.

  • Do not mount the Docker socket unless docker.enabled is true.

Known limits

  • No auto-reconnect at the MCP layer; if the child process dies, restart it.

  • timeoutMs inside DSH only honours itself when the tool-call-timeout policy plugin is loaded. The plugin here also races every call against a hard 20-second deadline so a hung check cannot wedge a turn.

  • Tool discovery is snapshotted at startup — edit config.json and restart.

  • The container has no ps. To see whether the MCP child exists, read /proc/<pid>/status (Name:, PPid:), or list children from PID 1:

    docker exec dsh sh -c 'cat /proc/1/task/1/children'
    docker exec dsh sh -c 'grep -H "^PPid:" /proc/[0-9]*/status | grep -w 1'
    docker top dsh

    From the host, docker top dsh is the least error-prone of the four.

  • Occasionally the stdio child survives the turn that started it. In one observed case the node /workspace/home-monitor/server/index.js process was reparented to PID 1 and stayed there: PPid: 1, wchan: do_epoll_wait, sockets on fd 0 and 1, the only entry in /proc/1/task/1/children, started ~452 s after the container booted. The same probe run twice more tore the child down normally, so this is an intermittent teardown race in the MCP client, not a wiring problem — tool calls still worked while it lingered. If you find a stale child, kill it yourself:

    docker exec dsh sh -c 'kill $(cat /proc/1/task/1/children)'

    Check the command line first (cat /proc/<pid>/cmdline | tr "\0" " ") so you do not kill something else PID 1 happened to inherit.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    D
    maintenance
    MCP server for running infrastructure health checks with TIBET provenance. It enables users to define, execute, and audit process health checks with dependency chaining and drift tracking.
    6
    MIT
  • A
    license
    A
    quality
    C
    maintenance
    MCP server for monitoring health of MCP servers, GitHub Actions, GitLab pipelines, and HTTP endpoints with health history, TLS expiry, assertions, diagnostics, and alerts.
    22
    63 npm
    2
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Read-only MCP server to inspect allowlisted Docker containers, systemd services, JSONL logs, and HTTP health endpoints without arbitrary shell access.
    MIT
  • F
    license
    Not graded
    quality
    C
    maintenance
    Provides read-only Proxmox cluster oversight and configuration compliance checks through MCP, covering health, inventory, storage, backups, snapshots, and updates.
    -