@modelprofile.com/mcp-system

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? Classifies candidate paths without modifying them; growth-history recording is optional. 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. The list is curatable per host: extraRegenerableCacheNames adds names the caller declares regenerable (every report then says "user-declared … as declared by configuration, not established by this tool", and all other gates — ownership, liveness, staleness — still apply), and removeRegenerableCacheNames withdraws built-in names, which only ever makes the tool more conservative and outranks the extra list.

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/user/.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

mcp-system 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/mcp-system';

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:

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

Growth

By default, system_status records a filesystem sample and scan_artifacts records the directory sizes it already computed. Pass record: false to either tool to skip that write. 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 disable history writes. This does not disable explicit reclamation through ReclaimPlanner.apply(). The legacy state path and environment variable remain stable so upgrading the package does not hide existing history.

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.

Filesystem Probing

Every filesystem probe is asynchronous and bounded on its own. statfs on a hung NFS or CIFS mount blocks in the kernel and cannot be cancelled, so a synchronous probe would freeze the caller — a status bar sampling on a timer, or an MCP server mid-call — for as long as the mount stayed unreachable.

option default purpose
statTimeoutMs 2000 per-mount budget, armed when the call is made; a mount that misses it is reported as unavailable
statConcurrency UV_THREADPOOL_SIZE or 4 how many mounts are probed at once
excludeFilesystemTypes [] further types to skip on top of the built-in pseudo-filesystem set
minimumTotalBytes 0 capacity floor for enumerated filesystems; the root fallback ignores it
mountPoints all restrict monitoring to these mount points
mountsFilePath /proc/self/mountinfo mount table to enumerate; the /proc/mounts format is accepted too, but carries no device numbers
statfs fs.promises.statfs the probe itself
diskstatsFilePath /proc/diskstats per-device I/O counters
sysfsRoot /sys where block devices are resolved
blockDeviceProbe stat of the mount source how a mount source is turned into a device number
monotonicClock performance.now() the clock that stamps each I/O sample and ages remembered resolutions
resolutionLifetimeMs 60000 how long a mount's resolution to its disks is trusted before the stack is walked again

statfs runs on the libuv threadpool, which is why statConcurrency defaults to the effective pool size: probing more mounts at once than the pool has workers only queues them. It is also why a probe is never duplicated. A mount that does not answer within its budget is reported with available: false, zeroed numbers, severity warn, and a reason saying it did not answer; the abandoned call is left to finish on its own, holding one pool worker until the kernel returns. A second ask for the same mount — a later poll, or a listing that overlaps another one — joins that outstanding call against a fresh budget rather than making a new one. A healthy mount whose probe is merely still in flight therefore answers with its real numbers as soon as the call lands, and a hung one reaches the same timeout it would have reached anyway; without this, a caller polling every couple of seconds would pin the whole pool on one hung mount and stall every other fs, dns, zlib and crypto call in the process. Bind mounts are deduplicated by device before any probe runs, so twenty bind mounts of one hung device cost one probe, not twenty. Its zeroes are never used as a measurement: check_headroom refuses with stop rather than projecting from them, system_status prints its columns as unknown, and the growth history skips it, because recording zeroes would read back later as a filesystem that emptied and then refilled. A statfs that fails outright — an unmounted path, a permission error — is dropped from the listing instead, which is what it has always been.

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.

Disk Utilisation

Every filesystem from listFilesystems() (and so from getStatus()) carries io: the cumulative /proc/diskstats counters of the disks behind it — completed reads and writes, bytes read and written, and ioTicksMs, the milliseconds a disk had I/O in flight — stamped with a monotonic sampledAtMs. When they cannot be attributed, io is null and ioUnavailableReason says why: a network, FUSE, ZFS or tmpfs source is no local block device, and a platform other than Linux has no per-device counters. A status that did not come from a listing — statFilesystem(), or resolveFilesystemForPath() for a path outside every listed mount — carries io: null with a reason as well. A single sample is only a baseline; two samples give a rate:

import { SystemMonitor, diskIoRates, readIoPressure } from '@modelprofile.com/mcp-system';

const monitor = new SystemMonitor();
const before = await monitor.listFilesystems();
// … one polling interval later
const after = await monitor.listFilesystems();

for (const current of after) {
  const previous = before.find((entry) => entry.mountPoint === current.mountPoint);
  const rates = previous?.io && current.io ? diskIoRates(previous.io, current.io) : null;
  // rates: { intervalMs, busyPercent, readBytesPerSecond, writeBytesPerSecond,
  //          readsPerSecond, writesPerSecond, devices } or null
}

// system-wide: { someAvg10, someAvg60, fullAvg10, fullAvg60 } or null without PSI
const pressure = await readIoPressure();

A mount is resolved to its disks the way the kernel stacks them: the source device's number, /sys/dev/block/<major>:<minor>, a partition's parent disk, and every slaves/ entry of a stacked device (LVM, LUKS, md, any device-mapper target), recursively and deduplicated. The source device comes first, because btrfs reports an anonymous device number; the mount table's device number is the fallback for a source node that does not exist, such as /dev/root. Only sources under /dev are inspected — a CIFS source like //server/share is free text, and inspecting it as a path could wake an automounter. The walk ends at devices without slaves, so a loop, zram, nbd, rbd or drbd device, or a zvol, is reported as itself rather than as the storage beneath it; and under dm-multipath each path is a device of its own, so busyPercent understates the LUN behind them.

The walk is bounded in depth and size, and the whole I/O sample runs before any statfs probe starts, so it never queues behind this listing's own probes; a probe an earlier listing abandoned on a hung mount still holds its threadpool worker, exactly as it does for the mount table read. The walk's result — a failure included — is remembered per mount for resolutionLifetimeMs (one minute by default), so a steady listing costs one /proc/diskstats read more than before, plus one walk per mount per lifetime. The lifetime is what notices a stack that changes while it stays mounted: mdadm --add or --replace, pvmove, lvextend onto a new volume. A listing that finds a remembered disk missing from /proc/diskstats reports that once and walks the stack again on the next listing, and mounts that disappear are forgotten.

The counters, and so the rates, cover all I/O on those disks — every partition, filesystem and swap area on them — not only the filesystem's own, so filesystems that share a disk report identical io and rates. diskIoRates(previous, current) measures the disks present in both samples. busyPercent is the busiest disk's share of the interval with I/O in flight, clamped to 0–100 — iostat's %util. Throughput and IOPS are sums over the disks, so a RAID 1 write counts once per mirror, which is what the disks did. It returns null when no time passed, when the samples share no disk, or when any counter went backwards, because a replaced device or a reset counter would otherwise produce an invented rate.

busyPercent is not a share of throughput capacity — the kernel does not know a device's capacity. It measures how much of the time the disk was never idle. A rotating disk serves one request at a time, so 100% there does mean saturated. An SSD, and NVMe in particular, serves many requests in parallel and reads 100% as soon as one request is always outstanding, long before it is saturated. Read busyPercent as "this disk never idled", read the byte and operation rates as the load, and read readIoPressure() — the kernel's pressure stall information, the share of time tasks were stalled waiting on I/O — as the answer to whether the host is actually held back by it.

Package Migration

The package and executable were renamed for consistent mcp-* naming:

Previous Replacement
@modelprofile.com/system-mcp @modelprofile.com/mcp-system
system-mcp mcp-system

The previous package remains installable after deprecation, and existing lockfiles continue to resolve it. Update package references and MCP client commands explicitly. The persisted $XDG_STATE_HOME/system-mcp directory, SYSTEM_MCP_STATE_DIR environment variable, and .system-mcp-scratch.json marker filename intentionally retain their established names for compatibility and reclamation safety.

Install

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

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

Requires Node.js >= 24.

Register in Your Harness

Claude Code

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

OpenCode

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

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

Codex

In ~/.codex/config.toml:

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

Library API

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

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

// a caller with a narrower question — "show me the real disks" — can say so
const disks = new SystemMonitor({
  excludeFilesystemTypes: ['tmpfs', 'devtmpfs'],
  minimumTotalBytes: 1024 ** 3,
});
const volumes = await disks.listFilesystems();

// 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 standalone MCP stdio server yourself
const standalone = new SystemMcpServer();
await standalone.start();

ArtifactClassifier takes explicit roots, a configuredPnpmStore (pass null to make every store unknown), extraProtectedPrefixes, extraRegenerableCacheNames / removeRegenerableCacheNames for per-host cache-list curation, 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.

Compose Into an Existing MCP Server

SystemMcpTools registers the same six definitions and handlers used by the standalone server on an externally owned McpServer:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { SystemMcpTools } from '@modelprofile.com/mcp-system';

declare const hostOwnedTransport: Transport;
const sharedServer = new McpServer({ name: 'my-composed-server', version: '1.0.0' });
const systemTools = new SystemMcpTools();

systemTools.register(sharedServer);
await sharedServer.connect(hostOwnedTransport);

// When the host lifecycle ends, disconnect first and drain the component second.
await sharedServer.close();
await systemTools.close();

The component owns its SystemMonitor, GrowthTracker, ArtifactClassifier, and ReclaimPlanner instances, all of which can be injected through the constructor options. It never connects, closes, or otherwise assumes ownership of the shared server or transport. Register it before the host connects. At most four tool calls are admitted concurrently; additional calls fail immediately and may be retried after an active call settles. At shutdown the host must first await sharedServer.close(), then await systemTools.close(). Each close() attempt stops admission and gives already-admitted reads, scans, history writes, or reclaim operations 30 seconds to settle before reporting a retryable cleanup failure. A started reclaim with apply: true is drained rather than aborted midway. Timed-out operations and registrations remain owned by the same component, and admission stays closed. If either host shutdown or component removal fails, retain the same objects and retry their close() methods.

sharedServer must be the SDK's high-level McpServer. Other components may register their tools through McpServer.registerTool(), but the host must not install custom low-level handlers for tools/list or tools/call through sharedServer.server.setRequestHandler(); those handlers are owned by the high-level server. Duplicate high-level tool names throw, and a collision rolls back any system registrations completed earlier in the same attempt without replacing or removing host-owned tools.

unregister() exists only to synchronously cancel a pre-connect registration. It throws while the shared server is connected because SDK 1.30's RegisteredTool.remove() emits an unawaited list-change notification in that state. Use the disconnect-then-close() sequence for every connected lifecycle.

The standalone SystemMcpServer composes the same component, definitions, handlers, and formatters, but owns the server and transport it creates. It is one-shot. Its close() method closes the server first, performs one bounded tool-drain attempt second, and retains the exact server and transport after any failure so a second close() retries cleanup rather than becoming a no-op.

Behavior Notes

  • scan_artifacts, explain_path and plan() never mutate candidate paths. system_status and scan_artifacts may append growth history unless recording is disabled. The only method in the package that deletes candidate paths is ReclaimPlanner.apply(), and it throws unless handed a plan carrying apply: true.
  • The MCP reclaim handler passes apply: true to the planner only when the validated tool argument is explicitly the boolean true; omission and false remain report-only in standalone and composed servers.
  • 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
942 KiB
Languages
TypeScript 100%