feat(connector): support user-defined custom MCP servers (custom_mcp)

- Manager: branch on connector_type == 'custom_mcp', build pack from
  extra_config (server_uri + auth_type) instead of catalog. Add
  connector_id parameter for restore-from-DB id preservation.
- Service: pass extra_config and connector_id through to manager on both
  create and after_start rehydration paths. Rehydration now reads
  config_json so custom_mcp connectors survive restart.
- Frontend: align catalog type to backend (auth_fields), add select +
  default support, conditional token/header_name fields driven by
  auth_type, and submit a config payload alongside credentials for
  custom_mcp.
- Confirmation: custom_mcp tools are NOT in any catalog.confirm_actions,
  so the existing ConfirmationInterceptor naturally returns False --
  custom_mcp writes execute without confirmation (intentional MVP scope).
This commit is contained in:
alan.cl
2026-05-26 13:56:09 +08:00
parent 6eb5f1b961
commit 6add267298
6 changed files with 331 additions and 74 deletions

View File

@@ -205,41 +205,87 @@ class ConnectorManager(BaseComponent):
connector_type: str,
credentials: Dict[str, Any],
name: Optional[str] = None,
extra_config: Optional[Dict[str, Any]] = None,
connector_id: Optional[str] = None,
) -> str:
"""Validate, encrypt and activate a new connector instance.
Two branches are supported:
* **Built-in catalog connector** — ``connector_type`` must exist in the
loaded catalog (e.g. ``"feishu"``, ``"github"``). Server URI and
header mapping are taken from the catalog entry.
* **User-defined custom MCP server** — when
``connector_type == "custom_mcp"``, ``extra_config`` must supply
``server_uri`` (the SSE endpoint). Header mapping is derived from
``extra_config['auth_type']`` (``"none"``, ``"bearer"`` or
``"token"``) and the optional ``extra_config['header_name']``.
Args:
connector_type (str): A key that must exist in the loaded catalog
(e.g. ``"feishu"``, ``"github"``).
connector_type (str): A catalog key, or the literal
``"custom_mcp"`` for user-defined MCP servers.
credentials (Dict[str, Any]): Raw credential key/value pairs as
required by the connector's ``auth.fields`` spec.
name (Optional[str]): Human-readable label for this connector
instance. Defaults to *connector_type*.
extra_config (Optional[Dict[str, Any]]): Required when
``connector_type == "custom_mcp"``. Must contain
``{'server_uri': str, 'auth_type': str in
('none','bearer','token'), 'header_name': str (optional)}``.
connector_id (Optional[str]): Preserve a known UUID (used by
rehydration during process startup). When ``None``, a fresh
hex token is generated.
Returns:
str: A unique connector_id (hex string) that identifies this live
connector instance.
str: A unique connector_id (hex string or caller-supplied) that
identifies this live connector instance.
Raises:
ValueError: When *connector_type* is not found in the catalog.
ValueError: When *connector_type* is not found in the catalog and
is not ``"custom_mcp"``, or when ``custom_mcp`` is requested
without a valid ``server_uri`` in ``extra_config``.
"""
entry = self._catalog.get(connector_type)
if entry is None:
available = [e.type for e in self._catalog.list()]
raise ValueError(
f"Unknown connector type '{connector_type}'. "
f"Available types: {available}"
)
connector_id = secrets.token_hex(16)
connector_id = connector_id or secrets.token_hex(16)
salt = self._credential_store.generate_salt()
self._salts[connector_id] = salt
str_credentials: Dict[str, str] = {k: str(v) for k, v in credentials.items()}
if connector_type == "custom_mcp":
if not extra_config or "server_uri" not in extra_config:
raise ValueError("custom_mcp requires extra_config.server_uri")
server_uri = extra_config["server_uri"]
auth_type = extra_config.get("auth_type", "none")
if auth_type == "bearer":
header_mapping = {"token": "Authorization"}
# Auto-prefix Bearer if not already
token_val = str_credentials.get("token", "")
if token_val and not token_val.startswith("Bearer "):
str_credentials["token"] = f"Bearer {token_val}"
elif auth_type == "token":
header_mapping = {
"token": extra_config.get("header_name", "Authorization")
}
else: # auth_type == "none"
header_mapping = {}
display_name = name or "Custom MCP"
else:
entry = self._catalog.get(connector_type)
if entry is None:
available = [e.type for e in self._catalog.list()]
raise ValueError(
f"Unknown connector type '{connector_type}'. "
f"Available types: {available} "
f"(or 'custom_mcp' for user-defined MCP servers)"
)
server_uri = entry.mcp_server.server_uri
header_mapping = entry.auth.header_mapping
display_name = name or entry.display_name
self._credential_store.encrypt(str_credentials, salt)
headers: Dict[str, str] = {}
for field_name, header_name in entry.auth.header_mapping.items():
for field_name, header_name in header_mapping.items():
field_value = str_credentials.get(field_name)
if field_value is not None:
headers[header_name] = field_value
@@ -247,8 +293,7 @@ class ConnectorManager(BaseComponent):
# Import lazily to avoid circular dependency at module load time
from dbgpt.agent.resource.tool.pack import MCPToolPack
server_uri = entry.mcp_server.server_uri
pack_name = name or connector_type
pack_name = display_name
pack = MCPToolPack(
mcp_servers=server_uri,
default_headers=headers,

View File

@@ -287,3 +287,114 @@ def test_remove_connector_clears_connector_types():
assert cid not in manager._connector_types
assert not manager._has_multiple_instances_of_type("yuque")
# ---------------------------------------------------------------------------
# Task F custom_mcp branch + connector_id preservation
# ---------------------------------------------------------------------------
def _patch_preload_resource(monkeypatch):
"""Make MCPToolPack.preload_resource a no-op for tests."""
from dbgpt.agent.resource.tool import pack as pack_mod
async def _noop(self):
return None
monkeypatch.setattr(pack_mod.MCPToolPack, "preload_resource", _noop)
def test_create_connector_custom_mcp_bearer(monkeypatch: pytest.MonkeyPatch):
"""custom_mcp + auth_type=bearer should auto-prefix the token with 'Bearer '."""
_patch_preload_resource(monkeypatch)
manager = ConnectorManager()
import asyncio
cid = asyncio.run(
manager.create_connector(
connector_type="custom_mcp",
credentials={"token": "abc"},
extra_config={"server_uri": "http://x", "auth_type": "bearer"},
name="My MCP",
)
)
pack = manager._active_packs[cid]
assert pack._default_headers == {"Authorization": "Bearer abc"}
assert manager._connector_types[cid] == "custom_mcp"
assert manager._statuses[cid] == ConnectorStatus.active
def test_create_connector_custom_mcp_token(monkeypatch: pytest.MonkeyPatch):
"""custom_mcp + auth_type=token should use the raw token with a custom header name."""
_patch_preload_resource(monkeypatch)
manager = ConnectorManager()
import asyncio
cid = asyncio.run(
manager.create_connector(
connector_type="custom_mcp",
credentials={"token": "secret-value"},
extra_config={
"server_uri": "http://x",
"auth_type": "token",
"header_name": "X-Custom",
},
name="My MCP",
)
)
pack = manager._active_packs[cid]
assert pack._default_headers == {"X-Custom": "secret-value"}
def test_create_connector_custom_mcp_missing_server_uri_raises():
manager = ConnectorManager()
import asyncio
with pytest.raises(ValueError, match="custom_mcp requires extra_config.server_uri"):
asyncio.run(
manager.create_connector(
connector_type="custom_mcp",
credentials={},
extra_config={},
)
)
def test_create_connector_preserves_provided_connector_id(
monkeypatch: pytest.MonkeyPatch,
):
_patch_preload_resource(monkeypatch)
manager = ConnectorManager()
import asyncio
cid = asyncio.run(
manager.create_connector(
connector_type="custom_mcp",
credentials={},
extra_config={"server_uri": "http://x", "auth_type": "none"},
connector_id="fixed-id",
)
)
assert cid == "fixed-id"
assert "fixed-id" in manager._active_packs
def test_create_connector_unknown_type_message_mentions_custom_mcp():
manager = ConnectorManager()
import asyncio
with pytest.raises(ValueError, match="custom_mcp"):
asyncio.run(
manager.create_connector(
connector_type="whatever",
credentials={},
)
)

View File

@@ -7,8 +7,8 @@ from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
from dbgpt.agent.resource.connector.manager import ConnectorManager
from dbgpt.agent.resource.connector.credential import CredentialStore
from dbgpt.agent.resource.connector.manager import ConnectorManager
from dbgpt.component import SystemApp
from dbgpt.storage.metadata import BaseDao
from dbgpt.util import get_or_create_event_loop
@@ -133,18 +133,28 @@ class ConnectorService(
loop = get_or_create_event_loop()
with self._dao.session() as session:
active_connectors = [
{
"connector_id": entity.connector_id,
"connector_type": entity.connector_type,
"display_name": entity.display_name,
"encrypted_credentials": entity.encrypted_credentials,
"encryption_salt": entity.encryption_salt,
}
for entity in session.query(ConnectorInstanceEntity)
active_connectors = []
for entity in (
session.query(ConnectorInstanceEntity)
.filter(ConnectorInstanceEntity.status == "active")
.all()
]
):
extra_config: Optional[Dict[str, Any]] = None
if entity.config_json:
try:
extra_config = json.loads(entity.config_json)
except Exception:
extra_config = None
active_connectors.append(
{
"connector_id": entity.connector_id,
"connector_type": entity.connector_type,
"display_name": entity.display_name,
"encrypted_credentials": entity.encrypted_credentials,
"encryption_salt": entity.encryption_salt,
"config": extra_config,
}
)
for connector in active_connectors:
try:
@@ -157,6 +167,8 @@ class ConnectorService(
connector_type=connector["connector_type"],
credentials=credentials,
name=connector["display_name"],
extra_config=connector.get("config"),
connector_id=connector["connector_id"],
)
)
except Exception as exc:
@@ -226,6 +238,8 @@ class ConnectorService(
connector_type=request.connector_type,
credentials=request.credentials,
name=request.display_name,
extra_config=request.config,
connector_id=connector_id,
)
)

View File

@@ -32,7 +32,9 @@ def service(system_app: SystemApp):
return instance
def test_create_connector_encrypts_credentials_before_persisting(service: ConnectorService):
def test_create_connector_encrypts_credentials_before_persisting(
service: ConnectorService,
):
credentials = {"token": "secret-token"}
response = service.create_connector(
ConnectorCreateRequest(
@@ -119,11 +121,14 @@ def test_create_connector_activates_external_connector_manager(
)
)
fake_manager.create_connector.assert_awaited_once_with(
connector_type="github",
credentials={"token": "secret-token"},
name="GitHub Ops",
)
fake_manager.create_connector.assert_awaited_once()
call_kwargs = fake_manager.create_connector.call_args.kwargs
assert call_kwargs["connector_type"] == "github"
assert call_kwargs["credentials"] == {"token": "secret-token"}
assert call_kwargs["name"] == "GitHub Ops"
assert call_kwargs["extra_config"] is None
assert isinstance(call_kwargs["connector_id"], str)
assert call_kwargs["connector_id"]
def test_after_start_rehydrates_active_connectors_with_decrypted_credentials(
@@ -162,4 +167,6 @@ def test_after_start_rehydrates_active_connectors_with_decrypted_credentials(
connector_type="github",
credentials=credentials,
name="GitHub Ops",
extra_config=None,
connector_id="connector-1",
)

View File

@@ -1,6 +1,6 @@
import { Button, Form, Input, Modal, Select } from 'antd';
import React, { useEffect } from 'react';
import { ConnectorCatalogEntry, ConnectorInstance, CreateConnectorRequest } from './types';
import React, { useEffect, useMemo } from 'react';
import { ConnectorAuthField, ConnectorCatalogEntry, ConnectorInstance, CreateConnectorRequest } from './types';
interface ConnectorFormProps {
open: boolean;
@@ -11,40 +11,105 @@ interface ConnectorFormProps {
initialValues?: ConnectorInstance;
}
const ConnectorForm: React.FC<ConnectorFormProps> = ({ open, onClose, onSubmit, catalog, catalogLoading, initialValues }) => {
const [form] = Form.useForm<{
display_name: string;
connector_type: string;
credentials: Record<string, string>;
}>();
// Fields that for custom_mcp belong to `config` (not `credentials`)
const CUSTOM_MCP_CONFIG_FIELDS = new Set(['server_uri', 'auth_type', 'header_name']);
type FormShape = {
display_name: string;
connector_type: string;
// All field values are flat, keyed by field.name; we split into credentials/config on submit.
fields: Record<string, string>;
};
const ConnectorForm: React.FC<ConnectorFormProps> = ({
open,
onClose,
onSubmit,
catalog,
catalogLoading,
initialValues,
}) => {
const [form] = Form.useForm<FormShape>();
const selectedType = Form.useWatch('connector_type', form);
const watchedFields = Form.useWatch('fields', form) ?? {};
const selectedCatalog = catalog.find(c => c.type === selectedType);
const isCustomMcp = selectedType === 'custom_mcp';
const authType = watchedFields?.auth_type;
const visibleFields: ConnectorAuthField[] = useMemo(() => {
if (!selectedCatalog) return [];
if (!isCustomMcp) return selectedCatalog.auth_fields ?? [];
// For custom_mcp, hide token/header_name based on auth_type
return (selectedCatalog.auth_fields ?? []).filter(field => {
if (field.name === 'token') return authType === 'bearer' || authType === 'token';
if (field.name === 'header_name') return authType === 'token';
return true;
});
}, [selectedCatalog, isCustomMcp, authType]);
useEffect(() => {
if (open) {
if (initialValues) {
form.setFieldsValue({
display_name: initialValues.display_name,
connector_type: initialValues.connector_type,
credentials: (initialValues.config as Record<string, string>) ?? {},
});
} else {
form.resetFields();
if (!open) return;
if (initialValues) {
// Re-hydrate from stored config (custom_mcp) or credentials (built-in).
const merged: Record<string, string> = {};
if (initialValues.config) {
for (const [k, v] of Object.entries(initialValues.config)) {
if (v != null) merged[k] = String(v);
}
}
form.setFieldsValue({
display_name: initialValues.display_name,
connector_type: initialValues.connector_type,
fields: merged,
});
} else {
form.resetFields();
}
}, [open, initialValues, form]);
const handleFinish = (values: {
display_name: string;
connector_type: string;
credentials?: Record<string, string>;
}) => {
// Apply defaults when a type is (re)selected.
useEffect(() => {
if (!selectedCatalog) return;
const current = form.getFieldValue('fields') ?? {};
let changed = false;
const next: Record<string, string> = { ...current };
for (const field of selectedCatalog.auth_fields ?? []) {
if (field.default !== undefined && (next[field.name] === undefined || next[field.name] === '')) {
next[field.name] = field.default;
changed = true;
}
}
if (changed) {
form.setFieldsValue({ fields: next });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedType]);
const handleFinish = (values: FormShape) => {
const all = values.fields ?? {};
const credentials: Record<string, string> = {};
const config: Record<string, unknown> = {};
for (const field of selectedCatalog?.auth_fields ?? []) {
const value = all[field.name];
if (value === undefined || value === '') continue;
if (isCustomMcp && CUSTOM_MCP_CONFIG_FIELDS.has(field.name)) {
config[field.name] = value;
} else {
credentials[field.name] = value;
}
}
const request: CreateConnectorRequest = {
connector_type: values.connector_type,
display_name: values.display_name,
credentials: values.credentials ?? {},
credentials,
};
if (isCustomMcp) {
request.config = config;
}
onSubmit(request);
};
@@ -80,21 +145,32 @@ const ConnectorForm: React.FC<ConnectorFormProps> = ({ open, onClose, onSubmit,
/>
</Form.Item>
{selectedCatalog &&
selectedCatalog.auth.fields.map(field => (
{visibleFields.map(field => {
const placeholder = `请输入${field.label}`;
let control: React.ReactNode;
if (field.type === 'select') {
control = (
<Select
placeholder={`请选择${field.label}`}
options={(field.options ?? []).map(opt => ({ label: opt, value: opt }))}
/>
);
} else if (field.type === 'password') {
control = <Input.Password placeholder={placeholder} />;
} else {
control = <Input type={field.type} placeholder={placeholder} />;
}
return (
<Form.Item
key={field.name}
name={['credentials', field.name]}
name={['fields', field.name]}
label={field.label}
rules={field.required ? [{ required: true, message: `请输入${field.label}` }] : []}
>
{field.type === 'password' ? (
<Input.Password placeholder={`请输入${field.label}`} />
) : (
<Input type={field.type} placeholder={`请输入${field.label}`} />
)}
{control}
</Form.Item>
))}
);
})}
<div className='flex justify-end gap-2'>
<Button onClick={onClose}></Button>

View File

@@ -15,26 +15,30 @@ export interface ConnectorInstance {
created_at?: string;
}
export interface ConnectorAuthField {
name: string;
label: string;
type: 'text' | 'password' | 'url' | 'select';
required: boolean;
options?: string[];
default?: string;
}
export interface ConnectorCatalogEntry {
type: string;
display_name: string;
description: string;
icon?: string;
category: string;
auth: {
fields: Array<{
name: string;
label: string;
type: 'text' | 'password' | 'url';
required: boolean;
}>;
};
is_custom?: boolean;
auth_fields: ConnectorAuthField[];
}
export interface CreateConnectorRequest {
connector_type: string;
display_name: string;
credentials: Record<string, string>;
config?: Record<string, unknown>;
}
export interface PendingConfirmation {