docker-mcp-server 2.1.0__tar.gz → 2.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/PKG-INFO +1 -1
  2. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/system.py +44 -4
  3. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/manifest.json +1 -1
  4. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/pyproject.toml +1 -1
  5. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_system.py +72 -0
  6. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/uv.lock +1 -1
  7. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.claude/commands/docker-sdk.md +0 -0
  8. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.claude/settings.json +0 -0
  9. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.dockerignore +0 -0
  10. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.github/CODEOWNERS +0 -0
  11. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  12. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  13. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  14. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.github/actions/file-failure-issue/action.yaml +0 -0
  15. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.github/copilot-instructions.md +0 -0
  16. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.github/dependabot.yaml +0 -0
  17. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.github/release.yml +0 -0
  18. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.github/workflows/canary.yaml +0 -0
  19. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.github/workflows/codeql.yaml +0 -0
  20. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.github/workflows/images.yaml +0 -0
  21. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.github/workflows/premerge.yaml +0 -0
  22. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.github/workflows/publish-homebrew.yaml +0 -0
  23. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.github/workflows/publish.yaml +0 -0
  24. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.gitignore +0 -0
  25. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.mcpbignore +0 -0
  26. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/.python-version +0 -0
  27. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/CLAUDE.md +0 -0
  28. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/CODE_OF_CONDUCT.md +0 -0
  29. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/CONTRIBUTING.md +0 -0
  30. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/DOCKERHUB.md +0 -0
  31. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/Dockerfile +0 -0
  32. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/LICENSE +0 -0
  33. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/MIGRATION-2.0.md +0 -0
  34. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/PRIVACY.md +0 -0
  35. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/README.md +0 -0
  36. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/SECURITY.md +0 -0
  37. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/assets/README.md +0 -0
  38. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/assets/icon.png +0 -0
  39. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/__init__.py +0 -0
  40. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/__main__.py +0 -0
  41. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/_env.py +0 -0
  42. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/_hosts.py +0 -0
  43. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/server.py +0 -0
  44. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/__init__.py +0 -0
  45. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/_cli.py +0 -0
  46. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/_labels.py +0 -0
  47. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/_ssh_proxy.py +0 -0
  48. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/_utils.py +0 -0
  49. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/buildx.py +0 -0
  50. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/compose.py +0 -0
  51. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/configs.py +0 -0
  52. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/containers.py +0 -0
  53. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/context.py +0 -0
  54. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/images.py +0 -0
  55. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/networks.py +0 -0
  56. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/nodes.py +0 -0
  57. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/plugins.py +0 -0
  58. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/prompts.py +0 -0
  59. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/registry.py +0 -0
  60. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/resources.py +0 -0
  61. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/scout.py +0 -0
  62. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/secrets.py +0 -0
  63. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/services.py +0 -0
  64. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/stack.py +0 -0
  65. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/swarm.py +0 -0
  66. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/docker_mcp/tools/volumes.py +0 -0
  67. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/glama.json +0 -0
  68. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/mcpb_run.py +0 -0
  69. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/scripts/build-mcpb.sh +0 -0
  70. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/scripts/docker-mcp-server.rb.tpl +0 -0
  71. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/server.json +0 -0
  72. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/__init__.py +0 -0
  73. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/conftest.py +0 -0
  74. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/integration/__init__.py +0 -0
  75. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/integration/conftest.py +0 -0
  76. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/integration/test_buildx.py +0 -0
  77. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/integration/test_cli.py +0 -0
  78. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/integration/test_compose.py +0 -0
  79. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/integration/test_containers.py +0 -0
  80. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/integration/test_context.py +0 -0
  81. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/integration/test_file_payloads.py +0 -0
  82. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/integration/test_networks.py +0 -0
  83. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/integration/test_nodes.py +0 -0
  84. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/integration/test_registry.py +0 -0
  85. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/integration/test_scout.py +0 -0
  86. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/integration/test_services.py +0 -0
  87. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/integration/test_smoke.py +0 -0
  88. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/integration/test_stack.py +0 -0
  89. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_buildx.py +0 -0
  90. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_cli.py +0 -0
  91. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_compose.py +0 -0
  92. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_configs.py +0 -0
  93. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_containers.py +0 -0
  94. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_context.py +0 -0
  95. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_env.py +0 -0
  96. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_hosts.py +0 -0
  97. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_images.py +0 -0
  98. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_labels.py +0 -0
  99. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_main.py +0 -0
  100. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_naming.py +0 -0
  101. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_networks.py +0 -0
  102. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_nodes.py +0 -0
  103. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_plugins.py +0 -0
  104. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_prompts.py +0 -0
  105. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_pyproject_pins.py +0 -0
  106. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_registry.py +0 -0
  107. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_resources.py +0 -0
  108. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_scout.py +0 -0
  109. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_secrets.py +0 -0
  110. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_server.py +0 -0
  111. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_services.py +0 -0
  112. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_ssh_proxy.py +0 -0
  113. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_stack.py +0 -0
  114. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_swarm.py +0 -0
  115. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_utils.py +0 -0
  116. {docker_mcp_server-2.1.0 → docker_mcp_server-2.1.1}/tests/test_volumes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docker-mcp-server
3
- Version: 2.1.0
3
+ Version: 2.1.1
4
4
  Summary: MCP server for managing Docker resources via the Docker SDK for Python
5
5
  Project-URL: Homepage, https://github.com/GavinLucas/docker-mcp
6
6
  Project-URL: Repository, https://github.com/GavinLucas/docker-mcp
@@ -3,6 +3,7 @@
3
3
  import os
4
4
  import sys
5
5
  import threading
6
+ import urllib.parse
6
7
  from pathlib import Path
7
8
 
8
9
  import docker
@@ -20,6 +21,7 @@ from docker_mcp._hosts import (
20
21
  resolve_auto,
21
22
  )
22
23
  from docker_mcp.server import tool
24
+ from docker_mcp.tools._ssh_proxy import parse_ssh_url
23
25
  from docker_mcp.tools._utils import classify_host_kernel, close_stream_quietly, env_flag, in_container
24
26
 
25
27
  # One lazily-built docker-py client per configured host label (the pool). FastMCP runs sync tools
@@ -114,13 +116,52 @@ def _close_client_quietly(client: docker.DockerClient) -> None:
114
116
  pass
115
117
 
116
118
 
119
+ def _ensure_ssh_port(url: str) -> str:
120
+ """
121
+ Work around a docker-py bug for an `ssh://` URL with no explicit port: `docker.utils.parse_host()`
122
+ hardcodes port 22 into the URL *before* `SSHHTTPAdapter._create_paramiko_client` ever runs, so that
123
+ adapter's own `~/.ssh/config` `Port` fallback (which only fires while the port is still unset) never
124
+ triggers — a non-22 `Port` in `~/.ssh/config` is silently ignored. We splice in the configured port
125
+ ourselves first, reusing the exact `~/.ssh/config` lookup `_ssh_proxy.parse_ssh_url` already does for
126
+ the CLI-backed tools, so both tool families honor a non-default SSH port the same way.
127
+
128
+ args: url: str - a DOCKER_HOST/host URL; only `ssh://` URLs with no explicit port are affected
129
+ returns: str - `url` unchanged, or with the `~/.ssh/config` port spliced into the netloc
130
+ """
131
+ if not url.startswith("ssh://"):
132
+ return url
133
+ parsed = urllib.parse.urlparse(url)
134
+ try:
135
+ has_port = parsed.port is not None
136
+ except ValueError:
137
+ # A malformed port (e.g. "ssh://host:abc") — leave url untouched so docker-py's own
138
+ # validation raises its clearer error instead of this helper failing first.
139
+ return url
140
+ if has_port:
141
+ return url
142
+ try:
143
+ # parse_ssh_url raises ValueError on a missing hostname (e.g. "ssh://" or "ssh://@") or a
144
+ # non-integer `Port` in ~/.ssh/config — leave url untouched in either case rather than
145
+ # raising a bare ValueError here instead of the connection error docker-py would produce.
146
+ target = parse_ssh_url(url)
147
+ except ValueError:
148
+ return url
149
+ if target.port is None:
150
+ return url
151
+ return urllib.parse.urlunparse(parsed._replace(netloc=f"{parsed.netloc}:{target.port}"))
152
+
153
+
117
154
  def _build_default_client() -> docker.DockerClient:
118
155
  """Client for DOCKER_HOST when set (via from_env, honoring its TLS env), else the resolved endpoint.
119
156
 
120
157
  auto/local resolution lives in docker_mcp._hosts now (the pure registry layer); resolve_auto() is
121
158
  the relocated _resolve_default_base_url().
122
159
  """
123
- if os.environ.get("DOCKER_HOST"):
160
+ docker_host = os.environ.get("DOCKER_HOST")
161
+ if docker_host:
162
+ fixed = _ensure_ssh_port(docker_host)
163
+ if fixed != docker_host:
164
+ return docker.from_env(environment={**os.environ, "DOCKER_HOST": fixed})
124
165
  return docker.from_env()
125
166
  base_url = resolve_auto()
126
167
  return docker.DockerClient(base_url=base_url) if base_url else docker.from_env()
@@ -164,9 +205,8 @@ def _build_client(host: Host) -> docker.DockerClient:
164
205
  tls = _tls_config_for(host)
165
206
  if host.url is None:
166
207
  return docker.DockerClient(tls=tls) if tls is not None else docker.DockerClient()
167
- return (
168
- docker.DockerClient(base_url=host.url, tls=tls) if tls is not None else docker.DockerClient(base_url=host.url)
169
- )
208
+ url = _ensure_ssh_port(host.url)
209
+ return docker.DockerClient(base_url=url, tls=tls) if tls is not None else docker.DockerClient(base_url=url)
170
210
 
171
211
 
172
212
  def _get_client(host: str | None = None) -> docker.DockerClient:
@@ -2,7 +2,7 @@
2
2
  "manifest_version": "0.4",
3
3
  "name": "docker-mcp-server",
4
4
  "display_name": "Docker MCP Server",
5
- "version": "2.1.0",
5
+ "version": "2.1.1",
6
6
  "description": "Manage Docker through the Docker SDK for Python and the docker CLI: containers, images, networks, volumes, Compose, Swarm, Buildx, Scout, and OCI registries, exposed as MCP tools.",
7
7
  "long_description": "Manage one or more Docker daemons through your AI agent — containers, images, networks, volumes, Compose, Swarm, Buildx, Scout, and OCI registries, exposed as MCP tools.\n\nDiscovery is automatic for your local socket with no configuration required. Or point it at additional remote daemons over TCP, TLS, or SSH (and mark any of them read-only to monitor without the risk of accidental changes). It also surfaces logs and stats as resources for triage. Read-only / no-destructive / disabled-domain switches let you register a restricted tool surface.\n\nAnswer questions like: ‘why did my local container crash?’, ‘is my production system under memory pressure?’, ‘what differences are there between my staging and production systems?’\n\nRuns entirely on your machine and sends no telemetry — see the Privacy Policy.",
8
8
  "author": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "docker-mcp-server"
3
- version = "2.1.0"
3
+ version = "2.1.1"
4
4
  description = "MCP server for managing Docker resources via the Docker SDK for Python"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -571,3 +571,75 @@ def test_build_client_platform_default_ignores_ambient_docker_host(monkeypatch):
571
571
  assert system_module._build_client(Host("local", None)) is sentinel
572
572
  ctor.assert_called_once_with() # no base_url -> platform socket/npipe; ambient DOCKER_HOST ignored
573
573
  from_env.assert_not_called()
574
+
575
+
576
+ # ---------- _ensure_ssh_port: work around docker-py hardcoding port 22 for a bare ssh:// URL ----------
577
+
578
+
579
+ def test_ensure_ssh_port_ignores_non_ssh_url():
580
+ assert system_module._ensure_ssh_port("tcp://prod:2376") == "tcp://prod:2376"
581
+
582
+
583
+ def test_ensure_ssh_port_leaves_explicit_port_alone(monkeypatch):
584
+ # An explicit port must win even if ~/.ssh/config also has one — no lookup should even be needed.
585
+ monkeypatch.setattr(system_module, "parse_ssh_url", MagicMock(side_effect=AssertionError("should not be called")))
586
+ assert system_module._ensure_ssh_port("ssh://bob@example.com:1234") == "ssh://bob@example.com:1234"
587
+
588
+
589
+ def test_ensure_ssh_port_leaves_malformed_port_for_docker_py_to_reject(monkeypatch):
590
+ # A non-numeric port makes urlparse's .port property raise ValueError — must not propagate out of
591
+ # this helper; leave the url untouched so docker-py's own validation is what surfaces the error.
592
+ monkeypatch.setattr(system_module, "parse_ssh_url", MagicMock(side_effect=AssertionError("should not be called")))
593
+ assert system_module._ensure_ssh_port("ssh://bob@example.com:abc") == "ssh://bob@example.com:abc"
594
+
595
+
596
+ def test_ensure_ssh_port_leaves_hostless_url_for_docker_py_to_reject(monkeypatch):
597
+ # No hostname (e.g. "ssh://" or "ssh://@") makes the real parse_ssh_url raise ValueError — must
598
+ # not propagate out of this helper; leave docker-py's own validation to reject the malformed url.
599
+ monkeypatch.setattr("os.path.exists", lambda _path: False) # no ~/.ssh/config lookup needed
600
+ assert system_module._ensure_ssh_port("ssh://") == "ssh://"
601
+ assert system_module._ensure_ssh_port("ssh://@") == "ssh://@"
602
+
603
+
604
+ def test_ensure_ssh_port_leaves_url_alone_when_ssh_config_port_is_malformed(tmp_path, monkeypatch):
605
+ # A non-integer `Port` in ~/.ssh/config makes parse_ssh_url raise ValueError (int() failure) —
606
+ # must not propagate out of this helper; leave docker-py's own validation to surface the error.
607
+ config = tmp_path / "config"
608
+ config.write_text("Host example.com\n Port notanumber\n")
609
+ monkeypatch.setattr("os.path.expanduser", lambda p: str(config) if p == "~/.ssh/config" else p)
610
+ assert system_module._ensure_ssh_port("ssh://bob@example.com") == "ssh://bob@example.com"
611
+
612
+
613
+ def test_ensure_ssh_port_splices_in_ssh_config_port(tmp_path, monkeypatch):
614
+ config = tmp_path / "config"
615
+ config.write_text("Host example.com\n Port 1234\n")
616
+ monkeypatch.setattr("os.path.expanduser", lambda p: str(config) if p == "~/.ssh/config" else p)
617
+ assert system_module._ensure_ssh_port("ssh://bob@example.com") == "ssh://bob@example.com:1234"
618
+
619
+
620
+ def test_ensure_ssh_port_unchanged_when_config_has_no_port(monkeypatch):
621
+ monkeypatch.setattr("os.path.exists", lambda _path: False) # no ~/.ssh/config at all
622
+ assert system_module._ensure_ssh_port("ssh://bob@example.com") == "ssh://bob@example.com"
623
+
624
+
625
+ def test_build_default_client_splices_ssh_config_port_into_docker_host(tmp_path, monkeypatch):
626
+ config = tmp_path / "config"
627
+ config.write_text("Host example.com\n Port 1234\n")
628
+ monkeypatch.setattr("os.path.expanduser", lambda p: str(config) if p == "~/.ssh/config" else p)
629
+ monkeypatch.setenv("DOCKER_HOST", "ssh://bob@example.com")
630
+ sentinel = MagicMock()
631
+ with patch("docker_mcp.tools.system.docker.from_env", return_value=sentinel) as from_env:
632
+ assert system_module._build_default_client() is sentinel
633
+ assert from_env.call_args.kwargs["environment"]["DOCKER_HOST"] == "ssh://bob@example.com:1234"
634
+
635
+
636
+ def test_build_client_splices_ssh_config_port_for_multi_host_ssh_entry(tmp_path, monkeypatch):
637
+ _set_multi(monkeypatch)
638
+ config = tmp_path / "config"
639
+ config.write_text("Host example.com\n Port 1234\n")
640
+ monkeypatch.setattr("os.path.expanduser", lambda p: str(config) if p == "~/.ssh/config" else p)
641
+ host = Host("remote", "ssh://bob@example.com")
642
+ sentinel = MagicMock()
643
+ with patch("docker_mcp.tools.system.docker.DockerClient", return_value=sentinel) as ctor:
644
+ assert system_module._build_client(host) is sentinel
645
+ ctor.assert_called_once_with(base_url="ssh://bob@example.com:1234")
@@ -282,7 +282,7 @@ ssh = [
282
282
 
283
283
  [[package]]
284
284
  name = "docker-mcp-server"
285
- version = "2.1.0"
285
+ version = "2.1.1"
286
286
  source = { editable = "." }
287
287
  dependencies = [
288
288
  { name = "cryptography", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" },