From 6add267298951ca8528427d24d8fe08d5cbdff0a Mon Sep 17 00:00:00 2001 From: "alan.cl" <1165243776@qq.com> Date: Tue, 26 May 2026 13:56:09 +0800 Subject: [PATCH] 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). --- .../dbgpt/agent/resource/connector/manager.py | 79 +++++++--- .../resource/connector/tests/test_manager.py | 111 ++++++++++++++ .../dbgpt_serve/connector/service/service.py | 36 +++-- .../connector/tests/test_service.py | 19 ++- .../connector/ConnectorForm.tsx | 140 ++++++++++++++---- web/new-components/connector/types.ts | 20 ++- 6 files changed, 331 insertions(+), 74 deletions(-) diff --git a/packages/dbgpt-core/src/dbgpt/agent/resource/connector/manager.py b/packages/dbgpt-core/src/dbgpt/agent/resource/connector/manager.py index c981d9384..102582c80 100644 --- a/packages/dbgpt-core/src/dbgpt/agent/resource/connector/manager.py +++ b/packages/dbgpt-core/src/dbgpt/agent/resource/connector/manager.py @@ -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, diff --git a/packages/dbgpt-core/src/dbgpt/agent/resource/connector/tests/test_manager.py b/packages/dbgpt-core/src/dbgpt/agent/resource/connector/tests/test_manager.py index 1b17c5259..a76da8250 100644 --- a/packages/dbgpt-core/src/dbgpt/agent/resource/connector/tests/test_manager.py +++ b/packages/dbgpt-core/src/dbgpt/agent/resource/connector/tests/test_manager.py @@ -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={}, + ) + ) diff --git a/packages/dbgpt-serve/src/dbgpt_serve/connector/service/service.py b/packages/dbgpt-serve/src/dbgpt_serve/connector/service/service.py index 1eb4faf09..e94c824b1 100644 --- a/packages/dbgpt-serve/src/dbgpt_serve/connector/service/service.py +++ b/packages/dbgpt-serve/src/dbgpt_serve/connector/service/service.py @@ -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, ) ) diff --git a/packages/dbgpt-serve/src/dbgpt_serve/connector/tests/test_service.py b/packages/dbgpt-serve/src/dbgpt_serve/connector/tests/test_service.py index 618f9bacd..864a82e14 100644 --- a/packages/dbgpt-serve/src/dbgpt_serve/connector/tests/test_service.py +++ b/packages/dbgpt-serve/src/dbgpt_serve/connector/tests/test_service.py @@ -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", ) diff --git a/web/new-components/connector/ConnectorForm.tsx b/web/new-components/connector/ConnectorForm.tsx index 83bb98144..170b1db42 100644 --- a/web/new-components/connector/ConnectorForm.tsx +++ b/web/new-components/connector/ConnectorForm.tsx @@ -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 = ({ open, onClose, onSubmit, catalog, catalogLoading, initialValues }) => { - const [form] = Form.useForm<{ - display_name: string; - connector_type: string; - credentials: Record; - }>(); +// 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; +}; + +const ConnectorForm: React.FC = ({ + open, + onClose, + onSubmit, + catalog, + catalogLoading, + initialValues, +}) => { + const [form] = Form.useForm(); 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) ?? {}, - }); - } else { - form.resetFields(); + if (!open) return; + if (initialValues) { + // Re-hydrate from stored config (custom_mcp) or credentials (built-in). + const merged: Record = {}; + 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; - }) => { + // Apply defaults when a type is (re)selected. + useEffect(() => { + if (!selectedCatalog) return; + const current = form.getFieldValue('fields') ?? {}; + let changed = false; + const next: Record = { ...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 = {}; + const config: Record = {}; + + 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 = ({ open, onClose, onSubmit, /> - {selectedCatalog && - selectedCatalog.auth.fields.map(field => ( + {visibleFields.map(field => { + const placeholder = `请输入${field.label}`; + let control: React.ReactNode; + if (field.type === 'select') { + control = ( + ; + } + return ( - {field.type === 'password' ? ( - - ) : ( - - )} + {control} - ))} + ); + })}
diff --git a/web/new-components/connector/types.ts b/web/new-components/connector/types.ts index 22b187bd1..6bb5a346b 100644 --- a/web/new-components/connector/types.ts +++ b/web/new-components/connector/types.ts @@ -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; + config?: Record; } export interface PendingConfirmation {