mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat: Atomic update mcp image
This commit is contained in:
+5
-3
@@ -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"]
|
||||
|
||||
+22
-3
@@ -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 {
|
||||
logger.Error("mcp server failed", slog.Any("error", err))
|
||||
os.Exit(1)
|
||||
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
@@ -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"
|
||||
|
||||
@@ -165,12 +165,20 @@ func (h *ContainerdHandler) CreateContainer(c echo.Context) error {
|
||||
}
|
||||
|
||||
specOpts := []oci.SpecOpts{
|
||||
oci.WithMounts([]specs.Mount{{
|
||||
Destination: dataMount,
|
||||
Type: "bind",
|
||||
Source: dataDir,
|
||||
Options: []string{"rbind", "rw"},
|
||||
}}),
|
||||
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"),
|
||||
}
|
||||
|
||||
|
||||
+14
-6
@@ -98,12 +98,20 @@ func (m *Manager) EnsureUser(ctx context.Context, userID string) error {
|
||||
}
|
||||
|
||||
specOpts := []oci.SpecOpts{
|
||||
oci.WithMounts([]specs.Mount{{
|
||||
Destination: dataMount,
|
||||
Type: "bind",
|
||||
Source: dataDir,
|
||||
Options: []string{"rbind", "rw"},
|
||||
}}),
|
||||
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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Executable
+38
@@ -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
|
||||
Executable
+31
@@ -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
|
||||
Executable
+16
@@ -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"
|
||||
Executable
+16
@@ -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" .
|
||||
Reference in New Issue
Block a user