From 7a9ebdcbbac10534f1bf4857234d1dbcb57e3829 Mon Sep 17 00:00:00 2001 From: Christian Kreiling Date: Sun, 30 Mar 2025 16:01:18 -0400 Subject: [PATCH 1/9] Start slow, adding settings and a new Tool for listing custom secrets --- src/mcp_server_docker/input_schemas.py | 4 ++++ src/mcp_server_docker/output_schemas.py | 13 +++++++++++++ src/mcp_server_docker/server.py | 9 +++++++++ src/mcp_server_docker/settings.py | 9 +++++++-- 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/mcp_server_docker/input_schemas.py b/src/mcp_server_docker/input_schemas.py index a417b84..06b7d4b 100644 --- a/src/mcp_server_docker/input_schemas.py +++ b/src/mcp_server_docker/input_schemas.py @@ -205,6 +205,10 @@ class RemoveVolumeInput(JSONParsingModel): force: bool = Field(False, description="Force remove the volume") +class ListCustomSecretsInput(JSONParsingModel): + pass + + class DockerComposePromptInput(BaseModel): name: str containers: str diff --git a/src/mcp_server_docker/output_schemas.py b/src/mcp_server_docker/output_schemas.py index b3b844c..91fa677 100644 --- a/src/mcp_server_docker/output_schemas.py +++ b/src/mcp_server_docker/output_schemas.py @@ -45,6 +45,19 @@ def docker_to_dict( "hostname": config.get("Hostname"), "user": config.get("User"), "image": config.get("Image"), + # It's common for Docker containers to have secrets configured as + # plaintext env vars, so we only inform the LLM of the keys. + # It's unclear how best to share env values with the LLM without + # risking exposure. A few approaches that come to mind: + # + # - Naive: redact values with keys containing "password" or "key" + # - Advanced: use a tool like detect-secrets for detection: https://github.com/Yelp/detect-secrets + # - Manual: require users to explicitly mark some env vars as secrets with MCP server configuration + # + # Perhaps some combination of these would be best. In any case, + # users of this MCP server should have to opt-in to this behavior since + # it poses a security risk no matter what. + "env_keys": config.get("Env", {}).keys(), }, } diff --git a/src/mcp_server_docker/server.py b/src/mcp_server_docker/server.py index 35a7ae1..d73dbc0 100644 --- a/src/mcp_server_docker/server.py +++ b/src/mcp_server_docker/server.py @@ -18,6 +18,7 @@ DockerComposePromptInput, FetchContainerLogsInput, ListContainersInput, + ListCustomSecretsInput, ListImagesInput, ListNetworksInput, ListVolumesInput, @@ -336,6 +337,11 @@ async def list_tools() -> list[types.Tool]: description="Remove a Docker volume", inputSchema=RemoveVolumeInput.model_json_schema(), ), + types.Tool( + name="list_custom_secret_names", + description="List the names of custom secrets available to mount on containers", + inputSchema=ListCustomSecretsInput.model_json_schema(), + ), ] @@ -465,6 +471,9 @@ async def call_tool( volume.remove(force=args.force) result = docker_to_dict(volume) + elif name == "list_custom_secret_names": + result = _server_settings.docker_secrets.keys() + else: return [types.TextContent(type="text", text=f"Unknown tool: {name}")] diff --git a/src/mcp_server_docker/settings.py b/src/mcp_server_docker/settings.py index a1fe54c..82ead27 100644 --- a/src/mcp_server_docker/settings.py +++ b/src/mcp_server_docker/settings.py @@ -1,5 +1,10 @@ +from pydantic import SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict -class ServerSettings(BaseSettings): - model_config = SettingsConfigDict(env_prefix="mcp_server_") +class ServerSettings(BaseSettings, cli_parse_args=True): + model_config = SettingsConfigDict( + env_prefix="mcp_server_", env_nested_delimiter="__" + ) + + docker_secrets: dict[str, SecretStr] = {} From e61ba195f35163e5ca65b8b653a9e7e015cd3df8 Mon Sep 17 00:00:00 2001 From: Christian Kreiling Date: Sun, 30 Mar 2025 16:12:58 -0400 Subject: [PATCH 2/9] Sprinkle in functionality to mount secrets --- src/mcp_server_docker/input_schemas.py | 19 +++++++++++++++++++ src/mcp_server_docker/server.py | 3 +++ 2 files changed, 22 insertions(+) diff --git a/src/mcp_server_docker/input_schemas.py b/src/mcp_server_docker/input_schemas.py index 06b7d4b..7bbe26b 100644 --- a/src/mcp_server_docker/input_schemas.py +++ b/src/mcp_server_docker/input_schemas.py @@ -5,6 +5,7 @@ from pydantic import ( BaseModel, Field, + SecretStr, ValidationInfo, computed_field, field_validator, @@ -107,6 +108,24 @@ class CreateContainerInput(JSONParsingModel): description="Container labels, either as a dictionary or a list of key=value strings", ) auto_remove: bool = Field(False, description="Automatically remove the container") + custom_secrets_env: dict[str, str] = Field( + {}, description="Custom secrets to mount as environment variables." + ) + + def inject_secrets_to_environment(self, secrets: dict[str, SecretStr]) -> None: + """Add secret values to environment variables.""" + if not self.custom_secrets_env: + return + + missing_secrets = set(self.custom_secrets_env) - set(secrets) + if missing_secrets: + raise ValueError(f"Custom secrets do not exist: {missing_secrets}") + + if self.environment is None: + self.environment = {} + + for secret_name, env_var_name in self.custom_secrets_env.items(): + self.environment[env_var_name] = secrets[secret_name].get_secret_value() class RecreateContainerInput(CreateContainerInput): diff --git a/src/mcp_server_docker/server.py b/src/mcp_server_docker/server.py index d73dbc0..286489f 100644 --- a/src/mcp_server_docker/server.py +++ b/src/mcp_server_docker/server.py @@ -362,11 +362,13 @@ async def call_tool( elif name == "create_container": args = CreateContainerInput.model_validate(arguments) + args.inject_secrets_to_environment(_server_settings.docker_secrets) container = _docker.containers.create(**args.model_dump()) result = docker_to_dict(container) elif name == "run_container": args = CreateContainerInput.model_validate(arguments) + args.inject_secrets_to_environment(_server_settings.docker_secrets) container = _docker.containers.run(**args.model_dump()) result = docker_to_dict(container) @@ -378,6 +380,7 @@ async def call_tool( container.remove() run_args = CreateContainerInput.model_validate(arguments) + run_args.inject_secrets_to_environment(_server_settings.docker_secrets) container = _docker.containers.run(**run_args.model_dump()) result = docker_to_dict(container) From 036130f53220b65719f8064de5ece6140362729c Mon Sep 17 00:00:00 2001 From: Christian Kreiling Date: Sun, 30 Mar 2025 16:45:40 -0400 Subject: [PATCH 3/9] Prefer using Pydantic to set secrets on input types --- src/mcp_server_docker/input_schemas.py | 42 ++++++++++++++++++++------ src/mcp_server_docker/server.py | 12 +++++--- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/mcp_server_docker/input_schemas.py b/src/mcp_server_docker/input_schemas.py index 7bbe26b..efb4b34 100644 --- a/src/mcp_server_docker/input_schemas.py +++ b/src/mcp_server_docker/input_schemas.py @@ -12,6 +12,8 @@ model_validator, ) +from pydantic.json_schema import SkipJsonSchema + class JSONParsingModel(BaseModel): """ @@ -108,24 +110,46 @@ class CreateContainerInput(JSONParsingModel): description="Container labels, either as a dictionary or a list of key=value strings", ) auto_remove: bool = Field(False, description="Automatically remove the container") - custom_secrets_env: dict[str, str] = Field( - {}, description="Custom secrets to mount as environment variables." + custom_secrets_environment: dict[str, str] = Field( + {}, + description="Map of env var name to secret name. The custom secret value associated to the given name will be mounted as the given env var.", + exclude=True, ) + secrets: SkipJsonSchema[dict[str, SecretStr]] = Field(..., exclude=True) - def inject_secrets_to_environment(self, secrets: dict[str, SecretStr]) -> None: + @model_validator(mode="after") + def inject_secrets_to_environment(self): """Add secret values to environment variables.""" - if not self.custom_secrets_env: - return + if not self.custom_secrets_environment: + return self - missing_secrets = set(self.custom_secrets_env) - set(secrets) + missing_secrets = set(self.custom_secrets_environment.values()) - set( + self.secrets + ) if missing_secrets: - raise ValueError(f"Custom secrets do not exist: {missing_secrets}") + raise ValueError(f"Some custom secrets do not exist: {missing_secrets}") if self.environment is None: self.environment = {} - for secret_name, env_var_name in self.custom_secrets_env.items(): - self.environment[env_var_name] = secrets[secret_name].get_secret_value() + for env_var_name, secret_name in self.custom_secrets_environment.items(): + self.environment[env_var_name] = self.secrets[ + secret_name + ].get_secret_value() + + if self.labels is None: + self.labels = {} + + custom_secrets_env_json = json.dumps(self.custom_secrets_environment) + + if isinstance(self.labels, list): + self.labels.append( + f"mcp-server-docker.custom-secrets='{custom_secrets_env_json}'" + ) + elif isinstance(self.labels, dict): + self.labels["mcp-server-docker.custom-secrets"] = custom_secrets_env_json + + return self class RecreateContainerInput(CreateContainerInput): diff --git a/src/mcp_server_docker/server.py b/src/mcp_server_docker/server.py index 286489f..a3d8660 100644 --- a/src/mcp_server_docker/server.py +++ b/src/mcp_server_docker/server.py @@ -361,18 +361,21 @@ async def call_tool( result = [docker_to_dict(c) for c in containers] elif name == "create_container": - args = CreateContainerInput.model_validate(arguments) - args.inject_secrets_to_environment(_server_settings.docker_secrets) + args = CreateContainerInput.model_validate( + {**arguments, "secrets": _server_settings.docker_secrets} + ) container = _docker.containers.create(**args.model_dump()) result = docker_to_dict(container) elif name == "run_container": - args = CreateContainerInput.model_validate(arguments) - args.inject_secrets_to_environment(_server_settings.docker_secrets) + args = CreateContainerInput.model_validate( + {**arguments, "secrets": _server_settings.docker_secrets} + ) container = _docker.containers.run(**args.model_dump()) result = docker_to_dict(container) elif name == "recreate_container": + arguments = {**arguments, "secrets": _server_settings.docker_secrets} args = RecreateContainerInput.model_validate(arguments) container = _docker.containers.get(args.resolved_container_id) @@ -380,7 +383,6 @@ async def call_tool( container.remove() run_args = CreateContainerInput.model_validate(arguments) - run_args.inject_secrets_to_environment(_server_settings.docker_secrets) container = _docker.containers.run(**run_args.model_dump()) result = docker_to_dict(container) From 9b02a1a6c0deec4aa1a0fb9bed7a6d209a692fae Mon Sep 17 00:00:00 2001 From: Christian Kreiling Date: Sun, 30 Mar 2025 20:27:18 -0400 Subject: [PATCH 4/9] Add some README docs --- README.md | 44 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7d97567..fa71eb3 100644 --- a/README.md +++ b/README.md @@ -154,18 +154,13 @@ The server implements a couple resources for every container: - `create_volume` - `remove_volume` -## 🚧 Disclaimers - -### Sensitive Data +### Custom Secrets -**DO NOT CONFIGURE CONTAINERS WITH SENSITIVE DATA.** This includes API keys, -database passwords, etc. +For details, see the Custom Secrets section below. -Any sensitive data exchanged with the LLM is inherently compromised, unless the -LLM is running on your local machine. +- `list_custom_secret_names` -If you are interested in securely passing secrets to containers, file an issue -on this repository with your use-case. +## 🚧 Disclaimers ### Reviewing Created Containers @@ -183,6 +178,37 @@ This server uses the Python Docker SDK's `from_env` method. For configuration details, see [the documentation](https://docker-py.readthedocs.io/en/stable/client.html#docker.client.from_env). +### 🔑 Custom Secrets + +This MCP server provides a secure way to by keep sensitive configuration data +hidden from the LLM while making it accessible to containers created by the LLM. + +Example configuration: + +``` +"mcpServers": { + "mcp-server-docker": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-v", + "/var/run/docker.sock:/var/run/docker.sock", + "mcp-server-docker:latest", + "--docker_secrets", + "openai_api_key=oai-1234567890" + ] + } +} +``` + +The LLM uses the `list_custom_secret_names` to discover available secrets. It +then maps environment variable names to secret names for container access. When +the LLM requests container information, such as through the `list_containers` +tool, the server only reveals the environment variable names, not their values, +ensuring sensitive data remains protected. + ## 💻 Development Prefer using Devbox to configure your development environment. From b4680a0cf216e5fd41f0a406217f1f3b9283f4ae Mon Sep 17 00:00:00 2001 From: Christian Kreiling Date: Sun, 30 Mar 2025 20:29:39 -0400 Subject: [PATCH 5/9] Add a line item to readme.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index fa71eb3..6e94dbd 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ An MCP server for managing Docker with natural language! - 🚀 Compose containers with natural language - 🔍 Introspect & debug running containers - 📀 Manage persistent data with Docker volumes +- 🔑 Securely configure containers with sensitive data ## ❓ Who is this for? From 9192dba8731967d19b99db18911197a64db526c4 Mon Sep 17 00:00:00 2001 From: Christian Kreiling Date: Tue, 1 Apr 2025 22:27:30 -0400 Subject: [PATCH 6/9] Add dotenv support instead of specifying in MCP config directly --- README.md | 11 +++++++---- pyproject.toml | 1 + src/mcp_server_docker/settings.py | 15 +++++++++++++-- uv.lock | 2 ++ 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6e94dbd..72b833e 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ details, see This MCP server provides a secure way to by keep sensitive configuration data hidden from the LLM while making it accessible to containers created by the LLM. -Example configuration: +Example configuration running in Docker: ``` "mcpServers": { @@ -195,16 +195,19 @@ Example configuration: "-i", "--rm", "-v", + "/home/myuser/mcp-secrets.env:/var/secrets/.env:ro", + "-v", "/var/run/docker.sock:/var/run/docker.sock", "mcp-server-docker:latest", - "--docker_secrets", - "openai_api_key=oai-1234567890" + "--docker_secrets_env_files", + "/var/secrets/.env" ] } } ``` -The LLM uses the `list_custom_secret_names` to discover available secrets. It +Secrets are configured as key-value pairs in dotenv files, which the server +reads at runtime. The LLM uses the `list_custom_secret_names` to discover available secrets. It then maps environment variable names to secret names for container access. When the LLM requests container information, such as through the `list_containers` tool, the server only reveals the environment variable names, not their values, diff --git a/pyproject.toml b/pyproject.toml index 752269d..d15575e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "docker>=7.1.0", "mcp>=1.1.0", "pydantic-settings>=2.6.1", + "python-dotenv>=1.0.1", ] [[project.authors]] diff --git a/src/mcp_server_docker/settings.py b/src/mcp_server_docker/settings.py index 82ead27..a166583 100644 --- a/src/mcp_server_docker/settings.py +++ b/src/mcp_server_docker/settings.py @@ -1,4 +1,5 @@ -from pydantic import SecretStr +import dotenv +from pydantic import FilePath, SecretStr, computed_field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -7,4 +8,14 @@ class ServerSettings(BaseSettings, cli_parse_args=True): env_prefix="mcp_server_", env_nested_delimiter="__" ) - docker_secrets: dict[str, SecretStr] = {} + docker_secrets_env_files: list[FilePath] = [] + + @computed_field + @property + def docker_secrets(self) -> dict[str, SecretStr]: + return { + k: SecretStr(v) + for file in self.docker_secrets_env_files + for k, v in dotenv.dotenv_values(file).items() + if v is not None + } diff --git a/uv.lock b/uv.lock index 19e472d..e618950 100644 --- a/uv.lock +++ b/uv.lock @@ -186,6 +186,7 @@ dependencies = [ { name = "docker" }, { name = "mcp" }, { name = "pydantic-settings" }, + { name = "python-dotenv" }, ] [package.metadata] @@ -193,6 +194,7 @@ requires-dist = [ { name = "docker", specifier = ">=7.1.0" }, { name = "mcp", specifier = ">=1.1.0" }, { name = "pydantic-settings", specifier = ">=2.6.1" }, + { name = "python-dotenv", specifier = ">=1.0.1" }, ] [[package]] From 2c5f85541309e951cbc3330f2c14070126d06214 Mon Sep 17 00:00:00 2001 From: Christian Kreiling Date: Fri, 4 Apr 2025 13:00:31 -0400 Subject: [PATCH 7/9] Use a match statement for runtime type checking --- src/mcp_server_docker/input_schemas.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/mcp_server_docker/input_schemas.py b/src/mcp_server_docker/input_schemas.py index efb4b34..88bae9a 100644 --- a/src/mcp_server_docker/input_schemas.py +++ b/src/mcp_server_docker/input_schemas.py @@ -1,6 +1,6 @@ import json from datetime import datetime -from typing import Any, Literal, get_args, get_origin +from typing import Any, Literal, assert_never, get_args, get_origin from pydantic import ( BaseModel, @@ -142,12 +142,17 @@ def inject_secrets_to_environment(self): custom_secrets_env_json = json.dumps(self.custom_secrets_environment) - if isinstance(self.labels, list): - self.labels.append( - f"mcp-server-docker.custom-secrets='{custom_secrets_env_json}'" - ) - elif isinstance(self.labels, dict): - self.labels["mcp-server-docker.custom-secrets"] = custom_secrets_env_json + match self.labels: + case list(): + self.labels.append( + f"mcp-server-docker.custom-secrets='{custom_secrets_env_json}'" + ) + case dict(): + self.labels["mcp-server-docker.custom-secrets"] = ( + custom_secrets_env_json + ) + case _: + assert_never(self.labels) return self From a7987044e2ae1a03d09e1abbbf03aad9e41d0b73 Mon Sep 17 00:00:00 2001 From: Christian Kreiling Date: Fri, 4 Apr 2025 21:05:24 -0400 Subject: [PATCH 8/9] Use constructors instead... --- src/mcp_server_docker/server.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/mcp_server_docker/server.py b/src/mcp_server_docker/server.py index a3d8660..880b095 100644 --- a/src/mcp_server_docker/server.py +++ b/src/mcp_server_docker/server.py @@ -361,28 +361,31 @@ async def call_tool( result = [docker_to_dict(c) for c in containers] elif name == "create_container": - args = CreateContainerInput.model_validate( - {**arguments, "secrets": _server_settings.docker_secrets} + args = CreateContainerInput( + **arguments, secrets=_server_settings.docker_secrets ) container = _docker.containers.create(**args.model_dump()) result = docker_to_dict(container) elif name == "run_container": - args = CreateContainerInput.model_validate( - {**arguments, "secrets": _server_settings.docker_secrets} + args = CreateContainerInput( + **arguments, secrets=_server_settings.docker_secrets ) container = _docker.containers.run(**args.model_dump()) result = docker_to_dict(container) elif name == "recreate_container": - arguments = {**arguments, "secrets": _server_settings.docker_secrets} - args = RecreateContainerInput.model_validate(arguments) + args = RecreateContainerInput( + **arguments, secrets=_server_settings.docker_secrets + ) container = _docker.containers.get(args.resolved_container_id) container.stop() container.remove() - run_args = CreateContainerInput.model_validate(arguments) + run_args = CreateContainerInput( + **arguments, secrets=_server_settings.docker_secrets + ) container = _docker.containers.run(**run_args.model_dump()) result = docker_to_dict(container) From 3edac8eab421a49e9ab800060861ae5594e48a01 Mon Sep 17 00:00:00 2001 From: Christian Kreiling Date: Fri, 4 Apr 2025 21:13:30 -0400 Subject: [PATCH 9/9] Fix new tool --- src/mcp_server_docker/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp_server_docker/server.py b/src/mcp_server_docker/server.py index 880b095..4ad2002 100644 --- a/src/mcp_server_docker/server.py +++ b/src/mcp_server_docker/server.py @@ -480,7 +480,7 @@ async def call_tool( result = docker_to_dict(volume) elif name == "list_custom_secret_names": - result = _server_settings.docker_secrets.keys() + result = list(_server_settings.docker_secrets.keys()) else: return [types.TextContent(type="text", text=f"Unknown tool: {name}")]