feat(devenv): MCP dev hot-reload with image-based approach (#145)

Add mcp-build.sh that compiles the MCP binary and packages it as an
OCI image layer on top of the base rootfs, imported directly into
containerd. Air triggers rebuild on code changes, cleaning stale
containers automatically.

Consolidate dev-only files (Dockerfiles, entrypoint, config, build
script) into devenv/ to separate dev tooling from production artifacts.
Skip image pull when already imported to speed up dev startup.
This commit is contained in:
BBQ
2026-03-02 14:59:48 +08:00
committed by GitHub
parent f9f968f13f
commit 04bce702b7
12 changed files with 131 additions and 26 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ root = "."
tmp_dir = "tmp" tmp_dir = "tmp"
[build] [build]
cmd = "go build -o ./tmp/memoh-server ./cmd/agent/main.go" cmd = "go build -o ./tmp/memoh-server ./cmd/agent/main.go && sh devenv/mcp-build.sh"
bin = "./tmp/memoh-server" bin = "./tmp/memoh-server"
args_bin = ["serve"] args_bin = ["serve"]
include_ext = ["go", "toml"] include_ext = ["go", "toml"]
+4 -11
View File
@@ -125,18 +125,11 @@ Memoh/
├── db/ # Database ├── db/ # Database
│ ├── migrations/ # SQL migration files │ ├── migrations/ # SQL migration files
│ └── queries/ # SQL query files (sqlc input) │ └── queries/ # SQL query files (sqlc input)
├── conf/ # Configuration templates ├── conf/ # Configuration templates (app.example.toml, app.docker.toml)
│ ├── app.example.toml # Default configuration template ├── devenv/ # Dev environment (docker-compose, dev Dockerfiles, app.dev.toml, mcp-build.sh)
│ ├── app.dev.toml # Development configuration ├── docker/ # Production Docker build & runtime (Dockerfiles, entrypoints, nginx.conf)
│ ├── app.docker.toml # Docker deployment configuration
│ ├── app.apple.toml # macOS (Apple Virtualization) configuration
│ └── app.windows.toml # Windows configuration
├── devenv/ # Development environment (docker-compose for local infra)
├── docker/ # Docker build & runtime (Dockerfiles, entrypoints, nginx)
├── docs/ # Documentation site ├── docs/ # Documentation site
├── scripts/ # Utility scripts ├── scripts/ # Utility scripts (db, release, install)
├── assets/ # Static assets (images, etc.)
├── data/ # Runtime data directory
├── docker-compose.yml # Docker Compose orchestration (production) ├── docker-compose.yml # Docker Compose orchestration (production)
├── mise.toml # mise tasks and tool version definitions ├── mise.toml # mise tasks and tool version definitions
├── sqlc.yaml # sqlc code generation config ├── sqlc.yaml # sqlc code generation config
+3 -3
View File
@@ -59,9 +59,9 @@ mise run dev:restart -- server # Restart a specific service
## Project Layout ## Project Layout
``` ```
conf/ — Configuration templates (app.example.toml, app.dev.toml, app.docker.toml) conf/ — Configuration templates (app.example.toml, app.docker.toml)
devenv/ — Containerized development environment (docker-compose) devenv/ — Dev environment (docker-compose, dev Dockerfiles, app.dev.toml, mcp-build.sh)
docker/ — Docker build & runtime (Dockerfiles, entrypoints) docker/ — Production Docker build & runtime (Dockerfiles, entrypoints)
cmd/ — Go application entry points cmd/ — Go application entry points
internal/ — Go backend core code internal/ — Go backend core code
agent/ — Agent Gateway (Bun/Elysia) agent/ — Agent Gateway (Bun/Elysia)
+1 -1
View File
@@ -1,6 +1,6 @@
# Memoh configuration template # Memoh configuration template
# Copy to config.toml and adjust values for your environment. # Copy to config.toml and adjust values for your environment.
# For local development, use: cp conf/app.dev.toml config.toml # For local development, use: cp devenv/app.dev.toml config.toml
[log] [log]
level = "info" level = "info"
@@ -1,5 +1,28 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ---- Stage 1: Assemble MCP image rootfs (runtime deps only, no Go binary) ----
FROM alpine:latest AS mcp-rootfs
RUN apk add --no-cache grep curl bash
RUN apk add --no-cache nodejs npm
RUN apk add --no-cache python3 && \
curl -LsSf https://astral.sh/uv/install.sh | sh && \
ln -sf /root/.local/bin/uv /usr/local/bin/uv && \
ln -sf /root/.local/bin/uvx /usr/local/bin/uvx
COPY cmd/mcp/template /opt/mcp-template
RUN printf '#!/bin/sh\n\
[ -e /app/mcp ] || { mkdir -p /app; [ -f /opt/mcp ] && cp -a /opt/mcp /app/mcp 2>/dev/null || true; }\n\
if [ -x /app/mcp ]; then exec /app/mcp "$@"; fi\n\
exec /opt/mcp "$@"\n' > /opt/entrypoint.sh && chmod +x /opt/entrypoint.sh
RUN tar -cf /tmp/rootfs.tar \
--exclude='./proc' --exclude='./sys' --exclude='./dev' \
--exclude='./tmp' --exclude='./run' \
-C / .
# ---- Stage 2: Dev server image ----
FROM golang:1.25-alpine FROM golang:1.25-alpine
WORKDIR /workspace WORKDIR /workspace
@@ -50,7 +73,10 @@ RUN printf '%s\n' \
' ]' \ ' ]' \
'}' > /etc/cni/net.d/10-memoh.conflist '}' > /etc/cni/net.d/10-memoh.conflist
COPY docker/server-dev-entrypoint.sh /entrypoint.sh # Raw MCP rootfs for mcp-build.sh to package with compiled binary
COPY --from=mcp-rootfs /tmp/rootfs.tar /opt/images/memoh-mcp-rootfs.tar
COPY devenv/server-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
VOLUME ["/var/lib/containerd", "/opt/memoh/data"] VOLUME ["/var/lib/containerd", "/opt/memoh/data"]
+6 -6
View File
@@ -37,7 +37,7 @@ services:
deps: deps:
build: build:
context: .. context: ..
dockerfile: docker/Dockerfile.web.dev dockerfile: devenv/Dockerfile.web
container_name: memoh-dev-deps container_name: memoh-dev-deps
working_dir: /workspace working_dir: /workspace
command: ["pnpm", "install"] command: ["pnpm", "install"]
@@ -50,13 +50,13 @@ services:
migrate: migrate:
build: build:
context: .. context: ..
dockerfile: docker/Dockerfile.server.dev dockerfile: devenv/Dockerfile.server
container_name: memoh-dev-migrate container_name: memoh-dev-migrate
working_dir: /workspace working_dir: /workspace
entrypoint: [] entrypoint: []
command: ["go", "run", "./cmd/agent/main.go", "migrate", "up"] command: ["go", "run", "./cmd/agent/main.go", "migrate", "up"]
environment: environment:
CONFIG_PATH: /workspace/conf/app.dev.toml CONFIG_PATH: /workspace/devenv/app.dev.toml
volumes: volumes:
- ..:/workspace - ..:/workspace
- go_mod_cache:/go/pkg/mod - go_mod_cache:/go/pkg/mod
@@ -71,14 +71,14 @@ services:
server: server:
build: build:
context: .. context: ..
dockerfile: docker/Dockerfile.server.dev dockerfile: devenv/Dockerfile.server
container_name: memoh-dev-server container_name: memoh-dev-server
working_dir: /workspace working_dir: /workspace
privileged: true privileged: true
pid: host pid: host
command: ["air", "-c", ".air.toml"] command: ["air", "-c", ".air.toml"]
environment: environment:
CONFIG_PATH: /workspace/conf/app.dev.toml CONFIG_PATH: /workspace/devenv/app.dev.toml
volumes: volumes:
- ..:/workspace - ..:/workspace
- go_mod_cache:/go/pkg/mod - go_mod_cache:/go/pkg/mod
@@ -121,7 +121,7 @@ services:
web: web:
build: build:
context: .. context: ..
dockerfile: docker/Dockerfile.web.dev dockerfile: devenv/Dockerfile.web
container_name: memoh-dev-web container_name: memoh-dev-web
working_dir: /workspace/packages/web working_dir: /workspace/packages/web
command: ["pnpm", "dev"] command: ["pnpm", "dev"]
+67
View File
@@ -0,0 +1,67 @@
#!/bin/sh
# Build MCP binary, package as containerd image, and import.
# Called by air after server build — safe to skip outside dev container.
set -e
MCP_IMAGE="${MCP_IMAGE:-docker.io/memohai/mcp:latest}"
MCP_BINARY="/opt/memoh/data/.dev/mcp"
BASE_ROOTFS="/opt/images/memoh-mcp-rootfs.tar"
[ -f "$BASE_ROOTFS" ] || exit 0
command -v ctr >/dev/null 2>&1 || exit 0
mkdir -p "$(dirname "$MCP_BINARY")"
OLD_HASH=$(sha256sum "$MCP_BINARY" 2>/dev/null | cut -d' ' -f1)
go build -o "$MCP_BINARY" ./cmd/mcp || exit 0
NEW_HASH=$(sha256sum "$MCP_BINARY" | cut -d' ' -f1)
[ "$OLD_HASH" = "$NEW_HASH" ] && exit 0
echo "[mcp-dev] Binary changed, rebuilding MCP image..."
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
# Layer 1: base rootfs (symlink to avoid copying the large file)
LAYER1_SHA=$(sha256sum "$BASE_ROOTFS" | cut -d' ' -f1)
mkdir -p "$WORK/$LAYER1_SHA"
ln -s "$BASE_ROOTFS" "$WORK/$LAYER1_SHA/layer.tar"
# Layer 2: compiled binary overlay
mkdir -p "$WORK/overlay/opt"
cp "$MCP_BINARY" "$WORK/overlay/opt/mcp"
chmod +x "$WORK/overlay/opt/mcp"
tar -cf "$WORK/layer2.tar" -C "$WORK/overlay" opt
LAYER2_SHA=$(sha256sum "$WORK/layer2.tar" | cut -d' ' -f1)
mkdir -p "$WORK/$LAYER2_SHA"
mv "$WORK/layer2.tar" "$WORK/$LAYER2_SHA/layer.tar"
# OCI image config
ARCH=$(uname -m)
case "$ARCH" in aarch64|arm64) ARCH="arm64" ;; x86_64|amd64) ARCH="amd64" ;; esac
printf '{"architecture":"%s","os":"linux","created":"1970-01-01T00:00:00Z","config":{"Entrypoint":["/opt/entrypoint.sh"],"WorkingDir":"/app","Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]},"rootfs":{"type":"layers","diff_ids":["sha256:%s","sha256:%s"]},"history":[{"created":"1970-01-01T00:00:00Z","comment":"memoh-mcp rootfs"},{"created":"1970-01-01T00:00:00Z","comment":"memoh-mcp binary"}]}' \
"$ARCH" "$LAYER1_SHA" "$LAYER2_SHA" > "$WORK/config.json"
CONFIG_SHA=$(sha256sum "$WORK/config.json" | cut -d' ' -f1)
mv "$WORK/config.json" "$WORK/$CONFIG_SHA.json"
printf '[{"Config":"%s.json","RepoTags":["%s"],"Layers":["%s/layer.tar","%s/layer.tar"]}]' \
"$CONFIG_SHA" "$MCP_IMAGE" "$LAYER1_SHA" "$LAYER2_SHA" > "$WORK/manifest.json"
# -h follows symlinks (layer 1 is symlinked to avoid copying)
tar -chf "$WORK/memoh-mcp.tar" -C "$WORK" manifest.json "$CONFIG_SHA.json" "$LAYER1_SHA/" "$LAYER2_SHA/"
# Replace image in containerd
ctr -n default images rm "$MCP_IMAGE" 2>/dev/null || true
ctr -n default images import --all-platforms "$WORK/memoh-mcp.tar" 2>&1 || true
# Clean old MCP containers so they recreate with new image
for c in $(ctr -n default containers ls -q 2>/dev/null | grep "^mcp-"); do
ctr -n default tasks kill "$c" 2>/dev/null || true
ctr -n default tasks delete "$c" 2>/dev/null || true
ctr -n default containers delete "$c" 2>/dev/null || true
done
echo "[mcp-dev] Done. Containers will auto-recreate with new image."
@@ -28,8 +28,14 @@ if ! ctr version >/dev/null 2>&1; then
echo "ERROR: containerd not responsive after 30s" echo "ERROR: containerd not responsive after 30s"
exit 1 exit 1
fi fi
echo "containerd is running (pid $CONTAINERD_PID)"
echo "containerd is ready, starting server command..." # Build MCP binary and import as containerd image
echo "Building MCP image..."
(cd /workspace && sh devenv/mcp-build.sh)
echo "MCP image ready."
echo "Starting server..."
trap 'kill ${SERVER_PID:-0} 2>/dev/null || true; kill ${CONTAINERD_PID:-0} 2>/dev/null || true; wait' TERM INT trap 'kill ${SERVER_PID:-0} 2>/dev/null || true; kill ${CONTAINERD_PID:-0} 2>/dev/null || true; wait' TERM INT
+4
View File
@@ -91,6 +91,10 @@ func (m *Manager) lockContainer(containerID string) func() {
func (m *Manager) Init(ctx context.Context) error { func (m *Manager) Init(ctx context.Context) error {
image := m.imageRef() image := m.imageRef()
if _, err := m.service.GetImage(ctx, image); err == nil {
return nil
}
_, err := m.service.PullImage(ctx, image, &ctr.PullImageOptions{ _, err := m.service.PullImage(ctx, image, &ctr.PullImageOptions{
Unpack: true, Unpack: true,
Snapshotter: m.cfg.Snapshotter, Snapshotter: m.cfg.Snapshotter,
+11 -2
View File
@@ -53,7 +53,7 @@ description = "Start development environment"
run = """ run = """
#!/bin/bash #!/bin/bash
set -e set -e
cp conf/app.dev.toml config.toml cp devenv/app.dev.toml config.toml
docker compose -f devenv/docker-compose.yml up --build docker compose -f devenv/docker-compose.yml up --build
""" """
@@ -69,6 +69,15 @@ run = "docker compose -f devenv/docker-compose.yml logs -f"
description = "Restart a service (usage: mise run dev:restart -- server)" description = "Restart a service (usage: mise run dev:restart -- server)"
run = "docker compose -f devenv/docker-compose.yml restart $@" run = "docker compose -f devenv/docker-compose.yml restart $@"
[tasks."mcp:build"]
description = "Manually build MCP dev binary (normally auto-triggered by air)"
run = """
#!/bin/bash
set -e
docker compose -f devenv/docker-compose.yml exec server \
sh -c 'cd /workspace && sh devenv/mcp-build.sh'
"""
[tasks.db-up] [tasks.db-up]
description = "Initialize and Migrate Database" description = "Initialize and Migrate Database"
run = "scripts/db-up.sh" run = "scripts/db-up.sh"
@@ -116,6 +125,6 @@ depends = [
run = """ run = """
#!/bin/bash #!/bin/bash
set -e set -e
cp conf/app.dev.toml config.toml cp devenv/app.dev.toml config.toml
echo '✓ Setup complete! Run: mise run dev' echo '✓ Setup complete! Run: mise run dev'
""" """