setCredentialBindingChoices((current) => ({ ...current, [kind]: secretID }))}
+ />
) : (
{t("agents.detail.capabilities.enableDialog.noCredential")}
@@ -573,7 +687,12 @@ export function AgentConfigTab({
const agentCapabilitiesQ = useAgentCapabilitiesQuery(workspaceID, agent.id)
const workspaceCapabilitiesQ = useCapabilitiesQuery(workspaceID)
const credentialsQ = useMyCredentials()
+ const secretsQ = useSecrets(workspaceID)
const credentials = credentialsQ.data?.credentials ?? []
+ const sharedSecrets = useMemo(
+ () => (secretsQ.data?.secrets ?? []).filter((secret) => secret.kind === "capability_inline" && secret.status === "active"),
+ [secretsQ.data?.secrets],
+ )
const installedCapabilities = agentCapabilitiesQ.data?.installed ?? []
const availableCapabilities = agentCapabilitiesQ.data?.available ?? workspaceCapabilitiesQ.data?.capabilities ?? []
const installedIDs = new Set(installedCapabilities.map((item) => item.capability_id))
@@ -606,6 +725,7 @@ export function AgentConfigTab({
enabledCaps={enabledCaps}
installable={installable}
credentials={credentials}
+ sharedSecrets={sharedSecrets}
loading={agentCapabilitiesQ.isLoading || workspaceCapabilitiesQ.isLoading}
error={agentCapabilitiesQ.error ?? workspaceCapabilitiesQ.error}
onToast={onToast}
@@ -621,6 +741,7 @@ function ConfigCapabilitiesSection({
enabledCaps,
installable,
credentials,
+ sharedSecrets,
loading,
error,
onToast,
@@ -631,6 +752,7 @@ function ConfigCapabilitiesSection({
enabledCaps: Array<{ binding: AgentCapability; capability?: Capability }>
installable: Capability[]
credentials: UserCredential[]
+ sharedSecrets: Secret[]
loading: boolean
error: unknown
onToast: (message: string) => void
@@ -700,6 +822,7 @@ function ConfigCapabilitiesSection({
agent={agent}
workspaceID={workspaceID}
credentials={credentials}
+ sharedSecrets={sharedSecrets}
mode="enabled"
onToast={onToast}
/>
@@ -715,6 +838,7 @@ function ConfigCapabilitiesSection({
workspaceID={workspaceID}
installable={installable}
credentials={credentials}
+ sharedSecrets={sharedSecrets}
onToast={onToast}
/>
@@ -728,6 +852,7 @@ function AddCapabilityDialog({
workspaceID,
installable,
credentials,
+ sharedSecrets,
onToast,
}: {
open: boolean
@@ -736,6 +861,7 @@ function AddCapabilityDialog({
workspaceID: string | null
installable: Capability[]
credentials: UserCredential[]
+ sharedSecrets: Secret[]
onToast: (message: string) => void
}) {
const { t } = useTranslation("admin")
@@ -777,6 +903,7 @@ function AddCapabilityDialog({
agent={agent}
workspaceID={workspaceID}
credentials={credentials}
+ sharedSecrets={sharedSecrets}
mode="available"
onToast={(msg) => {
onToast(msg)
diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml
index edd898c..1a27a47 100644
--- a/docs/openapi/openapi.yaml
+++ b/docs/openapi/openapi.yaml
@@ -4860,6 +4860,12 @@ paths:
additionalProperties:
type: string
type: object
+ "422":
+ description: Target version credential requirements are not satisfied
+ schema:
+ additionalProperties:
+ type: string
+ type: object
"503":
description: Database-backed capability APIs are disabled
schema:
diff --git a/server/internal/capability/credentialbinding/binding.go b/server/internal/capability/credentialbinding/binding.go
new file mode 100644
index 0000000..1fe29a3
--- /dev/null
+++ b/server/internal/capability/credentialbinding/binding.go
@@ -0,0 +1,99 @@
+package credentialbinding
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+)
+
+type Source string
+
+const (
+ SourcePersonal Source = "personal"
+ SourceShared Source = "shared"
+)
+
+type Binding struct {
+ Source Source
+ SecretID string
+}
+
+func (b Binding) IsShared() bool {
+ return b.Source == SourceShared && strings.TrimSpace(b.SecretID) != ""
+}
+
+// ParseStrict validates the complete credential_bindings payload for API
+// writes. Malformed entries are rejected instead of silently falling back.
+func ParseStrict(config map[string]any) (map[string]Binding, error) {
+ return parse(config, true)
+}
+
+// ParseLenient reads persisted bindings for runtime use. Invalid legacy rows
+// are ignored so one malformed entry does not prevent an agent from starting.
+func ParseLenient(config map[string]any) map[string]Binding {
+ bindings, _ := parse(config, false)
+ return bindings
+}
+
+func MergeLenient(target map[string]Binding, config map[string]any) {
+ for kind, binding := range ParseLenient(config) {
+ target[kind] = binding
+ }
+}
+
+func parse(config map[string]any, strict bool) (map[string]Binding, error) {
+ result := map[string]Binding{}
+ if len(config) == 0 {
+ return result, nil
+ }
+ raw, exists := config["credential_bindings"]
+ if !exists || raw == nil {
+ return result, nil
+ }
+ bindings, ok := raw.(map[string]any)
+ if !ok {
+ if strict {
+ return nil, errors.New("credential_bindings must be an object")
+ }
+ return result, nil
+ }
+ for rawKind, rawBinding := range bindings {
+ kind := strings.TrimSpace(rawKind)
+ binding, ok := rawBinding.(map[string]any)
+ if kind == "" || !ok {
+ if strict {
+ return nil, errors.New("credential_bindings entries must be non-empty objects")
+ }
+ continue
+ }
+ source := Source(strings.TrimSpace(stringValue(binding["source"])))
+ secretID := strings.TrimSpace(stringValue(binding["secret_id"]))
+ switch source {
+ case SourcePersonal:
+ result[kind] = Binding{Source: SourcePersonal}
+ case SourceShared:
+ if secretID == "" {
+ if strict {
+ return nil, fmt.Errorf("credential_bindings[%s].secret_id is required for shared source", kind)
+ }
+ continue
+ }
+ result[kind] = Binding{Source: SourceShared, SecretID: secretID}
+ case "":
+ if strict {
+ return nil, fmt.Errorf("credential_bindings[%s].source must be personal or shared", kind)
+ }
+ result[kind] = Binding{Source: SourcePersonal}
+ default:
+ if strict {
+ return nil, fmt.Errorf("credential_bindings[%s].source must be personal or shared", kind)
+ }
+ }
+ }
+ return result, nil
+}
+
+func stringValue(value any) string {
+ valueString, _ := value.(string)
+ return valueString
+}
diff --git a/server/internal/capability/credentialbinding/binding_test.go b/server/internal/capability/credentialbinding/binding_test.go
new file mode 100644
index 0000000..53300e6
--- /dev/null
+++ b/server/internal/capability/credentialbinding/binding_test.go
@@ -0,0 +1,47 @@
+package credentialbinding
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestParseStrict(t *testing.T) {
+ bindings, err := ParseStrict(map[string]any{
+ "credential_bindings": map[string]any{
+ "github_pat": map[string]any{"source": "personal"},
+ "mcp_oauth": map[string]any{"source": "shared", "secret_id": "secret-1"},
+ },
+ })
+ if err != nil {
+ t.Fatalf("ParseStrict returned error: %v", err)
+ }
+ if bindings["github_pat"].Source != SourcePersonal {
+ t.Fatalf("personal binding = %#v", bindings["github_pat"])
+ }
+ if !bindings["mcp_oauth"].IsShared() || bindings["mcp_oauth"].SecretID != "secret-1" {
+ t.Fatalf("shared binding = %#v", bindings["mcp_oauth"])
+ }
+
+ _, err = ParseStrict(map[string]any{
+ "credential_bindings": map[string]any{
+ "mcp_oauth": map[string]any{"source": "shared"},
+ },
+ })
+ if err == nil || !strings.Contains(err.Error(), "secret_id") {
+ t.Fatalf("missing secret_id error = %v", err)
+ }
+}
+
+func TestParseLenientDropsMalformedBindings(t *testing.T) {
+ bindings := ParseLenient(map[string]any{
+ "credential_bindings": map[string]any{
+ "valid": map[string]any{"source": "shared", "secret_id": "secret-1"},
+ "missing": map[string]any{"source": "shared"},
+ "unknown": map[string]any{"source": "other"},
+ "legacy": map[string]any{},
+ },
+ })
+ if len(bindings) != 2 || !bindings["valid"].IsShared() || bindings["legacy"].Source != SourcePersonal {
+ t.Fatalf("bindings = %#v", bindings)
+ }
+}
diff --git a/server/internal/connector/agentdaemon/capability_runtime.go b/server/internal/connector/agentdaemon/capability_runtime.go
index a56d9de..f022b63 100644
--- a/server/internal/connector/agentdaemon/capability_runtime.go
+++ b/server/internal/connector/agentdaemon/capability_runtime.go
@@ -583,6 +583,10 @@ func (c *Connector) resolveMCPCapability(
// table lookup) or, by default, per-user user_credentials keyed by
// the conversation initiator.
bindings := ParseCredentialBindings(in.AgentConfig)
+ // A binding configured on this Agent-Capability pair overrides the
+ // legacy agent-wide default, allowing two OAuth connectors to use
+ // different workspace Secrets even though both use mcp_oauth.
+ mergeBindings(bindings, cap.Configuration)
credentialValues, sharedSecretIDs, missing, err := c.resolveCredentialValues(ctx, in, cap, credentialCache, bindings)
if err != nil {
return nil, nil, nil, err
diff --git a/server/internal/connector/agentdaemon/capability_runtime_test.go b/server/internal/connector/agentdaemon/capability_runtime_test.go
index 25f6f7e..7c5ed43 100644
--- a/server/internal/connector/agentdaemon/capability_runtime_test.go
+++ b/server/internal/connector/agentdaemon/capability_runtime_test.go
@@ -10,6 +10,7 @@ import (
"time"
"github.com/MiniMax-AI-Dev/parsar/server/internal/auth/mcpoauth"
+ "github.com/MiniMax-AI-Dev/parsar/server/internal/capability"
"github.com/MiniMax-AI-Dev/parsar/server/internal/capability/canonical"
"github.com/MiniMax-AI-Dev/parsar/server/internal/connector"
"github.com/MiniMax-AI-Dev/parsar/server/internal/secrets"
@@ -468,10 +469,18 @@ func TestResolveCapabilityAdditions_UsesWorkspaceOAuthHeader(t *testing.T) {
"Authorization": {
Mode: canonical.EnvModeCredentialRef,
Prefix: "Bearer ",
- CredentialKindCode: "notion_integration",
+ CredentialKindCode: capability.CredentialKindMCPOAuth,
},
},
- }}, []store.RequiredCredential{{Kind: "notion_integration", Required: true}})
+ }}, []store.RequiredCredential{{Kind: capability.CredentialKindMCPOAuth, Required: true}})
+ row.Configuration = map[string]any{
+ "credential_bindings": map[string]any{
+ capability.CredentialKindMCPOAuth: map[string]any{
+ "source": "shared",
+ "secret_id": "secret-1",
+ },
+ },
+ }
c := &Connector{
capabilities: stubCapabilityStore{rows: []store.EnabledCapabilityRead{row}},
modelResolver: &fakeModelResolver{secret: store.SecretPayload{
@@ -483,14 +492,6 @@ func TestResolveCapabilityAdditions_UsesWorkspaceOAuthHeader(t *testing.T) {
}
in := defaultPromptInput()
in.ConversationInitiatorID = ""
- in.AgentConfig = map[string]any{
- "credential_bindings": map[string]any{
- "notion_integration": map[string]any{
- "source": "shared",
- "secret_id": "secret-1",
- },
- },
- }
got, err := c.resolveCapabilityAdditions(context.Background(), in, "claude_code")
if err != nil {
t.Fatalf("resolveCapabilityAdditions: %v", err)
@@ -501,6 +502,77 @@ func TestResolveCapabilityAdditions_UsesWorkspaceOAuthHeader(t *testing.T) {
}
}
+func TestResolveCapabilityAdditions_UsesCapabilityScopedOAuthBindings(t *testing.T) {
+ svc := testSecretsService(t)
+ newCredential := func(token, resource string) mcpoauth.Credential {
+ return mcpoauth.Credential{
+ AccessToken: token,
+ ClientID: "client-1",
+ TokenEndpointAuthMethod: "none",
+ TokenEndpoint: resource + "/token",
+ Resource: resource,
+ }
+ }
+ newRow := func(id, name, serverName, url, secretID string) store.EnabledCapabilityRead {
+ row := newMCPRow(t, id, name, []canonical.MCPServer{{
+ Name: serverName,
+ Transport: canonical.MCPTransportStreamableHTTP,
+ URL: url,
+ Headers: map[string]canonical.EnvValue{
+ "Authorization": {
+ Mode: canonical.EnvModeCredentialRef,
+ Prefix: "Bearer ",
+ CredentialKindCode: capability.CredentialKindMCPOAuth,
+ },
+ },
+ }}, []store.RequiredCredential{{Kind: capability.CredentialKindMCPOAuth, Required: true}})
+ row.Configuration = map[string]any{
+ "credential_bindings": map[string]any{
+ capability.CredentialKindMCPOAuth: map[string]any{
+ "source": "shared",
+ "secret_id": secretID,
+ },
+ },
+ }
+ return row
+ }
+ notionURL := "https://mcp.notion.com/mcp"
+ githubURL := "https://api.githubcopilot.com/mcp"
+ resolver := &fakeModelResolver{secrets: map[string]store.SecretPayload{
+ "secret-notion": {
+ SecretRead: store.SecretRead{ID: "secret-notion", Status: "active"},
+ EncryptedPayload: encryptPayload(t, svc, newCredential("notion-token", notionURL).Payload()),
+ },
+ "secret-github": {
+ SecretRead: store.SecretRead{ID: "secret-github", Status: "active"},
+ EncryptedPayload: encryptPayload(t, svc, newCredential("github-token", githubURL).Payload()),
+ },
+ }}
+ c := &Connector{
+ capabilities: stubCapabilityStore{rows: []store.EnabledCapabilityRead{
+ newRow("mcp-notion", "Notion", "notion", notionURL, "secret-notion"),
+ newRow("mcp-github", "GitHub", "github", githubURL, "secret-github"),
+ }},
+ modelResolver: resolver,
+ secrets: svc,
+ log: discardLogger(),
+ }
+ in := defaultPromptInput()
+ in.ConversationInitiatorID = ""
+ got, err := c.resolveCapabilityAdditions(context.Background(), in, "claude_code")
+ if err != nil {
+ t.Fatalf("resolveCapabilityAdditions: %v", err)
+ }
+ notionHeaders := got.MCPServers["notion"].(map[string]any)["headers"].(map[string]string)
+ githubHeaders := got.MCPServers["github"].(map[string]any)["headers"].(map[string]string)
+ if notionHeaders["Authorization"] != "Bearer notion-token" {
+ t.Fatalf("notion Authorization = %q", notionHeaders["Authorization"])
+ }
+ if githubHeaders["Authorization"] != "Bearer github-token" {
+ t.Fatalf("github Authorization = %q", githubHeaders["Authorization"])
+ }
+}
+
func TestResolveCapabilityAdditions_MCPWithCredential(t *testing.T) {
svc := testSecretsService(t)
ciphertext := encryptPayload(t, svc, map[string]any{"token": "ghp_realtoken123"})
diff --git a/server/internal/connector/agentdaemon/credential_binding.go b/server/internal/connector/agentdaemon/credential_binding.go
index 151904d..675907c 100644
--- a/server/internal/connector/agentdaemon/credential_binding.go
+++ b/server/internal/connector/agentdaemon/credential_binding.go
@@ -2,6 +2,8 @@ package agentdaemon
import (
"strings"
+
+ "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/credentialbinding"
)
// CredentialBindingSource discriminates how an agent-level credential
@@ -13,26 +15,17 @@ import (
// SecretID; the same plaintext is served to every caller.
//
// Bindings are read from agent_config.credential_bindings[].
-type CredentialBindingSource string
+type CredentialBindingSource = credentialbinding.Source
const (
- CredentialBindingPersonal CredentialBindingSource = "personal"
- CredentialBindingShared CredentialBindingSource = "shared"
+ CredentialBindingPersonal = credentialbinding.SourcePersonal
+ CredentialBindingShared = credentialbinding.SourceShared
)
// CredentialBinding is the parsed agent-level binding for one credential
// kind. Source=="" is treated as personal (back-compat with agents created
// before credential_bindings existed).
-type CredentialBinding struct {
- Source CredentialBindingSource
- SecretID string
-}
-
-// IsShared returns true when this binding should bypass user_credentials
-// lookup and serve a workspace secret instead.
-func (b CredentialBinding) IsShared() bool {
- return b.Source == CredentialBindingShared && strings.TrimSpace(b.SecretID) != ""
-}
+type CredentialBinding = credentialbinding.Binding
// ParseCredentialBindings extracts the credential_bindings map from the
// agent_config.
@@ -41,47 +34,11 @@ func (b CredentialBinding) IsShared() bool {
// dropped silently to avoid hard-failing a run on a malformed config;
// callers fall back to personal in that case.
func ParseCredentialBindings(agentConfig map[string]any) map[string]CredentialBinding {
- out := map[string]CredentialBinding{}
- mergeBindings(out, agentConfig)
- return out
+ return credentialbinding.ParseLenient(agentConfig)
}
func mergeBindings(out map[string]CredentialBinding, cfg map[string]any) {
- if len(cfg) == 0 {
- return
- }
- raw, ok := cfg["credential_bindings"]
- if !ok {
- return
- }
- m, ok := raw.(map[string]any)
- if !ok {
- return
- }
- for kind, entry := range m {
- kind = strings.TrimSpace(kind)
- if kind == "" {
- continue
- }
- obj, ok := entry.(map[string]any)
- if !ok {
- continue
- }
- source, _ := obj["source"].(string)
- secretID, _ := obj["secret_id"].(string)
- switch CredentialBindingSource(strings.TrimSpace(source)) {
- case CredentialBindingShared:
- if strings.TrimSpace(secretID) == "" {
- continue
- }
- out[kind] = CredentialBinding{
- Source: CredentialBindingShared,
- SecretID: strings.TrimSpace(secretID),
- }
- case CredentialBindingPersonal, "":
- out[kind] = CredentialBinding{Source: CredentialBindingPersonal}
- }
- }
+ credentialbinding.MergeLenient(out, cfg)
}
// ParseModelCredentialBinding extracts the optional model_credential_binding
diff --git a/server/internal/connector/agentdaemon/model_injection_test.go b/server/internal/connector/agentdaemon/model_injection_test.go
index 499bcf3..4e4520b 100644
--- a/server/internal/connector/agentdaemon/model_injection_test.go
+++ b/server/internal/connector/agentdaemon/model_injection_test.go
@@ -18,6 +18,7 @@ import (
type fakeModelResolver struct {
runtime store.ModelRuntime
secret store.SecretPayload
+ secrets map[string]store.SecretPayload
modelErr error
secretErr error
@@ -60,10 +61,13 @@ func (f *fakeModelResolver) ResolveModelRuntimeForUser(_ context.Context, _, _ s
return f.runtime, nil
}
-func (f *fakeModelResolver) GetSecretPayload(_ context.Context, _, _ string) (store.SecretPayload, error) {
+func (f *fakeModelResolver) GetSecretPayload(_ context.Context, _, secretID string) (store.SecretPayload, error) {
if f.secretErr != nil {
return store.SecretPayload{}, f.secretErr
}
+ if secret, ok := f.secrets[secretID]; ok {
+ return secret, nil
+ }
return f.secret, nil
}
diff --git a/server/internal/dev/agent_credential_binding.go b/server/internal/dev/agent_credential_binding.go
index 70e143a..4f5341f 100644
--- a/server/internal/dev/agent_credential_binding.go
+++ b/server/internal/dev/agent_credential_binding.go
@@ -7,6 +7,7 @@ import (
"os"
"strings"
+ "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/credentialbinding"
"github.com/MiniMax-AI-Dev/parsar/server/internal/secrets"
"github.com/MiniMax-AI-Dev/parsar/server/internal/store"
)
@@ -116,27 +117,13 @@ func validateAgentVisibilityBindings(visibility string, cfg map[string]any) erro
if strings.TrimSpace(visibility) != agentVisibilityPublic {
return nil
}
- // Per-capability credential bindings.
- if raw, ok := cfg["credential_bindings"]; ok {
- if raw != nil {
- bindings, ok := raw.(map[string]any)
- if !ok {
- return errors.New("credential_bindings must be an object")
- }
- for kind, entry := range bindings {
- obj, ok := entry.(map[string]any)
- if !ok {
- return fmt.Errorf("credential_bindings[%s] must be an object", kind)
- }
- source, _ := obj["source"].(string)
- if strings.TrimSpace(source) != "shared" {
- return fmt.Errorf("public agents cannot use personal credentials (credential_bindings[%s].source=%q)", kind, source)
- }
- secretID, _ := obj["secret_id"].(string)
- if strings.TrimSpace(secretID) == "" {
- return fmt.Errorf("credential_bindings[%s].secret_id is required for shared source", kind)
- }
- }
+ bindings, err := credentialbinding.ParseStrict(cfg)
+ if err != nil {
+ return err
+ }
+ for kind, binding := range bindings {
+ if !binding.IsShared() {
+ return fmt.Errorf("public agents cannot use personal credentials (credential_bindings[%s].source=%q)", kind, binding.Source)
}
}
// Optional model-level binding.
diff --git a/server/internal/dev/capability_credential_binding.go b/server/internal/dev/capability_credential_binding.go
new file mode 100644
index 0000000..83daee1
--- /dev/null
+++ b/server/internal/dev/capability_credential_binding.go
@@ -0,0 +1,164 @@
+package dev
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/MiniMax-AI-Dev/parsar/server/internal/capability"
+ "github.com/MiniMax-AI-Dev/parsar/server/internal/capability/credentialbinding"
+ "github.com/MiniMax-AI-Dev/parsar/server/internal/store"
+)
+
+type credentialBindingSecretStore interface {
+ GetSecretPayload(ctx context.Context, workspaceID string, secretID string) (store.SecretPayload, error)
+}
+
+type agentCapabilityCredentialBindingStore interface {
+ credentialBindingSecretStore
+ ListAgentCapabilities(ctx context.Context, agentID string) ([]store.AgentCapabilityRead, error)
+ GetCapabilityVersion(ctx context.Context, capabilityVersionID string) (store.CapabilityVersionRead, error)
+}
+
+type capabilityCredentialValidationError struct {
+ err error
+}
+
+func (e *capabilityCredentialValidationError) Error() string {
+ return e.err.Error()
+}
+
+type capabilityCredentialBindingValidationInput struct {
+ WorkspaceID string
+ AgentVisibility string
+ AgentConfig map[string]any
+ Version store.CapabilityVersionRead
+ Configuration map[string]any
+}
+
+func validateCapabilityCredentialBindings(
+ ctx context.Context,
+ secretStore credentialBindingSecretStore,
+ input capabilityCredentialBindingValidationInput,
+) error {
+ bindings, err := credentialbinding.ParseStrict(input.Configuration)
+ if err != nil {
+ return fmt.Errorf("configuration.%w", err)
+ }
+ requiredKinds := make(map[string]bool, len(input.Version.RequiredCredentials))
+ for _, required := range input.Version.RequiredCredentials {
+ kind := strings.TrimSpace(required.Kind)
+ if required.Required && kind != "" {
+ requiredKinds[kind] = true
+ }
+ }
+ for kind := range bindings {
+ if !requiredKinds[kind] {
+ return errors.New("credential binding kind is not required by this capability")
+ }
+ }
+
+ agentBindings := credentialbinding.ParseLenient(input.AgentConfig)
+ for kind := range requiredKinds {
+ binding, configured := bindings[kind]
+ if !configured {
+ binding, configured = agentBindings[kind]
+ }
+ if !configured || binding.Source == credentialbinding.SourcePersonal {
+ if strings.TrimSpace(input.AgentVisibility) == agentVisibilityPublic {
+ return errors.New("public agents require a shared secret for every capability credential")
+ }
+ continue
+ }
+ if err := validateSharedCapabilityCredential(ctx, secretStore, input.WorkspaceID, kind, binding.SecretID, input.Version.SourcePayload); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func validateAgentCapabilityBindingsForVisibility(
+ ctx context.Context,
+ credentialStore agentCapabilityCredentialBindingStore,
+ agent store.AgentSummary,
+ visibility string,
+) error {
+ if err := validateAgentVisibilityBindings(visibility, agent.Config); err != nil {
+ return &capabilityCredentialValidationError{err: err}
+ }
+ if strings.TrimSpace(visibility) != agentVisibilityPublic {
+ return nil
+ }
+ bindings, err := credentialStore.ListAgentCapabilities(ctx, agent.ID)
+ if err != nil {
+ return fmt.Errorf("list agent capabilities: %w", err)
+ }
+ for _, binding := range bindings {
+ if !binding.Enabled {
+ continue
+ }
+ version, err := credentialStore.GetCapabilityVersion(ctx, binding.CapabilityVersionID)
+ if err != nil {
+ return fmt.Errorf("get capability version %s: %w", binding.CapabilityVersionID, err)
+ }
+ if err := validateCapabilityCredentialBindings(ctx, credentialStore, capabilityCredentialBindingValidationInput{
+ WorkspaceID: agent.WorkspaceID,
+ AgentVisibility: visibility,
+ AgentConfig: agent.Config,
+ Version: version,
+ Configuration: binding.Configuration,
+ }); err != nil {
+ return &capabilityCredentialValidationError{err: err}
+ }
+ }
+ return nil
+}
+
+func validateSharedCapabilityCredential(
+ ctx context.Context,
+ secretStore credentialBindingSecretStore,
+ workspaceID string,
+ kind string,
+ secretID string,
+ sourcePayload json.RawMessage,
+) error {
+ secretID = strings.TrimSpace(secretID)
+ if !isUUID(secretID) {
+ return errors.New("credential binding secret_id must be a valid uuid")
+ }
+ secret, err := secretStore.GetSecretPayload(ctx, workspaceID, secretID)
+ if err != nil || secret.Status != "active" || secret.Kind != "capability_inline" {
+ return errors.New("credential binding secret is unavailable")
+ }
+ secretKind := strings.TrimSpace(metadataStringValue(secret.Metadata, "credential_kind_code"))
+ if secretKind != "" && secretKind != kind {
+ return errors.New("credential binding secret has the wrong credential kind")
+ }
+ catalogID := catalogIDFromSourcePayload(sourcePayload)
+ if kind == capability.CredentialKindMCPOAuth && catalogID != "" &&
+ (secretKind != kind || secret.AuthType != "oauth2" || strings.TrimSpace(secret.Provider) != catalogID) {
+ return errors.New("credential binding secret belongs to a different MCP connector")
+ }
+ return nil
+}
+
+func metadataStringValue(metadata map[string]any, key string) string {
+ value, _ := metadata[key].(string)
+ return value
+}
+
+func catalogIDFromSourcePayload(sourcePayload json.RawMessage) string {
+ if len(sourcePayload) == 0 {
+ return ""
+ }
+ var source struct {
+ SourceFormat string `json:"source_format"`
+ CatalogID string `json:"catalog_id"`
+ }
+ if err := json.Unmarshal(sourcePayload, &source); err != nil || source.SourceFormat != "mcp_catalog" {
+ return ""
+ }
+ return strings.TrimSpace(source.CatalogID)
+}
diff --git a/server/internal/dev/capability_credential_binding_test.go b/server/internal/dev/capability_credential_binding_test.go
new file mode 100644
index 0000000..7fc4c22
--- /dev/null
+++ b/server/internal/dev/capability_credential_binding_test.go
@@ -0,0 +1,51 @@
+package dev
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/MiniMax-AI-Dev/parsar/server/internal/capability"
+ "github.com/MiniMax-AI-Dev/parsar/server/internal/store"
+)
+
+type credentialBindingSecretStub struct {
+ provider string
+}
+
+func (s credentialBindingSecretStub) GetSecretPayload(context.Context, string, string) (store.SecretPayload, error) {
+ return store.SecretPayload{SecretRead: store.SecretRead{
+ Kind: "capability_inline",
+ Provider: s.provider,
+ AuthType: "oauth2",
+ Status: "active",
+ Metadata: map[string]any{"credential_kind_code": capability.CredentialKindMCPOAuth},
+ }}, nil
+}
+
+func TestValidateCapabilityCredentialBindingsChecksAgentFallbackProvider(t *testing.T) {
+ input := capabilityCredentialBindingValidationInput{
+ WorkspaceID: "00000000-0000-0000-0000-000000000002",
+ AgentConfig: map[string]any{
+ "credential_bindings": map[string]any{
+ capability.CredentialKindMCPOAuth: map[string]any{
+ "source": "shared",
+ "secret_id": "00000000-0000-0000-0000-000000000099",
+ },
+ },
+ },
+ Version: store.CapabilityVersionRead{
+ SourcePayload: json.RawMessage(`{"source_format":"mcp_catalog","catalog_id":"notion"}`),
+ RequiredCredentials: []store.RequiredCredential{{Kind: capability.CredentialKindMCPOAuth, Required: true}},
+ },
+ }
+
+ err := validateCapabilityCredentialBindings(context.Background(), credentialBindingSecretStub{provider: "github"}, input)
+ if err == nil || !strings.Contains(err.Error(), "different MCP connector") {
+ t.Fatalf("provider mismatch error = %v", err)
+ }
+ if err := validateCapabilityCredentialBindings(context.Background(), credentialBindingSecretStub{provider: "notion"}, input); err != nil {
+ t.Fatalf("matching provider returned error: %v", err)
+ }
+}
diff --git a/server/internal/dev/capability_routes.go b/server/internal/dev/capability_routes.go
index 0acbcaf..8487fca 100644
--- a/server/internal/dev/capability_routes.go
+++ b/server/internal/dev/capability_routes.go
@@ -1417,6 +1417,21 @@ func enableAgentCapability(runtimeStore RuntimeStore) http.HandlerFunc {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
+ agentRecord, err := runtimeStore.GetAgent(r.Context(), agentID)
+ if err != nil {
+ writeCapabilityError(w, err, "failed to get agent")
+ return
+ }
+ if err := validateCapabilityCredentialBindings(r.Context(), runtimeStore, capabilityCredentialBindingValidationInput{
+ WorkspaceID: agent.WorkspaceID,
+ AgentVisibility: agentRecord.Visibility,
+ AgentConfig: agentRecord.Config,
+ Version: version,
+ Configuration: body.Configuration,
+ }); err != nil {
+ writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
+ return
+ }
enabled, err := runtimeStore.EnableAgentCapability(r.Context(), agentID, versionID, body.Configuration, body.PinningMode)
if err != nil {
writeCapabilityError(w, err, "failed to enable agent capability")
@@ -1525,6 +1540,7 @@ func setBuiltinCapability(runtimeStore RuntimeStore) http.HandlerFunc {
// @Failure 400 {object} map[string]string "Invalid UUID or malformed body"
// @Failure 403 {object} map[string]string "Not agent creator"
// @Failure 404 {object} map[string]string "Agent, capability, or version not found"
+// @Failure 422 {object} map[string]string "Target version credential requirements are not satisfied"
// @Failure 503 {object} map[string]string "Database-backed capability APIs are disabled"
// @Router /api/v1/workspaces/{workspaceID}/agents/{agentID}/capabilities/{capabilityID}/upgrade [post]
func upgradeAgentCapability(runtimeStore RuntimeStore) http.HandlerFunc {
@@ -1547,6 +1563,48 @@ func upgradeAgentCapability(runtimeStore RuntimeStore) http.HandlerFunc {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "new_version_id must be a valid uuid"})
return
}
+ version, err := runtimeStore.GetCapabilityVersion(r.Context(), body.NewVersionID)
+ if err != nil {
+ writeCapabilityError(w, err, "failed to get capability version")
+ return
+ }
+ if version.CapabilityID != capabilityID {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "capability version does not belong to capability"})
+ return
+ }
+ agent, err := runtimeStore.GetAgent(r.Context(), agentID)
+ if err != nil {
+ writeCapabilityError(w, err, "failed to get agent")
+ return
+ }
+ bindings, err := runtimeStore.ListAgentCapabilities(r.Context(), agentID)
+ if err != nil {
+ writeCapabilityError(w, err, "failed to list agent capabilities")
+ return
+ }
+ var configuration map[string]any
+ bindingFound := false
+ for _, binding := range bindings {
+ if binding.CapabilityID == capabilityID {
+ configuration = binding.Configuration
+ bindingFound = true
+ break
+ }
+ }
+ if !bindingFound {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent capability not found"})
+ return
+ }
+ if err := validateCapabilityCredentialBindings(r.Context(), runtimeStore, capabilityCredentialBindingValidationInput{
+ WorkspaceID: agent.WorkspaceID,
+ AgentVisibility: agent.Visibility,
+ AgentConfig: agent.Config,
+ Version: version,
+ Configuration: configuration,
+ }); err != nil {
+ writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
+ return
+ }
upgraded, err := runtimeStore.UpgradeAgentCapability(r.Context(), agentID, capabilityID, body.NewVersionID, body.PinningMode)
if err != nil {
writeCapabilityError(w, err, "failed to upgrade agent capability")
diff --git a/server/internal/dev/routes_agents.go b/server/internal/dev/routes_agents.go
index f5f4660..7cfcb6f 100644
--- a/server/internal/dev/routes_agents.go
+++ b/server/internal/dev/routes_agents.go
@@ -343,8 +343,38 @@ func createAgent(runtimeStore RuntimeStore, agentDaemonSandbox AgentDaemonSandbo
return
}
initialCapabilities := make([]store.InitialAgentCapabilityInput, 0, len(req.InitialCapabilities))
- for _, capability := range req.InitialCapabilities {
- initialCapabilities = append(initialCapabilities, store.InitialAgentCapabilityInput{CapabilityVersionID: capability.CapabilityVersionID, Configuration: capability.Configuration, PinningMode: capability.PinningMode})
+ for _, requested := range req.InitialCapabilities {
+ versionID := strings.TrimSpace(requested.CapabilityVersionID)
+ if !isUUID(versionID) {
+ writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "capability_version_id must be a valid uuid"})
+ return
+ }
+ version, err := runtimeStore.GetCapabilityVersion(r.Context(), versionID)
+ if err != nil {
+ writeCapabilityError(w, err, "failed to get capability version")
+ return
+ }
+ capabilityRecord, err := runtimeStore.GetCapability(r.Context(), version.CapabilityID)
+ if err != nil {
+ writeCapabilityError(w, err, "failed to get capability")
+ return
+ }
+ if capabilityRecord.WorkspaceID != workspaceID &&
+ (capabilityRecord.Visibility != "public" || capabilityRecord.DeprecatedAt != nil || capabilityRecord.Status != "active") {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": "marketplace capability is unavailable"})
+ return
+ }
+ if err := validateCapabilityCredentialBindings(r.Context(), runtimeStore, capabilityCredentialBindingValidationInput{
+ WorkspaceID: workspaceID,
+ AgentVisibility: req.Visibility,
+ AgentConfig: req.Config,
+ Version: version,
+ Configuration: requested.Configuration,
+ }); err != nil {
+ writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
+ return
+ }
+ initialCapabilities = append(initialCapabilities, store.InitialAgentCapabilityInput{CapabilityVersionID: versionID, Configuration: requested.Configuration, PinningMode: requested.PinningMode})
}
result, err := runtimeStore.CreateAgent(r.Context(), store.CreateAgentInput{WorkspaceID: workspaceID, Name: req.Name, Description: req.Description, ConnectorType: req.ConnectorType, SystemPrompt: req.SystemPrompt, DefaultModelID: req.DefaultModelID, Capabilities: req.Capabilities, CapabilitiesSet: hasCaps, InitialCapabilities: initialCapabilities, Runtime: "", AgentConfig: req.Config, Visibility: req.Visibility, Slug: req.Slug, CreatedBy: actorIDFromRequest(r)})
if err != nil {
@@ -543,6 +573,10 @@ func syncAgentCapabilities(
for _, ac := range existing {
existingByCapID[ac.CapabilityID] = ac
}
+ agent, err := rs.GetAgent(ctx, agentID)
+ if err != nil {
+ return fmt.Errorf("syncAgentCapabilities: get agent: %w", err)
+ }
// 2. Resolve desired names. A name can come from this workspace's own
// capabilities, OR from the marketplace (a public capability published
@@ -624,6 +658,22 @@ func syncAgentCapabilities(
if cap.fromMarketplace {
mode = store.PinningModePinned
}
+ version, err := rs.GetCapabilityVersion(ctx, latestVersionID)
+ if err != nil {
+ log.Bg().Warn("syncAgentCapabilities: get version failed, skipping",
+ "capability_id", cap.capabilityID, "name", name, "version_id", latestVersionID, "err", err)
+ continue
+ }
+ if err := validateCapabilityCredentialBindings(ctx, rs, capabilityCredentialBindingValidationInput{
+ WorkspaceID: workspaceID,
+ AgentVisibility: agent.Visibility,
+ AgentConfig: agent.Config,
+ Version: version,
+ }); err != nil {
+ log.Bg().Warn("syncAgentCapabilities: credential validation failed, skipping",
+ "capability_id", cap.capabilityID, "name", name, "version_id", latestVersionID, "err", err)
+ continue
+ }
if _, err := rs.EnableAgentCapability(ctx, agentID, latestVersionID, nil, mode); err != nil {
log.Bg().Warn("syncAgentCapabilities: enable failed, skipping",
"capability_id", cap.capabilityID, "name", name, "version_id", latestVersionID, "err", err)
@@ -694,6 +744,15 @@ func updateAgentVisibility(runtimeStore RuntimeStore) http.HandlerFunc {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
+ if err := validateAgentCapabilityBindingsForVisibility(r.Context(), runtimeStore, agent, req.Visibility); err != nil {
+ var validationErr *capabilityCredentialValidationError
+ if errors.As(err, &validationErr) {
+ writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": validationErr.Error()})
+ return
+ }
+ writeStoreAgentError(w, err)
+ return
+ }
change, err := runtimeStore.UpdateAgentVisibility(r.Context(), agentID, req.Visibility, actorIDFromRequest(r))
if err != nil {
if errors.Is(err, store.ErrInvalidAgentVisibility) {
diff --git a/server/internal/dev/routes_capability_test.go b/server/internal/dev/routes_capability_test.go
index 5cbf180..3b6d468 100644
--- a/server/internal/dev/routes_capability_test.go
+++ b/server/internal/dev/routes_capability_test.go
@@ -198,6 +198,132 @@ func TestCapabilityAgentEnableRBACWorkspaceAndUniqueUpdate(t *testing.T) {
assertSingleAgentCapability(t, db, ownedPA, capID, v2)
}
+func TestCapabilityEnablePersistsSelectedSharedSecret(t *testing.T) {
+ r, db := capabilityTestRouter(t, map[string]string{testUserAID: "member"}, nil)
+ capID, versionID, _ := insertCapabilityVersions(t, db, store.DefaultDevFixtureIDs().WorkspaceID, "Shared Secret MCP")
+ agentID := insertAgentForOwner(t, db, testUserAID, "shared-secret-agent")
+ secretID := "00000000-0000-0000-0000-000000000099"
+ if _, err := db.Exec(context.Background(), `
+ insert into secrets(id, slug, name, kind, provider, auth_type, encrypted_payload, key_version, status, metadata, created_by, created_at, updated_at)
+ values ($1, 'shared-github-test', 'Shared GitHub', 'capability_inline', 'inline', 'literal', '\x01'::bytea, 'v1', 'active', $2::jsonb, $3, now(), now())
+ `, secretID, `{"workspace_id":"`+store.DefaultDevFixtureIDs().WorkspaceID+`","credential_kind_code":"github_pat"}`, testUserAID); err != nil {
+ t.Fatalf("insert shared secret: %v", err)
+ }
+
+ res := serveCapabilityRoute(t, r, http.MethodPost,
+ "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+agentID+"/capabilities/"+versionID+"/enable",
+ `{"configuration":{"credential_bindings":{"github_pat":{"source":"shared","secret_id":"`+secretID+`"}}}}`, testUserAID)
+ if res.Code != http.StatusOK {
+ t.Fatalf("enable expected 200, got %d: %s", res.Code, res.Body.String())
+ }
+ assertSingleAgentCapability(t, db, agentID, capID, versionID)
+ var storedSecretID string
+ if err := db.QueryRow(context.Background(), `
+ select configuration #>> '{credential_bindings,github_pat,secret_id}'
+ from agent_capabilities where agent_id = $1
+ `, agentID).Scan(&storedSecretID); err != nil {
+ t.Fatalf("read stored binding: %v", err)
+ }
+ if storedSecretID != secretID {
+ t.Fatalf("stored secret_id=%q want %q", storedSecretID, secretID)
+ }
+}
+
+func TestCapabilityEnableRejectsOAuthSecretFromDifferentCatalogConnector(t *testing.T) {
+ r, db := capabilityTestRouter(t, map[string]string{testUserAID: "member"}, nil)
+ capID, versionID, _ := insertCapabilityVersions(t, db, store.DefaultDevFixtureIDs().WorkspaceID, "Notion MCP")
+ agentID := insertAgentForOwner(t, db, testUserAID, "notion-agent")
+ secretID := "00000000-0000-0000-0000-000000000098"
+ if _, err := db.Exec(context.Background(), `
+ update capability_version
+ set source_payload = '{"source_format":"mcp_catalog","catalog_id":"notion"}'::jsonb,
+ required_credentials = '[{"kind":"mcp_oauth","required":true}]'::jsonb
+ where id = $1
+ `, versionID); err != nil {
+ t.Fatalf("mark catalog capability: %v", err)
+ }
+ if _, err := db.Exec(context.Background(), `
+ insert into secrets(id, slug, name, kind, provider, auth_type, encrypted_payload, key_version, status, metadata, created_by, created_at, updated_at)
+ values ($1, 'github-oauth-test', 'GitHub OAuth', 'capability_inline', 'github', 'oauth2', '\x01'::bytea, 'v1', 'active', $2::jsonb, $3, now(), now())
+ `, secretID, `{"workspace_id":"`+store.DefaultDevFixtureIDs().WorkspaceID+`","credential_kind_code":"mcp_oauth"}`, testUserAID); err != nil {
+ t.Fatalf("insert shared secret: %v", err)
+ }
+
+ res := serveCapabilityRoute(t, r, http.MethodPost,
+ "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+agentID+"/capabilities/"+versionID+"/enable",
+ `{"configuration":{"credential_bindings":{"mcp_oauth":{"source":"shared","secret_id":"`+secretID+`"}}}}`, testUserAID)
+ if res.Code != http.StatusUnprocessableEntity || !strings.Contains(res.Body.String(), "different MCP connector") {
+ t.Fatalf("enable with wrong connector secret expected 422, got %d: %s", res.Code, res.Body.String())
+ }
+ var count int
+ if err := db.QueryRow(context.Background(), `select count(*) from agent_capabilities where agent_id = $1 and capability_id = $2`, agentID, capID).Scan(&count); err != nil {
+ t.Fatalf("count agent capabilities: %v", err)
+ }
+ if count != 0 {
+ t.Fatalf("agent capability was created with a mismatched connector secret")
+ }
+}
+
+func TestCapabilityUpgradeValidatesNewVersionCredentials(t *testing.T) {
+ r, db := capabilityTestRouter(t, map[string]string{testUserAID: "admin"}, nil)
+ capID, v1, v2 := insertCapabilityVersions(t, db, store.DefaultDevFixtureIDs().WorkspaceID, "Credential Upgrade MCP")
+ if _, err := db.Exec(context.Background(), `
+ update capability_version
+ set required_credentials = case
+ when id = $1 then '[]'::jsonb
+ when id = $2 then '[{"kind":"github_pat","required":true}]'::jsonb
+ else required_credentials
+ end
+ where id in ($1, $2)
+ `, v1, v2); err != nil {
+ t.Fatalf("update capability credentials: %v", err)
+ }
+ agentID := insertAgentForOwner(t, db, testUserAID, "credential-upgrade-agent")
+ if _, err := db.Exec(context.Background(), `update agents set visibility = 'public' where id = $1`, agentID); err != nil {
+ t.Fatalf("make agent public: %v", err)
+ }
+ enabled := serveCapabilityRoute(t, r, http.MethodPost,
+ "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+agentID+"/capabilities/"+v1+"/enable",
+ `{}`, testUserAID)
+ if enabled.Code != http.StatusOK {
+ t.Fatalf("enable v1 expected 200, got %d: %s", enabled.Code, enabled.Body.String())
+ }
+
+ upgraded := serveCapabilityRoute(t, r, http.MethodPost,
+ "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+agentID+"/capabilities/"+capID+"/upgrade",
+ `{"new_version_id":"`+v2+`"}`, testUserAID)
+ if upgraded.Code != http.StatusUnprocessableEntity || !strings.Contains(upgraded.Body.String(), "public agents require a shared secret") {
+ t.Fatalf("upgrade without v2 credential expected 422, got %d: %s", upgraded.Code, upgraded.Body.String())
+ }
+ assertSingleAgentCapability(t, db, agentID, capID, v1)
+}
+
+func TestAgentVisibilityRejectsPersonalCapabilityCredential(t *testing.T) {
+ r, db := capabilityTestRouter(t, map[string]string{testUserAID: "admin"}, nil)
+ capID, versionID, _ := insertCapabilityVersions(t, db, store.DefaultDevFixtureIDs().WorkspaceID, "Public Visibility MCP")
+ agentID := insertAgentForOwner(t, db, testUserAID, "public-visibility-agent")
+ enabled := serveCapabilityRoute(t, r, http.MethodPost,
+ "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+agentID+"/capabilities/"+versionID+"/enable",
+ `{"configuration":{"credential_bindings":{"github_pat":{"source":"personal"}}}}`, testUserAID)
+ if enabled.Code != http.StatusOK {
+ t.Fatalf("enable personal capability expected 200, got %d: %s", enabled.Code, enabled.Body.String())
+ }
+ assertSingleAgentCapability(t, db, agentID, capID, versionID)
+
+ visibility := serveCapabilityRoute(t, r, http.MethodPatch,
+ "/api/v1/agents/"+agentID+"/visibility", `{"visibility":"public"}`, testUserAID)
+ if visibility.Code != http.StatusUnprocessableEntity || !strings.Contains(visibility.Body.String(), "public agents require a shared secret") {
+ t.Fatalf("public visibility with personal capability credential expected 422, got %d: %s", visibility.Code, visibility.Body.String())
+ }
+ var storedVisibility string
+ if err := db.QueryRow(context.Background(), `select visibility from agents where id = $1`, agentID).Scan(&storedVisibility); err != nil {
+ t.Fatalf("read agent visibility: %v", err)
+ }
+ if storedVisibility != "workspace" {
+ t.Fatalf("visibility changed to %q after rejected update", storedVisibility)
+ }
+}
+
func TestCapabilityMarketplacePublishLifecycleSecretCheckAndDeleteRollback(t *testing.T) {
r, db := capabilityTestRouter(t, map[string]string{store.DefaultDevFixtureIDs().UserID: "admin"}, nil)
capID, _, _ := insertCapabilityVersions(t, db, store.DefaultDevFixtureIDs().WorkspaceID, "Marketplace Secret")
diff --git a/server/internal/dev/routes_test.go b/server/internal/dev/routes_test.go
index 8d20412..8e3f0b9 100644
--- a/server/internal/dev/routes_test.go
+++ b/server/internal/dev/routes_test.go
@@ -15,6 +15,7 @@ import (
"time"
"github.com/MiniMax-AI-Dev/parsar/server/internal/auth"
+ "github.com/MiniMax-AI-Dev/parsar/server/internal/capability"
gatewaypkg "github.com/MiniMax-AI-Dev/parsar/server/internal/gateway"
"github.com/MiniMax-AI-Dev/parsar/server/internal/secrets"
"github.com/MiniMax-AI-Dev/parsar/server/internal/store"
@@ -2472,6 +2473,48 @@ type stubRuntimeStore struct {
httpEndpoint string
}
+type createAgentOAuthValidationStore struct {
+ stubRuntimeStore
+ secretProvider string
+ createCalls int
+}
+
+func (s *createAgentOAuthValidationStore) GetCapabilityVersion(context.Context, string) (store.CapabilityVersionRead, error) {
+ return store.CapabilityVersionRead{
+ ID: "00000000-0000-0000-0000-000000000c02",
+ CapabilityID: "00000000-0000-0000-0000-000000000c01",
+ Version: "1.0.0",
+ SourcePayload: json.RawMessage(`{"source_format":"mcp_catalog","catalog_id":"notion"}`),
+ RequiredCredentials: []store.RequiredCredential{{Kind: capability.CredentialKindMCPOAuth, Required: true}},
+ }, nil
+}
+
+func (s *createAgentOAuthValidationStore) GetCapability(context.Context, string) (store.CapabilityRead, error) {
+ return store.CapabilityRead{
+ ID: "00000000-0000-0000-0000-000000000c01",
+ WorkspaceID: "00000000-0000-0000-0000-000000000002",
+ Type: "mcp",
+ Visibility: "workspace",
+ Status: "active",
+ }, nil
+}
+
+func (s *createAgentOAuthValidationStore) GetSecretPayload(context.Context, string, string) (store.SecretPayload, error) {
+ return store.SecretPayload{SecretRead: store.SecretRead{
+ ID: "00000000-0000-0000-0000-000000000099",
+ Kind: "capability_inline",
+ Provider: s.secretProvider,
+ AuthType: "oauth2",
+ Status: "active",
+ Metadata: map[string]any{"credential_kind_code": capability.CredentialKindMCPOAuth},
+ }}, nil
+}
+
+func (s *createAgentOAuthValidationStore) CreateAgent(ctx context.Context, input store.CreateAgentInput) (store.CreateAgentResult, error) {
+ s.createCalls++
+ return s.stubRuntimeStore.CreateAgent(ctx, input)
+}
+
type roleStubStore struct {
stubRuntimeStore
roles map[string]string
@@ -4046,6 +4089,51 @@ func TestCreateAgentAPIHappyPathAndNameConflict(t *testing.T) {
}
}
+func TestCreateAgentValidatesInitialCapabilityOAuthBinding(t *testing.T) {
+ const body = `{
+ "name":"OAuth Agent",
+ "connector_type":"agent_daemon",
+ "visibility":"workspace",
+ "initial_capabilities":[{
+ "capability_version_id":"00000000-0000-0000-0000-000000000c02",
+ "configuration":{"credential_bindings":{"mcp_oauth":{"source":"shared","secret_id":"00000000-0000-0000-0000-000000000099"}}}
+ }],
+ "config":{"daemon_mode":"sandbox","agent_kind":"opencode"}
+ }`
+
+ t.Run("rejects a secret from another connector", func(t *testing.T) {
+ storeStub := &createAgentOAuthValidationStore{secretProvider: "github"}
+ r := chi.NewRouter()
+ RegisterRoutesWithStore(r, storeStub)
+ req := withTestUser(httptest.NewRequest(http.MethodPost, "/api/v1/workspaces/00000000-0000-0000-0000-000000000002/agents", strings.NewReader(body)))
+ req.Header.Set("Content-Type", "application/json")
+ res := httptest.NewRecorder()
+ r.ServeHTTP(res, req)
+ if res.Code != http.StatusUnprocessableEntity || !strings.Contains(res.Body.String(), "different MCP connector") {
+ t.Fatalf("expected 422 connector mismatch, got %d: %s", res.Code, res.Body.String())
+ }
+ if storeStub.createCalls != 0 {
+ t.Fatalf("CreateAgent calls = %d, want 0", storeStub.createCalls)
+ }
+ })
+
+ t.Run("accepts a matching connector secret", func(t *testing.T) {
+ storeStub := &createAgentOAuthValidationStore{secretProvider: "notion"}
+ r := chi.NewRouter()
+ RegisterRoutesWithStore(r, storeStub)
+ req := withTestUser(httptest.NewRequest(http.MethodPost, "/api/v1/workspaces/00000000-0000-0000-0000-000000000002/agents", strings.NewReader(body)))
+ req.Header.Set("Content-Type", "application/json")
+ res := httptest.NewRecorder()
+ r.ServeHTTP(res, req)
+ if res.Code != http.StatusCreated {
+ t.Fatalf("expected 201, got %d: %s", res.Code, res.Body.String())
+ }
+ if storeStub.createCalls != 1 {
+ t.Fatalf("CreateAgent calls = %d, want 1", storeStub.createCalls)
+ }
+ })
+}
+
func TestUpdateAgentAPIHappyPathAndImmutableSlug(t *testing.T) {
r := chi.NewRouter()
RegisterRoutesWithStore(r, stubRuntimeStore{})
diff --git a/tests/e2e/mcp-directory.spec.ts b/tests/e2e/mcp-directory.spec.ts
index 7ea8f0c..d734884 100644
--- a/tests/e2e/mcp-directory.spec.ts
+++ b/tests/e2e/mcp-directory.spec.ts
@@ -155,16 +155,12 @@ async function mockApp(
});
if (path === `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory`) {
if (directoryOverride && (await directoryOverride(route))) return;
- return json(route, {
- items: directoryItems,
- updated_at: "2026-07-23T00:00:00Z",
- source: "builtin",
- });
+ return json(route, { items: directoryItems });
}
if (path === `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory/context7`)
return json(route, { ...directoryItems[0], url: "https://mcp.context7.com/mcp" });
if (path === `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory/context7/import`)
- return json(route, { installed: true, capability_id: CAPABILITY_ID, created: true }, 201);
+ return json(route, { installed: true, capability_id: CAPABILITY_ID }, 201);
return json(route, {});
});
}