mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
9ceabf68c4
Replace the host bind-mount + containerd exec approach with a per-bot
in-container gRPC server (ContainerService, port 9090). All file I/O,
exec, and MCP stdio sessions now go through gRPC instead of running
shell commands or reading host-mounted directories.
Architecture changes:
- cmd/mcp: rewritten as a gRPC server (ContainerService) with full
file and exec API (ReadFile, WriteFile, ListDir, ReadRaw, WriteRaw,
Exec, Stat, Mkdir, Rename, DeleteFile)
- internal/mcp/mcpcontainer: protobuf definitions and generated stubs
- internal/mcp/mcpclient: gRPC client wrapper with connection pool
(Pool) and Provider interface for dependency injection
- mcp.Manager: add per-bot IP cache, gRPC connection pool, and
SetContainerIP/MCPClient methods; remove DataDir/Exec helpers
- containerd.Service: remove ExecTask/ExecTaskStreaming; network setup
now returns NetworkResult{IP} for pool routing
- internal/fs/service.go: deleted (replaced by mcpclient)
- handlers/fs.go: deleted; MCP stdio session logic moved to mcp_stdio.go
- container provider Executor: all tools (read/write/list/edit/exec)
now call gRPC client instead of running shell via exec
- storefs, containerfs, media, skills, memory: all I/O ported to
mcpclient.Provider
Database:
- migration 0022: drop host_path column from containers table
One-time data migration:
- migrateBindMountData: on first Start() after upgrade, copies old
bind-mount data into the container via gRPC, then renames src dir
to prevent re-migration; runs in background goroutine
Bug fixes:
- mcp_stdio: callRaw now returns full JSON-RPC envelope
{"jsonrpc","id","result"|"error"} matching protocol spec;
explicit "initialize" call now advances session init state to
prevent duplicate handshake on next non-initialize call
- mcpclient Pool: properly evict stale gRPC connection after snapshot
replace (container process recreated); use SetContainerIP instead
of direct map write so IP changes always evict pool entry
- migrateBindMountData: walkErr on directories now counted as failure
so partially-walked trees don't get incorrectly marked as migrated
- cmd/mcp/Dockerfile: removed dead file (docker/Dockerfile.mcp is the
canonical production build)
Tests:
- provider_test.go: restored with bufconn in-process gRPC mock
(fakeContainerService + staticProvider), 14 cases covering all 5
tools plus edge cases
- mcp_session_test.go: new, covers JSON-RPC envelope, init state
machine, pending cleanup on cancel/close, readLoop cancel
- storefs/service_test.go: restored (pure function roundtrip tests)
89 lines
2.0 KiB
Go
89 lines
2.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"syscall"
|
|
|
|
"github.com/memohai/memoh/internal/logger"
|
|
pb "github.com/memohai/memoh/internal/mcp/mcpcontainer"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/reflection"
|
|
)
|
|
|
|
const (
|
|
defaultListenAddr = ":9090"
|
|
templateDir = "/opt/mcp-template"
|
|
)
|
|
|
|
// initDataDir ensures /data exists and seeds template files on first boot.
|
|
func initDataDir() {
|
|
if err := os.MkdirAll(defaultWorkDir, 0o755); err != nil {
|
|
logger.Warn("failed to create data dir", slog.Any("error", err))
|
|
return
|
|
}
|
|
|
|
entries, err := os.ReadDir(templateDir)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
logger.Warn("failed to read template dir", slog.String("dir", templateDir), slog.Any("error", err))
|
|
}
|
|
return
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
dst := filepath.Join(defaultWorkDir, e.Name())
|
|
if _, err := os.Stat(dst); err == nil {
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(templateDir, e.Name()))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if err := os.WriteFile(dst, data, fs.FileMode(0o644)); err != nil {
|
|
logger.Warn("failed to seed template", slog.String("file", e.Name()), slog.Any("error", err))
|
|
}
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
initDataDir()
|
|
|
|
addr := os.Getenv("MCP_LISTEN_ADDR")
|
|
if addr == "" {
|
|
addr = defaultListenAddr
|
|
}
|
|
|
|
lis, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
logger.Error("failed to listen", slog.String("addr", addr), slog.Any("error", err))
|
|
os.Exit(1)
|
|
}
|
|
|
|
srv := grpc.NewServer()
|
|
pb.RegisterContainerServiceServer(srv, &containerServer{})
|
|
reflection.Register(srv)
|
|
|
|
go func() {
|
|
<-ctx.Done()
|
|
logger.Info("shutting down gRPC server")
|
|
srv.GracefulStop()
|
|
}()
|
|
|
|
logger.Info("mcp gRPC server listening", slog.String("addr", addr))
|
|
if err := srv.Serve(lis); err != nil {
|
|
logger.Error("gRPC server failed", slog.Any("error", err))
|
|
os.Exit(1)
|
|
}
|
|
}
|