Skip to main content
Glama
mikeZ98

mcp-server-suite

by mikeZ98

mcp-server-suite

A hardened Model Context Protocol (MCP) server that speaks JSON-RPC 2.0 over standard I/O and exposes three narrowly scoped tools:

Tool

Purpose

read_only_sql

One validated, row-capped SELECT against PostgreSQL

sandbox_fs_read

Bounded text reads confined to a single directory tree

system_metrics

Host CPU, memory and disk utilisation

The server is designed to be launched by an MCP client as a child process, inside an unprivileged container with a read-only root filesystem.

Design tenets

  • Fail closed. Every input crosses a strict schema (extra="forbid", strict types, no NaN/Infinity). Anything the policy does not explicitly allow is rejected with a machine-readable RFC 9457 problem.

  • Structured concurrency. One asyncio.TaskGroup owns every task in a session. Each tools/call is a child task that can be cancelled, deadline-bounded and drained on shutdown. No task outlives the session.

  • No runtime network dependencies. The transport is stdio. The only outbound connection the server can make is to the PostgreSQL DSN you configure, and the shipped Compose profile has networking disabled.

  • Defence in depth. Each control assumes the one before it failed. The SQL AST policy is backed by read-only transactions and a least-privilege role. Path resolution is backed by a race-free openat walk. The container adds a non-root UID, no capabilities and an immutable root filesystem.

Related MCP server: rag-mcp-agent-demo

Contents

Quickstart

# Container (recommended)
docker compose build
printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"cli","version":"1"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' |
  docker compose run --rm -T mcp-server

# From source (Python 3.12, uv)
uv sync
mkdir -p data
MCP_SAFE_DATA_DIR="$PWD/data" uv run mcp-server-suite

The server reads requests from stdin until EOF or SIGINT/SIGTERM. Responses go to stdout and logs go to stderr.

Architecture

flowchart TB
    client["MCP client<br/>(parent process)"]

    subgraph proc["mcp-server-suite process: UID 10001, read-only rootfs, no capabilities"]
        direction TB
        stdio["Framed stdio<br/>newline-delimited JSON, 1 MiB ceiling,<br/>oversized lines dropped while streaming"]
        guard["Protocol guard<br/>strict UTF-8 JSON-RPC 2.0, no batches,<br/>no NaN, no duplicate keys, typed ids"]
        dispatch["Strict dispatcher<br/>lifecycle state, method routing,<br/>tools/call envelope, tool lookup"]

        subgraph rails["Guardrails: one TaskGroup task per tools/call"]
            direction TB
            san["Structural sanitizer<br/>identifier keys, depth 8, 1000 items,<br/>control, bidi and surrogate code points"]
            lim["Concurrency limiter<br/>16 slots, FIFO, 5 s bounded wait"]
            dl["Execution deadline<br/>30 s asyncio.timeout"]
            schema["Strict argument schema<br/>Pydantic v2, extra=forbid"]
        end

        subgraph tools["Isolated tools"]
            sql["read_only_sql<br/>sqlglot AST policy"]
            fs["sandbox_fs_read<br/>openat + O_NOFOLLOW walk"]
            met["system_metrics<br/>psutil in worker thread"]
        end
    end

    pg[("PostgreSQL<br/>SELECT-only role")]
    data[("/safe_data_dir<br/>read-only mount")]
    host[("/proc")]

    client -- "stdin" --> stdio --> guard --> dispatch
    dispatch -- "initialize, ping, tools/list<br/>answered inline" --> client
    dispatch -- "tools/call" --> san --> lim --> dl --> schema
    schema --> sql
    schema --> fs
    schema --> met
    sql -- "BEGIN READ ONLY, always rolled back" --> pg
    fs --> data
    met --> host
    tools -- "stdout: ASCII-only JSON frames" --> client

Layer

Module

Guarantees

Framed stdio

core/framing.py, core/stdio.py

Memory bounded by frame limit + one 64 KiB chunk. An over-long line is discarded as it streams and reported once, then reading resumes at the next newline. Output is ensure_ascii JSON, so lone surrogates cannot break encoding.

Protocol guard

core/jsonrpc.py

Rejects invalid UTF-8, NaN/Infinity literals, duplicate object keys, pathological nesting, batches, non-string/integer ids and unknown envelope members.

Dispatcher

core/server.py

Enforces the initialize handshake before other methods, rejects duplicate in-flight ids, validates the tools/call envelope and the tool name before any tool code runs.

Guardrails

guardrails/

Sanitizer, then concurrency slot, then deadline. Every rejection is an in-band problem the model can act on.

Argument schema

core/registry.py, schemas/

Arguments are validated in JSON mode against frozen, strict Pydantic models. Results are re-checked against the tool's output contract.

Tools

tools/

Each tool carries its own isolation controls (see Tool contracts).

Session lifecycle and concurrency

sequenceDiagram
    autonumber
    participant C as MCP client
    participant L as Read loop
    participant T as tools/call tasks
    participant OS as Signals

    C->>L: initialize (protocolVersion 2025-11-25)
    L-->>C: result: protocolVersion, capabilities.tools, serverInfo
    C-)L: notifications/initialized
    C->>L: tools/list
    L-->>C: result: tools with inputSchema, outputSchema, annotations

    par Concurrent calls
        C->>L: tools/call id=7 system_metrics
        L->>T: spawn task 7 in the TaskGroup
        Note over T: sanitize, acquire slot, run under deadline
        T-->>C: result id=7: structuredContent, isError false
    and
        C->>L: tools/call id=8 read_only_sql
        L->>T: spawn task 8
        C-)L: notifications/cancelled requestId=8
        L->>T: cancel task 8
        Note over T,C: A cancelled request receives no response
    end

    OS->>L: SIGTERM or SIGINT
    L->>L: stop reading stdin
    L->>T: drain in-flight calls (5 s grace)
    alt Second signal or grace period expires
        L->>T: cancel remaining tasks
    end
    L-->>C: flush and close stdout, exit 0
  • initialize, ping and tools/list are answered inline, in arrival order. Each tools/call runs as its own task, so a slow tool never blocks the read loop and responses may arrive out of order (match them by id).

  • notifications/cancelled cancels the matching task. Per the MCP specification, a cancelled request gets no response.

  • EOF on stdin, SIGINT and SIGTERM all start the same graceful drain. A second signal cancels in-flight work immediately. A write failure on stdout (client gone) also triggers shutdown.

  • Blocking work (file reads, CPU sampling) runs in worker threads via asyncio.to_thread, so it never stalls the event loop.

Wire specification

Transport. One JSON-RPC 2.0 message per line (\n-terminated, an optional trailing \r is tolerated, blank lines are ignored). The maximum frame size is 1 MiB (1,048,576 bytes). Larger lines get a -32600 error with "data": {"max_frame_bytes": 1048576} and the session continues.

Protocol versions. 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05. A client requesting any other version is offered 2025-11-25, per the MCP negotiation rules.

Method

Kind

Before initialize

Notes

initialize

request

yes

Exactly once per session

ping

request

yes

Returns {}

tools/list

request

no

inputSchema, outputSchema and annotations per tool

tools/call

request

no

Concurrent; see Error model

notifications/initialized

notification

n/a

Marks the client ready

notifications/cancelled

notification

n/a

Cancels an in-flight tools/call by requestId

Unknown notifications are ignored. Unknown methods return -32601. Responses sent by the client are ignored, because the server issues no requests of its own.

Tool contracts

Every tool is advertised with readOnlyHint: true, destructiveHint: false, idempotentHint: true and openWorldHint: false. Successful calls return the result as both JSON structuredContent and an identical JSON text block.

read_only_sql

Registered only when MCP_SQL_DSN is set.

Argument

Type

Constraints

Default

query

string

6–10,000 chars, no NUL, not blank

—

max_rows

integer

1–50

50

Result

Type

Meaning

columns

string[]

Column names in result order

rows

array[] (≤ 50)

Values normalised to JSON: Decimal/UUID as text, timestamps ISO 8601, bytea as \x… hex

row_count

integer

Equals len(rows)

truncated

boolean

true when the server cap, not the query, bounded the rows

How a query is enforced:

  1. Single statement. The query is parsed with sqlglot (PostgreSQL dialect). More than one statement is a MCP_QUERY_VIOLATION.

  2. Root and tree policy. The root must be a SELECT or a set operation (UNION/INTERSECT/EXCEPT). The entire AST is walked, so a write hidden in a CTE (WITH d AS (DELETE …) SELECT …) is rejected. Rejected nodes include DML, DDL, DROP, ALTER, TRUNCATE, GRANT/REVOKE, SET, transaction control, SELECT INTO, row locks (FOR UPDATE) and anything sqlglot models only as an opaque command (DO, CALL, COPY, VACUUM, …).

  3. Function denylist. Functions with effects that survive a rolled-back read-only transaction are rejected: dblink*, lo_*, advisory locks, pg_sleep, pg_read_file, pg_ls_*, pg_terminate_backend, set_config, pg_notify, replication and WAL control, among others. Bind parameters are rejected (inline literals instead).

  4. Deterministic LIMIT. The outer LIMIT is rewritten to min(literal, max_rows). A missing, non-literal, ALL, PERCENT or WITH TIES limit becomes LIMIT max_rows. The SQL actually executed is regenerated from the validated AST (comments dropped) and validated a second time.

  5. Execution. The query runs inside BEGIN READ ONLY with a transaction-local statement_timeout, and the transaction is always rolled back. Pooled connections also default to default_transaction_read_only=on.

Input

Outcome

SELECT id, name FROM users

Executes SELECT id, name FROM users LIMIT 50

SELECT * FROM t LIMIT 10

Executes SELECT * FROM t LIMIT 10

SELECT * FROM t LIMIT 500

Executes SELECT * FROM t LIMIT 50

SELECT 1; DROP TABLE t

MCP_QUERY_VIOLATION: exactly one statement is allowed

WITH d AS (DELETE FROM t RETURNING *) SELECT * FROM d

MCP_QUERY_VIOLATION: DELETE is not allowed

SELECT pg_sleep(10)

MCP_QUERY_VIOLATION: function pg_sleep() is not allowed

PostgreSQL errors caused by the query are mapped in-band: 57014 (statement timeout) to MCP_EXECUTION_TIMEOUT, 25006 (read-only violation) to MCP_QUERY_VIOLATION, 42501 to MCP_ACCESS_DENIED, and SQLSTATE classes 21, 22, 42, 0A and 54 to MCP_VALIDATION_ERROR. Connection and server failures are infrastructure faults (-32603).

sandbox_fs_read

Registered only when MCP_SAFE_DATA_DIR is set (the container image sets it to /safe_data_dir).

Argument

Type

Constraints

Default

relative_path

string

1–4,096 chars, no NUL

—

encoding

string

A Python text codec (base64, zlib, rot13 are rejected)

utf-8

max_bytes

integer

1–1,048,576

65,536

Result

Type

Meaning

resolved_path

string

Canonical path relative to the sandbox root

content

string

Strictly decoded text

bytes_read

integer

Bytes decoded. A multi-byte character cut by max_bytes is dropped

truncated

boolean

The file is longer than max_bytes

How a read is enforced:

  1. Canonical containment. (root / relative_path).resolve() follows every symlink and the result must satisfy is_relative_to(root). Resolution is non-strict, so a path outside the sandbox is denied whether or not it exists (no existence oracle). Symlink loops are rejected.

  2. Race-free open. The resolved path is re-opened one component at a time with openat relative to a directory fd, using O_NOFOLLOW on every component. A directory swapped for a symlink between steps 1 and 2 fails with ELOOP and cannot escape. Symlinks that resolve inside the sandbox are followed by step 1, so the walk itself never meets one.

  3. File type. The leaf is opened O_NONBLOCK | O_NOCTTY and checked with fstat on the open descriptor. Only regular files are read. FIFOs, sockets and character or block devices are denied, and a FIFO cannot hang the worker.

  4. Bounded read. At most max_bytes + 1 bytes are read, in a worker thread, then strictly decoded.

  5. No path leaks. Problems echo at most 256 characters of the caller's own relative_path and never an absolute host path.

system_metrics

Registered unless MCP_METRICS_ENABLED=false.

Argument

Type

Default

include_disk

boolean

false

Result

Type

cpu

utilization_percent (0–100), core_count (≥ 1), load_average_1m (≥ 0)

memory

total_bytes, available_bytes (≤ total), percent_used (0–100)

disk_percent_used

0–100 for MCP_METRICS_DISK_PATH, or null when not requested

timestamp_utc

ISO 8601 timestamp with a zero UTC offset

Readings are clamped into range, because containers and VMs occasionally report slightly out-of-range values. The disk path is configuration only and is never echoed back.

Error model

MCP distinguishes two failure channels, and this server uses them deliberately:

  • In-band tool errors (result.isError: true). The tool ran (or was refused) because of what the caller asked for: invalid arguments, a policy violation, a denied path, a timeout or a full concurrency queue. The result carries one text block whose content is an RFC 9457 problem document. The calling model can read it, correct the arguments and retry. structuredContent is omitted because it would not match the tool's outputSchema.

  • Transport-level JSON-RPC errors (error). The message is wrong rather than the tool call: unparsable JSON, an invalid envelope, an unknown method, a call before initialize, an unknown tool name or a malformed tools/call envelope. Unexpected crashes inside a tool also land here as -32603. The traceback goes to stderr and the client sees only a generic message.

MCPErrorCode

RFC 9457 type

HTTP status

JSON-RPC code

Typical cause

MCP_VALIDATION_ERROR

urn:mcp-server-suite:problem:validation-error

422

-32602

Schema violation, SQL syntax, missing file, sanitizer rejection

MCP_ACCESS_DENIED

urn:mcp-server-suite:problem:access-denied

403

-32001

Path outside the sandbox, special file, SQL 42501

MCP_QUERY_VIOLATION

urn:mcp-server-suite:problem:query-violation

403

-32002

Write, multi-statement or denylisted function

MCP_EXECUTION_TIMEOUT

urn:mcp-server-suite:problem:execution-timeout

504

-32003

Tool deadline or PostgreSQL statement_timeout

MCP_INTERNAL_ERROR

urn:mcp-server-suite:problem:internal-error

500

-32603

Concurrency queue full (metadata.retryable: true) or crash

The JSON-RPC code is used when a problem travels as error.data, for example an unknown tool name, which is reported as -32602 with the problem attached. type and status are validated against the code, so the same code can never be emitted with conflicting semantics. The other JSON-RPC codes are the standard ones: -32700 parse error, -32600 invalid request, -32601 method not found.

In-band error (path traversal attempt):

{"jsonrpc":"2.0","id":3,"result":{"isError":true,"content":[{"type":"text",
 "text":"{\"type\": \"urn:mcp-server-suite:problem:access-denied\", \"title\": \"Access to the requested resource is denied\", \"status\": 403, \"detail\": \"'../../etc/passwd' resolves outside the sandbox\", \"code\": \"MCP_ACCESS_DENIED\", \"invalid_params\": [\"relative_path\"], \"metadata\": {}}"}]}}

Protocol error (unknown tool):

{"jsonrpc":"2.0","id":4,"error":{"code":-32602,"message":"Unknown tool: 'no_such_tool'",
 "data":{"type":"urn:mcp-server-suite:problem:validation-error","title":"Invalid tool arguments",
 "status":422,"detail":"Unknown tool: 'no_such_tool'","code":"MCP_VALIDATION_ERROR",
 "invalid_params":["name"],"metadata":{}}}}

Threat model (STRIDE)

Assets. Database contents and integrity, files on the host outside the sandbox, host availability, and the integrity of the published source.

Trust boundaries.

  1. The MCP client and the model driving it, which supply all tool arguments. These are treated as untrusted, including prompt-injected input.

  2. The process boundary, which the container hardens.

  3. The PostgreSQL server.

  4. The source supply chain (commits, dependencies, base images, CI actions).

MCP over stdio has no authentication of its own: whoever can spawn the process is the client. Access control is therefore the job of the launching environment, the mounted data and the database role.

Threat

Attack vector

Controls

Verified by

Spoofing

Commits published under a forged or tool-attributed identity.

Sole-committer invariant (.githooks/_policy.sh) checked on author and committer by pre-commit, commit-msg and pre-push. The same rules run in CI (scripts/audit-attribution.sh on the push or PR range). Tool-generated trailers are rejected. Local identity is pinned with user.useConfigOnly.

tests/unit/test_policy_parity.py, attribution-audit job

Tampering

SQL injection or write smuggling: stacked statements, writes in CTEs, SELECT INTO, row locks, side-effecting functions, comment tricks, unbounded result sets.

sqlglot AST walk over the whole tree. Single-statement rule. Root must be SELECT or a set operation. Function denylist. Executed SQL regenerated from the AST and re-validated. Deterministic LIMIT ≤ 50. BEGIN READ ONLY plus unconditional rollback. Read-only session defaults. SELECT-only database role (below).

test_sql_tool.py, Hypothesis grammar and attack fuzzing in test_fuzz.py

Tampering

Argument poisoning: bidi overrides (Trojan Source), zero-width characters, control characters, dunder or non-identifier keys.

Structural sanitizer runs before schema validation. Strict Pydantic models with extra="forbid".

test_guardrails.py, sanitizer fuzzing against unicodedata

Repudiation

A call that cannot be traced afterwards.

Errors and lifecycle events are logged to stderr (MCP_SERVER_SUITE_LOG_LEVEL). Unexpected crashes are logged with a traceback. There is no per-call audit log; capture stderr in the launching environment if you need one.

—

Information disclosure

Path traversal (../, absolute paths, symlink escapes, TOCTOU symlink swaps), reading devices or FIFOs, probing for file existence, host paths leaking in errors.

Non-strict canonical resolution plus is_relative_to (no existence oracle). Race-free openat walk with O_NOFOLLOW per component. fstat regular-file check on the open fd. O_NONBLOCK/O_NOCTTY leaf open. Problems carry only the caller's relative path. Internal errors are reported generically.

test_fs_tool.py, traversal fuzzing against a realpath oracle

Information disclosure

Credentials or data leaking through the server.

The DSN is a SecretStr (redacted in reprs). The shipped Compose profile has network_mode: none. The CI Trivy scan includes secret detection.

container-security job

Denial of service

Memory exhaustion via giant lines, crashing the session with lone UTF-16 surrogates, deeply nested JSON or SQL, call floods, slow tools, blocking I/O.

1 MiB frame ceiling, enforced while streaming. Surrogates are rejected by the sanitizer and Pydantic, and output is ASCII-escaped. Nesting depth and size limits, with parser RecursionError mapped to a validation error. 16-slot FIFO limiter with a bounded wait. 30 s per-call deadline plus PostgreSQL statement_timeout. File reads and CPU sampling offloaded to threads. Container CPU, memory and PID limits.

test_core.py, test_guardrails.py, e2e subprocess tests

Elevation of privilege

A compromised tool or dependency gaining root, capabilities or persistence.

Non-root UID/GID 10001:10001. Read-only root filesystem with tmpfs /tmp (noexec,nosuid,nodev). --cap-drop ALL. no-new-privileges. setuid/setgid bits stripped. pip, ensurepip, apt and dpkg removed from the image. PYTHONSAFEPATH=1. Base images and CI actions pinned by digest/SHA.

scripts/smoke-docker.sh, Trivy in CI

Residual risks and limitations

  • The SQL policy is a denylist over a parser's model of SQL, so it is a filter, not an isolation boundary. The database role is the boundary: give it SELECT on exactly the objects the model may see.

  • A deadline cancels the awaiting task immediately, but Python cannot interrupt a worker thread. Thread work is kept short by design: reads are capped and CPU sampling is ≤ 2 s.

  • Content returned by tools is data, not instructions. Clients should present it to models accordingly.

Configuration

All settings are environment variables, validated at startup. Invalid values stop the server rather than being silently replaced.

Variable

Default

Constraint

Effect

MCP_SQL_DSN

unset

postgres:// or postgresql://

Enables read_only_sql

MCP_SQL_CONNECT_TIMEOUT

5

> 0, ≤ 60 s

Connect and pool-acquire timeout

MCP_SQL_QUERY_TIMEOUT

10

> 0, ≤ 300 s

Client timeout and server statement_timeout

MCP_SQL_POOL_MAX_SIZE

4

1–32

Maximum pooled connections (created lazily)

MCP_SQL_APPLICATION_NAME

mcp-server-suite

1–63 chars

application_name seen in pg_stat_activity

MCP_SAFE_DATA_DIR

unset (/safe_data_dir in the image)

absolute, existing directory

Enables sandbox_fs_read

MCP_METRICS_ENABLED

true

boolean

Enables system_metrics

MCP_METRICS_CPU_SAMPLE_SECONDS

0.1

> 0, ≤ 2 s

CPU sampling interval

MCP_METRICS_DISK_PATH

/

absolute path

Filesystem reported by disk_percent_used

MCP_MAX_CONCURRENT_TOOLS

16

1–256

Concurrent tools/call slots

MCP_TOOL_ACQUIRE_TIMEOUT_SECONDS

5

0–60 s

Wait for a slot before rejecting (0 = reject immediately)

MCP_TOOL_TIMEOUT_SECONDS

30

> 0, ≤ 600 s

Per-call execution deadline

MCP_MAX_ARGUMENT_DEPTH

8

1–64

Maximum nesting of tool arguments

MCP_MAX_ARGUMENT_ITEMS

1000

1–100,000

Maximum items in any argument container

MCP_SERVER_SUITE_LOG_LEVEL

WARNING

logging level name

stderr log verbosity

Operations

Local development

Prerequisites: uv and, optionally, direnv. The .envrc keeps every cache, the virtualenv and temporary files under ./.cache/, and configures the git hooks and identity.

direnv allow                 # once; or export the variables from .envrc manually
uv sync --locked             # Python 3.12 + locked dependencies into .cache/venv

ruff check . && ruff format --check .
mypy src/ tests/ --strict
bandit -c pyproject.toml -r src/
pytest                       # sockets disabled, branch coverage, >= 95% floor
scripts/coverage-gate.sh     # 100% branch coverage on tools/sql.py, tools/fs.py, guardrails/

The test suite is hermetic. pytest-socket blocks all IP sockets for the whole run (Unix sockets stay available for the event loop), no test needs a live database, and MCP_* variables are cleared around each test. The suite covers:

  • Unit: contracts and controls.

  • Property-based: Hypothesis fuzzing, tests/unit/test_fuzz.py.

  • End-to-end: the real server as a subprocess under python -X dev, tests/e2e/. These cover handshakes, concurrency, malformed input, oversized frames, signals and file-descriptor/task leak checks.

HYPOTHESIS_PROFILE=ci pytest tests/unit/test_fuzz.py --no-cov   # 1000 derandomised examples per property
pytest -m e2e --no-cov                                           # subprocess protocol tests only

Mutation checks. The repository ships no mutation-testing script or framework, so mutation checks are run by hand. Disable one defence, run its test module, confirm it fails, then restore the file. For example:

# Drop O_NOFOLLOW from the directory walk: test_fs_tool.py must fail.
sed -i.bak 's/os.O_DIRECTORY | os.O_NOFOLLOW/os.O_DIRECTORY/' src/mcp_server_suite/tools/fs.py
pytest tests/unit/test_fs_tool.py --no-cov; mv src/mcp_server_suite/tools/fs.py.bak src/mcp_server_suite/tools/fs.py

# Skip the LIMIT rewrite: test_sql_tool.py must fail.
sed -i.bak 's/root.limit(effective, copy=False).sql/root.sql/' src/mcp_server_suite/tools/sql.py
pytest tests/unit/test_sql_tool.py --no-cov; mv src/mcp_server_suite/tools/sql.py.bak src/mcp_server_suite/tools/sql.py

git diff --exit-code src/    # confirm the tree is restored

Container deployment

The image is a two-stage build:

  • Builder: python:3.12-slim with uv installs the locked dependencies and the package into /opt/venv.

  • Runtime: the same digest-pinned base, with no pip, uv, compilers or package managers. It runs as 10001:10001 with STOPSIGNAL SIGTERM.

docker build -t mcp-server-suite:local .

docker run --rm -i \
  --read-only \
  --cap-drop ALL \
  --security-opt no-new-privileges:true \
  --network none \
  --tmpfs /tmp:rw,noexec,nosuid,nodev,size=16m \
  --pids-limit 64 --memory 256m --cpus 1 \
  -v "$PWD/data:/safe_data_dir:ro" \
  mcp-server-suite:local
  • Mount only the directory the model should read, read-only. Files must be readable by UID 10001 (world-readable files in a world-traversable directory are enough).

  • Keep -i and omit -t: a TTY would rewrite the newline-delimited stream.

  • To use read_only_sql, replace --network none with a network that reaches only the database, and pass -e MCP_SQL_DSN. Prefer an env file or secret store to putting the DSN on the command line.

docker-compose.yml applies the same profile, with resource reservations and a dedicated read-only safe-data volume:

docker compose build
docker compose run --rm -T mcp-server < requests.jsonl

scripts/smoke-docker.sh builds the image and checks the result:

  • the UID is 10001;

  • the root filesystem and /opt/venv reject writes while /tmp accepts them;

  • pip, package managers and setuid binaries are absent;

  • an MCP handshake over container stdio succeeds through both docker run and Compose;

  • docker stop exits cleanly.

--offline validates the Dockerfile and Compose file without a daemon.

An MCP client launches the container like any stdio server:

{
  "mcpServers": {
    "mcp-server-suite": {
      "command": "docker",
      "args": [
        "run", "--rm", "-i", "--read-only", "--cap-drop", "ALL",
        "--security-opt", "no-new-privileges:true", "--network", "none",
        "--tmpfs", "/tmp:rw,noexec,nosuid,nodev,size=16m",
        "-v", "mcp-safe-data:/safe_data_dir:ro",
        "mcp-server-suite:local"
      ]
    }
  }
}

PostgreSQL least-privilege role

The AST policy and read-only transactions reduce risk. The role is what actually bounds it. Create a dedicated login role that can only read the objects you intend to expose:

-- Dedicated login role: no inheritance, no special attributes.
CREATE ROLE mcp_reader LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE
  NOREPLICATION NOBYPASSRLS CONNECTION LIMIT 8;
\password mcp_reader

-- Server-side defaults that hold even if a client-side layer is bypassed.
ALTER ROLE mcp_reader SET default_transaction_read_only = on;
ALTER ROLE mcp_reader SET statement_timeout = '10s';
ALTER ROLE mcp_reader SET lock_timeout = '1s';
ALTER ROLE mcp_reader SET idle_in_transaction_session_timeout = '15s';

-- Read access to one schema only; ideally a schema of curated views.
GRANT CONNECT ON DATABASE app TO mcp_reader;
GRANT USAGE ON SCHEMA reporting TO mcp_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO mcp_reader;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA reporting
  GRANT SELECT ON TABLES TO mcp_reader;
  • Keep CONNECTION LIMIT at or above MCP_SQL_POOL_MAX_SIZE.

  • Never grant membership in pg_read_all_data, pg_read_server_files, pg_execute_server_program or pg_signal_backend.

  • Use row-level security or views to expose only the rows and columns the model needs.

  • Connect with TLS (?sslmode=verify-full):

    export MCP_SQL_DSN='postgresql://mcp_reader@db.internal:5432/app?sslmode=verify-full'

Continuous integration

.github/workflows/ci.yml runs on pushes to main and on every pull request, with contents: read permissions. Every action is pinned to a commit SHA, with its version in a comment.

Job

Gates

attribution-audit

Commit identity, subject and trailer audit of the PR or push range. Tracked-file hygiene audit.

code-quality

ruff lint and format, mypy --strict, bandit, pytest with HYPOTHESIS_PROFILE=ci and a 95% floor, scripts/coverage-gate.sh

container-security

Buildx image build, scripts/smoke-docker.sh against the live daemon, Trivy (fails on fixable CRITICAL/HIGH, reports unfixed ones)

Repository layout

src/mcp_server_suite/
  __main__.py        entry point: logging to stderr, stdio session
  core/              framing, JSON-RPC parsing, dispatcher, tool registry, stdio transport
  guardrails/        argument sanitizer, concurrency limiter, execution deadline
  schemas/           strict Pydantic contracts and the RFC 9457 error taxonomy
  tools/             read_only_sql, sandbox_fs_read, system_metrics
tests/
  unit/              contracts, controls, fuzzing, hermeticity, hook/CI policy parity
  e2e/               real-process stdio protocol tests
scripts/             attribution, hygiene, coverage and container smoke gates
.githooks/           pre-commit, commit-msg, pre-push (shared rules in _policy.sh)
Dockerfile, docker-compose.yml, .github/workflows/ci.yml

Contributing

  • Commits use Conventional Commits (type(scope): subject) with no generated trailers.

  • Author and committer must match the configured maintainer identity. The hooks in .githooks/ enforce this locally (git config core.hooksPath .githooks, set automatically by .envrc) and CI enforces the same rules.

  • Integrate changes with a fast-forward or rebase and push. Merging through the GitHub web UI records GitHub as the committer, which the audit rejects.

  • Keep caches and temporary files under ./.cache/. Never commit absolute machine paths; the hygiene gates reject them.

License

Released under the MIT License (SPDX: MIT). Copyright (c) 2026 mikeZ98.

Available Tools

1 tool
system_metricsSystem metricsA
Read-onlyIdempotent

Report host health: CPU utilisation, logical core count, 1-minute load average and memory usage; set include_disk for root-volume usage. Values are a point-in-time sample with a UTC timestamp.

NameRequiredDescriptionDefault
include_diskNo

Output Schema

NameRequiredDescription
cpuYes
memoryYes
timestamp_utcYes
disk_percent_usedNo

TDQS

A4.2/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already declare readOnly, idempotent, non-destructive and closed-world, so the safety profile is covered. The description adds genuinely useful context beyond that: values are a "point-in-time sample with a UTC timestamp," which tells the agent the data is a snapshot rather than a time series and how to interpret the timestamp. Missing only things like sampling latency or permission requirements.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Two tight sentences: the returned metrics are front-loaded, the optional parameter follows, and the sampling semantics close it out. No filler or redundant restatement of the name.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

An output schema exists, so return values need not be fully documented, yet the description still names the key fields. Combined with the snapshot/timestamp semantics and the parameter explanation, this is nearly complete; only minor operational details (units, cost/latency) are absent.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 0%, so the description must carry the burden for the single optional boolean. It does so explicitly: setting include_disk adds root-volume usage. The only gap is that it doesn't restate the default (false), which the schema already supplies as default=false.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a specific verb and resource ("Report host health") and enumerates exactly what is returned: CPU utilisation, logical core count, 1-minute load average, memory usage, plus optional root-volume usage. An agent knows precisely what this tool produces without opening the schema.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Usage is only implied — the framing "host health" and the point-in-time nature suggest quick inspection rather than historical trending, and include_disk is described as an opt-in extra. There are no sibling tools to route against, but the description never explicitly says when to reach for this versus another diagnostic approach, nor any exclusions.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 1 tool updatev0.1.0
    • First observedsystem_metrics

TDQS

A4.1/5.0

Scored across 1 tool

Disambiguation5/5

There is only one tool, so there is no possibility of selecting the wrong one. Its purpose (host health snapshot) is stated clearly and unambiguously.

Naming Consistency5/5

The single name system_metrics is snake_case, descriptive, and resource-oriented, with no competing conventions to clash with. Consistency cannot be violated with one tool, and the name reads predictably.

Tool Count2/5

A one-tool server is too thin for a general 'mcp-server-suite' scope; everything is crammed into a single snapshot call. It is well-scoped for what it does, but the surface is minimal.

Completeness3/5

CPU, load, core count, memory, and optional disk cover the core host-health snapshot, but there is no history/time-series, network, process, uptime, or multi-host coverage. Agents needing anything beyond a point-in-time sample hit a dead end.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • F
    license
    A
    quality
    B
    maintenance
    Enables AI agents to safely interact with GitHub, query Postgres read-only, check deployment status, and search/read files within an allowlisted sandbox—all with dry-run writes and SQL/path safety gates.
    8
    -
  • A
    license
    A
    quality
    C
    maintenance
    Enables read-only document retrieval and SQLite exploration through tools over stdio, enforcing a safety gate before SQL execution.
    3
    MIT
  • A
    license
    A
    quality
    B
    maintenance
    Enables coding agents to safely investigate PostgreSQL runtime state through bounded, read-oriented MCP tools for row-and-foreign-key neighborhood inspection, schema and statistics lookup, and table listing, with truncation, redaction, timeouts, and audit logging.
    4
    37 npm
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables AI models to securely analyze SQLite datasets and read local text documents through a local stdio interface. It provides read-only querying, schema discovery, pagination, and timeout protections to prevent data leaks or destructive changes.
    MIT