feat: add Supermarket integration (MCP & Skill marketplace) (#309)

* feat: add Supermarket integration (MCP & Skill marketplace)

Backend:
- Add [supermarket] config section with base_url (default: supermarket.memoh.ai)
- Add SupermarketHandler with proxy endpoints for MCPs, Skills, and Tags
- Add install endpoints: POST /bots/:id/supermarket/install-mcp (creates MCP
  connection with env vars) and install-skill (downloads tar.gz, extracts to
  container via gRPC)
- Register handler in FX wiring, generate Swagger docs and TypeScript SDK

Frontend:
- Add /settings/supermarket route with Store icon in sidebar
- Create supermarket page with search, tag filtering, MCP and Skill sections
- Add MCP/Skill card components with tag badges and install buttons
- Add install dialogs: MCP (bot selector + env var form), Skill (bot selector)
- Add i18n entries for en.json and zh.json

* fix: improve supermarket install UX

- Create BotSelect component with avatar + name using UI Select
- Replace NativeSelect in install dialogs and usage page with BotSelect
- Change MCP install flow: navigate to bot detail MCP tab with pre-filled
  draft instead of direct install, letting users review before saving
- Move Supermarket sidebar entry between Browser and Usage

* web: remove supermarket page top tag selector bar

Drop the horizontal tag chips and getSupermarketTags fetch; keep
search and tag filter via card tag clicks with clearable badge.

* web: add homepage link to supermarket MCP and Skill cards

Show an external-link icon next to the card title when homepage is
available, opening in a new tab on click.
This commit is contained in:
Acbox Liu
2026-03-31 02:22:39 +08:00
committed by Acbox
parent 49e5f3d8ae
commit faaf13a0e9
25 changed files with 3168 additions and 24 deletions
+444
View File
@@ -0,0 +1,444 @@
package handlers
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"path"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/memohai/memoh/internal/accounts"
"github.com/memohai/memoh/internal/bots"
"github.com/memohai/memoh/internal/config"
"github.com/memohai/memoh/internal/mcp"
"github.com/memohai/memoh/internal/workspace"
)
type SupermarketHandler struct {
baseURL string
httpClient *http.Client
mcpService *mcp.ConnectionService
manager *workspace.Manager
botService *bots.Service
accountService *accounts.Service
logger *slog.Logger
}
func NewSupermarketHandler(
log *slog.Logger,
cfg config.Config,
mcpService *mcp.ConnectionService,
manager *workspace.Manager,
botService *bots.Service,
accountService *accounts.Service,
) *SupermarketHandler {
return &SupermarketHandler{
baseURL: cfg.Supermarket.GetBaseURL(),
httpClient: &http.Client{Timeout: 30 * time.Second},
mcpService: mcpService,
manager: manager,
botService: botService,
accountService: accountService,
logger: log.With(slog.String("handler", "supermarket")),
}
}
func (h *SupermarketHandler) Register(e *echo.Echo) {
g := e.Group("/supermarket")
g.GET("/mcps", h.ListMcps)
g.GET("/mcps/:id", h.GetMcp)
g.GET("/skills", h.ListSkills)
g.GET("/skills/:id", h.GetSkill)
g.GET("/tags", h.ListTags)
ig := e.Group("/bots/:bot_id/supermarket")
ig.POST("/install-mcp", h.InstallMcp)
ig.POST("/install-skill", h.InstallSkill)
}
func (h *SupermarketHandler) requireBotAccess(c echo.Context) (string, error) {
channelIdentityID, err := RequireChannelIdentityID(c)
if err != nil {
return "", err
}
botID := c.Param("bot_id")
if _, err := AuthorizeBotAccess(c.Request().Context(), h.botService, h.accountService, channelIdentityID, botID); err != nil {
return "", err
}
return botID, nil
}
// proxy forwards a GET request to the supermarket and streams the JSON response back.
func (h *SupermarketHandler) proxy(c echo.Context, upstreamPath string) error {
url := h.baseURL + upstreamPath
if qs := c.QueryString(); qs != "" {
url += "?" + qs
}
req, err := http.NewRequestWithContext(c.Request().Context(), http.MethodGet, url, nil)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
req.Header.Set("Accept", "application/json")
resp, err := h.httpClient.Do(req) //nolint:gosec // URL constructed from trusted config
if err != nil {
h.logger.Error("supermarket proxy failed", slog.String("url", url), slog.Any("error", err))
return echo.NewHTTPError(http.StatusBadGateway, "supermarket unreachable")
}
defer func() { _ = resp.Body.Close() }()
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
c.Response().WriteHeader(resp.StatusCode)
_, _ = io.Copy(c.Response(), resp.Body)
return nil
}
// ListMcps godoc
// @Summary List MCPs from supermarket
// @Tags supermarket
// @Param q query string false "Search query"
// @Param tag query string false "Filter by tag"
// @Param transport query string false "Filter by transport type"
// @Param page query int false "Page number"
// @Param limit query int false "Items per page"
// @Success 200 {object} SupermarketMcpListResponse
// @Failure 502 {object} ErrorResponse
// @Router /supermarket/mcps [get].
func (h *SupermarketHandler) ListMcps(c echo.Context) error {
return h.proxy(c, "/api/mcps")
}
// GetMcp godoc
// @Summary Get MCP detail from supermarket
// @Tags supermarket
// @Param id path string true "MCP ID"
// @Success 200 {object} SupermarketMcpEntry
// @Failure 404 {object} ErrorResponse
// @Failure 502 {object} ErrorResponse
// @Router /supermarket/mcps/{id} [get].
func (h *SupermarketHandler) GetMcp(c echo.Context) error {
id := c.Param("id")
return h.proxy(c, "/api/mcps/"+id)
}
// ListSkills godoc
// @Summary List skills from supermarket
// @Tags supermarket
// @Param q query string false "Search query"
// @Param tag query string false "Filter by tag"
// @Param page query int false "Page number"
// @Param limit query int false "Items per page"
// @Success 200 {object} SupermarketSkillListResponse
// @Failure 502 {object} ErrorResponse
// @Router /supermarket/skills [get].
func (h *SupermarketHandler) ListSkills(c echo.Context) error {
return h.proxy(c, "/api/skills")
}
// GetSkill godoc
// @Summary Get skill detail from supermarket
// @Tags supermarket
// @Param id path string true "Skill ID"
// @Success 200 {object} SupermarketSkillEntry
// @Failure 404 {object} ErrorResponse
// @Failure 502 {object} ErrorResponse
// @Router /supermarket/skills/{id} [get].
func (h *SupermarketHandler) GetSkill(c echo.Context) error {
id := c.Param("id")
return h.proxy(c, "/api/skills/"+id)
}
// ListTags godoc
// @Summary List all tags from supermarket
// @Tags supermarket
// @Success 200 {object} SupermarketTagsResponse
// @Failure 502 {object} ErrorResponse
// @Router /supermarket/tags [get].
func (h *SupermarketHandler) ListTags(c echo.Context) error {
return h.proxy(c, "/api/tags")
}
// --- Install endpoints ---
// InstallMcpRequest is the request body for installing an MCP from supermarket.
type InstallMcpRequest struct {
McpID string `json:"mcp_id"`
Env map[string]string `json:"env,omitempty"`
}
// InstallSkillRequest is the request body for installing a skill from supermarket.
type InstallSkillRequest struct {
SkillID string `json:"skill_id"`
}
// InstallMcp godoc
// @Summary Install MCP from supermarket to bot
// @Tags supermarket
// @Param bot_id path string true "Bot ID"
// @Param payload body InstallMcpRequest true "Install MCP request"
// @Success 200 {object} mcp.Connection
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 502 {object} ErrorResponse
// @Router /bots/{bot_id}/supermarket/install-mcp [post].
func (h *SupermarketHandler) InstallMcp(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
return err
}
var req InstallMcpRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if strings.TrimSpace(req.McpID) == "" {
return echo.NewHTTPError(http.StatusBadRequest, "mcp_id is required")
}
entry, err := h.fetchMcpEntry(c, req.McpID)
if err != nil {
return err
}
upsert := h.mcpEntryToUpsert(entry, req.Env)
conn, err := h.mcpService.Create(c.Request().Context(), botID, upsert)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, conn)
}
// InstallSkill godoc
// @Summary Install skill from supermarket to bot container
// @Tags supermarket
// @Param bot_id path string true "Bot ID"
// @Param payload body InstallSkillRequest true "Install skill request"
// @Success 200 {object} map[string]bool
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 502 {object} ErrorResponse
// @Router /bots/{bot_id}/supermarket/install-skill [post].
func (h *SupermarketHandler) InstallSkill(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
return err
}
var req InstallSkillRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
skillID := strings.TrimSpace(req.SkillID)
if skillID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "skill_id is required")
}
if strings.Contains(skillID, "..") || strings.Contains(skillID, "/") {
return echo.NewHTTPError(http.StatusBadRequest, "invalid skill_id")
}
ctx := c.Request().Context()
client, err := h.manager.MCPClient(ctx, botID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("container not reachable: %v", err))
}
downloadURL := h.baseURL + "/api/skills/" + skillID + "/download"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
resp, err := h.httpClient.Do(httpReq) //nolint:gosec // URL constructed from trusted config
if err != nil {
h.logger.Error("supermarket skill download failed", slog.String("url", downloadURL), slog.Any("error", err))
return echo.NewHTTPError(http.StatusBadGateway, "supermarket unreachable")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("skill %q not found in supermarket", skillID))
}
if resp.StatusCode != http.StatusOK {
return echo.NewHTTPError(http.StatusBadGateway, fmt.Sprintf("supermarket returned status %d", resp.StatusCode))
}
gz, err := gzip.NewReader(resp.Body)
if err != nil {
return echo.NewHTTPError(http.StatusBadGateway, "invalid gzip response from supermarket")
}
defer func() { _ = gz.Close() }()
skillDir := path.Join(skillsDirPath, skillID)
if err := client.Mkdir(ctx, skillDir); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("mkdir failed: %v", err))
}
tr := tar.NewReader(gz)
filesWritten := 0
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return echo.NewHTTPError(http.StatusBadGateway, fmt.Sprintf("invalid tar: %v", err))
}
if hdr.Typeflag != tar.TypeReg {
continue
}
relativePath := strings.TrimPrefix(hdr.Name, skillID+"/")
if relativePath == "" || strings.Contains(relativePath, "..") {
continue
}
content, err := io.ReadAll(tr)
if err != nil {
return echo.NewHTTPError(http.StatusBadGateway, fmt.Sprintf("read tar entry failed: %v", err))
}
filePath := path.Join(skillDir, relativePath)
dir := path.Dir(filePath)
if dir != skillDir {
_ = client.Mkdir(ctx, dir)
}
if err := client.WriteFile(ctx, filePath, content); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("write file %s failed: %v", relativePath, err))
}
filesWritten++
}
if filesWritten == 0 {
return echo.NewHTTPError(http.StatusBadGateway, "skill archive was empty")
}
return c.JSON(http.StatusOK, map[string]any{"ok": true, "files_written": filesWritten})
}
// --- Supermarket upstream types (for swagger) ---
type SupermarketAuthor struct {
Name string `json:"name"`
Email string `json:"email"`
}
type SupermarketConfigVar struct {
Key string `json:"key"`
Description string `json:"description"`
DefaultValue string `json:"defaultValue,omitempty"`
}
type SupermarketMcpEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Author SupermarketAuthor `json:"author"`
Transport string `json:"transport"`
Icon string `json:"icon,omitempty"`
Homepage string `json:"homepage,omitempty"`
Tags []string `json:"tags,omitempty"`
URL string `json:"url,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Headers []SupermarketConfigVar `json:"headers,omitempty"`
Env []SupermarketConfigVar `json:"env,omitempty"`
}
type SupermarketMcpListResponse struct {
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
Data []SupermarketMcpEntry `json:"data"`
}
type SupermarketSkillMetadata struct {
Author SupermarketAuthor `json:"author"`
Tags []string `json:"tags,omitempty"`
Homepage string `json:"homepage,omitempty"`
}
type SupermarketSkillEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Metadata SupermarketSkillMetadata `json:"metadata"`
Content string `json:"content"`
Files []string `json:"files"`
}
type SupermarketSkillListResponse struct {
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
Data []SupermarketSkillEntry `json:"data"`
}
type SupermarketTagsResponse struct {
Tags []string `json:"tags"`
}
// --- Internal helpers ---
func (h *SupermarketHandler) fetchMcpEntry(c echo.Context, mcpID string) (SupermarketMcpEntry, error) {
url := h.baseURL + "/api/mcps/" + mcpID
req, err := http.NewRequestWithContext(c.Request().Context(), http.MethodGet, url, nil)
if err != nil {
return SupermarketMcpEntry{}, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
req.Header.Set("Accept", "application/json")
resp, err := h.httpClient.Do(req) //nolint:gosec // URL constructed from trusted config
if err != nil {
h.logger.Error("supermarket fetch failed", slog.String("url", url), slog.Any("error", err))
return SupermarketMcpEntry{}, echo.NewHTTPError(http.StatusBadGateway, "supermarket unreachable")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return SupermarketMcpEntry{}, echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("MCP %q not found in supermarket", mcpID))
}
if resp.StatusCode != http.StatusOK {
return SupermarketMcpEntry{}, echo.NewHTTPError(http.StatusBadGateway, fmt.Sprintf("supermarket returned status %d", resp.StatusCode))
}
var entry SupermarketMcpEntry
if err := json.NewDecoder(resp.Body).Decode(&entry); err != nil {
return SupermarketMcpEntry{}, echo.NewHTTPError(http.StatusBadGateway, "invalid JSON from supermarket")
}
return entry, nil
}
func (*SupermarketHandler) mcpEntryToUpsert(entry SupermarketMcpEntry, envOverrides map[string]string) mcp.UpsertRequest {
headers := make(map[string]string, len(entry.Headers))
for _, hdr := range entry.Headers {
headers[hdr.Key] = hdr.DefaultValue
}
env := make(map[string]string, len(entry.Env))
for _, e := range entry.Env {
if override, ok := envOverrides[e.Key]; ok {
env[e.Key] = override
} else {
env[e.Key] = e.DefaultValue
}
}
return mcp.UpsertRequest{
Name: entry.Name,
Command: entry.Command,
Args: entry.Args,
URL: entry.URL,
Headers: headers,
Env: env,
Transport: entry.Transport,
}
}