mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-24 16:22:52 +00:00
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.
153 lines
4.5 KiB
Go
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()
|
|
}
|