Files
1Panel/core/app/service/mcp.go
蘭 e29679768b feat: add MCP error metadata handling and improve source policy resolution (#14270)
* feat: add MCP error metadata handling and improve source policy resolution

* fix: adjust height-diff for improved table layout
2026-11-02 22:17:12 +08:00

773 lines
26 KiB
Go

package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"slices"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/1Panel-dev/1Panel/core/app/auth"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/global"
"github.com/1Panel-dev/1Panel/core/utils/encrypt"
contract "github.com/1Panel-dev/1Panel/core/utils/mcp"
"github.com/1Panel-dev/1Panel/core/utils/xpack"
"github.com/1Panel-dev/1Panel/core/utils/xpack/providers"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
var mcpMutationMu sync.Mutex
var mcpExecutionSlots = make(chan struct{}, 8)
var mcpCallWindow = struct {
sync.Mutex
entries map[string]mcpWindow
}{entries: make(map[string]mcpWindow)}
type mcpWindow struct {
start time.Time
calls int
}
func admitMCPCall(id string, now time.Time) bool {
mcpCallWindow.Lock()
defer mcpCallWindow.Unlock()
window, exists := mcpCallWindow.entries[id]
if !exists || now.Sub(window.start) >= time.Minute {
if len(mcpCallWindow.entries) >= 4096 {
for key, value := range mcpCallWindow.entries {
if now.Sub(value.start) >= time.Minute {
delete(mcpCallWindow.entries, key)
}
}
if len(mcpCallWindow.entries) >= 4096 && !exists {
return false
}
}
window = mcpWindow{start: now}
}
if window.calls >= 120 {
return false
}
window.calls++
mcpCallWindow.entries[id] = window
return true
}
type MCPManagementAuthorizer interface {
AuthorizeMCPManagement(auth.APIKeyOwner, string, []string) error
}
type MCPAPIAuthorizer interface {
AuthorizeMCPAPI(auth.APIKeyOwner, string, string, string) error
}
type MCPExecutor func(context.Context, contract.APIRequest) (any, error)
type MCPService struct {
DB *gorm.DB
Provider auth.APIKeyOwnerProvider
Authorizer MCPManagementAuthorizer
APIAuthorizer MCPAPIAuthorizer
NodeProvider providers.MCPNodeProvider
Execute MCPExecutor
Enabled func() (bool, error)
Now func() time.Time
LoadCatalog func() ([]contract.Tool, error)
SecretKey func() (string, error)
EnrollmentAudit func(*gorm.DB, *model.OperationLog) error
}
var NewMCPService = func() *MCPService {
owner, _ := xpack.AuthProvider.(auth.APIKeyOwnerProvider)
authorizer, _ := xpack.AuthProvider.(MCPManagementAuthorizer)
apiAuthorizer, _ := xpack.AuthProvider.(MCPAPIAuthorizer)
nodeProvider, _ := xpack.AuthProvider.(providers.MCPNodeProvider)
return &MCPService{DB: global.DB, Provider: owner, Authorizer: authorizer, APIAuthorizer: apiAuthorizer, NodeProvider: nodeProvider, Execute: executeMCPAPI, Now: time.Now, SecretKey: encrypt.APIKeyEncryptionKey, LoadCatalog: func() ([]contract.Tool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return NewMCPRuntimeService().Catalog(ctx)
}, Enabled: func() (bool, error) {
enabled, err := (repo.MCPRepo{DB: global.DB}).Enabled()
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return enabled, err
}}
}
func (s *MCPService) authorize(owner auth.APIKeyOwner, codes ...string) error {
if s.Authorizer == nil || s.Authorizer.AuthorizeMCPManagement(owner, "local", codes) != nil {
return contract.Forbidden()
}
return nil
}
func (s *MCPService) authorizeAPI(owner auth.APIKeyOwner, nodeID, method, path string) error {
if s.APIAuthorizer == nil || s.APIAuthorizer.AuthorizeMCPAPI(owner, nodeID, method, path) != nil {
return contract.Forbidden()
}
return nil
}
func (s *MCPService) authorizeToolOnNode(owner auth.APIKeyOwner, nodeID string, tool contract.Tool) error {
for _, op := range tool.Operations {
if err := s.authorizeAPI(owner, nodeID, op.Method, contract.PermissionPath(op)); err != nil {
return err
}
}
return nil
}
func (s *MCPService) ManagementOwner(c *gin.Context, write bool) (auth.APIKeyOwner, error) {
if c.GetHeader("Authorization") != "" {
return auth.APIKeyOwner{}, contract.Forbidden()
}
if _, err := auth.RequireAPIKeySession(c); err != nil {
return auth.APIKeyOwner{}, contract.Fail("MCP_UNAUTHENTICATED", 401, "A panel session is required")
}
if s.Provider == nil {
return auth.APIKeyOwner{}, contract.Forbidden()
}
owner, err := s.Provider.CurrentAPIKeyOwner(c)
if err != nil {
return auth.APIKeyOwner{}, contract.Forbidden()
}
code := "mcp_access_view"
if write {
code = "mcp_access_manage"
}
if err = s.authorize(owner, code); err != nil {
return auth.APIKeyOwner{}, err
}
return owner, nil
}
func validMCPPage(page, size int) bool { return page > 0 && page <= 1000000 && size > 0 && size <= 100 }
func (s *MCPService) catalog() ([]contract.Tool, error) {
unavailable := func() error {
return contract.Fail("MCP_CATALOG_UNAVAILABLE", 503, "The installed MCP tool catalog is unavailable or incompatible")
}
if s.LoadCatalog == nil {
return nil, unavailable()
}
tools, err := s.LoadCatalog()
if err != nil {
return nil, unavailable()
}
tools, err = contract.ValidateCatalog(tools)
if err != nil {
return nil, unavailable()
}
return tools, nil
}
func catalogIndex(tools []contract.Tool) map[string]contract.Tool {
index := make(map[string]contract.Tool, len(tools))
for _, tool := range tools {
index[tool.ToolID] = tool
}
return index
}
func mcpGrantAllows(grant model.MCPGrant, tool contract.Tool) bool {
return grant.ToolID == tool.ToolID && grant.PolicyHash != "" &&
contract.ToolAuthorizationAllows(grant.AuthorizationSnapshot, grant.PolicyHash, tool)
}
func mcpPolicyChanged() error {
return contract.Fail("MCP_POLICY_CHANGED", 409, "Tool API policy changed; reload the catalog and explicitly save the credential authorization again")
}
func (s *MCPService) Catalog(owner auth.APIKeyOwner) (map[string]any, error) {
catalog, err := s.catalog()
if err != nil {
return nil, err
}
nodes, err := s.Nodes(owner)
if err != nil {
return nil, err
}
tools := []contract.Tool{}
toolNodeIDs := make(map[string][]string)
nodeIDs := mcpNodeItemIDs(nodes)
for _, tool := range catalog {
allowed := []string{}
for _, nodeID := range nodeIDs {
if mcpIsNodeDiscovery(tool) || s.authorizeToolOnNode(owner, nodeID, tool) == nil {
allowed = append(allowed, nodeID)
}
}
if len(allowed) > 0 || mcpIsNodeDiscovery(tool) {
tools = append(tools, tool)
toolNodeIDs[tool.ToolID] = allowed
}
}
return map[string]any{"contractVersion": contract.ContractVersion, "scope": "nodes", "tools": tools, "toolNodeIds": toolNodeIDs}, nil
}
func (s *MCPService) validateFields(owner auth.APIKeyOwner, fields *dto.MCPCredentialFields, checkExpiry bool) ([]model.MCPGrant, error) {
fields.Name = strings.TrimSpace(fields.Name)
fields.Description = strings.TrimSpace(fields.Description)
if fields.Name == "" || utf8.RuneCountInString(fields.Name) > 64 || utf8.RuneCountInString(fields.Description) > 256 || strings.ContainsAny(fields.Name, "\x00\r\n") || len(fields.Tools) > 256 {
return nil, contract.Invalid()
}
if checkExpiry && fields.ExpiresAt != nil && !fields.ExpiresAt.After(s.Now()) {
return nil, contract.Invalid()
}
if err := s.validateNodeIDs(owner, &fields.NodeIDs); err != nil {
return nil, err
}
catalog, err := s.catalog()
if err != nil {
return nil, err
}
index := catalogIndex(catalog)
slices.Sort(fields.Tools)
fields.Tools = slices.Compact(fields.Tools)
if len(fields.ToolPolicies) != len(fields.Tools) {
return nil, mcpPolicyChanged()
}
if len(fields.ToolReadOnly) != len(fields.Tools) {
return nil, mcpPolicyChanged()
}
grants := make([]model.MCPGrant, 0, len(fields.Tools))
for _, id := range fields.Tools {
tool, ok := index[id]
if !ok || fields.ToolPolicies[id] == "" || fields.ToolPolicies[id] != tool.PolicyHash {
return nil, mcpPolicyChanged()
}
readOnly, reviewed := fields.ToolReadOnly[id]
if !reviewed || readOnly != tool.ReadOnly {
return nil, mcpPolicyChanged()
}
if err := s.authorizeToolOnNodes(owner, fields.NodeIDs, tool); err != nil {
return nil, err
}
snapshot, err := contract.ToolAuthorizationSnapshot(tool)
if err != nil {
return nil, err
}
grants = append(grants, model.MCPGrant{ToolID: id, PolicyHash: tool.PolicyHash, AuthorizationSnapshot: snapshot})
}
return grants, nil
}
func (s *MCPService) credentialItem(key model.MCPCredential, catalog map[string]contract.Tool) (dto.MCPCredentialItem, error) {
nodeIDs, err := mcpCredentialNodeIDs(key)
if err != nil {
return dto.MCPCredentialItem{}, err
}
grants, err := (repo.MCPRepo{DB: s.DB}).Grants(key.ID)
if err != nil {
return dto.MCPCredentialItem{}, err
}
tools := []string{}
policies := map[string]string{}
readOnly := map[string]bool{}
stale := []string{}
for _, grant := range grants {
tools = append(tools, grant.ToolID)
policies[grant.ToolID] = grant.PolicyHash
tool, exists := catalog[grant.ToolID]
if exists {
readOnly[grant.ToolID] = tool.ReadOnly
}
if exists && mcpGrantAllows(grant, tool) {
// Bind this compatibility decision to the catalog used to calculate
// it, while leaving the stored consent baseline unchanged.
policies[grant.ToolID] = tool.PolicyHash
} else {
stale = append(stale, grant.ToolID)
}
}
status := key.Status
if status != "Revoked" && key.ExpiresAt != nil && !s.Now().Before(*key.ExpiresAt) {
status = "Expired"
}
return dto.MCPCredentialItem{ID: key.ID, MCPCredentialFields: dto.MCPCredentialFields{Name: key.Name, Description: key.Description, ExpiresAt: key.ExpiresAt, Tools: tools, ToolPolicies: policies, ToolReadOnly: readOnly, NodeIDs: nodeIDs}, StaleTools: stale, KeyHint: key.KeyHint, Status: status, Revision: key.Revision, CreatedAt: key.CreatedAt, OwnerName: key.OwnerName}, nil
}
func (s *MCPService) Search(owner auth.APIKeyOwner, req dto.MCPCredentialSearch) (map[string]any, error) {
if !validMCPPage(req.Page, req.PageSize) {
return nil, contract.Invalid()
}
keys, total, err := (repo.MCPRepo{DB: s.DB}).SearchCredentials(owner.Type, owner.ID, req.Page, req.PageSize, req.ExcludeRevoked)
if err != nil {
return nil, err
}
items := []dto.MCPCredentialItem{}
catalog, _ := s.catalog()
index := catalogIndex(catalog)
for _, key := range keys {
item, err := s.credentialItem(key, index)
if err != nil {
return nil, err
}
items = append(items, item)
}
return map[string]any{"items": items, "total": total}, nil
}
func tokenDigest(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func (s *MCPService) Create(owner auth.APIKeyOwner, req dto.MCPCredentialCreate) (result *dto.MCPCredentialCreated, resultErr error) {
defer func() {
if resultErr != nil {
if err := s.auditEnrollment(s.DB, owner, "create", dto.MCPCredentialMutation{}, resultErr); err != nil {
result, resultErr = nil, err
}
}
}()
if _, err := uuid.Parse(req.RequestID); err != nil {
return nil, contract.Invalid()
}
grants, err := s.validateFields(owner, &req.MCPCredentialFields, true)
if err != nil {
return nil, err
}
fields, _ := json.Marshal(req.MCPCredentialFields)
requestHash := tokenDigest(string(fields))
var key model.MCPCredential
var token string
repeated := false
mcpMutationMu.Lock()
err = s.DB.Transaction(func(tx *gorm.DB) error {
store := repo.MCPRepo{DB: tx}
var err error
key, err = store.CredentialByRequest(owner.Type, owner.ID, req.RequestID)
if err == nil {
if key.RequestHash != requestHash {
return contract.Conflict("request_reused", "Creation request already used")
}
repeated = true
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
count, err := store.CountActiveName(owner.Type, owner.ID, req.Name, "")
if err != nil {
return err
}
if count > 0 {
return contract.Conflict("credential_name_exists", "Credential name already exists")
}
secret := make([]byte, 32)
if _, err = rand.Read(secret); err != nil {
return err
}
token = "mcp_" + hex.EncodeToString(secret)
key = model.MCPCredential{ID: uuid.NewString(), OwnerType: owner.Type, OwnerID: owner.ID, OwnerName: owner.Name, Name: req.Name, ActiveName: &req.Name, Description: req.Description, NodeIDs: mcpEncodeNodeIDs(req.NodeIDs), RequestID: req.RequestID, RequestHash: requestHash, TokenHash: tokenDigest(token), KeyHint: token[:8] + "…" + token[len(token)-4:], Status: "Enable", ExpiresAt: req.ExpiresAt, Revision: 1}
if err = s.sealCredentialToken(&key, token); err != nil {
return err
}
if err = store.CreateCredential(&key); err != nil {
return err
}
if err = store.ReplaceTools(key.ID, grants); err != nil {
return err
}
return s.auditEnrollment(tx, owner, "create", dto.MCPCredentialMutation{ID: key.ID, Revision: key.Revision}, nil)
})
mcpMutationMu.Unlock()
if err != nil {
return nil, err
}
catalog, _ := s.catalog()
item, err := s.credentialItem(key, catalogIndex(catalog))
if err != nil {
return nil, err
}
return &dto.MCPCredentialCreated{Item: item, Token: token, AlreadyCreated: repeated}, nil
}
func (s *MCPService) mutate(owner auth.APIKeyOwner, req dto.MCPCredentialMutation, change func(*gorm.DB, *model.MCPCredential) error) error {
if _, err := uuid.Parse(req.ID); err != nil || req.Revision == 0 {
return contract.Invalid()
}
mcpMutationMu.Lock()
defer mcpMutationMu.Unlock()
return s.DB.Transaction(func(tx *gorm.DB) error {
store := repo.MCPRepo{DB: tx}
key, err := store.Credential(owner.Type, owner.ID, req.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return contract.Forbidden()
}
return err
}
if key.Revision != req.Revision {
return contract.Conflict("credential_revision_changed", "Credential changed; refresh and retry")
}
if key.Status == "Revoked" {
return contract.Conflict("credential_revoked", "Credential was revoked")
}
if err := change(tx, &key); err != nil {
return err
}
if key.Status == "Revoked" {
key.SecretCiphertext = ""
key.SecretVersion = 0
}
key.Revision++
affected, err := store.UpdateCredential(&key, req.Revision)
if err != nil {
return err
}
if affected != 1 {
return contract.Conflict("credential_revision_changed", "Credential changed; refresh and retry")
}
return nil
})
}
func (s *MCPService) Update(owner auth.APIKeyOwner, req dto.MCPCredentialUpdate) error {
if _, err := uuid.Parse(req.ID); err != nil || req.Revision == 0 {
return contract.Invalid()
}
grants, err := s.validateFields(owner, &req.MCPCredentialFields, true)
if err != nil {
return err
}
return s.mutate(owner, req.MCPCredentialMutation, func(tx *gorm.DB, key *model.MCPCredential) error {
count, err := (repo.MCPRepo{DB: tx}).CountActiveName(owner.Type, owner.ID, req.Name, key.ID)
if err != nil {
return err
}
if count > 0 {
return contract.Conflict("credential_name_exists", "Credential name already exists")
}
key.Name = req.Name
key.ActiveName = &req.Name
key.Description = req.Description
key.NodeIDs = mcpEncodeNodeIDs(req.NodeIDs)
key.ExpiresAt = req.ExpiresAt
return (repo.MCPRepo{DB: tx}).ReplaceTools(key.ID, grants)
})
}
func (s *MCPService) Status(owner auth.APIKeyOwner, req dto.MCPCredentialStatus) error {
if req.Status != "Enable" && req.Status != "Disable" {
return contract.Invalid()
}
return s.mutate(owner, req.MCPCredentialMutation, func(tx *gorm.DB, key *model.MCPCredential) error {
if req.Status == "Enable" {
catalog, err := s.catalog()
if err != nil {
return err
}
index := catalogIndex(catalog)
grants, err := (repo.MCPRepo{DB: tx}).Grants(key.ID)
if err != nil {
return err
}
nodeIDs, err := mcpCredentialNodeIDs(*key)
if err != nil {
return err
}
fields := dto.MCPCredentialFields{Name: key.Name, Description: key.Description, ExpiresAt: key.ExpiresAt, ToolPolicies: map[string]string{}, ToolReadOnly: map[string]bool{}, NodeIDs: nodeIDs}
for _, grant := range grants {
tool, exists := index[grant.ToolID]
if !exists || !mcpGrantAllows(grant, tool) {
return mcpPolicyChanged()
}
fields.Tools = append(fields.Tools, grant.ToolID)
fields.ToolPolicies[grant.ToolID] = tool.PolicyHash
fields.ToolReadOnly[grant.ToolID] = tool.ReadOnly
}
if _, err = s.validateFields(owner, &fields, true); err != nil {
return err
}
}
key.Status = req.Status
return nil
})
}
func (s *MCPService) Revoke(owner auth.APIKeyOwner, req dto.MCPCredentialMutation) error {
return s.mutate(owner, req, func(tx *gorm.DB, key *model.MCPCredential) error {
now := s.Now()
key.Status = "Revoked"
key.RevokedAt = &now
key.ActiveName = nil
return (repo.MCPRepo{DB: tx}).ReplaceTools(key.ID, nil)
})
}
func (s *MCPService) Authenticate(c *gin.Context, token string) (model.MCPCredential, auth.APIKeyOwner, error) {
var key model.MCPCredential
var owner auth.APIKeyOwner
if len(token) != 68 || !strings.HasPrefix(token, "mcp_") {
return key, owner, contract.Fail("MCP_UNAUTHENTICATED", 401, "Invalid MCP credential")
}
if _, err := hex.DecodeString(token[4:]); err != nil {
return key, owner, contract.Fail("MCP_UNAUTHENTICATED", 401, "Invalid MCP credential")
}
var err error
key, err = (repo.MCPRepo{DB: s.DB}).Token(tokenDigest(token))
if err != nil {
return key, owner, contract.Fail("MCP_UNAUTHENTICATED", 401, "Invalid MCP credential")
}
if key.Status != "Enable" || key.ExpiresAt != nil && !s.Now().Before(*key.ExpiresAt) {
return key, owner, contract.Fail("MCP_UNAUTHENTICATED", 401, "MCP credential is inactive or expired")
}
if s.Provider == nil {
return key, owner, contract.Forbidden()
}
owner, err = s.Provider.ResolveAPIKeyOwner(c, key.OwnerType, key.OwnerID)
if err != nil {
return key, owner, contract.Forbidden()
}
if s.Enabled == nil {
return key, owner, contract.Fail("MCP_DISABLED", 503, "MCP access is disabled")
}
enabled, err := s.Enabled()
if err != nil || !enabled {
return key, owner, contract.Fail("MCP_DISABLED", 503, "MCP access is disabled")
}
return key, owner, nil
}
func (s *MCPService) RuntimeTools(c *gin.Context, token string) (map[string]any, error) {
key, _, err := s.Authenticate(c, token)
if err != nil {
return nil, err
}
catalog, err := s.catalog()
if err != nil {
return nil, err
}
grants, err := (repo.MCPRepo{DB: s.DB}).Grants(key.ID)
if err != nil {
return nil, err
}
policies := make(map[string]model.MCPGrant, len(grants))
for _, grant := range grants {
policies[grant.ToolID] = grant
}
latest, owner, err := s.Authenticate(c, token)
if err != nil {
return nil, err
}
if latest.Revision != key.Revision {
return nil, contract.Forbidden()
}
nodes, err := s.credentialNodes(latest, owner)
if err != nil {
return nil, err
}
tools := []contract.Tool{}
for _, tool := range catalog {
if grant, exists := policies[tool.ToolID]; exists && mcpGrantAllows(grant, tool) && s.authorizeToolOnNodes(owner, mcpNodeItemIDs(nodes), tool) == nil {
tools = append(tools, tool)
}
}
return map[string]any{"contractVersion": contract.ContractVersion, "tools": tools}, nil
}
func (s *MCPService) authorizeInvocation(key model.MCPCredential, owner auth.APIKeyOwner, req contract.Invoke) (contract.Tool, error) {
catalog, err := s.catalog()
if err != nil {
return contract.Tool{}, err
}
tool, ok := catalogIndex(catalog)[req.ToolID]
if !ok {
return contract.Tool{}, contract.Fail("MCP_UNKNOWN_TOOL", 400, "Unknown tool")
}
if req.SchemaVersion != tool.SchemaVersion || req.PolicyHash == "" || req.PolicyHash != tool.PolicyHash {
return contract.Tool{}, mcpPolicyChanged()
}
if !slices.ContainsFunc(tool.Operations, func(op contract.Operation) bool { return op.ID == req.OperationID }) {
return contract.Tool{}, contract.Forbidden()
}
grants, err := (repo.MCPRepo{DB: s.DB}).Grants(key.ID)
if err != nil {
return contract.Tool{}, err
}
granted := false
for _, grant := range grants {
if grant.ToolID != tool.ToolID {
continue
}
if !mcpGrantAllows(grant, tool) {
return contract.Tool{}, mcpPolicyChanged()
}
granted = true
break
}
if !granted {
return contract.Tool{}, contract.Forbidden()
}
if err := s.authorizeInvocationNode(key, owner, req.NodeID, tool); err != nil {
return contract.Tool{}, err
}
return tool, nil
}
func (s *MCPService) Invoke(c *gin.Context, token string, req contract.Invoke) (*contract.InvocationResult, error) {
started := s.Now()
nodeID, nodeErr := contract.NormalizeNodeID(req.NodeID)
if nodeErr == nil {
req.NodeID = nodeID
}
done, err := useMCPAdmission(token, req)
if err != nil {
return nil, err
}
defer done()
record := model.MCPInvocation{ID: uuid.NewString(), ToolID: safeMCPAuditID(req.ToolID), RequestID: safeMCPAuditID(req.RequestID), OperationID: safeMCPAuditID(req.OperationID), PolicyHash: safeMCPPolicyHash(req.PolicyHash), NodeID: safeMCPAuditID(req.NodeID), StartedAt: started, Status: "denied"}
key, owner, callErr := s.Authenticate(c, token)
if key.ID == "" {
return nil, callErr
}
if key.ID != "" {
record.CredentialID = key.ID
record.CredentialName = key.Name
record.OwnerType = key.OwnerType
record.OwnerID = key.OwnerID
record.OwnerName = key.OwnerName
record.Revision = key.Revision
}
record.Status = "running"
if err := (repo.MCPRepo{DB: s.DB}).CreateInvocation(&record); err != nil {
return nil, contract.Fail("MCP_EXECUTION_FAILED", 503, "Invocation audit is unavailable")
}
record.Status = "denied"
finish := func(err error, result any) (*contract.InvocationResult, error) {
now := s.Now()
record.FinishedAt = &now
record.DurationMs = now.Sub(started).Milliseconds()
if err == nil {
record.Status = "success"
} else {
var e *contract.Error
if !errors.As(err, &e) {
e = contract.Fail("MCP_EXECUTION_FAILED", 502, "Tool execution failed; inspect the panel invocation record before retrying")
}
record.Code = e.Code
e.InvocationID = record.ID
err = e
}
if auditErr := s.finishInvocationAudit(record); auditErr != nil {
return nil, &contract.Error{Code: "MCP_EXECUTION_FAILED", Status: 503, Message: "Invocation outcome could not be recorded; do not retry a write automatically", InvocationID: record.ID}
}
if err != nil {
return nil, err
}
return &contract.InvocationResult{InvocationID: record.ID, NodeID: req.NodeID, Result: result}, nil
}
if callErr != nil {
return finish(callErr, nil)
}
if nodeErr != nil {
return finish(contract.Invalid(), nil)
}
tool, err := s.authorizeInvocation(key, owner, req)
if err != nil {
return finish(err, nil)
}
operation, apiPath, arguments, err := contract.NormalizeOperation(tool, req)
if err != nil {
return finish(err, nil)
}
latest, latestOwner, err := s.Authenticate(c, token)
if err != nil {
return finish(err, nil)
}
if latest.Revision != key.Revision {
return finish(contract.Forbidden(), nil)
}
currentTool, err := s.authorizeInvocation(latest, latestOwner, req)
if err != nil {
return finish(err, nil)
}
latest, latestOwner, err = s.Authenticate(c, token)
if err != nil {
return finish(err, nil)
}
if latest.Revision != key.Revision || s.authorizeInvocationNode(latest, latestOwner, req.NodeID, currentTool) != nil {
return finish(contract.Forbidden(), nil)
}
discovery := mcpIsNodeDiscovery(currentTool)
if !discovery && s.authorizeAPI(latestOwner, req.NodeID, operation.Method, apiPath) != nil {
return finish(contract.Forbidden(), nil)
}
if !discovery && s.Execute == nil {
return finish(contract.Fail("MCP_NODE_UNAVAILABLE", 503, "Selected node is unavailable"), nil)
}
requestContext := c.Request.Context()
if requestContext.Err() != nil {
return finish(contract.Fail("MCP_EXECUTION_FAILED", 499, "Request ended before execution"), nil)
}
if operation.Method != http.MethodGet {
requestContext = context.WithoutCancel(requestContext)
}
ctx, cancel := context.WithTimeout(requestContext, 330*time.Second)
defer cancel()
enabled, enabledErr := s.Enabled()
if enabledErr != nil || !enabled {
return finish(contract.Fail("MCP_DISABLED", 503, "MCP access is disabled"), nil)
}
admitted, admissionErr := (repo.MCPRepo{DB: s.DB}).InvocationCredential(key.ID, key.Revision)
if errors.Is(admissionErr, gorm.ErrRecordNotFound) {
return finish(contract.Forbidden(), nil)
}
if admissionErr != nil {
return finish(contract.Fail("MCP_EXECUTION_FAILED", 503, "Credential admission is unavailable"), nil)
}
if admitted.ExpiresAt != nil && !s.Now().Before(*admitted.ExpiresAt) {
return finish(contract.Forbidden(), nil)
}
record.Status = "failed"
if discovery {
nodes, err := s.credentialNodes(latest, latestOwner)
if err != nil {
return finish(err, nil)
}
return finish(nil, map[string]any{"items": nodes})
}
result, err := s.Execute(ctx, contract.APIRequest{InvocationID: record.ID, NodeID: req.NodeID, Owner: latestOwner, Method: operation.Method, Path: apiPath, Arguments: arguments, ResponseFields: operation.ResponseFields, ReadOnly: operation.Method == http.MethodGet})
return finish(err, result)
}
func safeMCPPolicyHash(value string) string {
data, err := hex.DecodeString(value)
if err != nil || len(data) != sha256.Size {
return ""
}
return strings.ToLower(value)
}
func safeMCPAuditID(value string) string {
if len(value) > 128 {
return "invalid"
}
for _, r := range value {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("_.:-", r)) {
return "invalid"
}
}
return value
}
func (s *MCPService) Invocations(owner auth.APIKeyOwner, req dto.MCPInvocationSearch) (map[string]any, error) {
if !validMCPPage(req.Page, req.PageSize) {
return nil, contract.Invalid()
}
if req.Status != "" {
if req.Status != "success" && req.Status != "denied" && req.Status != "failed" && req.Status != "running" {
return nil, contract.Invalid()
}
}
items, total, err := (repo.MCPRepo{DB: s.DB}).SearchInvocations(owner.Type, owner.ID, req.Page, req.PageSize, req.CredentialID, req.ToolID, req.Status)
if err != nil {
return nil, err
}
return map[string]any{"items": items, "total": total}, nil
}
func MCPBearer(c *gin.Context) (string, error) {
if auth.HasAPICredentials(c) || c.GetHeader("CurrentNode") != "" || c.GetHeader("X-Panel-User") != "" || c.Request.URL.RawQuery != "" {
return "", contract.Invalid()
}
values := c.Request.Header.Values("Authorization")
if len(values) != 1 {
return "", contract.Fail("MCP_UNAUTHENTICATED", http.StatusUnauthorized, "MCP Bearer credential required")
}
parts := strings.Fields(values[0])
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return "", contract.Fail("MCP_UNAUTHENTICATED", 401, "MCP Bearer credential required")
}
return parts[1], nil
}