mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
415 lines
13 KiB
Go
415 lines
13 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/labstack/echo/v4"
|
|
|
|
"github.com/memohai/memoh/internal/accounts"
|
|
"github.com/memohai/memoh/internal/bots"
|
|
"github.com/memohai/memoh/internal/mcp"
|
|
)
|
|
|
|
type MCPHandler struct {
|
|
service *mcp.ConnectionService
|
|
botService *bots.Service
|
|
accountService *accounts.Service
|
|
fedGateway *MCPFederationGateway
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func NewMCPHandler(log *slog.Logger, service *mcp.ConnectionService, botService *bots.Service, accountService *accounts.Service, fedGateway *MCPFederationGateway) *MCPHandler {
|
|
return &MCPHandler{
|
|
service: service,
|
|
botService: botService,
|
|
accountService: accountService,
|
|
fedGateway: fedGateway,
|
|
logger: log.With(slog.String("handler", "mcp")),
|
|
}
|
|
}
|
|
|
|
func (h *MCPHandler) Register(e *echo.Echo) {
|
|
group := e.Group("/bots/:bot_id/mcp")
|
|
group.GET("", h.List)
|
|
group.POST("", h.Create)
|
|
group.GET("/:id", h.Get)
|
|
group.PUT("/:id", h.Update)
|
|
group.DELETE("/:id", h.Delete)
|
|
group.POST("/:id/probe", h.Probe)
|
|
|
|
ops := e.Group("/bots/:bot_id/mcp-ops")
|
|
ops.PUT("/import", h.Import)
|
|
ops.GET("/export", h.Export)
|
|
ops.POST("/batch-delete", h.BatchDelete)
|
|
}
|
|
|
|
// List godoc
|
|
// @Summary List MCP connections
|
|
// @Description List MCP connections for a bot
|
|
// @Tags mcp
|
|
// @Success 200 {object} mcp.ListResponse
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 404 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/mcp [get].
|
|
func (h *MCPHandler) List(c echo.Context) error {
|
|
userID, err := h.requireChannelIdentityID(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
botID := strings.TrimSpace(c.Param("bot_id"))
|
|
if botID == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "bot id is required")
|
|
}
|
|
if _, err := h.authorizeBotAccess(c.Request().Context(), userID, botID); err != nil {
|
|
return err
|
|
}
|
|
items, err := h.service.ListByBot(c.Request().Context(), botID)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
return c.JSON(http.StatusOK, mcp.ListResponse{Items: items})
|
|
}
|
|
|
|
// Create godoc
|
|
// @Summary Create MCP connection
|
|
// @Description Create a MCP connection for a bot
|
|
// @Tags mcp
|
|
// @Param payload body mcp.UpsertRequest true "MCP payload"
|
|
// @Success 201 {object} mcp.Connection
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 404 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/mcp [post].
|
|
func (h *MCPHandler) Create(c echo.Context) error {
|
|
userID, err := h.requireChannelIdentityID(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
botID := strings.TrimSpace(c.Param("bot_id"))
|
|
if botID == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "bot id is required")
|
|
}
|
|
if _, err := h.authorizeBotAccess(c.Request().Context(), userID, botID); err != nil {
|
|
return err
|
|
}
|
|
var req mcp.UpsertRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
resp, err := h.service.Create(c.Request().Context(), botID, req)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
return c.JSON(http.StatusCreated, resp)
|
|
}
|
|
|
|
// Get godoc
|
|
// @Summary Get MCP connection
|
|
// @Description Get a MCP connection by ID
|
|
// @Tags mcp
|
|
// @Param id path string true "MCP ID"
|
|
// @Success 200 {object} mcp.Connection
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 404 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/mcp/{id} [get].
|
|
func (h *MCPHandler) Get(c echo.Context) error {
|
|
userID, err := h.requireChannelIdentityID(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
botID := strings.TrimSpace(c.Param("bot_id"))
|
|
if botID == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "bot id is required")
|
|
}
|
|
if _, err := h.authorizeBotAccess(c.Request().Context(), userID, botID); err != nil {
|
|
return err
|
|
}
|
|
id := strings.TrimSpace(c.Param("id"))
|
|
if id == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "id is required")
|
|
}
|
|
resp, err := h.service.Get(c.Request().Context(), botID, id)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return echo.NewHTTPError(http.StatusNotFound, "mcp connection not found")
|
|
}
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
return c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// Update godoc
|
|
// @Summary Update MCP connection
|
|
// @Description Update a MCP connection by ID
|
|
// @Tags mcp
|
|
// @Param id path string true "MCP ID"
|
|
// @Param payload body mcp.UpsertRequest true "MCP payload"
|
|
// @Success 200 {object} mcp.Connection
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 404 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/mcp/{id} [put].
|
|
func (h *MCPHandler) Update(c echo.Context) error {
|
|
userID, err := h.requireChannelIdentityID(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
botID := strings.TrimSpace(c.Param("bot_id"))
|
|
if botID == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "bot id is required")
|
|
}
|
|
if _, err := h.authorizeBotAccess(c.Request().Context(), userID, botID); err != nil {
|
|
return err
|
|
}
|
|
id := strings.TrimSpace(c.Param("id"))
|
|
if id == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "id is required")
|
|
}
|
|
var req mcp.UpsertRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
resp, err := h.service.Update(c.Request().Context(), botID, id, req)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return echo.NewHTTPError(http.StatusNotFound, "mcp connection not found")
|
|
}
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
return c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// Delete godoc
|
|
// @Summary Delete MCP connection
|
|
// @Description Delete a MCP connection by ID
|
|
// @Tags mcp
|
|
// @Param id path string true "MCP ID"
|
|
// @Success 204 "No Content"
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 404 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/mcp/{id} [delete].
|
|
func (h *MCPHandler) Delete(c echo.Context) error {
|
|
userID, err := h.requireChannelIdentityID(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
botID := strings.TrimSpace(c.Param("bot_id"))
|
|
if botID == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "bot id is required")
|
|
}
|
|
if _, err := h.authorizeBotAccess(c.Request().Context(), userID, botID); err != nil {
|
|
return err
|
|
}
|
|
id := strings.TrimSpace(c.Param("id"))
|
|
if id == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "id is required")
|
|
}
|
|
if err := h.service.Delete(c.Request().Context(), botID, id); err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
// ProbeResponse is the response for a probe operation.
|
|
type ProbeResponse struct {
|
|
Status string `json:"status"`
|
|
Tools []mcp.ToolDescriptor `json:"tools"`
|
|
Error string `json:"error,omitempty"`
|
|
AuthRequired bool `json:"auth_required,omitempty"`
|
|
}
|
|
|
|
// Probe godoc
|
|
// @Summary Probe MCP connection
|
|
// @Description Probe a MCP connection to discover tools and verify connectivity
|
|
// @Tags mcp
|
|
// @Param id path string true "MCP connection ID"
|
|
// @Success 200 {object} ProbeResponse
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 404 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/mcp/{id}/probe [post].
|
|
func (h *MCPHandler) Probe(c echo.Context) error {
|
|
userID, err := h.requireChannelIdentityID(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
botID := strings.TrimSpace(c.Param("bot_id"))
|
|
if botID == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "bot id is required")
|
|
}
|
|
if _, err := h.authorizeBotAccess(c.Request().Context(), userID, botID); err != nil {
|
|
return err
|
|
}
|
|
id := strings.TrimSpace(c.Param("id"))
|
|
if id == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "id is required")
|
|
}
|
|
conn, err := h.service.Get(c.Request().Context(), botID, id)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return echo.NewHTTPError(http.StatusNotFound, "mcp connection not found")
|
|
}
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
if h.fedGateway == nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "federation gateway not configured")
|
|
}
|
|
|
|
ctx := c.Request().Context()
|
|
var tools []mcp.ToolDescriptor
|
|
var probeErr error
|
|
|
|
switch strings.ToLower(strings.TrimSpace(conn.Type)) {
|
|
case "http":
|
|
tools, probeErr = h.fedGateway.ListHTTPConnectionTools(ctx, conn)
|
|
case "sse":
|
|
tools, probeErr = h.fedGateway.ListSSEConnectionTools(ctx, conn)
|
|
case "stdio":
|
|
tools, probeErr = h.fedGateway.ListStdioConnectionTools(ctx, botID, conn)
|
|
default:
|
|
probeErr = fmt.Errorf("unsupported connection type: %s", conn.Type)
|
|
}
|
|
|
|
resp := ProbeResponse{}
|
|
if probeErr != nil {
|
|
resp.Status = "error"
|
|
resp.Error = probeErr.Error()
|
|
resp.Tools = []mcp.ToolDescriptor{}
|
|
authRequired := strings.Contains(probeErr.Error(), "401") || strings.Contains(strings.ToLower(probeErr.Error()), "unauthorized")
|
|
resp.AuthRequired = authRequired
|
|
_ = h.service.UpdateProbeResult(ctx, botID, id, "error", []mcp.ToolDescriptor{}, probeErr.Error())
|
|
} else {
|
|
resp.Status = "connected"
|
|
if tools == nil {
|
|
tools = []mcp.ToolDescriptor{}
|
|
}
|
|
resp.Tools = tools
|
|
_ = h.service.UpdateProbeResult(ctx, botID, id, "connected", tools, "")
|
|
}
|
|
return c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// Import godoc
|
|
// @Summary Import MCP connections
|
|
// @Description Batch import MCP connections from standard mcpServers format. Existing connections (matched by name) get config updated with is_active preserved. New connections are created as active.
|
|
// @Tags mcp
|
|
// @Param payload body mcp.ImportRequest true "mcpServers dict"
|
|
// @Success 200 {object} mcp.ListResponse
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/mcp/import [put].
|
|
func (h *MCPHandler) Import(c echo.Context) error {
|
|
userID, err := h.requireChannelIdentityID(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
botID := strings.TrimSpace(c.Param("bot_id"))
|
|
if botID == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "bot id is required")
|
|
}
|
|
if _, err := h.authorizeBotAccess(c.Request().Context(), userID, botID); err != nil {
|
|
return err
|
|
}
|
|
var req mcp.ImportRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
items, err := h.service.Import(c.Request().Context(), botID, req)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
return c.JSON(http.StatusOK, mcp.ListResponse{Items: items})
|
|
}
|
|
|
|
// BatchDeleteRequest is the body for batch delete.
|
|
type BatchDeleteRequest struct {
|
|
IDs []string `json:"ids"`
|
|
}
|
|
|
|
// BatchDelete godoc
|
|
// @Summary Batch delete MCP connections
|
|
// @Description Delete multiple MCP connections by IDs.
|
|
// @Tags mcp
|
|
// @Param payload body BatchDeleteRequest true "IDs to delete"
|
|
// @Success 204 "No Content"
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/mcp-ops/batch-delete [post].
|
|
func (h *MCPHandler) BatchDelete(c echo.Context) error {
|
|
userID, err := h.requireChannelIdentityID(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
botID := strings.TrimSpace(c.Param("bot_id"))
|
|
if botID == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "bot id is required")
|
|
}
|
|
if _, err := h.authorizeBotAccess(c.Request().Context(), userID, botID); err != nil {
|
|
return err
|
|
}
|
|
var req BatchDeleteRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
if len(req.IDs) == 0 {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "ids are required")
|
|
}
|
|
if err := h.service.BatchDelete(c.Request().Context(), botID, req.IDs); err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
// Export godoc
|
|
// @Summary Export MCP connections
|
|
// @Description Export all MCP connections for a bot in standard mcpServers format.
|
|
// @Tags mcp
|
|
// @Success 200 {object} mcp.ExportResponse
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 403 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/mcp/export [get].
|
|
func (h *MCPHandler) Export(c echo.Context) error {
|
|
userID, err := h.requireChannelIdentityID(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
botID := strings.TrimSpace(c.Param("bot_id"))
|
|
if botID == "" {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "bot id is required")
|
|
}
|
|
if _, err := h.authorizeBotAccess(c.Request().Context(), userID, botID); err != nil {
|
|
return err
|
|
}
|
|
resp, err := h.service.ExportByBot(c.Request().Context(), botID)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
return c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
func (*MCPHandler) requireChannelIdentityID(c echo.Context) (string, error) {
|
|
return RequireChannelIdentityID(c)
|
|
}
|
|
|
|
func (h *MCPHandler) authorizeBotAccess(ctx context.Context, channelIdentityID, botID string) (bots.Bot, error) {
|
|
return AuthorizeBotAccess(ctx, h.botService, h.accountService, channelIdentityID, botID)
|
|
}
|