@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 itblocked— would qualify, but something concrete references it right nowlive— proven in useunknown— 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
realpathdiffers 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.
dockeris 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,.svnor.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-nodejsand 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
blockedrather thanlive— 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_pathandplan()create, move and delete nothing. The only method in the package that deletes isReclaimPlanner.apply(), and it throws unless handed a plan carryingapply: 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.
/tmpskips recently written entries by default (there are thousands);includeFresh: trueoverrides it. - Docker is consulted read-only, through
docker psanddocker inspect. Nothing is ever stopped, removed or pruned.
License and Legal Information
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.