diff --git a/README.md b/README.md index 7d97567..72b833e 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? @@ -154,18 +155,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 +179,40 @@ 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 running in Docker: + +``` +"mcpServers": { + "mcp-server-docker": { + "command": "docker", + "args": [ + "run", + "-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_env_files", + "/var/secrets/.env" + ] + } +} +``` + +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, +ensuring sensitive data remains protected. + ## 💻 Development Prefer using Devbox to configure your development environment. 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/input_schemas.py b/src/mcp_server_docker/input_schemas.py index a417b84..88bae9a 100644 --- a/src/mcp_server_docker/input_schemas.py +++ b/src/mcp_server_docker/input_schemas.py @@ -1,16 +1,19 @@ 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, Field, + SecretStr, ValidationInfo, computed_field, field_validator, model_validator, ) +from pydantic.json_schema import SkipJsonSchema + class JSONParsingModel(BaseModel): """ @@ -107,6 +110,51 @@ 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_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) + + @model_validator(mode="after") + def inject_secrets_to_environment(self): + """Add secret values to environment variables.""" + if not self.custom_secrets_environment: + return self + + missing_secrets = set(self.custom_secrets_environment.values()) - set( + self.secrets + ) + if missing_secrets: + raise ValueError(f"Some custom secrets do not exist: {missing_secrets}") + + if self.environment is None: + self.environment = {} + + 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) + + 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 class RecreateContainerInput(CreateContainerInput): @@ -205,6 +253,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..4ad2002 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(), + ), ] @@ -355,23 +361,31 @@ async def call_tool( result = [docker_to_dict(c) for c in containers] elif name == "create_container": - args = CreateContainerInput.model_validate(arguments) + 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) + args = CreateContainerInput( + **arguments, secrets=_server_settings.docker_secrets + ) container = _docker.containers.run(**args.model_dump()) result = docker_to_dict(container) elif name == "recreate_container": - 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) @@ -465,6 +479,9 @@ async def call_tool( volume.remove(force=args.force) result = docker_to_dict(volume) + elif name == "list_custom_secret_names": + result = list(_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..a166583 100644 --- a/src/mcp_server_docker/settings.py +++ b/src/mcp_server_docker/settings.py @@ -1,5 +1,21 @@ +import dotenv +from pydantic import FilePath, SecretStr, computed_field 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_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]]