feat: Atomic update mcp image

This commit is contained in:
Ran
2026-02-05 02:40:10 +08:00
parent dd6d570eba
commit cb36b68ee4
12 changed files with 300 additions and 19 deletions
+5 -3
View File
@@ -10,6 +10,8 @@ ARG COMMIT_HASH=unknown
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} \
go build -trimpath -ldflags "-s -w -X github.com/memohai/memoh/internal/version.CommitHash=${COMMIT_HASH}" -o /out/mcp ./cmd/mcp
FROM busybox:latest
COPY --from=build /out/mcp /mcp
ENTRYPOINT ["/mcp"]
FROM alpine:latest
RUN apk add --no-cache grep
WORKDIR /app
COPY --from=build /out/mcp /app/mcp
ENTRYPOINT ["/app/mcp"]
+21 -2
View File
@@ -2,8 +2,12 @@ package main
import (
"context"
"errors"
"io"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/memohai/memoh/internal/logger"
"github.com/memohai/memoh/internal/mcp"
@@ -12,13 +16,28 @@ import (
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
server := gomcp.NewServer(
&gomcp.Implementation{Name: "memoh-mcp", Version: version.GetInfo()},
nil,
)
mcp.RegisterTools(server)
if err := server.Run(context.Background(), &gomcp.StdioTransport{}); err != nil {
err := server.Run(ctx, &gomcp.StdioTransport{})
if ctx.Err() != nil {
return
}
if err == nil {
logger.Warn("mcp server exited without error; waiting for shutdown signal")
<-ctx.Done()
return
}
if errors.Is(err, io.EOF) {
logger.Warn("mcp stdio closed; waiting for shutdown signal")
<-ctx.Done()
return
}
logger.Error("mcp server failed", slog.Any("error", err))
os.Exit(1)
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ socket_path = "/run/containerd/containerd.sock"
namespace = "default"
[mcp]
busybox_image = "docker.io/library/busybox:latest"
busybox_image = "docker.io/library/memoh-mcp:dev"
snapshotter = "overlayfs"
data_root = "data"
data_mount = "/data"
+10 -2
View File
@@ -165,12 +165,20 @@ func (h *ContainerdHandler) CreateContainer(c echo.Context) error {
}
specOpts := []oci.SpecOpts{
oci.WithMounts([]specs.Mount{{
oci.WithMounts([]specs.Mount{
{
Destination: dataMount,
Type: "bind",
Source: dataDir,
Options: []string{"rbind", "rw"},
}}),
},
{
Destination: "/app",
Type: "bind",
Source: dataDir,
Options: []string{"rbind", "rw"},
},
}),
oci.WithProcessArgs("/bin/sh", "-lc", "sleep 2147483647"),
}
+10 -2
View File
@@ -98,12 +98,20 @@ func (m *Manager) EnsureUser(ctx context.Context, userID string) error {
}
specOpts := []oci.SpecOpts{
oci.WithMounts([]specs.Mount{{
oci.WithMounts([]specs.Mount{
{
Destination: dataMount,
Type: "bind",
Source: dataDir,
Options: []string{"rbind", "rw"},
}}),
},
{
Destination: "/app",
Type: "bind",
Source: dataDir,
Options: []string{"rbind", "rw"},
},
}),
}
_, err = m.service.CreateContainer(ctx, ctr.CreateContainerRequest{
+56
View File
@@ -1,12 +1,14 @@
package mcp
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"io/fs"
"mime"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
@@ -109,6 +111,17 @@ type FSReadBase64Output struct {
MimeType string `json:"mime_type" jsonschema:"detected mime type"`
}
type GrepInput struct {
Pattern string `json:"pattern" jsonschema:"grep pattern"`
Args []string `json:"args" jsonschema:"grep options (flags only)"`
}
type GrepOutput struct {
Stdout string `json:"stdout" jsonschema:"grep standard output"`
Stderr string `json:"stderr" jsonschema:"grep standard error"`
ExitCode int `json:"exit_code" jsonschema:"grep exit code"`
}
func RegisterTools(server *sdkmcp.Server) {
sdkmcp.AddTool(server, &sdkmcp.Tool{Name: "echo", Description: "echo input text"}, echoTool)
sdkmcp.AddTool(server, &sdkmcp.Tool{Name: "fs.read", Description: "read file content"}, fsReadTool)
@@ -120,6 +133,7 @@ func RegisterTools(server *sdkmcp.Server) {
sdkmcp.AddTool(server, &sdkmcp.Tool{Name: "fs.apply_patch", Description: "apply unified diff patch"}, fsApplyPatchTool)
sdkmcp.AddTool(server, &sdkmcp.Tool{Name: "fs.mkdir", Description: "create directory (mkdir -p)"}, fsMkdirTool)
sdkmcp.AddTool(server, &sdkmcp.Tool{Name: "fs.rename", Description: "rename/move file or directory"}, fsRenameTool)
sdkmcp.AddTool(server, &sdkmcp.Tool{Name: "grep", Description: "grep within /data using GNU grep"}, grepTool)
}
func echoTool(ctx context.Context, req *sdkmcp.CallToolRequest, input EchoInput) (
@@ -196,6 +210,48 @@ func fsReadBase64Tool(ctx context.Context, req *sdkmcp.CallToolRequest, input FS
}, nil
}
func grepTool(ctx context.Context, req *sdkmcp.CallToolRequest, input GrepInput) (
*sdkmcp.CallToolResult,
GrepOutput,
error,
) {
if strings.TrimSpace(input.Pattern) == "" {
return nil, GrepOutput{}, fmt.Errorf("pattern is required")
}
if stat, err := os.Stat("/data"); err != nil || !stat.IsDir() {
return nil, GrepOutput{}, fmt.Errorf("/data is not available")
}
args := append([]string{}, input.Args...)
args = append(args, input.Pattern, ".")
cmd := exec.CommandContext(ctx, "grep", args...)
cmd.Dir = "/data"
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
exitCode := 0
if err := cmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
if exitCode != 1 {
return nil, GrepOutput{}, fmt.Errorf("grep failed: %s", strings.TrimSpace(stderr.String()))
}
} else {
return nil, GrepOutput{}, err
}
}
return nil, GrepOutput{
Stdout: stdout.String(),
Stderr: stderr.String(),
ExitCode: exitCode,
}, nil
}
func fsWriteTool(ctx context.Context, req *sdkmcp.CallToolRequest, input FSWriteInput) (
*sdkmcp.CallToolResult,
FSWriteOutput,
+55
View File
@@ -7,9 +7,11 @@ import (
"strings"
"time"
"github.com/containerd/containerd/v2/pkg/oci"
"github.com/containerd/errdefs"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/opencontainers/runtime-spec/specs-go"
"github.com/memohai/memoh/internal/config"
@@ -65,12 +67,39 @@ func (m *Manager) CreateVersion(ctx context.Context, userID string) (*VersionInf
return nil, err
}
dataDir, err := m.ensureUserDir(userID)
if err != nil {
return nil, err
}
dataMount := m.cfg.DataMount
if dataMount == "" {
dataMount = config.DefaultDataMount
}
specOpts := []oci.SpecOpts{
oci.WithMounts([]specs.Mount{
{
Destination: dataMount,
Type: "bind",
Source: dataDir,
Options: []string{"rbind", "rw"},
},
{
Destination: "/app",
Type: "bind",
Source: dataDir,
Options: []string{"rbind", "rw"},
},
}),
}
_, err = m.service.CreateContainerFromSnapshot(ctx, ctr.CreateContainerRequest{
ID: containerID,
ImageRef: info.Image,
SnapshotID: activeSnapshotID,
Snapshotter: info.Snapshotter,
Labels: info.Labels,
SpecOpts: specOpts,
})
if err != nil {
return nil, err
@@ -165,12 +194,38 @@ func (m *Manager) RollbackVersion(ctx context.Context, userID string, version in
return err
}
dataDir, err := m.ensureUserDir(userID)
if err != nil {
return err
}
dataMount := m.cfg.DataMount
if dataMount == "" {
dataMount = config.DefaultDataMount
}
specOpts := []oci.SpecOpts{
oci.WithMounts([]specs.Mount{
{
Destination: dataMount,
Type: "bind",
Source: dataDir,
Options: []string{"rbind", "rw"},
},
{
Destination: "/app",
Type: "bind",
Source: dataDir,
Options: []string{"rbind", "rw"},
},
}),
}
_, err = m.service.CreateContainerFromSnapshot(ctx, ctr.CreateContainerRequest{
ID: containerID,
ImageRef: info.Image,
SnapshotID: activeSnapshotID,
Snapshotter: info.Snapshotter,
Labels: info.Labels,
SpecOpts: specOpts,
})
if err != nil {
return err
+32
View File
@@ -9,6 +9,8 @@ node = "25"
bun = "latest"
# pnpm for workspace management
pnpm = "10"
# Lima for macOS
lima = { version = "latest", platform = "darwin" }
[task_config]
dir = "{{cwd}}"
@@ -24,6 +26,24 @@ run = "pnpm install"
description = "Install Go dependencies"
run = "go mod download"
[tasks.lima-up]
run = """
if [ "$(uname -s)" = "Darwin" ]; then
limactl start default
fi
"""
[tasks.lima-down]
run = """
if [ "$(uname -s)" = "Darwin" ]; then
limactl stop default
fi
"""
[tasks.containerd-install]
description = "Install containerd or verify in Lima"
run = "scripts/containerd-install.sh"
[tasks.swagger-generate]
description = "Generate Swagger documentation"
run = "cd internal/handlers && go generate"
@@ -45,6 +65,18 @@ description = "Install CLI"
depends = ["//:pnpm-install"]
run = "cd packages/cli && npm install -g"
[tasks.mcp-image-up]
description = "Build MCP container image"
run = "scripts/mcp-image-up.sh"
[tasks.mcp-image-down]
description = "Remove MCP container image"
run = "scripts/mcp-image-down.sh"
[tasks.compile-mcp]
description = "Build MCP binary into /app and signal container"
run = "scripts/compile-mcp.sh"
[tasks.dev]
description = "Start development environment"
depends = [
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env sh
set -e
APP_DIR=${APP_DIR:-/app}
BIN_NAME=${BIN_NAME:-mcp}
STOP_SIGNAL=${STOP_SIGNAL:-TERM}
CONTAINER_NAME=${CONTAINER_NAME:-}
TARGET_OS=${TARGET_OS:-linux}
TARGET_ARCH=${TARGET_ARCH:-}
if [ -z "$TARGET_ARCH" ]; then
case "$(uname -m)" in
arm64|aarch64)
TARGET_ARCH=arm64
;;
x86_64|amd64)
TARGET_ARCH=amd64
;;
*)
TARGET_ARCH=amd64
;;
esac
fi
mkdir -p "$APP_DIR"
GOOS="$TARGET_OS" GOARCH="$TARGET_ARCH" go build -trimpath -ldflags "-s -w" -o "${APP_DIR}/${BIN_NAME}.new" ./cmd/mcp
mv -f "${APP_DIR}/${BIN_NAME}.new" "${APP_DIR}/${BIN_NAME}"
if [ -n "$CONTAINER_NAME" ]; then
if [ "$(uname -s)" = "Darwin" ]; then
limactl shell default -- nerdctl kill -s "$STOP_SIGNAL" "$CONTAINER_NAME"
else
nerdctl kill -s "$STOP_SIGNAL" "$CONTAINER_NAME"
fi
else
echo "CONTAINER_NAME is empty; skip sending stop signal."
fi
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env sh
set -e
if [ "$(uname -s)" = "Darwin" ]; then
limactl start default
limactl shell default -- sudo containerd --version
exit $?
fi
if command -v containerd >/dev/null 2>&1 && command -v nerdctl >/dev/null 2>&1; then
containerd --version
nerdctl --version
exit 0
fi
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y containerd nerdctl
elif command -v dnf >/dev/null 2>&1; then
sudo dnf install -y containerd nerdctl
elif command -v yum >/dev/null 2>&1; then
sudo yum install -y containerd nerdctl
elif command -v apk >/dev/null 2>&1; then
sudo apk add --no-cache containerd nerdctl
else
echo "No supported package manager found. Install containerd manually."
exit 1
fi
containerd --version
nerdctl --version
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env sh
set -e
IMAGE="memoh-mcp:dev"
if [ "$(uname -s)" = "Darwin" ]; then
limactl shell default -- nerdctl rmi -f "$IMAGE"
exit $?
fi
if ! command -v nerdctl >/dev/null 2>&1; then
echo "nerdctl not found. Install nerdctl to remove images."
exit 1
fi
nerdctl rmi -f "$IMAGE"
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env sh
set -e
IMAGE="memoh-mcp:dev"
if [ "$(uname -s)" = "Darwin" ]; then
limactl shell default -- nerdctl build -f cmd/mcp/Dockerfile -t "$IMAGE" .
exit $?
fi
if ! command -v nerdctl >/dev/null 2>&1; then
echo "nerdctl not found. Install nerdctl to build images."
exit 1
fi
nerdctl build -f cmd/mcp/Dockerfile -t "$IMAGE" .