feat(bitrix24): replace two MCP text inputs with a filtered dropdown (#1392)

* feat(bitrix24): replace two MCP text inputs with a filtered dropdown [B24:2794]

Bitrix24 channel creation used to demand two hand-typed strings —
mcp_server_name and mcp_base_url — plus zero indication of which MCP
servers can actually auto-onboard. Typos silently disabled provisioning
and admin had to know which servers implement /api/auto-onboard.

Ship a single dropdown backed by mcp_servers.require_user_credentials,
plus the machinery to make it work end-to-end.

Phase 1 — DB & store
* Promote require_user_credentials from settings JSONB to a top-level
  column on mcp_servers (PG migration 000089, SQLite migration v54).
* Backfill from existing settings blobs so no admin needs to re-tick.
* Add MCPServerData.RequireUserCredentials to the Go store layer, plumb
  through Create / Get / GetByName / List / Update on both stores,
  extend the export DTO, and add require_user_credentials to the HTTP
  allowlist.
* Bump RequiredSchemaVersion 87 -> 89 (jumping 88, which was on disk
  but not wired) and SchemaVersion 53 -> 54 with an
  idempotentColumnMigration guard.

Phase 2 — Bitrix24 channel factory
* Add MCPServerID (UUID string) to bitrix24 InstanceConfig, keep
  MCPServerName + MCPBaseURL as legacy fallback with a "deprecated"
  doc comment.
* Factory validation accepts either mcp_server_id alone or the legacy
  pair; half-config still fails fast.
* initMCPProvisioner prefers GetServer(id) and sources the base URL
  from mcp_servers.url when the id path is used. Legacy name path
  unchanged so pre-migration configs keep working.
* Log line now carries mcp_server_id + require_user_credentials so
  operators can eyeball the wiring.

Phase 3 — Frontend types & MCP form
* Add optional top-level require_user_credentials to MCPServerData /
  MCPServerInput in both ui/web and ui/desktop/frontend types.
* mcp-form-dialog reads the top-level flag first and falls back to
  settings.require_user_credentials so cached responses from
  pre-upgrade backends still render correctly.
* On submit send both the top-level flag AND the legacy settings
  entry so mid-rollout backends stay consistent.

Phase 4 — Bitrix24 channel form dropdown
* New mcp-select field type + MCPServerSelect component. Uses the
  shared useMCP() react-query cache and filters client-side to
  servers whose require_user_credentials is true (OR settings
  JSONB during the migration window).
* Explicit "None (disable MCP provisioning)" option so admins can
  clear the binding without editing config JSON.
* Legacy mcp_server_name / mcp_base_url text inputs kept in the
  Advanced panel, relabelled "(legacy)" with pointer help text.

Phase 5 — channel_instances.config backfill
* PG migration 000090 and SQLite migration v55 rewrite existing
  bitrix24 channel_instances.config to add mcp_server_id by
  resolving mcp_server_name against mcp_servers, tenant-scoped
  via agents.tenant_id (channel_instances doesn't carry tenant_id
  directly).
* Idempotent — only touches rows already carrying
  mcp_server_name that lack mcp_server_id. Legacy keys are left
  in place so provisioner.go can still fall back for unmigrated
  or future-created legacy configs.
* down.sql drops the mcp_server_id key. Provisioner immediately
  reverts to the legacy fallback path.

Tests
* provisioner_test.go: three new cases exercise the mcp_server_id
  path (invalid UUID string, valid UUID with missing row, valid
  UUID with a per-user row). fakeMCPStore gains a serversByID
  map and a real GetServer implementation.
* Existing legacy-config tests unchanged and still green.

Verification
* go build ./... && go build -tags sqliteonly ./...
* go vet ./internal/mcp/... ./internal/channels/bitrix24/...
    ./internal/store/... ./internal/http/...
* go test ./internal/mcp/... ./internal/channels/bitrix24/...  -> ok
* Live-tested against a local docker image on the goclaw-deploy
  postgres. Migrations 89 + 90 applied cleanly. Three existing
  bitrix24 channels (bitrix-sales / nguyen-dao-openline / tieu-vi)
  had their configs backfilled with the b24-syn-mcp UUID and the
  provisioner boots with require_user_credentials=true. UI dropdown
  correctly shows only b24-syn-mcp (the only server with the flag
  ticked) alongside a "None" clearer option.

Surface parity
* Gateway server: store + factory + provisioner + HTTP allowlist.
* API contract: adds require_user_credentials + mcp_server_id
  as optional fields on existing routes. No new endpoints.
* Web UI: MCP form + Bitrix24 channel form + shared types.
* CLI/runtime package: N/A because no CLI subcommand reads the
  mcp_server_id field.

* fix(bitrix24): derive auto-onboard base URL from mcp_servers.url origin [B24:2794]

The Phase 2 refactor swapped provisioner base-URL sourcing from the
legacy per-channel MCPBaseURL config field (which historically stored the
MCP server's ORIGIN, e.g. https://mcp.example.com) to mcp_servers.url,
which stores the JSON-RPC ENDPOINT the agent loop dials (e.g.
https://mcp.example.com/mcp). The two are semantically different but
share a single column.

mcp_client.newMCPClient then appends "/api/auto-onboard" to whatever
baseURL it receives, so the id-path started POSTing to
".../mcp/api/auto-onboard" — 404 for every per-user credential mint and
refresh. Existing users kept working only until their cached access
tokens expired.

Fix: derive the origin (scheme://host[:port]) from server.URL before
handing it to the auto-onboard client. The legacy path is untouched
because MCPBaseURL from channel config is already the origin.

  https://b24-mcp-dev.synity.so/mcp   ->  https://b24-mcp-dev.synity.so
  https://mcp.example.com/mcp/        ->  https://mcp.example.com
  https://mcp.example.com             ->  https://mcp.example.com

Table-driven test covers six shapes plus four error cases (empty,
whitespace-only, no scheme, no host). Updated the existing
TestInitMCPProvisioner_MCPServerID fixture to seed a URL with the /mcp
subpath so it regression-guards the same code path.

Verified live: user 614 sent a message that triggered the expired-cred
refresh branch; goclaw logged "self-refreshed user credentials
created=false" and the agent immediately reported
"mcp.user_tools_loaded user=614 tools=2". Before this fix the same event
logged 'auto-onboard failed: mcp auto-onboard: 404 Not Found'.

Surface parity:
  - Gateway server: provisioner + one new helper (deriveAutoOnboardBaseURL).
  - API contract: N/A because the wire shape hasn't changed.
  - Web UI: N/A because the UI still writes mcp_server_id verbatim.
  - CLI/runtime: N/A because no CLI reads the derived base URL.

---------

Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
This commit is contained in:
SYNITYandDangTinh311 authored and GitHub committed 2026-07-08 19:23:21 +07:00
1 parent 7263f771c8
commit 3b8bc1bc4d
24 files changed
+561 -85

No files matched your search

+3 -2
View File
@@ -750,8 +750,9 @@ func runGateway() {
// the one used by pg.NewPGStores → NewPGBitrixPortalStore.
bitrixEncKey := os.Getenv("GOCLAW_ENCRYPTION_KEY")
// Use the MCP-aware factory variant so channels that opt into
// lazy per-user credential provisioning (via mcp_server_name +
// mcp_base_url in their instance config) can reach the partner's
// lazy per-user credential provisioning (via mcp_server_id — or
// the legacy mcp_server_name + mcp_base_url pair — in their
// instance config) can reach the partner's
// MCPServerStore. The MCP server authenticates each onboard call
// via the caller-supplied Bitrix access_token (the "Bitrix24
// OAuth → existing mcp_user_credentials bridge" — Bitrix-specific
+35 -19
View File
@@ -88,31 +88,43 @@ type bitrixInstanceConfig struct {
// Optional MCP lazy-provisioning binding (Phase C).
//
// When MCPServerName + MCPBaseURL are set AND the factory variant that
// accepts a MCPServerStore is used (FactoryWithPortalStoreAndMCP), the
// channel tries to mint per-user MCP credentials on first message:
// When MCPServerID (or the legacy MCPServerName+MCPBaseURL pair) is set
// AND the factory variant that accepts a MCPServerStore is used
// (FactoryWithPortalStoreAndMCP), the channel tries to mint per-user MCP
// credentials on first message:
//
// 1. Channel receives message from user U with OAuth tokens in event.
// 2. Channel looks up MCPUserCredentials(serverID, senderID). Present
// → skip. Absent → POST /api/auto-onboard on MCPBaseURL forwarding
// U's OAuth tokens. MCP server authenticates the call via Bitrix
// `profile` against the supplied access_token — no shared admin
// secret required — and responds with a per-user api_key, which
// channel stores via SetUserCredentials (the "Bitrix24 OAuth →
// existing mcp_user_credentials bridge" — Bitrix-specific glue,
// not a generic MCP architecture pattern).
// → skip. Absent → POST /api/auto-onboard on the resolved base URL
// forwarding U's OAuth tokens. MCP server authenticates the call
// via Bitrix `profile` against the supplied access_token — no
// shared admin secret required — and responds with a per-user
// api_key, which channel stores via SetUserCredentials (the
// "Bitrix24 OAuth → existing mcp_user_credentials bridge" —
// Bitrix-specific glue, not a generic MCP architecture pattern).
// 3. Agent pipeline downstream reads those creds naturally.
//
// Best-effort: if any step fails, channel logs a warning and forwards
// the message anyway — agent loop will just see no creds and skip
// that MCP server's tools. User gets a response, albeit without MCP.
//
// Half-config fails at factory load: both fields set or both empty.
//
// Skipped entirely for Open Channel bots (bot_type=O) — transient
// customers don't map to tenant_users.
MCPServerName string `json:"mcp_server_name,omitempty"` // mcp_servers.name
MCPBaseURL string `json:"mcp_base_url,omitempty"` // HTTPS root
//
// MCPServerID is the preferred wiring since v3.15: admin picks an MCP
// server from the dashboard dropdown (filtered to
// require_user_credentials=true rows) and the channel resolves both
// name and URL from the mcp_servers row.
MCPServerID string `json:"mcp_server_id,omitempty"` // mcp_servers.id (UUID)
// Deprecated: MCPServerName + MCPBaseURL are kept for backward-compat
// with configs written before the MCPServerID rollout. If MCPServerID
// is empty and both legacy fields are set the channel falls back to
// GetServerByName and uses MCPBaseURL as the base URL. New configs
// should only set MCPServerID; Phase 5 of the refactor migrates every
// existing row and drops these fields.
MCPServerName string `json:"mcp_server_name,omitempty"` // legacy — mcp_servers.name
MCPBaseURL string `json:"mcp_base_url,omitempty"` // legacy — HTTPS root
}
// Factory is the base channels.ChannelFactory signature. Bitrix24 requires a
@@ -198,13 +210,17 @@ func FactoryWithPortalStoreAndMCP(portalStore store.BitrixPortalStore, mcpStore
return nil, fmt.Errorf("bitrix24: invalid bot_type %q (must be \"B\" or \"O\")", ic.BotType)
}
// MCP provisioning config is all-or-nothing. Catching half-config here
// prevents a silent "provisioning disabled but you meant to enable it"
// surprise — admin either sets both or neither.
// MCP provisioning config uses mcp_server_id (preferred) or the
// legacy mcp_server_name + mcp_base_url pair. Catching half-config
// here prevents a silent "provisioning disabled but you meant to
// enable it" surprise when someone only fills one of the legacy
// fields. The new mcp_server_id path is self-contained (server
// name + URL both derived from the mcp_servers row at Start()).
hasServerID := strings.TrimSpace(ic.MCPServerID) != ""
hasServerName := strings.TrimSpace(ic.MCPServerName) != ""
hasBaseURL := strings.TrimSpace(ic.MCPBaseURL) != ""
if hasServerName != hasBaseURL {
return nil, errors.New("bitrix24: mcp_server_name and mcp_base_url must both be set, or both empty")
if !hasServerID && hasServerName != hasBaseURL {
return nil, errors.New("bitrix24: mcp_server_name and mcp_base_url must both be set, or both empty (or use mcp_server_id)")
}
// Shared process-wide router. InitWebhookRouter uses sync.Once so the
+106 -16
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log/slog"
"net/url"
"strings"
"sync"
"time"
@@ -94,41 +95,89 @@ func (c *Channel) initMCPProvisioner(ctx context.Context) error {
"channel", c.Name())
return nil
}
if strings.TrimSpace(c.cfg.MCPServerName) == "" || strings.TrimSpace(c.cfg.MCPBaseURL) == "" {
slog.Debug("bitrix24 mcp: provisioning disabled (mcp_server_name or mcp_base_url empty)",
hasServerID := strings.TrimSpace(c.cfg.MCPServerID) != ""
hasLegacy := strings.TrimSpace(c.cfg.MCPServerName) != "" && strings.TrimSpace(c.cfg.MCPBaseURL) != ""
if !hasServerID && !hasLegacy {
slog.Debug("bitrix24 mcp: provisioning disabled (no mcp_server_id and legacy fields empty)",
"channel", c.Name())
return nil
}
// Resolve server name → UUID once at startup. If the server name is
// wrong or the row doesn't exist yet, log and disable provisioning —
// don't block channel startup. Admin can create the server + reload
// the channel later.
// Resolve the mcp_servers row. Preferred path: mcp_server_id (UUID
// dashboards write since v3.15). Fallback: legacy name lookup for
// configs written before the refactor and not yet migrated.
//
// PGMCPServerStore.GetServerByName scopes the lookup by tenant_id from
// context (multi-tenant isolation). Channel.Start receives ctx from the
// instance loader without that scope set — wrap it explicitly with the
// channel's own tenant id so the lookup matches the row a tenant admin
// created via `bitrix-portal create` / dashboard.
// GetServer / GetServerByName both scope by tenant_id from context.
// Channel.Start receives ctx from the instance loader without that
// scope set — wrap it explicitly with the channel's own tenant id so
// the lookup matches the row a tenant admin created via the dashboard
// or `bitrix-portal create`.
lookupCtx := ctx
if tid := c.TenantID(); tid != uuid.Nil {
lookupCtx = store.WithTenantID(ctx, tid)
}
server, err := c.mcpStore.GetServerByName(lookupCtx, c.cfg.MCPServerName)
var (
server *store.MCPServerData
err error
)
if hasServerID {
serverUUID, parseErr := uuid.Parse(strings.TrimSpace(c.cfg.MCPServerID))
if parseErr != nil {
slog.Warn("bitrix24 mcp: provisioning disabled — invalid mcp_server_id",
"channel", c.Name(), "mcp_server_id", c.cfg.MCPServerID, "err", parseErr)
return nil
}
server, err = c.mcpStore.GetServer(lookupCtx, serverUUID)
} else {
server, err = c.mcpStore.GetServerByName(lookupCtx, c.cfg.MCPServerName)
}
if err != nil || server == nil {
slog.Warn("bitrix24 mcp: provisioning disabled — server not found",
"channel", c.Name(), "mcp_server_name", c.cfg.MCPServerName, "err", err)
"channel", c.Name(),
"mcp_server_id", c.cfg.MCPServerID,
"mcp_server_name", c.cfg.MCPServerName,
"err", err)
return nil
}
// Base URL sourcing: for the id-based path we derive the ORIGIN
// (scheme://host[:port]) from the mcp_servers row. The row's URL is
// the MCP JSON-RPC endpoint (e.g. https://mcp.example.com/mcp), but
// the /api/auto-onboard REST call the provisioner will POST expects
// the origin only — appending "/api/auto-onboard" to the JSON-RPC
// path would 404. Legacy path stays on MCPBaseURL, which historically
// was already the origin (admin gave us "https://mcp.example.com"),
// so a half-migrated fleet keeps working until Phase 5 rewrites configs.
baseURL := strings.TrimSpace(c.cfg.MCPBaseURL)
if hasServerID {
derived, deriveErr := deriveAutoOnboardBaseURL(server.URL)
if deriveErr != nil {
slog.Warn("bitrix24 mcp: provisioning disabled — mcp_servers.url unparseable",
"channel", c.Name(),
"mcp_server", server.Name,
"url", server.URL,
"err", deriveErr)
return nil
}
baseURL = derived
}
if baseURL == "" {
slog.Warn("bitrix24 mcp: provisioning disabled — mcp_servers row has empty url",
"channel", c.Name(), "mcp_server", server.Name)
return nil
}
c.mcpServerID = server.ID
c.mcpClient = newMCPClient(c.cfg.MCPBaseURL, 10*time.Second)
c.mcpClient = newMCPClient(baseURL, 10*time.Second)
c.mcpDebounce = make(map[mcpDebounceKey]time.Time)
slog.Info("bitrix24 mcp: provisioning enabled",
"channel", c.Name(),
"mcp_server", c.cfg.MCPServerName,
"mcp_server_id", server.ID)
"mcp_server", server.Name,
"mcp_server_id", server.ID,
"require_user_credentials", server.RequireUserCredentials)
return nil
}
@@ -342,6 +391,47 @@ func (c *Channel) selfRefreshUserCreds(ctx context.Context, userID string, exist
return nil
}
// deriveAutoOnboardBaseURL strips the path/query/fragment from an MCP server
// URL so what's left is safe to append "/api/auto-onboard" to. The
// mcp_servers.url column stores the JSON-RPC endpoint the agent loop dials
// for tool calls (which usually lives under a subpath like /mcp), while the
// per-user credential-minting REST call the Bitrix24 channel makes lives at
// the origin. This helper bridges the two conventions with a single URL
// column so operators don't have to fill in a second field.
//
// Examples:
// - "https://mcp.example.com/mcp" → "https://mcp.example.com"
// - "https://mcp.example.com/mcp/" → "https://mcp.example.com"
// - "https://mcp.example.com/" → "https://mcp.example.com"
// - "https://mcp.example.com" → "https://mcp.example.com"
// - "http://localhost:8080/some/path" → "http://localhost:8080"
//
// Returns an error when the string cannot be parsed as an absolute URL or
// carries no host — either case would produce a nonsensical base URL for
// the auto-onboard client and we prefer to disable provisioning rather than
// send credentials to a bogus origin.
func deriveAutoOnboardBaseURL(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", errors.New("mcp_servers.url is empty")
}
u, err := url.Parse(trimmed)
if err != nil {
return "", fmt.Errorf("parse mcp_servers.url: %w", err)
}
if u.Scheme == "" || u.Host == "" {
return "", fmt.Errorf("mcp_servers.url %q has no scheme or host", trimmed)
}
// Reset every component that could turn the origin back into a full URL
// so a future field addition here doesn't silently break the invariant.
u.Path = ""
u.RawPath = ""
u.RawQuery = ""
u.Fragment = ""
u.Opaque = ""
return u.String(), nil
}
// tryAcquireMCPProvision atomically checks the debounce window for
// (serverID, userID) and, if clear, records a fresh attempt — all under a
// single lock so concurrent webhook-retry events can't both pass the gate
+144 -2
View File
@@ -30,6 +30,7 @@ type fakeMCPStore struct {
mu sync.Mutex
serversByName map[string]*store.MCPServerData
serversByID map[uuid.UUID]*store.MCPServerData
userCreds map[string]store.MCPUserCredentials // key = serverID + ":" + userID
getUserCallCount int
@@ -39,6 +40,7 @@ type fakeMCPStore struct {
func newFakeMCPStore() *fakeMCPStore {
return &fakeMCPStore{
serversByName: map[string]*store.MCPServerData{},
serversByID: map[uuid.UUID]*store.MCPServerData{},
userCreds: map[string]store.MCPUserCredentials{},
}
}
@@ -81,8 +83,13 @@ func (f *fakeMCPStore) SetUserCredentials(_ context.Context, serverID uuid.UUID,
func (f *fakeMCPStore) CreateServer(_ context.Context, _ *store.MCPServerData) error {
return nil
}
func (f *fakeMCPStore) GetServer(_ context.Context, _ uuid.UUID) (*store.MCPServerData, error) {
return nil, nil
func (f *fakeMCPStore) GetServer(_ context.Context, id uuid.UUID) (*store.MCPServerData, error) {
f.mu.Lock()
defer f.mu.Unlock()
if s, ok := f.serversByID[id]; ok {
return s, nil
}
return nil, nil // partner's contract: nil + nil when absent
}
func (f *fakeMCPStore) ListServers(_ context.Context) ([]store.MCPServerData, error) { return nil, nil }
func (f *fakeMCPStore) UpdateServer(_ context.Context, _ uuid.UUID, _ map[string]any) error {
@@ -607,6 +614,141 @@ func TestInitMCPProvisioner_DisabledModes(t *testing.T) {
t.Errorf("missing server row should leave provisioner off")
}
})
// mcp_server_id path (v3.15+ dashboard wiring): the UUID string must
// parse and resolve to a live mcp_servers row. Broken UUID or missing
// row silently disables provisioning — no fallback to legacy name.
t.Run("mcp_server_id_invalid_uuid", func(t *testing.T) {
fs := newFakeStore()
resetWebhookRouterForTest()
defer resetWebhookRouterForTest()
mcpStore := newFakeMCPStore()
fn := FactoryWithPortalStoreAndMCP(fs, mcpStore, "")
ch, _ := fn("b1", nil, json.RawMessage(`{"portal":"p","bot_code":"c","bot_name":"n","mcp_server_id":"not-a-uuid"}`),
bus.New(), nil)
bc := ch.(*Channel)
if err := bc.initMCPProvisioner(context.Background()); err != nil {
t.Fatalf("init: %v", err)
}
if bc.mcpClient != nil || bc.mcpServerID != uuid.Nil {
t.Errorf("invalid mcp_server_id UUID should leave provisioner off")
}
})
t.Run("mcp_server_id_row_not_found", func(t *testing.T) {
fs := newFakeStore()
resetWebhookRouterForTest()
defer resetWebhookRouterForTest()
mcpStore := newFakeMCPStore()
// Intentionally do NOT seed serversByID — GetServer returns nil.
fn := FactoryWithPortalStoreAndMCP(fs, mcpStore, "")
ch, _ := fn("b1", nil, json.RawMessage(`{"portal":"p","bot_code":"c","bot_name":"n","mcp_server_id":"00000000-0000-0000-0000-000000000042"}`),
bus.New(), nil)
bc := ch.(*Channel)
if err := bc.initMCPProvisioner(context.Background()); err != nil {
t.Fatalf("init: %v", err)
}
if bc.mcpClient != nil || bc.mcpServerID != uuid.Nil {
t.Errorf("missing mcp_servers row for mcp_server_id should leave provisioner off")
}
})
}
// TestDeriveAutoOnboardBaseURL guards the fix for the Phase 2 regression
// where mcp_servers.url (JSON-RPC endpoint under /mcp) was passed verbatim
// to mcp_client.newMCPClient — which appends "/api/auto-onboard" to it,
// producing "…/mcp/api/auto-onboard" (404). The origin extraction is the
// contract the Bitrix24 auto-onboard client expects.
func TestDeriveAutoOnboardBaseURL(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"jsonrpc subpath", "https://mcp.example.com/mcp", "https://mcp.example.com"},
{"jsonrpc trailing slash", "https://mcp.example.com/mcp/", "https://mcp.example.com"},
{"origin trailing slash", "https://mcp.example.com/", "https://mcp.example.com"},
{"origin only", "https://mcp.example.com", "https://mcp.example.com"},
{"deep path with query", "http://localhost:8080/some/path?x=1#frag", "http://localhost:8080"},
{"prod b24 syn mcp", "https://b24-mcp-dev.synity.so/mcp", "https://b24-mcp-dev.synity.so"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := deriveAutoOnboardBaseURL(tc.in)
if err != nil {
t.Fatalf("deriveAutoOnboardBaseURL(%q) unexpected error: %v", tc.in, err)
}
if got != tc.want {
t.Errorf("deriveAutoOnboardBaseURL(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
errCases := []struct {
name string
in string
}{
{"empty", ""},
{"whitespace only", " \t"},
{"no scheme", "mcp.example.com/mcp"},
{"no host", "https:///path"},
}
for _, tc := range errCases {
t.Run("err_"+tc.name, func(t *testing.T) {
if _, err := deriveAutoOnboardBaseURL(tc.in); err == nil {
t.Errorf("deriveAutoOnboardBaseURL(%q) expected error, got nil", tc.in)
}
})
}
}
// TestInitMCPProvisioner_MCPServerID exercises the v3.15+ id-based wiring:
// mcp_server_id resolves to a live mcp_servers row whose URL becomes the
// base URL for /api/auto-onboard. Legacy MCPServerName + MCPBaseURL are
// ignored on this path (single source of truth).
func TestInitMCPProvisioner_MCPServerID(t *testing.T) {
fs := newFakeStore()
resetWebhookRouterForTest()
defer resetWebhookRouterForTest()
mcpStore := newFakeMCPStore()
serverID := uuid.MustParse("019df803-ec13-76c3-b1f5-60b0e80d3eec")
// URL carries a /mcp subpath because that's how partner MCP servers
// really configure the JSON-RPC endpoint. initMCPProvisioner must
// strip it before handing the value to mcp_client (which appends
// /api/auto-onboard). Regression guard for the Phase 2 fix.
mcpStore.serversByID[serverID] = &store.MCPServerData{
BaseModel: store.BaseModel{ID: serverID},
Name: "b24-syn-mcp",
URL: "https://b24-mcp.example.test/mcp",
Enabled: true,
// Field promoted from settings JSONB in Phase 89 — the provisioner
// reads it to log accurately and downstream provisionIfMissing
// respects it via manager-level checks.
RequireUserCredentials: true,
}
fn := FactoryWithPortalStoreAndMCP(fs, mcpStore, "")
cfg := `{"portal":"p","bot_code":"c","bot_name":"n","mcp_server_id":"019df803-ec13-76c3-b1f5-60b0e80d3eec"}`
ch, err := fn("b1", nil, json.RawMessage(cfg), bus.New(), nil)
if err != nil {
t.Fatalf("factory: %v", err)
}
bc := ch.(*Channel)
if err := bc.initMCPProvisioner(context.Background()); err != nil {
t.Fatalf("init: %v", err)
}
if bc.mcpClient == nil {
t.Fatalf("mcp_server_id path should wire mcpClient — got nil")
}
if bc.mcpServerID != serverID {
t.Errorf("mcpServerID = %s, want %s", bc.mcpServerID, serverID)
}
}
// newBareChannelForNotifyTest builds a Channel that's wired enough for
+2 -1
View File
@@ -74,7 +74,8 @@ var mcpServerAllowedFields = map[string]bool{
"name": true, "display_name": true, "transport": true, "command": true, "args": true,
"url": true, "api_key": true, "env": true, "headers": true,
"enabled": true, "tool_prefix": true, "timeout_sec": true,
"agent_id": true, "config": true, "settings": true,
"require_user_credentials": true,
"agent_id": true, "config": true, "settings": true,
}
var channelInstanceAllowedFields = map[string]bool{
+6 -3
View File
@@ -280,7 +280,10 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc
}
// Skip server if it requires scoped/user credentials and none are present.
if requireUserCreds(srv.Settings) {
// Prefer the top-level column (Phase 89 backfilled from settings JSONB);
// fall back to the legacy JSONB entry for cases where a caller mutated
// settings directly without touching the column.
if srv.RequireUserCredentials || requireUserCreds(srv.Settings) {
if userID == "" {
return nil
}
@@ -368,7 +371,7 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc
}
tenantID := store.TenantIDFromContext(ctx)
oauthUserID := ""
if requireUserCreds(srv.Settings) {
if srv.RequireUserCredentials || requireUserCreds(srv.Settings) {
oauthUserID = userID
}
token, err2 := m.oauthTokenProvider.GetValidToken(ctx, srv.ID, tenantID, oauthUserID)
@@ -441,7 +444,7 @@ func (m *Manager) LoadForAgent(ctx context.Context, agentID uuid.UUID, userID st
for _, info := range accessible {
// When loading at startup (userID=""), store servers requiring per-user
// credentials for later per-request resolution instead of skipping them.
if userID == "" && requireUserCreds(info.Server.Settings) && info.Server.Enabled {
if userID == "" && (info.Server.RequireUserCredentials || requireUserCreds(info.Server.Settings)) && info.Server.Enabled {
m.userCredServers = append(m.userCredServers, info)
slog.Debug("mcp.server.deferred_user_creds", "server", info.Server.Name)
continue
+7 -1
View File
@@ -24,7 +24,13 @@ type MCPServerData struct {
TimeoutSec int `json:"timeout_sec" db:"timeout_sec"`
Settings json.RawMessage `json:"settings,omitempty" db:"settings"`
Enabled bool `json:"enabled" db:"enabled"`
CreatedBy string `json:"created_by" db:"created_by"`
// RequireUserCredentials marks servers that mint credentials per-user at
// message time (e.g. Bitrix24 channel auto-onboard) instead of sharing a
// single admin api_key across every caller. Promoted from
// settings.require_user_credentials (JSONB) to a top-level column so
// channel factories can filter mcp_servers directly.
RequireUserCredentials bool `json:"require_user_credentials" db:"require_user_credentials"`
CreatedBy string `json:"created_by" db:"created_by"`
}
// MCPAgentGrant represents an MCP server grant to an agent.
+9 -8
View File
@@ -20,9 +20,10 @@ type MCPServerExport struct {
Args json.RawMessage `json:"args,omitempty"`
URL string `json:"url,omitempty"`
ToolPrefix string `json:"tool_prefix,omitempty"`
TimeoutSec int `json:"timeout_sec"`
Settings json.RawMessage `json:"settings,omitempty"`
Enabled bool `json:"enabled"`
TimeoutSec int `json:"timeout_sec"`
Settings json.RawMessage `json:"settings,omitempty"`
Enabled bool `json:"enabled"`
RequireUserCredentials bool `json:"require_user_credentials,omitempty"`
}
// MCPGrantWithKey references an MCP agent grant by server_name and agent_key (portable).
@@ -51,7 +52,7 @@ func ExportMCPServers(ctx context.Context, db *sql.DB) ([]MCPServerExport, error
rows, err := db.QueryContext(ctx,
"SELECT name, COALESCE(display_name,''), transport,"+
" COALESCE(command,''), args, COALESCE(url,''),"+
" COALESCE(tool_prefix,''), timeout_sec, settings, enabled"+
" COALESCE(tool_prefix,''), timeout_sec, settings, enabled, require_user_credentials"+
" FROM mcp_servers WHERE 1=1"+tc+
" ORDER BY name",
tcArgs...,
@@ -71,7 +72,7 @@ func ExportMCPServers(ctx context.Context, db *sql.DB) ([]MCPServerExport, error
if err := rows.Scan(
&srv.Name, &srv.DisplayName, &srv.Transport,
&srv.Command, &argsRaw, &srv.URL,
&srv.ToolPrefix, &srv.TimeoutSec, &settings, &srv.Enabled,
&srv.ToolPrefix, &srv.TimeoutSec, &settings, &srv.Enabled, &srv.RequireUserCredentials,
); err != nil {
slog.Warn("mcp_export.servers.scan", "error", err)
continue
@@ -185,12 +186,12 @@ func ImportMCPServer(ctx context.Context, db *sql.DB, srv MCPServerExport, creat
_, err = db.ExecContext(ctx,
`INSERT INTO mcp_servers
(id, name, display_name, transport, command, args, url,
tool_prefix, timeout_sec, settings, enabled,
tool_prefix, timeout_sec, settings, enabled, require_user_credentials,
created_by, created_at, updated_at, tenant_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),NOW(),$13)`,
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NOW(),NOW(),$14)`,
id, srv.Name, srv.DisplayName, srv.Transport,
srv.Command, jsonOrNull(srv.Args), srv.URL,
srv.ToolPrefix, srv.TimeoutSec, jsonOrNull(srv.Settings), srv.Enabled,
srv.ToolPrefix, srv.TimeoutSec, jsonOrNull(srv.Settings), srv.Enabled, srv.RequireUserCredentials,
createdBy, tid,
)
if err != nil {
+4 -4
View File
@@ -56,12 +56,12 @@ func (s *PGMCPServerStore) CreateServer(ctx context.Context, srv *store.MCPServe
_, err := s.db.ExecContext(ctx,
`INSERT INTO mcp_servers (id, name, display_name, transport, command, args, url, headers, env,
api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at, tenant_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`,
api_key, tool_prefix, timeout_sec, settings, enabled, require_user_credentials, created_by, created_at, updated_at, tenant_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)`,
srv.ID, srv.Name, nilStr(srv.DisplayName), srv.Transport, nilStr(srv.Command),
jsonOrEmpty(srv.Args), nilStr(srv.URL), encHeaders, encEnv,
nilStr(apiKey), nilStr(srv.ToolPrefix), srv.TimeoutSec,
jsonOrEmpty(srv.Settings), srv.Enabled, srv.CreatedBy, now, now, tenantID,
jsonOrEmpty(srv.Settings), srv.Enabled, srv.RequireUserCredentials, srv.CreatedBy, now, now, tenantID,
)
return err
}
@@ -69,7 +69,7 @@ func (s *PGMCPServerStore) CreateServer(ctx context.Context, srv *store.MCPServe
const mcpServerSelectCols = `id, name, COALESCE(display_name, '') AS display_name, transport,
COALESCE(command, '') AS command, args, COALESCE(url, '') AS url, headers, env,
COALESCE(api_key, '') AS api_key, COALESCE(tool_prefix, '') AS tool_prefix,
timeout_sec, settings, enabled, created_by, created_at, updated_at`
timeout_sec, settings, enabled, require_user_credentials, created_by, created_at, updated_at`
func (s *PGMCPServerStore) GetServer(ctx context.Context, id uuid.UUID) (*store.MCPServerData, error) {
q := `SELECT ` + mcpServerSelectCols + ` FROM mcp_servers WHERE id = $1`
+4 -4
View File
@@ -17,7 +17,7 @@ import (
)
const mcpServerSelectCols = `id, name, display_name, transport, command, args, url, headers, env,
api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at`
api_key, tool_prefix, timeout_sec, settings, enabled, require_user_credentials, created_by, created_at, updated_at`
// SQLiteMCPServerStore implements store.MCPServerStore backed by SQLite.
type SQLiteMCPServerStore struct {
@@ -59,12 +59,12 @@ func (s *SQLiteMCPServerStore) CreateServer(ctx context.Context, srv *store.MCPS
_, err := s.db.ExecContext(ctx,
`INSERT INTO mcp_servers (id, name, display_name, transport, command, args, url, headers, env,
api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at, tenant_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
api_key, tool_prefix, timeout_sec, settings, enabled, require_user_credentials, created_by, created_at, updated_at, tenant_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
srv.ID, srv.Name, nilStr(srv.DisplayName), srv.Transport, nilStr(srv.Command),
jsonOrEmpty(srv.Args), nilStr(srv.URL), encHeaders, encEnv,
nilStr(apiKey), nilStr(srv.ToolPrefix), srv.TimeoutSec,
jsonOrEmpty(srv.Settings), srv.Enabled, srv.CreatedBy, now, now, tenantID,
jsonOrEmpty(srv.Settings), srv.Enabled, srv.RequireUserCredentials, srv.CreatedBy, now, now, tenantID,
)
return err
}
+42 -1
View File
@@ -16,7 +16,7 @@ var schemaSQL string
// SchemaVersion is the current SQLite schema version.
// Bump this when adding new migration steps below.
const SchemaVersion = 55
const SchemaVersion = 57
// migrations maps version → SQL to apply when upgrading FROM that version.
// schema.sql always represents the LATEST full schema (for fresh DBs).
@@ -898,6 +898,45 @@ ALTER TABLE usage_event_rollups ADD COLUMN thinking_tokens BIGINT NOT NULL DEFAU
CREATE INDEX IF NOT EXISTS idx_channel_pending_messages_parent
ON channel_pending_messages(channel_name, parent_history_key)
WHERE parent_history_key <> '';`,
// Version 55 → 56: promote require_user_credentials from settings JSONB
// to a top-level column so channel factories can filter directly.
// Backfill reads the legacy JSONB via json_extract so no admin needs to
// re-tick after upgrading. Mirrors PG migration 000092. Idempotent-guarded
// via idempotentColumnMigration(55).
55: `ALTER TABLE mcp_servers ADD COLUMN require_user_credentials BOOLEAN NOT NULL DEFAULT 0;
UPDATE mcp_servers
SET require_user_credentials = COALESCE(CAST(json_extract(settings, '$.require_user_credentials') AS INTEGER), 0)
WHERE settings IS NOT NULL
AND json_extract(settings, '$.require_user_credentials') IS NOT NULL;`,
// Version 56 → 57: backfill Bitrix24 channel_instances.config with
// mcp_server_id by resolving the legacy mcp_server_name against
// mcp_servers (matched on the channel's agent tenant_id since
// channel_instances doesn't carry tenant_id directly). Mirrors PG
// migration 000093. Idempotent — only touches rows without an
// existing mcp_server_id key.
56: `UPDATE channel_instances
SET config = json_set(
COALESCE(config, '{}'),
'$.mcp_server_id',
(SELECT srv.id
FROM mcp_servers srv
WHERE srv.name = json_extract(channel_instances.config, '$.mcp_server_name')
AND srv.tenant_id = (
SELECT a.tenant_id FROM agents a WHERE a.id = channel_instances.agent_id
)
LIMIT 1)
),
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE channel_type = 'bitrix24'
AND json_extract(config, '$.mcp_server_name') IS NOT NULL
AND json_extract(config, '$.mcp_server_id') IS NULL
AND EXISTS (
SELECT 1 FROM mcp_servers srv
WHERE srv.name = json_extract(channel_instances.config, '$.mcp_server_name')
AND srv.tenant_id = (
SELECT a.tenant_id FROM agents a WHERE a.id = channel_instances.agent_id
)
);`,
}
const addUsageEventAnalyticsTables = `
@@ -1549,6 +1588,8 @@ func idempotentColumnMigration(version int) (string, string, bool) {
return "secure_cli_binaries", "adapter_name", true
case 51:
return "webhook_calls", "last_heartbeat_at", true
case 55:
return "mcp_servers", "require_user_credentials", true
default:
return "", "", false
}
+7
View File
@@ -692,6 +692,13 @@ CREATE TABLE IF NOT EXISTS mcp_servers (
timeout_sec INT DEFAULT 60,
settings TEXT NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT 1,
-- require_user_credentials mirrors settings.require_user_credentials but
-- promoted to a top-level column so the Bitrix24 channel factory can
-- filter mcp_servers directly (indexable) rather than parse the full
-- JSONB per row. false = shared admin api_key applies to every caller.
-- true = the server mints credentials per-user at message time (Bitrix24
-- auto-onboard etc.).
require_user_credentials BOOLEAN NOT NULL DEFAULT 0,
created_by VARCHAR(255) NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+21 -19
View File
@@ -104,28 +104,30 @@ type mcpServerRow struct {
ToolPrefix *string `json:"tool_prefix" db:"tool_prefix"`
TimeoutSec int `json:"timeout_sec" db:"timeout_sec"`
Settings json.RawMessage `json:"settings" db:"settings"`
Enabled bool `json:"enabled" db:"enabled"`
CreatedBy string `json:"created_by" db:"created_by"`
CreatedAt sqliteTime `json:"created_at" db:"created_at"`
UpdatedAt sqliteTime `json:"updated_at" db:"updated_at"`
Enabled bool `json:"enabled" db:"enabled"`
RequireUserCredentials bool `json:"require_user_credentials" db:"require_user_credentials"`
CreatedBy string `json:"created_by" db:"created_by"`
CreatedAt sqliteTime `json:"created_at" db:"created_at"`
UpdatedAt sqliteTime `json:"updated_at" db:"updated_at"`
}
func (r *mcpServerRow) toMCPServerData() store.MCPServerData {
return store.MCPServerData{
BaseModel: store.BaseModel{ID: r.ID, CreatedAt: r.CreatedAt.Time, UpdatedAt: r.UpdatedAt.Time},
Name: r.Name,
DisplayName: derefStr(r.DisplayName),
Transport: r.Transport,
Command: derefStr(r.Command),
Args: r.Args,
URL: derefStr(r.URL),
Headers: r.Headers,
Env: r.Env,
APIKey: derefStr(r.APIKey),
ToolPrefix: derefStr(r.ToolPrefix),
TimeoutSec: r.TimeoutSec,
Settings: r.Settings,
Enabled: r.Enabled,
CreatedBy: r.CreatedBy,
BaseModel: store.BaseModel{ID: r.ID, CreatedAt: r.CreatedAt.Time, UpdatedAt: r.UpdatedAt.Time},
Name: r.Name,
DisplayName: derefStr(r.DisplayName),
Transport: r.Transport,
Command: derefStr(r.Command),
Args: r.Args,
URL: derefStr(r.URL),
Headers: r.Headers,
Env: r.Env,
APIKey: derefStr(r.APIKey),
ToolPrefix: derefStr(r.ToolPrefix),
TimeoutSec: r.TimeoutSec,
Settings: r.Settings,
Enabled: r.Enabled,
RequireUserCredentials: r.RequireUserCredentials,
CreatedBy: r.CreatedBy,
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ package upgrade
// RequiredSchemaVersion is the schema migration version this binary requires.
// Bump this whenever adding a new SQL migration file.
const RequiredSchemaVersion uint = 91
const RequiredSchemaVersion uint = 93
@@ -0,0 +1 @@
ALTER TABLE mcp_servers DROP COLUMN IF EXISTS require_user_credentials;
@@ -0,0 +1,16 @@
-- Promote the require_user_credentials flag from settings JSONB to a top-level
-- column so channel factories can filter mcp_servers directly (indexable) and
-- reduce the "read the whole JSONB" cost paid on every message.
--
-- The JSONB entry stays in place for one release cycle for legacy readers
-- (internal/mcp/manager.go:requireUserCreds); a later migration removes it
-- once all callers are on the column.
ALTER TABLE mcp_servers
ADD COLUMN IF NOT EXISTS require_user_credentials BOOLEAN NOT NULL DEFAULT false;
-- Backfill the column from existing settings blobs so no admin has to
-- re-tick the checkbox after upgrading.
UPDATE mcp_servers
SET require_user_credentials = COALESCE((settings->>'require_user_credentials')::boolean, false)
WHERE settings IS NOT NULL
AND settings ? 'require_user_credentials';
@@ -0,0 +1,9 @@
-- Revert the backfill by dropping the mcp_server_id key from Bitrix24
-- channel_instances configs. The legacy mcp_server_name + mcp_base_url pair
-- was never touched by the up migration, so provisioning falls back to it
-- automatically once the new key is gone.
UPDATE channel_instances
SET config = config - 'mcp_server_id',
updated_at = NOW()
WHERE channel_type = 'bitrix24'
AND config ? 'mcp_server_id';
@@ -0,0 +1,25 @@
-- Backfill channel_instances.config -> mcp_server_id for Bitrix24 channels that
-- currently store the legacy mcp_server_name. Resolves the name to a UUID via
-- mcp_servers (scoped to the channel agent's tenant) so the new MCP dropdown
-- and factory path (Phase 89) can drive lookups without touching the legacy
-- mcp_server_name / mcp_base_url pair.
--
-- Idempotent: only touches rows where config already carries the legacy
-- mcp_server_name AND does not yet carry mcp_server_id. Rows whose name does
-- not resolve to any mcp_servers row in the same tenant are left alone -- the
-- factory falls back to the legacy pair for them until an admin opens the
-- channel form and picks a server from the dropdown.
UPDATE channel_instances ci
SET config = jsonb_set(
COALESCE(ci.config, '{}'::jsonb),
'{mcp_server_id}',
to_jsonb(srv.id::text)
),
updated_at = NOW()
FROM mcp_servers srv, agents a
WHERE ci.channel_type = 'bitrix24'
AND ci.config ? 'mcp_server_name'
AND NOT (ci.config ? 'mcp_server_id')
AND ci.config->>'mcp_server_name' = srv.name
AND ci.agent_id = a.id
AND a.tenant_id = srv.tenant_id;
+4
View File
@@ -12,6 +12,8 @@ export interface MCPServerData {
timeout_sec: number
settings?: { require_user_credentials?: boolean }
enabled: boolean
/** Top-level twin of `settings.require_user_credentials` (Phase 89 rollout). */
require_user_credentials?: boolean
created_by: string
agent_count?: number
created_at: string
@@ -30,6 +32,8 @@ export interface MCPServerInput {
tool_prefix?: string
timeout_sec?: number
enabled?: boolean
/** Top-level flag written since Phase 89; backends still accept the legacy JSONB entry. */
require_user_credentials?: boolean
}
export interface MCPAgentGrant {
@@ -0,0 +1,70 @@
import { useMemo } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useMCP } from "@/pages/mcp/hooks/use-mcp";
import type { MCPServerData } from "@/types/mcp";
/**
* Dropdown picker over MCP servers whose `require_user_credentials` flag is
* true — the population channels can meaningfully auto-onboard against.
*
* The list comes from the same `useMCP()` query the MCP admin page uses so
* cache is shared and the filter is a cheap client-side pass (~10 servers
* per portal on realistic setups). Legacy `mcp_server_name` fallback is
* handled at the Bitrix24 factory layer; this component only writes UUIDs.
*/
interface MCPServerSelectProps {
value: string;
onChange: (value: string | undefined) => void;
placeholder?: string;
disabled?: boolean;
}
const CLEAR_VALUE = "__none__";
export function MCPServerSelect({ value, onChange, placeholder, disabled }: MCPServerSelectProps) {
const { servers, loading } = useMCP();
const perUserServers = useMemo<MCPServerData[]>(
() =>
(servers ?? []).filter(
(s) => s.require_user_credentials || s.settings?.require_user_credentials,
),
[servers],
);
const selectValue = value && value !== "" ? value : CLEAR_VALUE;
return (
<div className="grid gap-1.5">
<Select
value={selectValue}
onValueChange={(next) => onChange(next === CLEAR_VALUE ? undefined : next)}
disabled={disabled || loading}
>
<SelectTrigger>
<SelectValue placeholder={placeholder ?? (loading ? "Loading MCP servers…" : "Select an MCP server (optional)")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={CLEAR_VALUE}>— None (disable MCP provisioning) —</SelectItem>
{perUserServers.length === 0 && !loading && (
<SelectItem value="__empty__" disabled>
No per-user MCP servers registered
</SelectItem>
)}
{perUserServers.map((srv) => (
<SelectItem key={srv.id} value={srv.id}>
{srv.display_name?.trim() || srv.name}
{srv.url ? <span className="ml-2 text-xs text-muted-foreground">({srv.url})</span> : null}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
@@ -17,6 +17,7 @@ import { SkillNameSelect } from "@/components/shared/skill-name-select";
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
import { InfoLabel } from "@/components/shared/info-label";
import { BitrixPortalSelect } from "./bitrix24/bitrix-portal-select";
import { MCPServerSelect } from "./bitrix24/mcp-server-select";
import { deliveryModelKey, isDeliveryModelKey, isDeliveryProviderKey } from "./channel-delivery-provider-fields";
import type { FieldDef } from "./channel-schemas";
@@ -424,6 +425,19 @@ function FieldRenderer({
</div>
);
case "mcp-select":
return (
<div className="grid gap-1.5">
<FieldLabel htmlFor={id} text={label} tip={tooltipHelp} />
<MCPServerSelect
value={(value as string) ?? ""}
onChange={onChange}
placeholder={field.placeholder}
/>
{inlineHelp && <p className="text-xs text-muted-foreground">{inlineHelp}</p>}
</div>
);
case "tags":
return (
<div className="grid gap-1.5">
+9 -3
View File
@@ -5,7 +5,7 @@ import { reasoningDeliveryOptions } from "./reasoning-delivery-config";
export interface FieldDef {
key: string;
label: string;
type: "text" | "password" | "number" | "boolean" | "select" | "multi-select" | "tags" | "tristate" | "textarea" | "tool-select" | "skill-select";
type: "text" | "password" | "number" | "boolean" | "select" | "multi-select" | "tags" | "tristate" | "textarea" | "tool-select" | "skill-select" | "mcp-select";
placeholder?: string;
required?: boolean;
defaultValue?: string | number | boolean | string[];
@@ -298,8 +298,14 @@ export const configSchema: Record<string, FieldDef[]> = {
{ key: "allow_from", label: "Allowed Users (DM)", type: "tags", help: "Bitrix24 user IDs allowed to DM the bot. Empty = no allowlist filter." },
{ key: "group_allow_from", label: "Allowed Users (Group)", type: "tags", help: "Separate allowlist for group senders." },
...chatBehaviorOverrideFields,
{ key: "mcp_server_name", label: "MCP Server Name", type: "text", advanced: true, placeholder: "bitrix24-prod", help: "Optional — name from mcp_servers table. Must be set together with MCP Base URL to enable per-user MCP credential auto-onboard. Leave both empty to disable." },
{ key: "mcp_base_url", label: "MCP Base URL", type: "text", advanced: true, placeholder: "https://mcp.example.com", help: "Optional — HTTPS root of the partner MCP server. Channel POSTs {mcp_base_url}/api/auto-onboard to mint per-user credentials on first-sight. The MCP server authenticates each call via the caller's Bitrix access_token, so no admin secret is required." },
// Preferred single-select input backed by mcp_servers.require_user_credentials.
// Base URL is resolved from the selected row at Start() — no separate field needed.
{ key: "mcp_server_id", label: "MCP Server", type: "mcp-select", help: "Optional — pick a per-user MCP server (only servers with \"Require user credentials\" ticked appear here). Channel POSTs {server.url}/api/auto-onboard on first-sight to mint the caller's per-user credential. Leave empty to disable MCP provisioning for this channel." },
// Legacy string fields — kept for backward-compat with configs written before
// the mcp_server_id rollout. Phase 5 migrates every existing channel to
// mcp_server_id and drops these entries.
{ key: "mcp_server_name", label: "MCP Server Name (legacy)", type: "text", advanced: true, placeholder: "bitrix24-prod", help: "Deprecated — use \"MCP Server\" dropdown instead. Kept for backward-compat with pre-Phase-89 configs; will be removed once every channel is migrated." },
{ key: "mcp_base_url", label: "MCP Base URL (legacy)", type: "text", advanced: true, placeholder: "https://mcp.example.com", help: "Deprecated — see the \"MCP Server\" dropdown. Legacy base URL used only when the new dropdown is empty and the legacy name is set." },
],
};
+8 -1
View File
@@ -100,7 +100,10 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest, on
toolPrefix: server?.tool_prefix ?? "",
timeout: server?.timeout_sec ?? 60,
enabled: server?.enabled ?? true,
requireUserCreds: server?.settings?.require_user_credentials ?? false,
// Prefer the top-level column (Phase 89 backfilled from settings JSONB);
// fall back to the legacy settings entry so cached responses from
// pre-upgrade backends still render the checkbox with the right value.
requireUserCreds: server?.require_user_credentials ?? server?.settings?.require_user_credentials ?? false,
toolHintsGlobal: server?.settings?.tool_hints?.global ?? "",
toolHintsTools: server?.settings?.tool_hints?.tools ?? {},
oauthEnabled: oauth?.auth_type === "oauth",
@@ -190,6 +193,10 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest, on
timeout_sec: data.timeout,
settings,
enabled: data.enabled,
// Send the promoted top-level flag alongside the legacy settings
// twin so both backends (pre- and post-Phase 89) stay in sync during
// the migration window. Phase 5 drops the settings entry.
require_user_credentials: data.requireUserCreds,
};
};
+14
View File
@@ -41,6 +41,14 @@ export interface MCPServerData {
timeout_sec: number;
settings?: MCPServerSettings;
enabled: boolean;
/**
* True when the server mints credentials per-user at message time
* (Bitrix24 auto-onboard etc.). Promoted from `settings.require_user_credentials`
* to a top-level column; the settings entry stays for one release cycle
* so legacy readers keep working. Prefer this field over the settings
* entry when both are present.
*/
require_user_credentials?: boolean;
created_by: string;
agent_count?: number;
created_at: string;
@@ -60,6 +68,12 @@ export interface MCPServerInput {
timeout_sec?: number;
settings?: MCPServerSettings;
enabled?: boolean;
/**
* Top-level twin of `settings.require_user_credentials`. Send both while
* the server backend is still on the migration window (readers may hit
* either field); after Phase 5 lands the settings entry disappears.
*/
require_user_credentials?: boolean;
}
export interface MCPToolInfo {