diff --git a/cmd/mcp/Dockerfile b/cmd/mcp/Dockerfile index 13dfc15f..8f386969 100644 --- a/cmd/mcp/Dockerfile +++ b/cmd/mcp/Dockerfile @@ -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"] diff --git a/cmd/mcp/main.go b/cmd/mcp/main.go index 78871162..ac5ed75f 100644 --- a/cmd/mcp/main.go +++ b/cmd/mcp/main.go @@ -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) } diff --git a/config.toml.example b/config.toml.example index 595271ab..3f1c5d4a 100644 --- a/config.toml.example +++ b/config.toml.example @@ -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" diff --git a/internal/handlers/containerd.go b/internal/handlers/containerd.go index ffd0c110..0dd759bb 100644 --- a/internal/handlers/containerd.go +++ b/internal/handlers/containerd.go @@ -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"), } diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index 13826793..59f0f08a 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -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{ diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index d45bbc8f..a27c012e 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -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, diff --git a/internal/mcp/versioning.go b/internal/mcp/versioning.go index 7aa09185..4d2ce99d 100644 --- a/internal/mcp/versioning.go +++ b/internal/mcp/versioning.go @@ -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 diff --git a/mise.toml b/mise.toml index 7501f620..3bec6710 100644 --- a/mise.toml +++ b/mise.toml @@ -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 = [ diff --git a/scripts/compile-mcp.sh b/scripts/compile-mcp.sh new file mode 100755 index 00000000..23b1c793 --- /dev/null +++ b/scripts/compile-mcp.sh @@ -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 diff --git a/scripts/containerd-install.sh b/scripts/containerd-install.sh new file mode 100755 index 00000000..8551e3ed --- /dev/null +++ b/scripts/containerd-install.sh @@ -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 diff --git a/scripts/mcp-image-down.sh b/scripts/mcp-image-down.sh new file mode 100755 index 00000000..51743113 --- /dev/null +++ b/scripts/mcp-image-down.sh @@ -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" diff --git a/scripts/mcp-image-up.sh b/scripts/mcp-image-up.sh new file mode 100755 index 00000000..934a4e6a --- /dev/null +++ b/scripts/mcp-image-up.sh @@ -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" .