Files
multica/server/internal/handler/plugin.go
T
4d495056e8 [MUL-6139] Expand Plugin skills and hosted MCP connections (#6920)
* feat(plugins): add remote MCP runtime

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): add private Exa example

Co-authored-by: multica-agent <[email protected]>

* docs(plugins): remove private plugin guide

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): stabilize remote MCP tool digests

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): preserve remote MCP notification status

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): make Exa example runnable end to end

Co-authored-by: multica-agent <[email protected]>

* fix(codex): record MCP tool calls in agent log

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): add community skills example

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): support multi-file skill bundles

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): add Mobbin MCP example

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): bundle full Matt Pocock skill set

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): add remote MCP OAuth connections

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): gate enable on remote MCP readiness

Co-authored-by: multica-agent <[email protected]>

* fix(plugin): authenticate remote mcp credential broker

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): harden remote mcp oauth flow

Co-authored-by: multica-agent <[email protected]>

* refactor(plugins): use a single feature flag

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): clean up remote MCP OAuth state

Co-authored-by: multica-agent <[email protected]>

* chore(plugins): remove Matt Pocock example

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-16 04:54:32 +08:00

1032 lines
40 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/featureflags"
"github.com/multica-ai/multica/server/internal/middleware"
"github.com/multica-ai/multica/server/internal/pluginbundled"
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/plugincontract"
"github.com/multica-ai/multica/server/pkg/pluginruntime"
)
func (h *Handler) pluginsV1Enabled(ctx context.Context) bool {
return featureflags.PluginsV1Enabled(ctx, h.FeatureFlags)
}
func (h *Handler) requirePluginsV1(w http.ResponseWriter, r *http.Request) bool {
if h.pluginsV1Enabled(r.Context()) {
return true
}
writeError(w, http.StatusServiceUnavailable, "Plugin management is not enabled")
return false
}
type pluginBindingResponse struct {
ScopeType string `json:"scope_type"`
ScopeID string `json:"scope_id"`
Enabled bool `json:"enabled"`
Revision int64 `json:"revision"`
}
type pluginContributionResponse struct {
Key string `json:"key"`
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
EntryPath string `json:"entry_path"`
EntryDigest string `json:"entry_digest"`
}
type pluginRemoteMCPConfigResponse struct {
ContributionKey string `json:"contribution_key"`
DefaultEndpoint string `json:"default_endpoint,omitempty"`
PreferredAuth string `json:"preferred_auth,omitempty"`
SupportedAuth []string `json:"supported_auth,omitempty"`
ConfigRevision int64 `json:"config_revision,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
EndpointDomain string `json:"endpoint_domain,omitempty"`
AuthType string `json:"auth_type,omitempty"`
AuthHeader string `json:"auth_header,omitempty"`
PublicConfig json.RawMessage `json:"public_config,omitempty"`
ConnectionScope string `json:"connection_scope,omitempty"`
ConnectedBy string `json:"connected_by,omitempty"`
CredentialState string `json:"credential_state"`
CredentialHint string `json:"credential_hint,omitempty"`
FailurePolicy string `json:"failure_policy,omitempty"`
ApprovedTools []pluginruntime.RemoteMCPTool `json:"approved_tools"`
DiscoveredTools []pluginruntime.RemoteMCPTool `json:"discovered_tools"`
DiscoveredDigest string `json:"discovered_schema_digest,omitempty"`
SchemaDigest string `json:"schema_digest,omitempty"`
Reviewed bool `json:"reviewed"`
Ready bool `json:"ready"`
}
func remoteMCPConfigResponseReady(config pluginRemoteMCPConfigResponse) bool {
credentialReady := config.CredentialState == "configured" || config.CredentialState == "not_required"
return config.ConfigRevision > 0 && config.Reviewed && len(config.ApprovedTools) > 0 && config.SchemaDigest != "" && credentialReady
}
type pluginInstallationResponse struct {
ID string `json:"id"`
PluginKey string `json:"plugin_key"`
DisplayName string `json:"display_name"`
DesiredVersion string `json:"desired_version"`
ActiveVersion string `json:"active_version,omitempty"`
Enabled bool `json:"enabled"`
DesiredGeneration int64 `json:"desired_generation"`
ActiveGeneration int64 `json:"active_generation"`
LifecycleStatus string `json:"lifecycle_status"`
HealthState string `json:"health_state,omitempty"`
HealthReason string `json:"health_reason,omitempty"`
Description string `json:"description,omitempty"`
Publisher string `json:"publisher"`
PublisherType string `json:"publisher_type"`
TrustTier string `json:"trust_tier"`
SourceKind string `json:"source_kind"`
SourceRef string `json:"source_ref"`
UploaderID string `json:"uploader_id,omitempty"`
ManifestDigest string `json:"manifest_digest"`
ArchiveDigest string `json:"archive_digest"`
ArtifactDigest string `json:"artifact_digest"`
SignatureVerified bool `json:"signature_verified"`
RequestedCaps []string `json:"requested_capabilities"`
AvailableVersions []string `json:"available_versions"`
Contributions []string `json:"contributions"`
ContributionInfo []pluginContributionResponse `json:"contribution_details"`
Bindings []pluginBindingResponse `json:"bindings"`
RemoteMCP []pluginRemoteMCPConfigResponse `json:"remote_mcp"`
}
func (h *Handler) pluginInstallationResponse(r *http.Request, installation db.PluginInstallation, health *db.PluginHealth) (pluginInstallationResponse, error) {
return h.pluginInstallationResponseWithReleases(r, installation, health, nil)
}
func (h *Handler) pluginInstallationResponseWithReleases(r *http.Request, installation db.PluginInstallation, health *db.PluginHealth, prefetchedReleases *[]db.PluginRelease) (pluginInstallationResponse, error) {
identity, err := h.Queries.GetPluginIdentity(r.Context(), installation.PluginID)
if err != nil {
return pluginInstallationResponse{}, err
}
desired, err := h.Queries.GetPluginRelease(r.Context(), installation.DesiredReleaseID)
if err != nil {
return pluginInstallationResponse{}, err
}
var manifest plugincontract.Manifest
if err := json.Unmarshal(desired.Manifest, &manifest); err != nil {
return pluginInstallationResponse{}, err
}
response := pluginInstallationResponse{
ID: uuidToString(installation.ID),
PluginKey: identity.PluginKey,
DisplayName: identity.DisplayName,
DesiredVersion: desired.Version,
Enabled: installation.Enabled,
DesiredGeneration: installation.DesiredGeneration,
ActiveGeneration: installation.ActiveGeneration,
LifecycleStatus: installation.LifecycleStatus,
Description: manifest.Metadata.Description,
Publisher: identity.PublisherID,
PublisherType: identity.PublisherType,
TrustTier: identity.TrustTier,
SourceKind: desired.SourceKind,
SourceRef: desired.SourceRef,
UploaderID: uuidToString(installation.InstalledBy),
ManifestDigest: desired.ManifestDigest,
ArchiveDigest: desired.ArchiveDigest,
ArtifactDigest: desired.ArtifactDigest,
SignatureVerified: desired.SourceKind == plugincontract.SourceBundled && desired.SignatureKeyID.Valid,
RequestedCaps: append([]string(nil), manifest.RequestedCapabilities...),
AvailableVersions: []string{},
Contributions: []string{},
ContributionInfo: []pluginContributionResponse{},
Bindings: []pluginBindingResponse{},
RemoteMCP: []pluginRemoteMCPConfigResponse{},
}
var releases []db.PluginRelease
if prefetchedReleases != nil {
releases = *prefetchedReleases
} else {
releases, err = h.Queries.ListPluginReleasesByPlugin(r.Context(), installation.PluginID)
if err != nil {
return pluginInstallationResponse{}, err
}
}
for _, release := range releases {
response.AvailableVersions = append(response.AvailableVersions, release.Version)
}
contributions, err := h.Queries.ListPluginContributionsByRelease(r.Context(), desired.ID)
if err != nil {
return pluginInstallationResponse{}, err
}
for _, contribution := range contributions {
response.Contributions = append(response.Contributions, contribution.ContributionKey)
response.ContributionInfo = append(response.ContributionInfo, pluginContributionResponse{
Key: contribution.ContributionKey,
Type: contribution.Type,
Name: contribution.DisplayName,
Description: contribution.Description,
EntryPath: contribution.EntryPath,
EntryDigest: contribution.EntryDigest,
})
if contribution.Type == plugincontract.ContributionRemoteMCPV1 {
remote := pluginRemoteMCPConfigResponse{
ContributionKey: contribution.ContributionKey,
CredentialState: "missing",
ApprovedTools: []pluginruntime.RemoteMCPTool{},
DiscoveredTools: []pluginruntime.RemoteMCPTool{},
}
for _, declaration := range manifest.Contributes.RemoteMCP {
if declaration.Key == contribution.ContributionKey {
remote.DefaultEndpoint = declaration.EndpointPolicy.DefaultEndpoint
remote.PreferredAuth = declaration.Authentication.Preferred
remote.SupportedAuth = append([]string(nil), declaration.Authentication.Supported...)
break
}
}
config, configErr := h.Queries.GetLatestPluginInstallationConfig(r.Context(), db.GetLatestPluginInstallationConfigParams{
WorkspaceID: installation.WorkspaceID, InstallationID: installation.ID, ContributionID: contribution.ID,
})
if configErr == nil {
remote.ConfigRevision = config.Revision
remote.Endpoint = config.Endpoint
remote.AuthType = config.AuthType
remote.AuthHeader = config.AuthHeader
remote.PublicConfig = append(json.RawMessage(nil), config.PublicConfig...)
remote.FailurePolicy = config.FailurePolicy
remote.SchemaDigest = config.SchemaDigest.String
remote.DiscoveredDigest = config.DiscoveredSchemaDigest.String
remote.Reviewed = config.ReviewedAt.Valid
if endpoint, parseErr := url.Parse(config.Endpoint); parseErr == nil {
remote.EndpointDomain = endpoint.Hostname()
}
_ = json.Unmarshal(config.ApprovedTools, &remote.ApprovedTools)
_ = json.Unmarshal(config.DiscoveredTools, &remote.DiscoveredTools)
if config.AuthType == "none" {
remote.CredentialState = "not_required"
} else if config.SecretRef.Valid {
secret, secretErr := h.Queries.GetActivePluginRemoteMCPSecret(r.Context(), db.GetActivePluginRemoteMCPSecretParams{
ID: config.SecretRef, WorkspaceID: installation.WorkspaceID,
InstallationID: installation.ID, ContributionID: contribution.ID,
})
if secretErr == nil {
remote.CredentialState = "configured"
remote.CredentialHint = secret.Hint
remote.ConnectedBy = uuidToString(secret.CreatedBy)
// V1 execution configs are workspace-pinned. Recording the
// connecting user now keeps the wire contract ready for a
// future per-user connection selector without exposing tokens.
remote.ConnectionScope = "workspace"
} else if errors.Is(secretErr, pgx.ErrNoRows) {
remote.CredentialState = "revoked"
}
}
} else if !errors.Is(configErr, pgx.ErrNoRows) {
return pluginInstallationResponse{}, configErr
}
remote.Ready = remoteMCPConfigResponseReady(remote)
response.RemoteMCP = append(response.RemoteMCP, remote)
}
}
bindings, err := h.Queries.ListLatestPluginBindings(r.Context(), installation.ID)
if err != nil {
return pluginInstallationResponse{}, err
}
for _, binding := range bindings {
response.Bindings = append(response.Bindings, pluginBindingResponse{
ScopeType: binding.ScopeType,
ScopeID: uuidToString(binding.ScopeID),
Enabled: binding.Enabled,
Revision: binding.BindingRevision,
})
}
if installation.ActiveReleaseID.Valid {
active, err := h.Queries.GetPluginRelease(r.Context(), installation.ActiveReleaseID)
if err != nil {
return pluginInstallationResponse{}, err
}
response.ActiveVersion = active.Version
}
if health != nil {
response.HealthState = health.State
response.HealthReason = health.ReasonCode
}
return response, nil
}
func (h *Handler) ListPlugins(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
workspaceID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace_id")
if !ok {
return
}
installations, err := h.Queries.ListWorkspacePluginInstallations(r.Context(), workspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list Plugins")
return
}
h.writePluginInstallations(w, r, workspaceID, installations)
}
func (h *Handler) ListPrivatePlugins(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
workspaceID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace_id")
if !ok {
return
}
installations, err := h.Queries.ListWorkspacePrivatePluginInstallations(r.Context(), workspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list Private Plugins")
return
}
h.writePluginInstallations(w, r, workspaceID, installations)
}
func (h *Handler) GetPrivatePluginStatus(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
workspaceID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace_id")
if !ok {
return
}
installation, err := h.Queries.GetWorkspacePrivatePluginInstallationByRef(r.Context(), db.GetWorkspacePrivatePluginInstallationByRefParams{
WorkspaceID: workspaceID,
PluginRef: chi.URLParam(r, "pluginRef"),
})
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Private Plugin installation not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load Private Plugin")
return
}
var health *db.PluginHealth
healthRow, healthErr := h.Queries.GetWorkspacePluginHealthByInstallation(r.Context(), db.GetWorkspacePluginHealthByInstallationParams{
WorkspaceID: workspaceID,
InstallationID: installation.ID,
})
if healthErr == nil {
health = &healthRow
} else if !errors.Is(healthErr, pgx.ErrNoRows) {
writeError(w, http.StatusInternalServerError, "failed to load Private Plugin")
return
}
response, responseErr := h.pluginInstallationResponse(r, installation, health)
if responseErr != nil {
writeError(w, http.StatusInternalServerError, "failed to load Private Plugin")
return
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) writePluginInstallations(w http.ResponseWriter, r *http.Request, workspaceID pgtype.UUID, installations []db.PluginInstallation) {
healthRows, _ := h.Queries.ListWorkspacePluginHealth(r.Context(), workspaceID)
healthByInstallation := make(map[string]db.PluginHealth)
for _, health := range healthRows {
key := uuidToString(health.InstallationID)
if _, exists := healthByInstallation[key]; !exists {
healthByInstallation[key] = health
}
}
releaseRows, err := h.Queries.ListWorkspacePluginReleases(r.Context(), workspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load Plugin releases")
return
}
releasesByPlugin := make(map[string][]db.PluginRelease)
for _, release := range releaseRows {
key := uuidToString(release.PluginID)
releasesByPlugin[key] = append(releasesByPlugin[key], release)
}
responses := make([]pluginInstallationResponse, 0, len(installations))
for _, installation := range installations {
health, hasHealth := healthByInstallation[uuidToString(installation.ID)]
var healthPtr *db.PluginHealth
if hasHealth {
healthPtr = &health
}
releases := releasesByPlugin[uuidToString(installation.PluginID)]
response, err := h.pluginInstallationResponseWithReleases(r, installation, healthPtr, &releases)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load Plugin")
return
}
responses = append(responses, response)
}
writeJSON(w, http.StatusOK, map[string]any{"plugins": responses})
}
type pluginCatalogReleaseResponse struct {
PluginKey string `json:"plugin_key"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Publisher string `json:"publisher"`
PublisherType string `json:"publisher_type"`
TrustTier string `json:"trust_tier"`
SourceKind string `json:"source_kind"`
SourceRef string `json:"source_ref"`
RequestedCapabilities []string `json:"requested_capabilities"`
HostAPI string `json:"host_api"`
RequiredDaemonFeatures []string `json:"required_daemon_features"`
SignatureKeyID string `json:"signature_key_id"`
SignatureVerified bool `json:"signature_verified"`
ManifestDigest string `json:"manifest_digest"`
ArchiveDigest string `json:"archive_digest"`
ArtifactDigest string `json:"artifact_digest"`
Compatible bool `json:"compatible"`
CompatibilityReason string `json:"compatibility_reason,omitempty"`
Contributions []pluginContributionResponse `json:"contributions"`
Installation *pluginInstallationResponse `json:"installation,omitempty"`
}
func (h *Handler) catalogReleaseResponse(r *http.Request, workspaceID pgtype.UUID, entry pluginbundled.CatalogEntry) (pluginCatalogReleaseResponse, error) {
manifest := entry.Release.Manifest
response := pluginCatalogReleaseResponse{
PluginKey: manifest.Metadata.Key,
Name: manifest.Metadata.Name,
Description: manifest.Metadata.Description,
Version: manifest.Metadata.Version,
Publisher: manifest.Metadata.Publisher,
PublisherType: entry.PublisherType,
TrustTier: entry.TrustTier,
SourceKind: entry.Release.SourceKind,
SourceRef: entry.Release.SourceRef,
RequestedCapabilities: append([]string(nil), manifest.RequestedCapabilities...),
HostAPI: manifest.Compatibility.HostAPI,
RequiredDaemonFeatures: append([]string(nil), manifest.Compatibility.RequiredDaemonFeatures...),
SignatureKeyID: entry.Release.SignatureKeyID,
SignatureVerified: true,
ManifestDigest: entry.Release.ManifestDigest,
ArchiveDigest: entry.Release.ArchiveDigest,
ArtifactDigest: entry.Release.ArtifactDigest,
Compatible: entry.Compatible,
CompatibilityReason: entry.CompatibilityWhy,
Contributions: make([]pluginContributionResponse, 0, len(entry.Release.Contributions)),
}
for _, contribution := range entry.Release.Contributions {
response.Contributions = append(response.Contributions, pluginContributionResponse{
Key: contribution.Key,
Type: contribution.Type,
Name: contribution.DisplayName,
Description: contribution.Description,
EntryPath: contribution.EntryPath,
EntryDigest: contribution.EntryDigest,
})
}
identity, err := h.Queries.GetOfficialPluginIdentityByKey(r.Context(), manifest.Metadata.Key)
if errors.Is(err, pgx.ErrNoRows) {
return response, nil
}
if err != nil {
return pluginCatalogReleaseResponse{}, err
}
installation, err := h.Queries.GetWorkspacePluginInstallation(r.Context(), db.GetWorkspacePluginInstallationParams{
WorkspaceID: workspaceID,
PluginID: identity.ID,
})
if errors.Is(err, pgx.ErrNoRows) {
return response, nil
}
if err != nil {
return pluginCatalogReleaseResponse{}, err
}
installed, err := h.pluginInstallationResponse(r, installation, nil)
if err != nil {
return pluginCatalogReleaseResponse{}, err
}
response.Installation = &installed
return response, nil
}
func (h *Handler) ListPluginCatalog(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
workspaceID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace_id")
if !ok {
return
}
entries := h.PluginService.CatalogEntries()
responses := make([]pluginCatalogReleaseResponse, 0, len(entries))
for _, entry := range entries {
response, err := h.catalogReleaseResponse(r, workspaceID, entry)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load Plugin catalog")
return
}
responses = append(responses, response)
}
diagnostics := h.PluginService.CatalogDiagnostics()
if diagnostics == nil {
diagnostics = []pluginbundled.Diagnostic{}
}
writeJSON(w, http.StatusOK, map[string]any{
"releases": responses,
"diagnostics": diagnostics,
})
}
func (h *Handler) GetPluginCatalogRelease(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
workspaceID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace_id")
if !ok {
return
}
entry, found := h.PluginService.FindCatalogRelease(chi.URLParam(r, "pluginKey"), r.URL.Query().Get("version"))
if !found {
writeError(w, http.StatusNotFound, "Plugin release not found")
return
}
response, err := h.catalogReleaseResponse(r, workspaceID, entry)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load Plugin catalog")
return
}
writeJSON(w, http.StatusOK, response)
}
type pluginReleaseRequest struct {
PluginKey string `json:"plugin_key"`
Version string `json:"version"`
}
func (h *Handler) InstallPlugin(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
workspaceID, actorID, ok := pluginRequestIDs(w, r)
if !ok {
return
}
var request pluginReleaseRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.PluginKey == "" || request.Version == "" {
writeError(w, http.StatusBadRequest, "plugin_key and version are required")
return
}
installation, err := h.PluginService.InstallCatalogRelease(r.Context(), workspaceID, actorID, request.PluginKey, request.Version)
if err != nil {
writePluginError(w, r, err)
return
}
response, err := h.pluginInstallationResponse(r, installation, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load Plugin")
return
}
writeJSON(w, http.StatusCreated, response)
}
func (h *Handler) InstallPrivatePlugin(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
workspaceID, actorID, ok := pluginRequestIDs(w, r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, plugincontract.MaxArchiveSize+plugincontract.MaxManifestSize)
if err := r.ParseMultipartForm(plugincontract.MaxArchiveSize + 1); err != nil {
writeError(w, http.StatusBadRequest, "Private Plugin package is required and must fit the package limit")
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll() //nolint:errcheck
}
file, _, err := r.FormFile("artifact")
if err != nil {
writeError(w, http.StatusBadRequest, "artifact file is required")
return
}
defer file.Close()
archive, err := io.ReadAll(io.LimitReader(file, plugincontract.MaxArchiveSize+1))
if err != nil || len(archive) > plugincontract.MaxArchiveSize {
writeError(w, http.StatusBadRequest, "Private Plugin package exceeds the package limit")
return
}
if _, validateErr := plugincontract.ValidateArtifact(archive); validateErr != nil {
writeError(w, http.StatusBadRequest, "Private Plugin package is invalid")
return
}
installation, err := h.PluginService.InstallPrivateArchive(r.Context(), workspaceID, actorID, archive)
if err != nil {
writePluginError(w, r, err)
return
}
response, err := h.pluginInstallationResponse(r, installation, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load Private Plugin")
return
}
writeJSON(w, http.StatusCreated, response)
}
func (h *Handler) UpgradePlugin(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
workspaceID, actorID, ok := pluginRequestIDs(w, r)
if !ok {
return
}
installationID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "installationId"), "installation_id")
if !ok {
return
}
if !h.requirePluginInstallation(w, r, workspaceID, installationID) {
return
}
var request pluginReleaseRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.PluginKey == "" || request.Version == "" {
writeError(w, http.StatusBadRequest, "plugin_key and version are required")
return
}
installation, err := h.PluginService.UpgradeCatalogRelease(r.Context(), workspaceID, installationID, actorID, request.PluginKey, request.Version)
if err != nil {
writePluginError(w, r, err)
return
}
response, err := h.pluginInstallationResponse(r, installation, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load Plugin")
return
}
writeJSON(w, http.StatusOK, response)
}
type pluginBindingRequest struct {
ScopeType string `json:"scope_type"`
ScopeID string `json:"scope_id"`
}
func (h *Handler) EnablePlugin(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
h.setPluginEnabled(w, r, true)
}
func (h *Handler) DisablePlugin(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
h.setPluginEnabled(w, r, false)
}
func (h *Handler) setPluginEnabled(w http.ResponseWriter, r *http.Request, enabled bool) {
workspaceID, actorID, ok := pluginRequestIDs(w, r)
if !ok {
return
}
installationID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "installationId"), "installation_id")
if !ok {
return
}
if !h.requirePluginInstallation(w, r, workspaceID, installationID) {
return
}
request := pluginBindingRequest{ScopeType: "workspace", ScopeID: uuidToString(workspaceID)}
if r.Body != nil && r.ContentLength != 0 {
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
}
if request.ScopeType == "workspace" && request.ScopeID == "" {
request.ScopeID = uuidToString(workspaceID)
}
scopeID, ok := parseUUIDOrBadRequest(w, request.ScopeID, "scope_id")
if !ok {
return
}
var installation db.PluginInstallation
var err error
if enabled {
installation, err = h.PluginService.EnablePlugin(r.Context(), workspaceID, installationID, actorID, request.ScopeType, scopeID)
} else {
installation, err = h.PluginService.DisablePlugin(r.Context(), workspaceID, installationID, actorID, request.ScopeType, scopeID)
}
if err != nil {
writePluginError(w, r, err)
return
}
response, err := h.pluginInstallationResponse(r, installation, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load Plugin")
return
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) RollbackPlugin(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
workspaceID, actorID, ok := pluginRequestIDs(w, r)
if !ok {
return
}
installationID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "installationId"), "installation_id")
if !ok {
return
}
if !h.requirePluginInstallation(w, r, workspaceID, installationID) {
return
}
var request struct {
Version string `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.Version == "" {
writeError(w, http.StatusBadRequest, "version is required")
return
}
installation, err := h.PluginService.RollbackPlugin(r.Context(), workspaceID, installationID, actorID, request.Version)
if err != nil {
writePluginError(w, r, err)
return
}
response, err := h.pluginInstallationResponse(r, installation, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load Plugin")
return
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) UninstallPlugin(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
workspaceID, actorID, ok := pluginRequestIDs(w, r)
if !ok {
return
}
installationID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "installationId"), "installation_id")
if !ok {
return
}
if !h.requirePluginInstallation(w, r, workspaceID, installationID) {
return
}
if err := h.PluginService.UninstallPlugin(r.Context(), workspaceID, installationID, actorID); err != nil {
writePluginError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
type remoteMCPConfigRequest struct {
Endpoint string `json:"endpoint"`
PublicConfig json.RawMessage `json:"public_config"`
AuthType string `json:"auth_type"`
AuthHeader string `json:"auth_header"`
Credential string `json:"credential"`
FailurePolicy string `json:"failure_policy"`
}
type remoteMCPOAuthStartRequest struct {
Endpoint string `json:"endpoint"`
PublicConfig json.RawMessage `json:"public_config"`
FailurePolicy string `json:"failure_policy"`
Scope string `json:"scope"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
ReturnTo string `json:"return_to"`
}
const remoteMCPOAuthCallbackPath = "/api/plugins/remote-mcp/oauth/callback"
func (h *Handler) StartPluginRemoteMCPOAuth(w http.ResponseWriter, r *http.Request) {
workspaceID, actorID, installationID, ok := h.remoteMCPRequestIDs(w, r)
if !ok {
return
}
var request remoteMCPOAuthStartRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
callbackBase := strings.TrimRight(h.cfg.PublicURL, "/")
if callbackBase == "" {
writeError(w, http.StatusServiceUnavailable, "MULTICA_PUBLIC_URL is required for Remote MCP OAuth")
return
}
result, err := h.PluginService.BeginRemoteMCPOAuth(r.Context(), workspaceID, installationID, actorID, chi.URLParam(r, "contributionKey"), service.RemoteMCPOAuthStartInput{
Endpoint: request.Endpoint, PublicConfig: request.PublicConfig, FailurePolicy: request.FailurePolicy,
Scope: request.Scope, ClientID: request.ClientID, ClientSecret: request.ClientSecret,
TokenEndpointAuthMethod: request.TokenEndpointAuthMethod,
AuthorizationEndpoint: request.AuthorizationEndpoint, TokenEndpoint: request.TokenEndpoint,
RedirectURI: callbackBase + remoteMCPOAuthCallbackPath, ReturnTo: request.ReturnTo,
})
if err != nil {
writePluginError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"authorization_url": result.AuthorizationURL})
}
// CompletePluginRemoteMCPOAuth is intentionally public. The single-use,
// short-lived state hash carries the installation and actor identity; browser
// cookies are neither required nor trusted on this cross-site callback.
func (h *Handler) CompletePluginRemoteMCPOAuth(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
result, err := h.PluginService.CompleteRemoteMCPOAuth(r.Context(), r.URL.Query().Get("state"), r.URL.Query().Get("code"))
if err != nil {
providerError := strings.TrimSpace(r.URL.Query().Get("error"))
if len(providerError) > 128 {
providerError = providerError[:128]
}
errorKind := "internal"
errorMessage := "Remote MCP OAuth callback failed"
var pluginErr *service.PluginError
if errors.As(err, &pluginErr) {
errorKind = string(pluginErr.Kind)
errorMessage = pluginErr.Message
}
slog.Warn("Remote MCP OAuth callback failed",
"error_kind", errorKind,
"error_message", errorMessage,
"provider_error", providerError,
"has_state", r.URL.Query().Get("state") != "",
"has_code", r.URL.Query().Get("code") != "",
)
}
returnTo := result.ReturnTo
if returnTo == "" {
returnTo = "/"
}
parsed, parseErr := url.Parse(returnTo)
if parseErr != nil || parsed.IsAbs() || parsed.Host != "" || !strings.HasPrefix(parsed.Path, "/") || strings.HasPrefix(parsed.Path, "//") {
parsed = &url.URL{Path: "/"}
}
query := parsed.Query()
if err == nil {
query.Set("remote_mcp_connected", "1")
} else {
query.Set("remote_mcp_error", "connect_failed")
}
parsed.RawQuery = query.Encode()
destination := parsed.String()
if appURL := resolveFrontendAppURL(); appURL != "" {
destination = strings.TrimRight(appURL, "/") + destination
}
http.Redirect(w, r, destination, http.StatusFound)
}
func (h *Handler) ConfigurePluginRemoteMCP(w http.ResponseWriter, r *http.Request) {
workspaceID, actorID, installationID, ok := h.remoteMCPRequestIDs(w, r)
if !ok {
return
}
var request remoteMCPConfigRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.Endpoint == "" {
writeError(w, http.StatusBadRequest, "endpoint is required")
return
}
result, err := h.PluginService.ConfigureRemoteMCP(r.Context(), workspaceID, installationID, actorID, chi.URLParam(r, "contributionKey"), service.RemoteMCPConfigInput{
Endpoint: request.Endpoint, PublicConfig: request.PublicConfig, AuthType: request.AuthType,
AuthHeader: request.AuthHeader, Credential: request.Credential, FailurePolicy: request.FailurePolicy,
})
if err != nil {
writePluginError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"config_revision": result.Config.Revision,
"credential_state": credentialState(result.Config),
"reviewed": false,
"discovered_tools": result.DiscoveredTools,
"discovered_schema_digest": result.SchemaDigest,
})
}
func (h *Handler) TestPluginRemoteMCP(w http.ResponseWriter, r *http.Request) {
workspaceID, _, installationID, ok := h.remoteMCPRequestIDs(w, r)
if !ok {
return
}
result, err := h.PluginService.TestRemoteMCP(r.Context(), workspaceID, installationID, chi.URLParam(r, "contributionKey"))
if err != nil {
writePluginError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true, "config_revision": result.Config.Revision,
"discovered_tools": result.DiscoveredTools, "discovered_schema_digest": result.SchemaDigest,
})
}
func (h *Handler) ReviewPluginRemoteMCPTools(w http.ResponseWriter, r *http.Request) {
workspaceID, actorID, installationID, ok := h.remoteMCPRequestIDs(w, r)
if !ok {
return
}
var request struct {
Tools []string `json:"tools"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeError(w, http.StatusBadRequest, "tools are required")
return
}
result, err := h.PluginService.ReviewRemoteMCPTools(r.Context(), workspaceID, installationID, actorID, chi.URLParam(r, "contributionKey"), request.Tools)
if err != nil {
writePluginError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"config_revision": result.Config.Revision, "reviewed": true,
"approved_tools": request.Tools, "schema_digest": result.SchemaDigest,
})
}
func (h *Handler) RevokePluginRemoteMCPCredential(w http.ResponseWriter, r *http.Request) {
workspaceID, actorID, installationID, ok := h.remoteMCPRequestIDs(w, r)
if !ok {
return
}
if err := h.PluginService.RevokeRemoteMCPCredential(r.Context(), workspaceID, installationID, actorID, chi.URLParam(r, "contributionKey")); err != nil {
writePluginError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ResolveTaskRemoteMCPCredential is daemon-only and intentionally returns
// write-only credential material. Management APIs and provider configuration
// never call this route. Every request re-checks the active scoped secret so
// revocation takes effect for running brokers before their next upstream call.
func (h *Handler) ResolveTaskRemoteMCPCredential(w http.ResponseWriter, r *http.Request) {
// DaemonAuth also accepts user PATs/JWTs for legacy daemon routes. This
// endpoint returns secret material, so it deliberately requires the
// workspace-scoped daemon-token path instead of that compatibility fallback.
if middleware.DaemonAuthPathFromContext(r.Context()) != middleware.DaemonAuthPathDaemonToken {
writeError(w, http.StatusForbidden, "daemon token required")
return
}
task, ok := h.requireDaemonTaskAccess(w, r, chi.URLParam(r, "taskId"))
if !ok {
return
}
// A daemon token is workspace scoped, but the credential belongs to the
// concrete machine executing this task. Do not let another daemon in the
// same workspace use its own token to resolve this task's secret.
runtime, err := h.Queries.GetAgentRuntime(r.Context(), task.RuntimeID)
if err != nil {
if !isNotFound(err) {
slog.Warn("load Remote MCP task runtime failed", "task_id", uuidToString(task.ID), "error", err)
writeError(w, http.StatusInternalServerError, "failed to load task runtime")
return
}
writeError(w, http.StatusNotFound, "task not found")
return
}
if !runtime.DaemonID.Valid || runtime.DaemonID.String != middleware.DaemonIDFromContext(r.Context()) {
writeError(w, http.StatusNotFound, "task not found")
return
}
header, credential, err := h.PluginService.ResolveTaskRemoteMCPCredential(r.Context(), task.ID, chi.URLParam(r, "contributionId"))
if err != nil {
writePluginError(w, r, err)
return
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]string{"credential_header": header, "credential": credential})
}
func (h *Handler) remoteMCPRequestIDs(w http.ResponseWriter, r *http.Request) (pgtype.UUID, pgtype.UUID, pgtype.UUID, bool) {
if !h.requirePluginsV1(w, r) {
return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, false
}
workspaceID, actorID, ok := pluginRequestIDs(w, r)
if !ok {
return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, false
}
installationID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "installationId"), "installation_id")
if !ok {
return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, false
}
if !h.requirePluginInstallation(w, r, workspaceID, installationID) {
return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, false
}
return workspaceID, actorID, installationID, true
}
func credentialState(config db.PluginInstallationConfig) string {
if config.AuthType == "none" {
return "not_required"
}
return "configured"
}
func (h *Handler) requirePluginInstallation(w http.ResponseWriter, r *http.Request, workspaceID, installationID pgtype.UUID) bool {
installation, err := h.Queries.GetPluginInstallation(r.Context(), installationID)
if err != nil || installation.WorkspaceID != workspaceID || installation.UninstalledAt.Valid {
writeError(w, http.StatusNotFound, "Plugin installation not found")
return false
}
return true
}
func writePluginError(w http.ResponseWriter, r *http.Request, err error) {
var pluginErr *service.PluginError
if !errors.As(err, &pluginErr) {
slog.Error("Plugin request failed", "method", r.Method, "path", r.URL.Path, "error", err)
writeError(w, http.StatusInternalServerError, "Plugin operation failed")
return
}
status := http.StatusInternalServerError
switch pluginErr.Kind {
case service.PluginErrorInvalid:
status = http.StatusBadRequest
case service.PluginErrorNotFound:
status = http.StatusNotFound
case service.PluginErrorConflict:
status = http.StatusConflict
case service.PluginErrorIncompatible:
status = http.StatusUnprocessableEntity
default:
slog.Error("Plugin request returned unknown classified error", "error", err)
writeError(w, http.StatusInternalServerError, "Plugin operation failed")
return
}
writeError(w, status, pluginErr.Message)
}
func pluginRequestIDs(w http.ResponseWriter, r *http.Request) (pgtype.UUID, pgtype.UUID, bool) {
workspaceID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "workspace_id")
if !ok {
return pgtype.UUID{}, pgtype.UUID{}, false
}
actorID, err := util.ParseUUID(requestUserID(r))
if err != nil {
writeError(w, http.StatusUnauthorized, "authentication required")
return pgtype.UUID{}, pgtype.UUID{}, false
}
return workspaceID, actorID, true
}