Skip to content

Add compact summary output format to list tools (bounded, docker ps-style) - #65

Open
mecworks wants to merge 1 commit into
ckreiling:mainfrom
mecworks:format/summary-list-output
Open

mecworks wants to merge 1 commit into
ckreiling:mainfrom
mecworks:format/summary-list-output

Conversation

@mecworks

Copy link
Copy Markdown

Summary

list_containers, list_images, and list_volumes returned the full
inspect-level shape for every object. That's fine for a single object, but
unusable for a listing: the payload is dominated by data a docker ps-style
answer never needs, and it's unbounded — it grows with what each
container's healthcheck prints.

On a 22-container host, a single list_containers call returned ~103 KB,
roughly 90% of which was health-check log output. For an LLM-facing tool, that
is the classic "inspect-everything" antipattern: the server can't know what the
model will need, so it should default to a minimal view and let the caller opt
into detail.

Root cause

docker_to_dict() (shared by every tool) embeds, for each container:

  • state = the raw inspect State, which includes
    Health.Log — Docker retains the last 5 healthcheck outputs and never
    bounds their size
    . A healthcheck that curls a web page (or prints a JSON
    status dump) therefore adds tens of KB per container, per listing.
  • image = a nested docker_to_dict(image) — repo tags, digests and OCI
    labels repeated for every row.
  • mounts = full mount definitions (source paths, driver, propagation).

remote-shell-mcp (a different Docker MCP) solved this by making list and
inspect separate verbs with a fixed 6-field row type. This PR does the same
thing within a single format parameter.

What this changes

Adds a format parameter — "summary" | "full", default "summary"
to the three list tools.

Summary returns a compact docker ps-style row:

name, short_id, status,
state.{Status, Running, Restarting, Dead, ExitCode, StartedAt},
image            (string ref, no nested object),
mounts           (trimmed to name + type),
health.{Status, FailingStreak}   (no Log)

plus a slim image/volume row (no labels/digests).

Full keeps the existing inspect shape for debugging. In both modes,
every Health.Log output entry is capped at 500 characters, so even a full
listing is bounded regardless of what a healthcheck prints.

Single-object tools (run_container, stop_container, …) are unchanged:
docker_to_dict() still defaults to format="full".

Behavior change (please read)

The default for the three list tools flips from inspect-level output to
the compact shape. Any existing client that parses the old fields from a
list_* call will need to pass format: "full" (or read the summary fields).
This is the intended fix — the old default was the bug — but it is a breaking
change for anyone scripting against the old list output.

Example output (real 22-container host, before → after)

Call Before After Reduction
list_containers (all 22) 102,965 chars 11,048 chars 89%
one web-app container row 24,013 chars 326 chars 99%
another web-app row 19,629 chars 323 chars 98%
a status-dumping proxy row 11,201 chars 424 chars 96%
list_images (27) 24,119 chars 4,666 chars 81%
list_volumes (13) 6,210 chars 3,189 chars 49%

A summary row:

{
  "name": "webapp-a",
  "short_id": "11aef40ef8cb",
  "status": "running",
  "state": {
    "Status": "running", "Running": true, "Restarting": false,
    "Dead": false, "ExitCode": 0, "StartedAt": "2026-09-15T15:55:05Z"
  },
  "image": "exampleorg/webapp-a:latest",
  "mounts": [],
  "health": { "Status": "healthy", "FailingStreak": 0 }
}

(In full mode, Health.Log entries longer than 500 chars are truncated with
a … [truncated] marker.)

Test plan

  • uv run pytest — 18 passed. One pre-existing test
    (test_production_stdio_command_lists_tools) spawns the real server and
    requires a live Docker daemon; it fails identically on unmodified main on a
    daemon-less host (verified by stashing this change and re-running) and is
    unrelated to this diff.
  • New tests/test_output_schemas.py (13 tests) exercises the real
    docker_to_dict — summary shape, full shape, the 500-char health-log cap,
    the image-ref fallback, overrides, networks — with faithful docker-py models,
    plus two end-to-end MCP calls proving list_containers defaults to the
    compact shape and run_container still returns the full object.
  • uv run ruff check and uv run ruff format --check (the repo's devbox lint
    commands): clean.
  • Live-daemon verification (read-only, over ssh:// to a 22-container
    host): list_containers default is 11,048 chars vs 102,965 full (89%
    smaller); 40 non-empty health-check outputs in full mode, 0 exceeding the
    cap; the format param is present on list_containers and absent from
    run_container.

Files

  • src/mcp_server_docker/output_schemas.py — split docker_to_dict into
    summary/full builders; added format param and the health-log cap.
  • src/mcp_server_docker/server.py — added format to the three list tools.
  • tests/test_output_schemas.py — new direct tests of the real serializer.
  • tests/test_server.py — updated the docker_to_dict mock to accept the new
    format kwarg.

list_containers, list_images and list_volumes returned the full
inspect-level shape for every object, which is unusable at fleet
scale:

* 'state' embeds State.Health.Log — Docker keeps the last 5
  healthcheck outputs and never bounds their size. A healthcheck that
  curls a web page (or prints a JSON status dump) adds tens of KB per
  container, per listing.
* the nested 'image' object repeats repo tags, digests and OCI labels
  for every container row.
* 'mounts' carries full mount definitions (source paths, drivers,
  propagation) for every row.

Observed on a 22-container host: a single list_containers call
returned ~103 KB, ~90% of it health-check log output.

Add a format parameter ('summary' | 'full', default 'summary') to the
three list tools. Summary returns a docker-ps-style row:

    name, short_id, status,
    state.{Status,Running,Restarting,Dead,ExitCode,StartedAt},
    image (string ref, no nested object),
    mounts trimmed to name+type,
    health.{Status,FailingStreak} (no Log)

plus a slim image/volume row (no labels/digests). Full keeps the
existing inspect shape for debugging, and caps every Health.Log
output entry at 500 characters in both modes, so even a full listing
is bounded regardless of what a healthcheck prints.

Single-object tools (run_container, stop_container, ...) are
unchanged: docker_to_dict defaults to format='full'.

Example output (real 22-container host, before -> after):

  list_containers            102,965 chars -> 11,048 chars (89% smaller)
  webapp-a row                  24,013 chars ->    326 chars
  webapp-b row                  19,629 chars ->    323 chars
  app-c row                     11,201 chars ->    424 chars

Verified against a live Docker daemon over ssh: 40 non-empty
health-check outputs in full mode, 0 exceeding the cap.
@mecworks

mecworks commented Sep 18, 2026

Copy link
Copy Markdown
Author

Proposed solution for #66: this PR adds the format parameter (default summary) and the 500-char health-log cap described there. Fixes #66.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant