@modelprofile.com/system-mcp

MCP server that watches host disk, inode, memory and load headroom and classifies known accumulating artifacts for conservative, explicit-apply reclamation. Register it in any MCP-capable coding agent and that agent can ask "am I about to run out of space?" before it starts expensive work — and, when the answer is no, find out exactly what can safely be given back.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Why This Exists

A full disk does not fail loudly. When a host reaches 100%, file edits report success and silently do not land, shell harnesses lose the ability to write their capture files, and the machine degrades into what looks like a collection of unrelated bugs. The useful moment to intervene was hours earlier, at 85%.

The other half of the problem is that the space is usually recoverable — stale scratch trees, a superseded package-manager store, caches that only cost a re-download — but nobody knows which, and the tools that clean up automatically are the ones that cannot be trusted. @git.zone/cli's ts/mod_docker/index.ts:9 runs docker system prune -a -f --volumes behind a label reading "Docker cleanup"; on a host running databases, registries and in-flight builds that is not cleanup, it is an outage.

So this package is deliberately lopsided: the monitoring is eager and cheap, and the deletion is reluctant and explicit.

Tools

Tool Question it answers Cost
check_headroom Can this machine take the work I am about to start? one statfs + two /proc reads
system_status What is the state of every filesystem, inode table, memory, swap and load right now? cheap
growth_report What is filling this machine, how fast, and when does it run out? reads recorded samples
scan_artifacts What is accumulating, and which of it is safe to give back? walks trees; seconds to minutes
explain_path Why is this one path (not) safe to reclaim? one tree walk
reclaim Give these named paths back. Reports unless apply: true. two tree walks

check_headroom

The tool this package exists for. Call it before a docker build, a pnpm install, a large clone, or a test run that writes fixtures:

check_headroom { "path": "/mnt/data/build", "gigabytesNeeded": 20, "inodesNeeded": 150000 }

It resolves the filesystem the path actually lands on, subtracts what you said you need, and answers GO, CAUTION or STOP — projected forward, not describing the present. It checks inodes as well as bytes, because a filesystem with hundreds of gigabytes free and no free inodes fails every write with ENOSPC and gives no hint why.

scan_artifacts

Read-only. Walks the scan roots and classifies everything it finds into four states:

  • reclaimable — owned by a known-disposable class, proven stale, and no visible process, container or mount uses it
  • blocked — would qualify, but something concrete references it right now
  • live — proven in use
  • unknown — evidence is missing, partial or contradictory. Always excluded.

Output separates what is on disk from what deleting would actually free, and ends with a ready-to-paste reclaim call for the reclaimable set and a "these need a human decision" list for everything else.

reclaim

reclaim { "paths": ["/tmp/some-stale-tree"] }                  → report, nothing deleted
reclaim { "paths": ["/tmp/some-stale-tree"], "apply": true }   → re-verify, then delete

There is no wildcard, no category sweep and no machine-wide prune. Every operation is scoped to paths you named, so this can never behave like docker system prune.

Classification Rules

Every candidate starts at unknown and only reaches reclaimable by accumulating positive evidence. Everything below is a way of not getting there.

Hard refusals, checked before anything else and again immediately before any delete:

  • outside the scan roots (/tmp, ~/.cache, ~/.npm, pnpm store parents) — nothing else is ever a candidate
  • under a protected system prefix (/etc, /usr, /var, /boot, /root, /proc, /sys, …), / itself, or the home directory itself
  • the scan root itself, or anything that is not a direct child of it
  • a symlink, or a path whose realpath differs from what was asked about
  • on a different filesystem than its root — that is a mount, not a child directory

Forced to unknown:

  • the tree could not be fully read: a truncated walk, an unreadable entry, an exhausted time budget. A tree that was not fully read cannot be proven stale.
  • any entry belongs to another user. Their processes are invisible to us, so nothing about their liveness can be established.
  • docker is installed but did not answer. A daemon we cannot query may be running a container on top of the directory.
  • the tree contains .git, .hg, .svn or .jj — somebody's working copy, possibly with unpushed work.
  • a marker file written by the creating tool says safeToPrune: false, or declares the directory in use until a future timestamp.
  • a package store that is not at the configured location, or whose version is not older than the configured one, or where the configured store could not be proven at all.
  • a cache that is not on the known-regenerable list. ~/.cache/claude-cli-nodejs and friends belong to live agents and are not this tool's business.

Vetoed by liveness, from positive evidence only:

  • a process has its working directory, an open file descriptor, or its running executable inside the tree
  • a running container bind-mounts the path, or any parent or child of it
  • a mount point sits inside the tree
  • a running process was started with the path on its command line (reported as blocked rather than live — a reference, not proof of current use)

Vetoed by staleness: anything written to inside the staleness window, measured from the newest write anywhere in the tree. A top-level mtime lies when only subdirectories are being touched.

Why absence of a holder is never proof

/proc/<pid>/fd is readable only for processes owned by the calling user. On the machine this was written for, 699 of 877 processes did not expose their open files. So "no holder found" can never mean "nothing uses this" — every report says how many processes stayed opaque.

That asymmetry is the design: liveness checks are vetoes, not licences. Reclaimability is earned separately, from ownership (inside a scan root, owned by this user, no foreign entries) and from staleness. A holder can only ever take reclaimability away.

Why sizes are reported twice

Content-addressable stores hardlink their files into every node_modules that uses them. Deleting the store copy of a file with a link count above one frees nothing at all. Reporting du output as reclaimable space is how a tool comes to promise gigabytes it cannot deliver.

So every measurement counts allocated blocks (not apparent size — sparse files differ wildly), deduplicates by inode, and splits the result:

path: /home/philkunz/.local/share/pnpm/store/v11
on disk: 5.04 GiB (4.65 GiB apparent)
freed by deleting: 2.58 GiB
  - 2.46 GiB of that is hardlinked elsewhere and would NOT be freed by deleting this path

Marker Files

system-mcp reads markers and never writes one into a directory it merely recognised.

A marker is only evidence because the tool that created the directory wrote it. An observer stamping directories it recognised would be manufacturing its own permission — and a second run would then read its own stamp back as proof of ownership. It would also bump the mtime that staleness is measured from, destroying the signal it was trying to record.

So the schema and the writer are exported for tools that create scratch directories:

import { writeScratchMarker } from '@modelprofile.com/system-mcp';

writeScratchMarker('/tmp/my-tool-workspace-20260725', {
  owner: '@my/tool',
  safeToPrune: false,
  purpose: 'holds an in-flight build cache',
});

safeToPrune: false is a hard refusal that outranks every other signal. expiresAt in the future keeps a directory off the list until it passes. A malformed marker is treated as no marker: it must not be able to grant permission.

Should Cleanup Ever Be Automatic?

No — and a scheduled report is worth a lot.

An unattended process that deletes things is the one nobody can audit after the fact. When something is missing a week later, the question "did the cleaner take it?" has no good answer, and the only safe response is to distrust the tool. Deletion therefore always needs a caller who named the paths and passed apply: true.

Reporting is the opposite: the failure mode of a scheduled report is an email nobody reads, and its success mode is somebody noticing at 85% instead of at 100%. So the CLI ships a one-shot mode whose exit code carries the severity:

system-mcp report      # exit 0 = ok, 1 = warn, 2 = critical
0 * * * * system-mcp report >> /var/log/system-mcp.log 2>&1

Growth

Every system_status call records a filesystem sample, and every scan_artifacts call records the directory sizes it already computed. Nothing is ever walked just to feed the history.

target                          kind          span    delta      per-day     time-to-full
/mnt/data                       filesystem    5d0h    +14.0 GiB  +2.80 GiB/d 361d0h
/tmp/dcrouter-dep-audit         directory     5d0h    +14.0 GiB  +2.80 GiB/d -

"This grew 14 GB in five days" is far more actionable than any snapshot, and time-to-full is the number worth acting on. History lives in $XDG_STATE_HOME/system-mcp/samples.jsonl (override with SYSTEM_MCP_STATE_DIR); pass stateDir: false to GrowthTracker to make every tool in the package strictly read-only.

Thresholds

Resource warn critical
disk space 85% used, or under 10 GiB available 95% used, or under 3 GiB available
inodes 85% used 95% used
memory under 10% available under 5% available
swap 50% used 90% used and memory also under pressure
load 1.5 per core (5 min) 3.0 per core (5 min)

Available space is statfs's bavail, not bfree: ext4 reserves blocks for root, and an unprivileged writer hits ENOSPC while bfree still looks healthy.

Absolute free-space floors only apply to filesystems at least ten times their size. A 2 GiB /boot can never satisfy a 3 GiB floor, and a monitor that reports it critical forever is one people learn to ignore.

Swap exhaustion while memory is still plentiful is a warn, not a critical: the kernel parked cold pages and never needed them back. It becomes critical only once memory is under pressure too.

Install

# global CLI (recommended for MCP registration)
pnpm add -g @modelprofile.com/system-mcp

# or as a library
pnpm install @modelprofile.com/system-mcp

Requires Node.js >= 24.

Register in Your Harness

Claude Code

claude mcp add --scope user system -- system-mcp

OpenCode

In ~/.config/opencode/opencode.json:

{
  "mcp": {
    "system": {
      "type": "local",
      "command": ["system-mcp"],
      "enabled": true
    }
  }
}

Codex

In ~/.codex/config.toml:

[mcp_servers.system]
command = "system-mcp"

Library API

import {
  SystemMonitor,
  GrowthTracker,
  LivenessProber,
  ArtifactClassifier,
  ReclaimPlanner,
  SystemMcpServer,
} from '@modelprofile.com/system-mcp';

// monitoring
const monitor = new SystemMonitor();
const status = monitor.getStatus();
const headroom = monitor.checkHeadroom({ path: '/mnt/data', bytesNeeded: 20 * 1024 ** 3 });

// growth
const tracker = new GrowthTracker();
tracker.record(status.filesystems);
const growth = tracker.report(7 * 24 * 60 * 60 * 1000);

// classification (read-only)
const classifier = new ArtifactClassifier({ staleDays: 7 });
const summary = await classifier.scan();
const candidate = await classifier.classifyPath('/tmp/some-tree');

// reclamation
const planner = new ReclaimPlanner({ classifier });
const plan = await planner.plan(['/tmp/some-tree']);   // never deletes
const result = await planner.apply({ ...plan, apply: true });

// run the MCP stdio server yourself
await new SystemMcpServer().start();

ArtifactClassifier takes explicit roots, a configuredPnpmStore (pass null to make every store unknown), extraProtectedPrefixes, and time budgets. LivenessProber takes dockerBinPath: false to declare that a host has no container runtime — the only way to opt out of the fail-closed docker behaviour, and deliberately a configuration decision rather than a tool argument an agent can flip.

Behavior Notes

  • scan_artifacts, explain_path and plan() create, move and delete nothing. The only method in the package that deletes is ReclaimPlanner.apply(), and it throws unless handed a plan carrying apply: true.
  • apply() re-classifies every path from scratch against a fresh liveness snapshot. If one item has become live, the entire batch is abandoned and nothing is removed — a caller who asked for six deletions and silently got five has learnt nothing about what changed underneath them.
  • Caches and package stores are always measured, even when fresh, because their size is the point. /tmp skips recently written entries by default (there are thousands); includeFresh: true overrides it.
  • Docker is consulted read-only, through docker ps and docker inspect. Nothing is ever stopped, removed or pruned.

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the repository license file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
No description provided
Readme
184 KiB
Languages
TypeScript 99.9%