mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 05:16:29 +02:00
* MUL-4009: hide not-configured Composio toolkits at Service layer Filter out toolkits with no enabled auth config in Service.ListToolkits so the Settings UI only shows connectable apps. A dead 'Not configured' card is noise for end users, so drop the entry entirely instead of showing a greyed label (which existed only to avoid a dead Connect button, MUL-3720). - service.go: only append connectable toolkits; drop the connectable-first sort (all entries are connectable now); a resolver error now returns an error (502) instead of masking to an empty catalog, so the UI shows its honest load-failed state rather than a misleading 'no apps configured'. - Keep the wire 'connectable' field (always true) for backward compat with older desktop clients that branch on it. - composio-tab.tsx: remove the 'Not configured' branch; keep the toolkit.connectable guard as a client-side backstop. - i18n: drop composio.not_connectable / not_connectable_hint from en/ja/ko/zh-Hans. - Update service + handler tests to assert filtering and the resolver-error path. Co-authored-by: multica-agent <github@multica.ai> * MUL-4009: address review — empty-state copy, 502 handler test, stale comments Review follow-up on #4880: - i18n: rewrite composio.page_description / empty_title / empty_description in all 4 locales. The empty state now means 'no toolkit with an enabled auth config in the project', not 'Composio returned no catalog' — the old copy misled users after filtering landed. - Add TestComposio_ListToolkits_ResolverErrorIs502: fakes an auth-config resolver error and asserts ListComposioToolkits returns 502, pinning the no-silent-empty-catalog behavior (composioFakeSDK gains listAuthErr). - Refresh stale 'full catalog / false connectable' docs in packages/core/types/composio.ts, composio/queries.ts, api/client.ts to the connectable-only model. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
229 lines
8.6 KiB
Go
229 lines
8.6 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/multica-ai/multica/server/internal/featureflags"
|
|
composio "github.com/multica-ai/multica/server/internal/integrations/composio"
|
|
)
|
|
|
|
// Composio integration handlers (MUL-3720, Stage 2 MVP). A Composio connection
|
|
// belongs to a user, not a workspace, so these handlers live outside the
|
|
// workspace-membership group. The four management endpoints (connect/init,
|
|
// toolkits, connections, delete) are user-scoped (requireUserID) and sit under
|
|
// the Auth middleware. ComposioCallback is the exception: it is a public route
|
|
// (outside the Auth group, see router.go / MUL-3843) because the browser often
|
|
// arrives without a session cookie — its identity comes from the signed state,
|
|
// not requireUserID. The whole block returns 503 when h.Composio is nil
|
|
// (COMPOSIO_API_KEY unset), matching the Lark/GitHub "integration not
|
|
// configured" convention.
|
|
|
|
// ComposioConnectInitRequest is the POST /connect/init body.
|
|
type ComposioConnectInitRequest struct {
|
|
ToolkitSlug string `json:"toolkit_slug"`
|
|
}
|
|
|
|
// ComposioConnectInitResponse carries the hosted Composio Connect Link the
|
|
// frontend redirects the user to.
|
|
type ComposioConnectInitResponse struct {
|
|
RedirectURL string `json:"redirect_url"`
|
|
}
|
|
|
|
// ComposioConnectionResponse is the wire shape for one connection row.
|
|
type ComposioConnectionResponse struct {
|
|
ID string `json:"id"`
|
|
ToolkitSlug string `json:"toolkit_slug"`
|
|
Status string `json:"status"`
|
|
ConnectedAt string `json:"connected_at"`
|
|
LastUsedAt *string `json:"last_used_at"`
|
|
}
|
|
|
|
// ComposioToolkitResponse is the wire shape for one toolkit in the catalog.
|
|
// Since MUL-4009 the catalog only contains connectable toolkits, so
|
|
// `connectable` is always true. The field is retained for backward
|
|
// compatibility with older desktop clients that branch on it (dropping it would
|
|
// make them treat every entry as non-connectable and hide the Connect button).
|
|
type ComposioToolkitResponse struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Logo string `json:"logo,omitempty"`
|
|
Category string `json:"category,omitempty"`
|
|
Connectable bool `json:"connectable"`
|
|
}
|
|
|
|
func (h *Handler) composioMCPAppsEnabled(ctx context.Context) bool {
|
|
return featureflags.ComposioMCPAppsEnabled(ctx, h.FeatureFlags)
|
|
}
|
|
|
|
// ComposioConnectInit (POST /api/integrations/composio/connect/init) starts a
|
|
// hosted Composio auth flow for the requested toolkit and returns the redirect
|
|
// URL. An unsupported toolkit slug is a 400 (the MVP only wires Notion).
|
|
func (h *Handler) ComposioConnectInit(w http.ResponseWriter, r *http.Request) {
|
|
if h.Composio == nil || !h.composioMCPAppsEnabled(r.Context()) {
|
|
writeError(w, http.StatusServiceUnavailable, "composio integration not configured")
|
|
return
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
userUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var req ComposioConnectInitRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.ToolkitSlug) == "" {
|
|
writeError(w, http.StatusBadRequest, "toolkit_slug is required")
|
|
return
|
|
}
|
|
|
|
redirectURL, err := h.Composio.BeginConnect(r.Context(), userUUID, req.ToolkitSlug)
|
|
if err != nil {
|
|
if errors.Is(err, composio.ErrToolkitNotSupported) {
|
|
writeError(w, http.StatusBadRequest, "toolkit not supported")
|
|
return
|
|
}
|
|
writeError(w, http.StatusBadGateway, "failed to start composio connect")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, ComposioConnectInitResponse{RedirectURL: redirectURL})
|
|
}
|
|
|
|
// ComposioCallback (GET /api/integrations/composio/callback) is the browser
|
|
// redirect target Composio sends the user back to after the hosted flow. It is
|
|
// registered as a PUBLIC route (outside the Auth middleware group — see
|
|
// router.go / MUL-3843), because the browser frequently lands here without a
|
|
// session cookie (expired session, SameSite/ITP stripping, private window,
|
|
// self-hosted callback subdomain). Identity therefore comes solely from the
|
|
// HMAC-signed `state` query param, which CompleteCallback verifies before
|
|
// doing anything. On success the row is upserted and the browser is redirected
|
|
// to the settings page; any failure redirects to the same page with a stable
|
|
// error code so the user is never left on a blank API response.
|
|
func (h *Handler) ComposioCallback(w http.ResponseWriter, r *http.Request) {
|
|
if h.Composio == nil || !h.composioMCPAppsEnabled(r.Context()) {
|
|
writeError(w, http.StatusServiceUnavailable, "composio integration not configured")
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
state := q.Get("state")
|
|
status := q.Get("status")
|
|
connectedAccountID := q.Get("connected_account_id")
|
|
|
|
slug, err := h.Composio.CompleteCallback(r.Context(), state, status, connectedAccountID)
|
|
if err != nil {
|
|
// Every failure (tampered/expired state, non-success status, write
|
|
// error) collapses to the generic failure redirect — we never tell the
|
|
// browser which check failed.
|
|
http.Redirect(w, r, h.Composio.CallbackRedirect(slug, false), http.StatusFound)
|
|
return
|
|
}
|
|
http.Redirect(w, r, h.Composio.CallbackRedirect(slug, true), http.StatusFound)
|
|
}
|
|
|
|
// ListComposioConnections (GET /api/integrations/composio/connections) returns
|
|
// the caller's active connections.
|
|
func (h *Handler) ListComposioConnections(w http.ResponseWriter, r *http.Request) {
|
|
if h.Composio == nil || !h.composioMCPAppsEnabled(r.Context()) {
|
|
writeError(w, http.StatusServiceUnavailable, "composio integration not configured")
|
|
return
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
userUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
|
|
if !ok {
|
|
return
|
|
}
|
|
conns, err := h.Composio.ListConnections(r.Context(), userUUID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list composio connections")
|
|
return
|
|
}
|
|
out := make([]ComposioConnectionResponse, 0, len(conns))
|
|
for _, c := range conns {
|
|
out = append(out, ComposioConnectionResponse{
|
|
ID: c.ID,
|
|
ToolkitSlug: c.ToolkitSlug,
|
|
Status: c.Status,
|
|
ConnectedAt: c.ConnectedAt,
|
|
LastUsedAt: c.LastUsedAt,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// ListComposioToolkits (GET /api/integrations/composio/toolkits) returns the
|
|
// connectable Composio toolkits for the Settings UI to render. Since MUL-4009
|
|
// the service filters out toolkits with no enabled auth config in the project,
|
|
// so every entry here is connectable; the `connectable` flag is kept on the
|
|
// wire for backward compatibility. The catalog itself is project-global (not
|
|
// per-user), but the route is user-scoped (requireUser) like the rest of the
|
|
// block. A resolver/upstream failure is a 502, letting the UI show its
|
|
// load-failed state rather than a misleading empty catalog.
|
|
func (h *Handler) ListComposioToolkits(w http.ResponseWriter, r *http.Request) {
|
|
if h.Composio == nil || !h.composioMCPAppsEnabled(r.Context()) {
|
|
writeError(w, http.StatusServiceUnavailable, "composio integration not configured")
|
|
return
|
|
}
|
|
if _, ok := requireUserID(w, r); !ok {
|
|
return
|
|
}
|
|
toolkits, err := h.Composio.ListToolkits(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "failed to list composio toolkits")
|
|
return
|
|
}
|
|
out := make([]ComposioToolkitResponse, 0, len(toolkits))
|
|
for _, tk := range toolkits {
|
|
out = append(out, ComposioToolkitResponse{
|
|
Slug: tk.Slug,
|
|
Name: tk.Name,
|
|
Logo: tk.LogoURL,
|
|
Category: tk.Category,
|
|
Connectable: tk.Connectable,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// DeleteComposioConnection (DELETE /api/integrations/composio/connections/{id})
|
|
// disconnects a connection the caller owns. Idempotent at the service layer;
|
|
// a connection that does not belong to the caller is a 404.
|
|
func (h *Handler) DeleteComposioConnection(w http.ResponseWriter, r *http.Request) {
|
|
if h.Composio == nil || !h.composioMCPAppsEnabled(r.Context()) {
|
|
writeError(w, http.StatusServiceUnavailable, "composio integration not configured")
|
|
return
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
userUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
|
|
if !ok {
|
|
return
|
|
}
|
|
connUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "connection id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.Composio.Disconnect(r.Context(), userUUID, connUUID); err != nil {
|
|
if errors.Is(err, composio.ErrConnectionNotFound) {
|
|
writeError(w, http.StatusNotFound, "composio connection not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusBadGateway, "failed to disconnect composio connection")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|