mirror of
https://github.com/csunny/DB-GPT.git
synced 2026-07-16 17:15:22 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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={},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user