Skip to content

Commit f301e8b

Browse files
authored
Add Dockerfile and docker-compose (#78)
* Prepare templated env vars and args for use * Fix: mcpd daemon --addr flag defaults to 0.0.0.0 bind address (localhost used when --dev mode is supplied) * Update daemon context to honour interrupt signal (not just terminate signal) * Update Environ() on Server to handle merging, filtering*, and expanding env vars (these are supplied to STDIO MCP client's env) * Args supplied to STDIO MCP client are now resolved (from ${} ) *Filtering: is currently based on the proposed format for the command: mcpd config export * Dockerfile * Dockerfile and docker-compose added * Updated Makefile to make building for linux easier * Improve end user error information * docs updates
1 parent 19f4290 commit f301e8b

File tree

13 files changed

+857
-45
lines changed

13 files changed

+857
-45
lines changed

Dockerfile

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# syntax=docker/dockerfile:1
2+
3+
# ==============================================================================
4+
# Builder Stage: Fetch uv binaries
5+
# ==============================================================================
6+
FROM ghcr.io/astral-sh/uv:0.7.20 AS uv-builder
7+
8+
# ==============================================================================
9+
# Final Stage: Build the production image.
10+
# Includes NodeJS to give mcpd access to the npx binary.
11+
# ==============================================================================
12+
FROM node:current-alpine3.22
13+
14+
# --- Metadata ---
15+
# The version label should be dynamically overridden in a CI/CD pipeline
16+
# (e.g., --label "org.opencontainers.image.version=${GIT_TAG}").
17+
LABEL org.opencontainers.image.authors="Mozilla AI <[email protected]>"
18+
LABEL org.opencontainers.image.description="A container for the mcpd application."
19+
LABEL org.opencontainers.image.version="1.0.0"
20+
21+
ARG MCPD_USER=mcpd
22+
ARG MCPD_HOME=/home/$MCPD_USER
23+
24+
# Sensible defaults but can be easily overridden by the user with `docker run -e KEY=VALUE`.
25+
ENV MCPD_API_PORT=8090
26+
ENV MCPD_LOG_LEVEL=info
27+
28+
# - Installs 'tini', a lightweight init system to properly manage processes.
29+
# - Adds a dedicated non-root group and user for security (using the ARG).
30+
# - Creates necessary directories for configs, logs, and user data.
31+
# - Sets correct ownership for the non-root user.
32+
USER root
33+
RUN apk add --no-cache python3 py3-pip tini && \
34+
addgroup -S $MCPD_USER && \
35+
adduser -D -S -h $MCPD_HOME -G $MCPD_USER $MCPD_USER && \
36+
mkdir -p \
37+
$MCPD_HOME/.config/mcpd \
38+
/var/log/mcpd \
39+
/etc/mcpd && \
40+
chown -R $MCPD_USER:$MCPD_USER $MCPD_HOME /var/log/mcpd
41+
42+
# Copy binaries from the dedicated 'uv-builder' stage.
43+
COPY --from=uv-builder /uv /uvx /usr/local/bin/
44+
45+
# Copy application binary and set ownership to the non-root user.
46+
# IMPORTANT: Config/secrets are NOT copied. They should be mounted at runtime.
47+
COPY --chown=$MCPD_USER:$MCPD_USER mcpd /usr/local/bin/mcpd
48+
49+
# Switch to the non-root user before execution.
50+
USER $MCPD_USER
51+
WORKDIR $MCPD_HOME
52+
53+
EXPOSE $MCPD_API_PORT
54+
55+
# Use 'tini' as the entrypoint to properly handle process signals (like CTRL+C)
56+
# and prevent zombie processes, ensuring clean container shutdown.
57+
ENTRYPOINT ["/sbin/tini", "--"]
58+
59+
CMD mcpd daemon \
60+
--addr 0.0.0.0:$MCPD_API_PORT \
61+
--log-level $MCPD_LOG_LEVEL \
62+
--log-path /var/log/mcpd/mcpd.log \
63+
--config-file /etc/mcpd/.mcpd.toml \
64+
--runtime-file /home/mcpd/.config/mcpd/secrets.prd.toml

Makefile

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ build:
2424
@echo "building mcpd (with flags: ${LDFLAGS})..."
2525
@go build -o mcpd -ldflags="${LDFLAGS}" .
2626

27+
build-linux:
28+
@echo "building mcpd for amd64/linux (with flags: ${LDFLAGS})..."
29+
@GOOS=linux GOARCH=amd64 go build -o mcpd -ldflags="${LDFLAGS}" .
30+
31+
build-linux-arm64:
32+
@echo "building mcpd for arm64/linux (with flags: ${LDFLAGS})..."
33+
@GOOS=linux GOARCH=arm64 go build -o mcpd -ldflags="${LDFLAGS}" .
34+
2735
install: build
2836
@# Copy the executable to the install directory
2937
@# Requires sudo if INSTALL_DIR is a system path like /usr/local/bin
@@ -60,4 +68,12 @@ docs-cli:
6068
## Updates mkdocs.yaml nav to match generated CLI docs
6169
docs-nav: docs-cli
6270
@go run -tags=docsgen_nav ./tools/docsgen/cli/nav.go
63-
@echo "navigation updated for MkDocs site"
71+
@echo "navigation updated for MkDocs site"
72+
73+
local-up: build-linux
74+
@echo "starting mcpd container in detached state"
75+
@docker compose up -d --build
76+
77+
local-down:
78+
@echo "stopping mcpd container"
79+
@docker compose down

cmd/daemon.go

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func NewDaemonCmd(baseCmd *cmd.BaseCmd, opt ...cmdopts.CmdOption) (*cobra.Comman
3939
}
4040

4141
cobraCommand := &cobra.Command{
42-
Use: "daemon",
42+
Use: "daemon [--dev] [--addr]",
4343
Short: "Launches an mcpd daemon instance",
4444
Long: "Launches an mcpd daemon instance, which starts MCP servers and provides routing via HTTP API",
4545
RunE: c.run,
@@ -55,9 +55,10 @@ func NewDaemonCmd(baseCmd *cmd.BaseCmd, opt ...cmdopts.CmdOption) (*cobra.Comman
5555
cobraCommand.Flags().StringVar(
5656
&c.Addr,
5757
"addr",
58-
"localhost:8090",
58+
"0.0.0.0:8090",
5959
"Address for the daemon to bind (not applicable in --dev mode)",
6060
)
61+
6162
cobraCommand.MarkFlagsMutuallyExclusive("dev", "addr")
6263

6364
return cobraCommand, nil
@@ -66,14 +67,22 @@ func NewDaemonCmd(baseCmd *cmd.BaseCmd, opt ...cmdopts.CmdOption) (*cobra.Comman
6667
// run is configured (via NewDaemonCmd) to be called by the Cobra framework when the command is executed.
6768
// It may return an error (or nil, when there is no error).
6869
func (c *DaemonCmd) run(cmd *cobra.Command, args []string) error {
70+
logger, err := c.Logger()
71+
if err != nil {
72+
return err
73+
}
74+
6975
// Validate flags.
7076
addr := strings.TrimSpace(c.Addr)
71-
if err := daemon.IsValidAddr(addr); err != nil {
72-
return err
77+
78+
// Override address for dev mode.
79+
if c.Dev {
80+
devAddr := "localhost:8090"
81+
logger.Info("Development-focused mode", "addr", addr, "override", devAddr)
82+
addr = devAddr
7383
}
7484

75-
logger, err := c.Logger()
76-
if err != nil {
85+
if err := daemon.IsValidAddr(addr); err != nil {
7786
return err
7887
}
7988

@@ -83,7 +92,11 @@ func (c *DaemonCmd) run(cmd *cobra.Command, args []string) error {
8392
}
8493

8594
// Create the signal handling context for the application.
86-
daemonCtx, daemonCtxCancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
95+
daemonCtx, daemonCtxCancel := signal.NotifyContext(
96+
context.Background(),
97+
os.Interrupt,
98+
syscall.SIGTERM, syscall.SIGINT,
99+
)
87100
defer daemonCtxCancel()
88101

89102
runErr := make(chan error, 1)
@@ -111,7 +124,7 @@ func (c *DaemonCmd) run(cmd *cobra.Command, args []string) error {
111124
err := <-runErr // Wait for cleanup and deferred logging.
112125
return err // Graceful Ctrl+C / SIGTERM.
113126
case err := <-runErr:
114-
logger.Error("error running daemon instance", "error", err)
127+
logger.Error("daemon exited with error", "error", err)
115128
return err // Propagate daemon failure.
116129
}
117130
}

docker-compose.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
services:
2+
mcpd:
3+
build:
4+
context: .
5+
args:
6+
MCPD_API_PORT: ${MCPD_API_PORT:-8090}
7+
container_name: mcpd
8+
env_file: .env
9+
environment:
10+
MCPD_API_PORT: ${MCPD_API_PORT:-8090}
11+
MCPD_LOG_LEVEL: ${MCPD_LOG_LEVEL:-INFO}
12+
ports:
13+
- "${MCPD_API_PORT:-8090}:${MCPD_API_PORT:-8090}"
14+
volumes:
15+
- "${HOME}/.config/mcpd/secrets.dev.toml:/home/mcpd/.config/mcpd/secrets.prd.toml:ro"
16+
- "./.mcpd.toml:/etc/mcpd/.mcpd.toml:ro"
17+
- "./mcpd-container-logs:/var/log/mcpd"

docs/makefile.md

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,19 @@ The `mcpd` project includes a `Makefile` to streamline common developer tasks.
1212
!!! note "Environment"
1313
Most commands assume you have Go installed and available in your `PATH`.
1414

15-
### 🧱 Build Commands
15+
### 🧱 Build
1616

1717
- **Build the binary**
1818
```bash
1919
make build
2020
```
2121

22+
!!! tip "Architectures and Operating Systems"
23+
You can explicitly build the binary for a different architecture (`amd64/arm64`) or operating systems with:
24+
25+
* `make build-linux`
26+
* `make build-linux-arm64`
27+
2228
- **Remove the compiled binary from the working directory**
2329
```bash
2430
make clean
@@ -29,14 +35,18 @@ The `mcpd` project includes a `Makefile` to streamline common developer tasks.
2935
sudo make install
3036
```
3137

38+
!!! note "Dependency"
39+
The `install` target relies on the standard `build` target.
40+
41+
3242
- **Uninstall the binary**
3343
```bash
3444
sudo make uninstall
3545
```
3646

3747
---
3848

39-
### 🧪 Test Commands
49+
### 🧪 Test
4050

4151
- **Run all Go tests**
4252
```bash
@@ -45,7 +55,27 @@ The `mcpd` project includes a `Makefile` to streamline common developer tasks.
4555

4656
---
4757

48-
## 📝 Documentation Commands
58+
### 🐳 Run
59+
60+
- **Start `mcpd` in a container**
61+
```bash
62+
make local-up
63+
```
64+
65+
!!! warning "Default files"
66+
By default the following files will be mounted to the container:
67+
68+
* `.mcpd.toml` - the project configuration file in this repository
69+
* `~/.config/mcpd/secrets.dev.toml` - the default location for runtime configuration
70+
71+
- **Stop mcpd**
72+
```bash
73+
make local-down
74+
```
75+
76+
---
77+
78+
### 📝 Documentation
4979

5080
These commands manage the [MkDocs](https://www.mkdocs.org) developer documentation site for `mcpd`.
5181

@@ -69,24 +99,28 @@ These commands manage the [MkDocs](https://www.mkdocs.org) developer documentati
6999
make docs
70100
```
71101

72-
!!! tip "First time?"
73-
The `docs-local` command will create a virtual environment using `uv`, install MkDocs + Material theme, and start the local server at [http://localhost:8000](http://localhost:8000).
102+
!!! tip "First time?"
103+
The `docs-local` command will create a virtual environment using `uv`, install MkDocs + Material theme, and start the local server at [http://localhost:8000](http://localhost:8000).
74104

75105
---
76106

77107
## 🧭 Target Reference
78108

79109
Here’s a complete list of Makefile targets:
80110

81-
| Target | Description |
82-
|----------------|-----------------------------------------------|
83-
| `build` | Compile the Go binary |
84-
| `install` | Install binary to system path |
85-
| `uninstall` | Remove installed binary |
86-
| `clean` | Remove compiled binary from working directory |
87-
| `test` | Run all Go tests |
88-
| `docs-cli` | Generate Markdown CLI reference docs |
89-
| `docs-nav` | Update CLI doc nav in `mkdocs.yaml` |
90-
| `docs-local` | Serve docs locally via `mkdocs serve` |
91-
| `docs` | Alias for `docs-local` (runs everything) |
111+
| Target | Description |
112+
|---------------------|-----------------------------------------------|
113+
| `build` | Compile the Go binary |
114+
| `build-linux` | Compile the Go binary for Linux on amd64 |
115+
| `build-linux-arm64` | Compile the Go binary for Linux on arm64 |
116+
| `install` | Install binary to system path |
117+
| `uninstall` | Remove installed binary |
118+
| `clean` | Remove compiled binary from working directory |
119+
| `test` | Run all Go tests |
120+
| `local-up` | Start `mcpd` in a Docker container |
121+
| `local-down` | Stop a running `mcpd` Docker container |
122+
| `docs-cli` | Generate Markdown CLI reference docs |
123+
| `docs-nav` | Update CLI doc nav in `mkdocs.yaml` |
124+
| `docs-local` | Serve docs locally via `mkdocs serve` |
125+
| `docs` | Alias for `docs-local` (runs everything) |
92126

docs/requirements.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
To use `mcpd`, ensure the following tools are installed:
44

5-
| Tool | Purpose | Notes |
6-
|----------------|------------------------------------------------|-------------------------------------------------------------------|
7-
| `Go >= 1.24.4` | Required for building `mcpd` and running tests | https://go.dev/doc/install |
8-
| `uv` | for running `uvx` Python packages | https://docs.astral.sh/uv/getting-started/installation/ |
9-
| `npx` | for running JavaScript/TypeScript packages | https://docs.npmjs.com/downloading-and-installing-node-js-and-npm |
5+
| Tool | Purpose | Notes |
6+
|----------------|-------------------------------------------------------------|-------------------------------------------------------------------|
7+
| `Docker` | Required if you want to run `mcpd` in a local container | https://www.docker.com/products/docker-desktop/ |
8+
| `Go >= 1.24.4` | Required for building `mcpd` and running tests | https://go.dev/doc/install |
9+
| `uv` | for running `uvx` Python packages in `mcpd`, and local docs | https://docs.astral.sh/uv/getting-started/installation/ |
10+
| `npx` | for running JavaScript/TypeScript packages in `mcpd` | https://docs.npmjs.com/downloading-and-installing-node-js-and-npm |
1011

1112
!!! note "Internet Connectivity"
1213
`mcpd` requires internet access to contact package registries and to allow MCP servers access to the internet if required when running.

internal/api/servers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ func handleServerToolCall(accessor contracts.MCPClientAccessor, server string, t
163163
} else if result == nil {
164164
return nil, fmt.Errorf("%w: %s/%s: result was nil", errors.ErrToolCallFailedUnknown, server, tool)
165165
} else if result.IsError {
166-
return nil, fmt.Errorf("%w: %s/%s: %v", errors.ErrToolCallFailedUnknown, server, tool, extractMessage(result.Content))
166+
return nil, fmt.Errorf("%w: %s/%s: %v", errors.ErrToolCallFailed, server, tool, extractMessage(result.Content))
167167
}
168168

169169
resp := &ToolCallResponse{}

internal/daemon/daemon.go

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -141,22 +141,17 @@ func (d *Daemon) startMCPServer(ctx context.Context, server runtime.Server) erro
141141

142142
// Strip arbitrary package prefix (e.g. uvx::)
143143
packageNameAndVersion := strings.TrimPrefix(server.Package, runtimeBinary+"::")
144-
env := server.Environ()
144+
145145
var args []string
146146
// TODO: npx requires '-y' before the package name
147147
if runtime.Runtime(runtimeBinary) == runtime.NPX {
148148
args = append(args, "y")
149149
}
150-
args = append([]string{packageNameAndVersion}, server.Args...)
150+
args = append([]string{packageNameAndVersion}, server.ResolvedArgs()...)
151151

152-
logger.Debug(
153-
"attempting to start server",
154-
"binary", runtimeBinary,
155-
"args", args,
156-
"environment", env,
157-
)
152+
logger.Debug("attempting to start server", "binary", runtimeBinary)
158153

159-
stdioClient, err := client.NewStdioMCPClient(runtimeBinary, env, args...)
154+
stdioClient, err := client.NewStdioMCPClient(runtimeBinary, server.Environ(), args...)
160155
if err != nil {
161156
return fmt.Errorf("error starting MCP server: '%s': %w", server.Name, err)
162157
}

internal/daemon/server.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,16 +115,16 @@ func mapError(logger hclog.Logger, err error) huma.StatusError {
115115
return huma.Error403Forbidden(err.Error())
116116
case stdErrors.Is(err, errors.ErrToolListFailed):
117117
logger.Error("Tool list failed", "error", err)
118-
return huma.Error502BadGateway("MCP server error listing tools")
118+
return huma.Error502BadGateway("MCP server error listing tools", err)
119119
case stdErrors.Is(err, errors.ErrToolCallFailed):
120120
logger.Error("Tool call failed", "error", err)
121-
return huma.Error502BadGateway("MCP server error calling tool")
121+
return huma.Error502BadGateway("MCP server error calling tool", err)
122122
case stdErrors.Is(err, errors.ErrToolCallFailedUnknown):
123123
logger.Error("Tool call failed, unknown error", "error", err)
124-
return huma.Error502BadGateway("MCP server unknown error calling tool")
124+
return huma.Error502BadGateway("MCP server unknown error calling tool", err)
125125
default:
126126
logger.Error("Unexpected error interacting with MCP server", "error", err)
127-
return huma.Error500InternalServerError("Internal server error")
127+
return huma.Error500InternalServerError("Internal server error", err)
128128
}
129129
}
130130

internal/runtime/runtimes.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ const (
2121

2222
// AnyIntersection returns true if any value in a is also in b.
2323
func AnyIntersection(a []Runtime, b []Runtime) bool {
24+
if a == nil || b == nil || len(a) == 0 || len(b) == 0 {
25+
return false
26+
}
27+
2428
set := map[Runtime]struct{}{}
2529
for _, v := range b {
2630
set[v] = struct{}{}

0 commit comments

Comments
 (0)