Files
Viet TranandGitHub db7a34e4f3 feat: standalone Import & Export page with Teams, Agents, Skills & MCP
Refactors Agent Transfer into standalone /import-export page with 3 scope tabs.

Teams: auto-bundle member agents (config, context files, memory, KG, cron, profiles, workspace). 
Agents: section picker with presets, merge mode support.
Skills & MCP: custom skills + MCP server configs (stripped secrets), conflict detection.

Security: scopeClauseAlias for JOINs, tenant scope on all queries, zip bomb protection, adminMiddleware on import routes.
DRY: shared storeExportToken + readTarGzEntries helpers.
i18n: complete en/vi/zh coverage.
2026-03-29 18:01:08 +07:00

153 lines
4.5 KiB
Go

package http
import (
"archive/tar"
"compress/gzip"
"context"
"database/sql"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"time"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
// SetDB injects the raw DB handle needed for MCP export/import direct queries.
func (h *MCPHandler) SetDB(db *sql.DB) {
h.db = db
}
// handleMCPExportPreview returns MCP export counts without building the archive.
func (h *MCPHandler) handleMCPExportPreview(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
if h.db == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "db not configured")})
return
}
preview, err := pg.ExportMCPPreview(r.Context(), h.db)
if err != nil {
slog.Error("mcp.export.preview", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError)})
return
}
writeJSON(w, http.StatusOK, preview)
}
// handleMCPExport builds and streams (or SSE-wraps) a MCP servers tar.gz archive.
func (h *MCPHandler) handleMCPExport(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
userID := store.UserIDFromContext(r.Context())
if h.db == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "db not configured")})
return
}
stream := r.URL.Query().Get("stream") == "true"
fileName := fmt.Sprintf("mcp-servers-%s.tar.gz", time.Now().UTC().Format("20060102"))
if stream {
flusher := initSSE(w)
if flusher == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "streaming not supported"})
return
}
tmpFile, err := os.CreateTemp("", "goclaw-mcp-export-*.tar.gz")
if err != nil {
sendSSE(w, flusher, "error", ProgressEvent{Phase: "init", Status: "error", Detail: "failed to create temp file"})
return
}
tmpPath := tmpFile.Name()
progressFn := func(ev ProgressEvent) { sendSSE(w, flusher, "progress", ev) }
buildErr := h.writeMCPExportArchive(r.Context(), tmpFile, progressFn)
tmpFile.Close()
if buildErr != nil {
slog.Error("mcp.export.sse", "error", buildErr)
sendSSE(w, flusher, "error", ProgressEvent{Phase: "archive", Status: "error", Detail: buildErr.Error()})
os.Remove(tmpPath)
return
}
token := storeExportToken("mcp", userID, tmpPath, fileName)
sendSSE(w, flusher, "complete", map[string]string{
"download_url": "/v1/export/download/" + token,
})
return
}
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, fileName))
if err := h.writeMCPExportArchive(r.Context(), w, nil); err != nil {
slog.Error("mcp.export.direct", "error", err)
}
}
// writeMCPExportArchive builds the MCP tar.gz: servers.jsonl + grants.jsonl.
func (h *MCPHandler) writeMCPExportArchive(ctx context.Context, w io.Writer, progressFn func(ProgressEvent)) error {
lw := &limitedWriter{w: w, limit: maxExportSize}
gw := gzip.NewWriter(lw)
tw := tar.NewWriter(gw)
servers, err := pg.ExportMCPServers(ctx, h.db)
if err != nil {
tw.Close()
gw.Close()
return fmt.Errorf("query mcp servers: %w", err)
}
if len(servers) > 0 {
data, err := marshalJSONL(servers)
if err != nil {
tw.Close()
gw.Close()
return fmt.Errorf("marshal servers: %w", err)
}
if err := addToTar(tw, "servers.jsonl", data); err != nil {
tw.Close()
gw.Close()
return fmt.Errorf("write servers.jsonl: %w", err)
}
}
if progressFn != nil {
progressFn(ProgressEvent{Phase: "servers", Status: "done", Current: len(servers), Total: len(servers), Detail: fmt.Sprintf("%d servers exported", len(servers))})
}
grants, err := pg.ExportMCPGrantsWithKeys(ctx, h.db)
if err != nil {
slog.Warn("mcp.export: query grants failed", "error", err)
}
if len(grants) > 0 {
data, err := marshalJSONL(grants)
if err != nil {
tw.Close()
gw.Close()
return fmt.Errorf("marshal grants: %w", err)
}
if err := addToTar(tw, "grants.jsonl", data); err != nil {
tw.Close()
gw.Close()
return fmt.Errorf("write grants.jsonl: %w", err)
}
}
if progressFn != nil {
progressFn(ProgressEvent{Phase: "grants", Status: "done", Current: len(grants), Total: len(grants), Detail: fmt.Sprintf("%d agent grants", len(grants))})
}
if err := tw.Close(); err != nil {
gw.Close()
return fmt.Errorf("close tar: %w", err)
}
return gw.Close()
}