Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> Co-authored-by: Novice <novice12185727@gmail.com>tags/1.9.0
| @@ -865,6 +865,7 @@ class ToolProviderMCPApi(Resource): | |||
| parser.add_argument( | |||
| "sse_read_timeout", type=float, required=False, nullable=False, location="json", default=300 | |||
| ) | |||
| parser.add_argument("headers", type=dict, required=False, nullable=True, location="json", default={}) | |||
| args = parser.parse_args() | |||
| user = current_user | |||
| if not is_valid_url(args["server_url"]): | |||
| @@ -881,6 +882,7 @@ class ToolProviderMCPApi(Resource): | |||
| server_identifier=args["server_identifier"], | |||
| timeout=args["timeout"], | |||
| sse_read_timeout=args["sse_read_timeout"], | |||
| headers=args["headers"], | |||
| ) | |||
| ) | |||
| @@ -898,6 +900,7 @@ class ToolProviderMCPApi(Resource): | |||
| parser.add_argument("server_identifier", type=str, required=True, nullable=False, location="json") | |||
| parser.add_argument("timeout", type=float, required=False, nullable=True, location="json") | |||
| parser.add_argument("sse_read_timeout", type=float, required=False, nullable=True, location="json") | |||
| parser.add_argument("headers", type=dict, required=False, nullable=True, location="json") | |||
| args = parser.parse_args() | |||
| if not is_valid_url(args["server_url"]): | |||
| if "[__HIDDEN__]" in args["server_url"]: | |||
| @@ -915,6 +918,7 @@ class ToolProviderMCPApi(Resource): | |||
| server_identifier=args["server_identifier"], | |||
| timeout=args.get("timeout"), | |||
| sse_read_timeout=args.get("sse_read_timeout"), | |||
| headers=args.get("headers"), | |||
| ) | |||
| return {"result": "success"} | |||
| @@ -951,6 +955,9 @@ class ToolMCPAuthApi(Resource): | |||
| authed=False, | |||
| authorization_code=args["authorization_code"], | |||
| for_list=True, | |||
| headers=provider.decrypted_headers, | |||
| timeout=provider.timeout, | |||
| sse_read_timeout=provider.sse_read_timeout, | |||
| ): | |||
| MCPToolManageService.update_mcp_provider_credentials( | |||
| mcp_provider=provider, | |||
| @@ -43,6 +43,10 @@ class ToolProviderApiEntity(BaseModel): | |||
| server_url: Optional[str] = Field(default="", description="The server url of the tool") | |||
| updated_at: int = Field(default_factory=lambda: int(datetime.now().timestamp())) | |||
| server_identifier: Optional[str] = Field(default="", description="The server identifier of the MCP tool") | |||
| timeout: Optional[float] = Field(default=30.0, description="The timeout of the MCP tool") | |||
| sse_read_timeout: Optional[float] = Field(default=300.0, description="The SSE read timeout of the MCP tool") | |||
| masked_headers: Optional[dict[str, str]] = Field(default=None, description="The masked headers of the MCP tool") | |||
| original_headers: Optional[dict[str, str]] = Field(default=None, description="The original headers of the MCP tool") | |||
| @field_validator("tools", mode="before") | |||
| @classmethod | |||
| @@ -65,6 +69,10 @@ class ToolProviderApiEntity(BaseModel): | |||
| if self.type == ToolProviderType.MCP: | |||
| optional_fields.update(self.optional_field("updated_at", self.updated_at)) | |||
| optional_fields.update(self.optional_field("server_identifier", self.server_identifier)) | |||
| optional_fields.update(self.optional_field("timeout", self.timeout)) | |||
| optional_fields.update(self.optional_field("sse_read_timeout", self.sse_read_timeout)) | |||
| optional_fields.update(self.optional_field("masked_headers", self.masked_headers)) | |||
| optional_fields.update(self.optional_field("original_headers", self.original_headers)) | |||
| return { | |||
| "id": self.id, | |||
| "author": self.author, | |||
| @@ -94,7 +94,7 @@ class MCPToolProviderController(ToolProviderController): | |||
| provider_id=db_provider.server_identifier or "", | |||
| tenant_id=db_provider.tenant_id or "", | |||
| server_url=db_provider.decrypted_server_url, | |||
| headers={}, # TODO: get headers from db provider | |||
| headers=db_provider.decrypted_headers or {}, | |||
| timeout=db_provider.timeout, | |||
| sse_read_timeout=db_provider.sse_read_timeout, | |||
| ) | |||
| @@ -0,0 +1,27 @@ | |||
| """add_headers_to_mcp_provider | |||
| Revision ID: c20211f18133 | |||
| Revises: 8d289573e1da | |||
| Create Date: 2025-08-29 10:07:54.163626 | |||
| """ | |||
| from alembic import op | |||
| import models as models | |||
| import sqlalchemy as sa | |||
| # revision identifiers, used by Alembic. | |||
| revision = 'c20211f18133' | |||
| down_revision = 'b95962a3885c' | |||
| branch_labels = None | |||
| depends_on = None | |||
| def upgrade(): | |||
| # Add encrypted_headers column to tool_mcp_providers table | |||
| op.add_column('tool_mcp_providers', sa.Column('encrypted_headers', sa.Text(), nullable=True)) | |||
| def downgrade(): | |||
| # Remove encrypted_headers column from tool_mcp_providers table | |||
| op.drop_column('tool_mcp_providers', 'encrypted_headers') | |||
| @@ -280,6 +280,8 @@ class MCPToolProvider(Base): | |||
| ) | |||
| timeout: Mapped[float] = mapped_column(sa.Float, nullable=False, server_default=sa.text("30")) | |||
| sse_read_timeout: Mapped[float] = mapped_column(sa.Float, nullable=False, server_default=sa.text("300")) | |||
| # encrypted headers for MCP server requests | |||
| encrypted_headers: Mapped[str | None] = mapped_column(sa.Text, nullable=True) | |||
| def load_user(self) -> Account | None: | |||
| return db.session.query(Account).where(Account.id == self.user_id).first() | |||
| @@ -310,6 +312,62 @@ class MCPToolProvider(Base): | |||
| def decrypted_server_url(self) -> str: | |||
| return encrypter.decrypt_token(self.tenant_id, self.server_url) | |||
| @property | |||
| def decrypted_headers(self) -> dict[str, Any]: | |||
| """Get decrypted headers for MCP server requests.""" | |||
| from core.entities.provider_entities import BasicProviderConfig | |||
| from core.helper.provider_cache import NoOpProviderCredentialCache | |||
| from core.tools.utils.encryption import create_provider_encrypter | |||
| try: | |||
| if not self.encrypted_headers: | |||
| return {} | |||
| headers_data = json.loads(self.encrypted_headers) | |||
| # Create dynamic config for all headers as SECRET_INPUT | |||
| config = [BasicProviderConfig(type=BasicProviderConfig.Type.SECRET_INPUT, name=key) for key in headers_data] | |||
| encrypter_instance, _ = create_provider_encrypter( | |||
| tenant_id=self.tenant_id, | |||
| config=config, | |||
| cache=NoOpProviderCredentialCache(), | |||
| ) | |||
| result = encrypter_instance.decrypt(headers_data) | |||
| return result | |||
| except Exception: | |||
| return {} | |||
| @property | |||
| def masked_headers(self) -> dict[str, Any]: | |||
| """Get masked headers for frontend display.""" | |||
| from core.entities.provider_entities import BasicProviderConfig | |||
| from core.helper.provider_cache import NoOpProviderCredentialCache | |||
| from core.tools.utils.encryption import create_provider_encrypter | |||
| try: | |||
| if not self.encrypted_headers: | |||
| return {} | |||
| headers_data = json.loads(self.encrypted_headers) | |||
| # Create dynamic config for all headers as SECRET_INPUT | |||
| config = [BasicProviderConfig(type=BasicProviderConfig.Type.SECRET_INPUT, name=key) for key in headers_data] | |||
| encrypter_instance, _ = create_provider_encrypter( | |||
| tenant_id=self.tenant_id, | |||
| config=config, | |||
| cache=NoOpProviderCredentialCache(), | |||
| ) | |||
| # First decrypt, then mask | |||
| decrypted_headers = encrypter_instance.decrypt(headers_data) | |||
| result = encrypter_instance.mask_tool_credentials(decrypted_headers) | |||
| return result | |||
| except Exception: | |||
| return {} | |||
| @property | |||
| def masked_server_url(self) -> str: | |||
| def mask_url(url: str, mask_char: str = "*") -> str: | |||
| @@ -1,7 +1,7 @@ | |||
| import hashlib | |||
| import json | |||
| from datetime import datetime | |||
| from typing import Any | |||
| from typing import Any, cast | |||
| from sqlalchemy import or_ | |||
| from sqlalchemy.exc import IntegrityError | |||
| @@ -27,6 +27,36 @@ class MCPToolManageService: | |||
| Service class for managing mcp tools. | |||
| """ | |||
| @staticmethod | |||
| def _encrypt_headers(headers: dict[str, str], tenant_id: str) -> dict[str, str]: | |||
| """ | |||
| Encrypt headers using ProviderConfigEncrypter with all headers as SECRET_INPUT. | |||
| Args: | |||
| headers: Dictionary of headers to encrypt | |||
| tenant_id: Tenant ID for encryption | |||
| Returns: | |||
| Dictionary with all headers encrypted | |||
| """ | |||
| if not headers: | |||
| return {} | |||
| from core.entities.provider_entities import BasicProviderConfig | |||
| from core.helper.provider_cache import NoOpProviderCredentialCache | |||
| from core.tools.utils.encryption import create_provider_encrypter | |||
| # Create dynamic config for all headers as SECRET_INPUT | |||
| config = [BasicProviderConfig(type=BasicProviderConfig.Type.SECRET_INPUT, name=key) for key in headers] | |||
| encrypter_instance, _ = create_provider_encrypter( | |||
| tenant_id=tenant_id, | |||
| config=config, | |||
| cache=NoOpProviderCredentialCache(), | |||
| ) | |||
| return cast(dict[str, str], encrypter_instance.encrypt(headers)) | |||
| @staticmethod | |||
| def get_mcp_provider_by_provider_id(provider_id: str, tenant_id: str) -> MCPToolProvider: | |||
| res = ( | |||
| @@ -61,6 +91,7 @@ class MCPToolManageService: | |||
| server_identifier: str, | |||
| timeout: float, | |||
| sse_read_timeout: float, | |||
| headers: dict[str, str] | None = None, | |||
| ) -> ToolProviderApiEntity: | |||
| server_url_hash = hashlib.sha256(server_url.encode()).hexdigest() | |||
| existing_provider = ( | |||
| @@ -83,6 +114,12 @@ class MCPToolManageService: | |||
| if existing_provider.server_identifier == server_identifier: | |||
| raise ValueError(f"MCP tool {server_identifier} already exists") | |||
| encrypted_server_url = encrypter.encrypt_token(tenant_id, server_url) | |||
| # Encrypt headers | |||
| encrypted_headers = None | |||
| if headers: | |||
| encrypted_headers_dict = MCPToolManageService._encrypt_headers(headers, tenant_id) | |||
| encrypted_headers = json.dumps(encrypted_headers_dict) | |||
| mcp_tool = MCPToolProvider( | |||
| tenant_id=tenant_id, | |||
| name=name, | |||
| @@ -95,6 +132,7 @@ class MCPToolManageService: | |||
| server_identifier=server_identifier, | |||
| timeout=timeout, | |||
| sse_read_timeout=sse_read_timeout, | |||
| encrypted_headers=encrypted_headers, | |||
| ) | |||
| db.session.add(mcp_tool) | |||
| db.session.commit() | |||
| @@ -118,9 +156,21 @@ class MCPToolManageService: | |||
| mcp_provider = cls.get_mcp_provider_by_provider_id(provider_id, tenant_id) | |||
| server_url = mcp_provider.decrypted_server_url | |||
| authed = mcp_provider.authed | |||
| headers = mcp_provider.decrypted_headers | |||
| timeout = mcp_provider.timeout | |||
| sse_read_timeout = mcp_provider.sse_read_timeout | |||
| try: | |||
| with MCPClient(server_url, provider_id, tenant_id, authed=authed, for_list=True) as mcp_client: | |||
| with MCPClient( | |||
| server_url, | |||
| provider_id, | |||
| tenant_id, | |||
| authed=authed, | |||
| for_list=True, | |||
| headers=headers, | |||
| timeout=timeout, | |||
| sse_read_timeout=sse_read_timeout, | |||
| ) as mcp_client: | |||
| tools = mcp_client.list_tools() | |||
| except MCPAuthError: | |||
| raise ValueError("Please auth the tool first") | |||
| @@ -172,6 +222,7 @@ class MCPToolManageService: | |||
| server_identifier: str, | |||
| timeout: float | None = None, | |||
| sse_read_timeout: float | None = None, | |||
| headers: dict[str, str] | None = None, | |||
| ): | |||
| mcp_provider = cls.get_mcp_provider_by_provider_id(provider_id, tenant_id) | |||
| @@ -207,6 +258,13 @@ class MCPToolManageService: | |||
| mcp_provider.timeout = timeout | |||
| if sse_read_timeout is not None: | |||
| mcp_provider.sse_read_timeout = sse_read_timeout | |||
| if headers is not None: | |||
| # Encrypt headers | |||
| if headers: | |||
| encrypted_headers_dict = MCPToolManageService._encrypt_headers(headers, tenant_id) | |||
| mcp_provider.encrypted_headers = json.dumps(encrypted_headers_dict) | |||
| else: | |||
| mcp_provider.encrypted_headers = None | |||
| db.session.commit() | |||
| except IntegrityError as e: | |||
| db.session.rollback() | |||
| @@ -242,6 +300,12 @@ class MCPToolManageService: | |||
| @classmethod | |||
| def _re_connect_mcp_provider(cls, server_url: str, provider_id: str, tenant_id: str): | |||
| # Get the existing provider to access headers and timeout settings | |||
| mcp_provider = cls.get_mcp_provider_by_provider_id(provider_id, tenant_id) | |||
| headers = mcp_provider.decrypted_headers | |||
| timeout = mcp_provider.timeout | |||
| sse_read_timeout = mcp_provider.sse_read_timeout | |||
| try: | |||
| with MCPClient( | |||
| server_url, | |||
| @@ -249,6 +313,9 @@ class MCPToolManageService: | |||
| tenant_id, | |||
| authed=False, | |||
| for_list=True, | |||
| headers=headers, | |||
| timeout=timeout, | |||
| sse_read_timeout=sse_read_timeout, | |||
| ) as mcp_client: | |||
| tools = mcp_client.list_tools() | |||
| return { | |||
| @@ -237,6 +237,10 @@ class ToolTransformService: | |||
| label=I18nObject(en_US=db_provider.name, zh_Hans=db_provider.name), | |||
| description=I18nObject(en_US="", zh_Hans=""), | |||
| server_identifier=db_provider.server_identifier, | |||
| timeout=db_provider.timeout, | |||
| sse_read_timeout=db_provider.sse_read_timeout, | |||
| masked_headers=db_provider.masked_headers, | |||
| original_headers=db_provider.decrypted_headers, | |||
| ) | |||
| @staticmethod | |||
| @@ -706,7 +706,14 @@ class TestMCPToolManageService: | |||
| # Verify mock interactions | |||
| mock_mcp_client.assert_called_once_with( | |||
| "https://example.com/mcp", mcp_provider.id, tenant.id, authed=False, for_list=True | |||
| "https://example.com/mcp", | |||
| mcp_provider.id, | |||
| tenant.id, | |||
| authed=False, | |||
| for_list=True, | |||
| headers={}, | |||
| timeout=30.0, | |||
| sse_read_timeout=300.0, | |||
| ) | |||
| def test_list_mcp_tool_from_remote_server_auth_error( | |||
| @@ -1181,6 +1188,11 @@ class TestMCPToolManageService: | |||
| db_session_with_containers, mock_external_service_dependencies | |||
| ) | |||
| # Create MCP provider first | |||
| mcp_provider = self._create_test_mcp_provider( | |||
| db_session_with_containers, mock_external_service_dependencies, tenant.id, account.id | |||
| ) | |||
| # Mock MCPClient and its context manager | |||
| mock_tools = [ | |||
| type("MockTool", (), {"model_dump": lambda self: {"name": "test_tool_1", "description": "Test tool 1"}})(), | |||
| @@ -1194,7 +1206,7 @@ class TestMCPToolManageService: | |||
| # Act: Execute the method under test | |||
| result = MCPToolManageService._re_connect_mcp_provider( | |||
| "https://example.com/mcp", "test_provider_id", tenant.id | |||
| "https://example.com/mcp", mcp_provider.id, tenant.id | |||
| ) | |||
| # Assert: Verify the expected outcomes | |||
| @@ -1213,7 +1225,14 @@ class TestMCPToolManageService: | |||
| # Verify mock interactions | |||
| mock_mcp_client.assert_called_once_with( | |||
| "https://example.com/mcp", "test_provider_id", tenant.id, authed=False, for_list=True | |||
| "https://example.com/mcp", | |||
| mcp_provider.id, | |||
| tenant.id, | |||
| authed=False, | |||
| for_list=True, | |||
| headers={}, | |||
| timeout=30.0, | |||
| sse_read_timeout=300.0, | |||
| ) | |||
| def test_re_connect_mcp_provider_auth_error(self, db_session_with_containers, mock_external_service_dependencies): | |||
| @@ -1231,6 +1250,11 @@ class TestMCPToolManageService: | |||
| db_session_with_containers, mock_external_service_dependencies | |||
| ) | |||
| # Create MCP provider first | |||
| mcp_provider = self._create_test_mcp_provider( | |||
| db_session_with_containers, mock_external_service_dependencies, tenant.id, account.id | |||
| ) | |||
| # Mock MCPClient to raise authentication error | |||
| with patch("services.tools.mcp_tools_manage_service.MCPClient") as mock_mcp_client: | |||
| from core.mcp.error import MCPAuthError | |||
| @@ -1240,7 +1264,7 @@ class TestMCPToolManageService: | |||
| # Act: Execute the method under test | |||
| result = MCPToolManageService._re_connect_mcp_provider( | |||
| "https://example.com/mcp", "test_provider_id", tenant.id | |||
| "https://example.com/mcp", mcp_provider.id, tenant.id | |||
| ) | |||
| # Assert: Verify the expected outcomes | |||
| @@ -1265,6 +1289,11 @@ class TestMCPToolManageService: | |||
| db_session_with_containers, mock_external_service_dependencies | |||
| ) | |||
| # Create MCP provider first | |||
| mcp_provider = self._create_test_mcp_provider( | |||
| db_session_with_containers, mock_external_service_dependencies, tenant.id, account.id | |||
| ) | |||
| # Mock MCPClient to raise connection error | |||
| with patch("services.tools.mcp_tools_manage_service.MCPClient") as mock_mcp_client: | |||
| from core.mcp.error import MCPError | |||
| @@ -1274,4 +1303,4 @@ class TestMCPToolManageService: | |||
| # Act & Assert: Verify proper error handling | |||
| with pytest.raises(ValueError, match="Failed to re-connect MCP server: Connection failed"): | |||
| MCPToolManageService._re_connect_mcp_provider("https://example.com/mcp", "test_provider_id", tenant.id) | |||
| MCPToolManageService._re_connect_mcp_provider("https://example.com/mcp", mcp_provider.id, tenant.id) | |||
| @@ -0,0 +1,143 @@ | |||
| 'use client' | |||
| import React, { useCallback } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { RiAddLine, RiDeleteBinLine } from '@remixicon/react' | |||
| import Input from '@/app/components/base/input' | |||
| import Button from '@/app/components/base/button' | |||
| import ActionButton from '@/app/components/base/action-button' | |||
| import cn from '@/utils/classnames' | |||
| export type HeaderItem = { | |||
| key: string | |||
| value: string | |||
| } | |||
| type Props = { | |||
| headers: Record<string, string> | |||
| onChange: (headers: Record<string, string>) => void | |||
| readonly?: boolean | |||
| isMasked?: boolean | |||
| } | |||
| const HeadersInput = ({ | |||
| headers, | |||
| onChange, | |||
| readonly = false, | |||
| isMasked = false, | |||
| }: Props) => { | |||
| const { t } = useTranslation() | |||
| const headerItems = Object.entries(headers).map(([key, value]) => ({ key, value })) | |||
| const handleItemChange = useCallback((index: number, field: 'key' | 'value', value: string) => { | |||
| const newItems = [...headerItems] | |||
| newItems[index] = { ...newItems[index], [field]: value } | |||
| const newHeaders = newItems.reduce((acc, item) => { | |||
| if (item.key.trim()) | |||
| acc[item.key.trim()] = item.value | |||
| return acc | |||
| }, {} as Record<string, string>) | |||
| onChange(newHeaders) | |||
| }, [headerItems, onChange]) | |||
| const handleRemoveItem = useCallback((index: number) => { | |||
| const newItems = headerItems.filter((_, i) => i !== index) | |||
| const newHeaders = newItems.reduce((acc, item) => { | |||
| if (item.key.trim()) | |||
| acc[item.key.trim()] = item.value | |||
| return acc | |||
| }, {} as Record<string, string>) | |||
| onChange(newHeaders) | |||
| }, [headerItems, onChange]) | |||
| const handleAddItem = useCallback(() => { | |||
| const newHeaders = { ...headers, '': '' } | |||
| onChange(newHeaders) | |||
| }, [headers, onChange]) | |||
| if (headerItems.length === 0) { | |||
| return ( | |||
| <div className='space-y-2'> | |||
| <div className='body-xs-regular text-text-tertiary'> | |||
| {t('tools.mcp.modal.noHeaders')} | |||
| </div> | |||
| {!readonly && ( | |||
| <Button | |||
| variant='secondary' | |||
| size='small' | |||
| onClick={handleAddItem} | |||
| className='w-full' | |||
| > | |||
| <RiAddLine className='mr-1 h-4 w-4' /> | |||
| {t('tools.mcp.modal.addHeader')} | |||
| </Button> | |||
| )} | |||
| </div> | |||
| ) | |||
| } | |||
| return ( | |||
| <div className='space-y-2'> | |||
| {isMasked && ( | |||
| <div className='body-xs-regular text-text-tertiary'> | |||
| {t('tools.mcp.modal.maskedHeadersTip')} | |||
| </div> | |||
| )} | |||
| <div className='overflow-hidden rounded-lg border border-divider-regular'> | |||
| <div className='system-xs-medium-uppercase bg-background-secondary flex h-7 items-center leading-7 text-text-tertiary'> | |||
| <div className='h-full w-1/2 border-r border-divider-regular pl-3'>{t('tools.mcp.modal.headerKey')}</div> | |||
| <div className='h-full w-1/2 pl-3 pr-1'>{t('tools.mcp.modal.headerValue')}</div> | |||
| </div> | |||
| {headerItems.map((item, index) => ( | |||
| <div key={index} className={cn( | |||
| 'flex items-center border-divider-regular', | |||
| index < headerItems.length - 1 && 'border-b', | |||
| )}> | |||
| <div className='w-1/2 border-r border-divider-regular'> | |||
| <Input | |||
| value={item.key} | |||
| onChange={e => handleItemChange(index, 'key', e.target.value)} | |||
| placeholder={t('tools.mcp.modal.headerKeyPlaceholder')} | |||
| className='rounded-none border-0' | |||
| readOnly={readonly} | |||
| /> | |||
| </div> | |||
| <div className='flex w-1/2 items-center'> | |||
| <Input | |||
| value={item.value} | |||
| onChange={e => handleItemChange(index, 'value', e.target.value)} | |||
| placeholder={t('tools.mcp.modal.headerValuePlaceholder')} | |||
| className='flex-1 rounded-none border-0' | |||
| readOnly={readonly} | |||
| /> | |||
| {!readonly && headerItems.length > 1 && ( | |||
| <ActionButton | |||
| onClick={() => handleRemoveItem(index)} | |||
| className='mr-2' | |||
| > | |||
| <RiDeleteBinLine className='h-4 w-4 text-text-destructive' /> | |||
| </ActionButton> | |||
| )} | |||
| </div> | |||
| </div> | |||
| ))} | |||
| </div> | |||
| {!readonly && ( | |||
| <Button | |||
| variant='secondary' | |||
| size='small' | |||
| onClick={handleAddItem} | |||
| className='w-full' | |||
| > | |||
| <RiAddLine className='mr-1 h-4 w-4' /> | |||
| {t('tools.mcp.modal.addHeader')} | |||
| </Button> | |||
| )} | |||
| </div> | |||
| ) | |||
| } | |||
| export default React.memo(HeadersInput) | |||
| @@ -9,6 +9,7 @@ import AppIcon from '@/app/components/base/app-icon' | |||
| import Modal from '@/app/components/base/modal' | |||
| import Button from '@/app/components/base/button' | |||
| import Input from '@/app/components/base/input' | |||
| import HeadersInput from './headers-input' | |||
| import type { AppIconType } from '@/types/app' | |||
| import type { ToolWithProvider } from '@/app/components/workflow/types' | |||
| import { noop } from 'lodash-es' | |||
| @@ -29,6 +30,7 @@ export type DuplicateAppModalProps = { | |||
| server_identifier: string | |||
| timeout: number | |||
| sse_read_timeout: number | |||
| headers?: Record<string, string> | |||
| }) => void | |||
| onHide: () => void | |||
| } | |||
| @@ -66,12 +68,38 @@ const MCPModal = ({ | |||
| const [appIcon, setAppIcon] = useState<AppIconSelection>(getIcon(data)) | |||
| const [showAppIconPicker, setShowAppIconPicker] = useState(false) | |||
| const [serverIdentifier, setServerIdentifier] = React.useState(data?.server_identifier || '') | |||
| const [timeout, setMcpTimeout] = React.useState(30) | |||
| const [sseReadTimeout, setSseReadTimeout] = React.useState(300) | |||
| const [timeout, setMcpTimeout] = React.useState(data?.timeout || 30) | |||
| const [sseReadTimeout, setSseReadTimeout] = React.useState(data?.sse_read_timeout || 300) | |||
| const [headers, setHeaders] = React.useState<Record<string, string>>( | |||
| data?.masked_headers || {}, | |||
| ) | |||
| const [isFetchingIcon, setIsFetchingIcon] = useState(false) | |||
| const appIconRef = useRef<HTMLDivElement>(null) | |||
| const isHovering = useHover(appIconRef) | |||
| // Update states when data changes (for edit mode) | |||
| React.useEffect(() => { | |||
| if (data) { | |||
| setUrl(data.server_url || '') | |||
| setName(data.name || '') | |||
| setServerIdentifier(data.server_identifier || '') | |||
| setMcpTimeout(data.timeout || 30) | |||
| setSseReadTimeout(data.sse_read_timeout || 300) | |||
| setHeaders(data.masked_headers || {}) | |||
| setAppIcon(getIcon(data)) | |||
| } | |||
| else { | |||
| // Reset for create mode | |||
| setUrl('') | |||
| setName('') | |||
| setServerIdentifier('') | |||
| setMcpTimeout(30) | |||
| setSseReadTimeout(300) | |||
| setHeaders({}) | |||
| setAppIcon(DEFAULT_ICON as AppIconSelection) | |||
| } | |||
| }, [data]) | |||
| const isValidUrl = (string: string) => { | |||
| try { | |||
| const urlPattern = /^(https?:\/\/)((([a-z\d]([a-z\d-]*[a-z\d])*)\.)+[a-z]{2,}|((\d{1,3}\.){3}\d{1,3})|localhost)(\:\d+)?(\/[-a-z\d%_.~+]*)*(\?[;&a-z\d%_.~+=-]*)?/i | |||
| @@ -129,6 +157,7 @@ const MCPModal = ({ | |||
| server_identifier: serverIdentifier.trim(), | |||
| timeout: timeout || 30, | |||
| sse_read_timeout: sseReadTimeout || 300, | |||
| headers: Object.keys(headers).length > 0 ? headers : undefined, | |||
| }) | |||
| if(isCreate) | |||
| onHide() | |||
| @@ -231,6 +260,18 @@ const MCPModal = ({ | |||
| placeholder={t('tools.mcp.modal.timeoutPlaceholder')} | |||
| /> | |||
| </div> | |||
| <div> | |||
| <div className='mb-1 flex h-6 items-center'> | |||
| <span className='system-sm-medium text-text-secondary'>{t('tools.mcp.modal.headers')}</span> | |||
| </div> | |||
| <div className='body-xs-regular mb-2 text-text-tertiary'>{t('tools.mcp.modal.headersTip')}</div> | |||
| <HeadersInput | |||
| headers={headers} | |||
| onChange={setHeaders} | |||
| readonly={false} | |||
| isMasked={!isCreate && Object.keys(headers).length > 0} | |||
| /> | |||
| </div> | |||
| </div> | |||
| <div className='flex flex-row-reverse pt-5'> | |||
| <Button disabled={!name || !url || !serverIdentifier || isFetchingIcon} className='ml-2' variant='primary' onClick={submit}>{data ? t('tools.mcp.modal.save') : t('tools.mcp.modal.confirm')}</Button> | |||
| @@ -59,6 +59,8 @@ export type Collection = { | |||
| server_identifier?: string | |||
| timeout?: number | |||
| sse_read_timeout?: number | |||
| headers?: Record<string, string> | |||
| masked_headers?: Record<string, string> | |||
| } | |||
| export type ToolParameter = { | |||
| @@ -184,4 +186,5 @@ export type MCPServerDetail = { | |||
| description: string | |||
| status: string | |||
| parameters?: Record<string, string> | |||
| headers?: Record<string, string> | |||
| } | |||
| @@ -187,12 +187,22 @@ const translation = { | |||
| serverIdentifier: 'Server Identifier', | |||
| serverIdentifierTip: 'Unique identifier for the MCP server within the workspace. Lowercase letters, numbers, underscores, and hyphens only. Up to 24 characters.', | |||
| serverIdentifierPlaceholder: 'Unique identifier, e.g., my-mcp-server', | |||
| serverIdentifierWarning: 'The server won’t be recognized by existing apps after an ID change', | |||
| serverIdentifierWarning: 'The server won\'t be recognized by existing apps after an ID change', | |||
| headers: 'Headers', | |||
| headersTip: 'Additional HTTP headers to send with MCP server requests', | |||
| headerKey: 'Header Name', | |||
| headerValue: 'Header Value', | |||
| headerKeyPlaceholder: 'e.g., Authorization', | |||
| headerValuePlaceholder: 'e.g., Bearer token123', | |||
| addHeader: 'Add Header', | |||
| noHeaders: 'No custom headers configured', | |||
| maskedHeadersTip: 'Header values are masked for security. Changes will update the actual values.', | |||
| cancel: 'Cancel', | |||
| save: 'Save', | |||
| confirm: 'Add & Authorize', | |||
| timeout: 'Timeout', | |||
| sseReadTimeout: 'SSE Read Timeout', | |||
| timeoutPlaceholder: '30', | |||
| }, | |||
| delete: 'Remove MCP Server', | |||
| deleteConfirmTitle: 'Would you like to remove {{mcp}}?', | |||
| @@ -37,8 +37,8 @@ const translation = { | |||
| tip: 'スタジオでワークフローをツールに公開する', | |||
| }, | |||
| mcp: { | |||
| title: '利用可能なMCPツールはありません', | |||
| tip: 'MCPサーバーを追加する', | |||
| title: '利用可能な MCP ツールはありません', | |||
| tip: 'MCP サーバーを追加する', | |||
| }, | |||
| agent: { | |||
| title: 'Agent strategy は利用できません', | |||
| @@ -85,13 +85,13 @@ const translation = { | |||
| apiKeyPlaceholder: 'API キーの HTTP ヘッダー名', | |||
| apiValuePlaceholder: 'API キーを入力してください', | |||
| api_key_query: 'クエリパラメータ', | |||
| queryParamPlaceholder: 'APIキーのクエリパラメータ名', | |||
| queryParamPlaceholder: 'API キーのクエリパラメータ名', | |||
| api_key_header: 'ヘッダー', | |||
| }, | |||
| key: 'キー', | |||
| value: '値', | |||
| queryParam: 'クエリパラメータ', | |||
| queryParamTooltip: 'APIキーのクエリパラメータとして渡す名前、例えば「https://example.com/test?key=API_KEY」の「key」。', | |||
| queryParamTooltip: 'API キーのクエリパラメータとして渡す名前、例えば「https://example.com/test?key=API_KEY」の「key」。', | |||
| }, | |||
| authHeaderPrefix: { | |||
| title: '認証タイプ', | |||
| @@ -169,32 +169,32 @@ const translation = { | |||
| noTools: 'ツールが見つかりませんでした', | |||
| mcp: { | |||
| create: { | |||
| cardTitle: 'MCPサーバー(HTTP)を追加', | |||
| cardLink: 'MCPサーバー統合について詳しく知る', | |||
| cardTitle: 'MCP サーバー(HTTP)を追加', | |||
| cardLink: 'MCP サーバー統合について詳しく知る', | |||
| }, | |||
| noConfigured: '未設定', | |||
| updateTime: '更新日時', | |||
| toolsCount: '{{count}} 個のツール', | |||
| noTools: '利用可能なツールはありません', | |||
| modal: { | |||
| title: 'MCPサーバー(HTTP)を追加', | |||
| editTitle: 'MCPサーバー(HTTP)を編集', | |||
| title: 'MCP サーバー(HTTP)を追加', | |||
| editTitle: 'MCP サーバー(HTTP)を編集', | |||
| name: '名前とアイコン', | |||
| namePlaceholder: 'MCPサーバーの名前を入力', | |||
| namePlaceholder: 'MCP サーバーの名前を入力', | |||
| serverUrl: 'サーバーURL', | |||
| serverUrlPlaceholder: 'サーバーエンドポイントのURLを入力', | |||
| serverUrlPlaceholder: 'サーバーエンドポイントの URL を入力', | |||
| serverUrlWarning: 'サーバーアドレスを更新すると、このサーバーに依存するアプリケーションに影響を与える可能性があります。', | |||
| serverIdentifier: 'サーバー識別子', | |||
| serverIdentifierTip: 'ワークスペース内でのMCPサーバーのユニーク識別子です。使用可能な文字は小文字、数字、アンダースコア、ハイフンで、最大24文字です。', | |||
| serverIdentifierTip: 'ワークスペース内での MCP サーバーのユニーク識別子です。使用可能な文字は小文字、数字、アンダースコア、ハイフンで、最大 24 文字です。', | |||
| serverIdentifierPlaceholder: 'ユニーク識別子(例:my-mcp-server)', | |||
| serverIdentifierWarning: 'IDを変更すると、既存のアプリケーションではサーバーが認識できなくなります。', | |||
| serverIdentifierWarning: 'ID を変更すると、既存のアプリケーションではサーバーが認識できなくなります。', | |||
| cancel: 'キャンセル', | |||
| save: '保存', | |||
| confirm: '追加して承認', | |||
| timeout: 'タイムアウト', | |||
| sseReadTimeout: 'SSE 読み取りタイムアウト', | |||
| }, | |||
| delete: 'MCPサーバーを削除', | |||
| delete: 'MCP サーバーを削除', | |||
| deleteConfirmTitle: '{{mcp}} を削除しますか?', | |||
| operation: { | |||
| edit: '編集', | |||
| @@ -213,23 +213,23 @@ const translation = { | |||
| toolUpdateConfirmTitle: 'ツールリストの更新', | |||
| toolUpdateConfirmContent: 'ツールリストを更新すると、既存のアプリケーションに重大な影響を与える可能性があります。続行しますか?', | |||
| toolsNum: '{{count}} 個のツールが含まれています', | |||
| onlyTool: '1つのツールが含まれています', | |||
| onlyTool: '1 つのツールが含まれています', | |||
| identifier: 'サーバー識別子(クリックしてコピー)', | |||
| server: { | |||
| title: 'MCPサーバー', | |||
| title: 'MCP サーバー', | |||
| url: 'サーバーURL', | |||
| reGen: 'サーバーURLを再生成しますか?', | |||
| reGen: 'サーバーURL を再生成しますか?', | |||
| addDescription: '説明を追加', | |||
| edit: '説明を編集', | |||
| modal: { | |||
| addTitle: 'MCPサーバーを有効化するための説明を追加', | |||
| addTitle: 'MCP サーバーを有効化するための説明を追加', | |||
| editTitle: '説明を編集', | |||
| description: '説明', | |||
| descriptionPlaceholder: 'このツールの機能とLLM(大規模言語モデル)での使用方法を説明してください。', | |||
| descriptionPlaceholder: 'このツールの機能と LLM(大規模言語モデル)での使用方法を説明してください。', | |||
| parameters: 'パラメータ', | |||
| parametersTip: '各パラメータの説明を追加して、LLMがその目的と制約を理解できるようにします。', | |||
| parametersTip: '各パラメータの説明を追加して、LLM がその目的と制約を理解できるようにします。', | |||
| parametersPlaceholder: 'パラメータの目的と制約', | |||
| confirm: 'MCPサーバーを有効にする', | |||
| confirm: 'MCP サーバーを有効にする', | |||
| }, | |||
| publishTip: 'アプリが公開されていません。まずアプリを公開してください。', | |||
| }, | |||
| @@ -81,7 +81,7 @@ const translation = { | |||
| type: '鉴权类型', | |||
| keyTooltip: 'HTTP 头部名称,如果你不知道是什么,可以将其保留为 Authorization 或设置为自定义值', | |||
| queryParam: '查询参数', | |||
| queryParamTooltip: '用于传递 API 密钥查询参数的名称, 如 "https://example.com/test?key=API_KEY" 中的 "key"参数', | |||
| queryParamTooltip: '用于传递 API 密钥查询参数的名称,如 "https://example.com/test?key=API_KEY" 中的 "key"参数', | |||
| types: { | |||
| none: '无', | |||
| api_key_header: '请求头', | |||
| @@ -188,11 +188,21 @@ const translation = { | |||
| serverIdentifierTip: '工作空间内服务器的唯一标识。支持小写字母、数字、下划线和连字符,最多 24 个字符。', | |||
| serverIdentifierPlaceholder: '服务器唯一标识,例如 my-mcp-server', | |||
| serverIdentifierWarning: '更改服务器标识符后,现有应用将无法识别此服务器', | |||
| headers: '请求头', | |||
| headersTip: '发送到 MCP 服务器的额外 HTTP 请求头', | |||
| headerKey: '请求头名称', | |||
| headerValue: '请求头值', | |||
| headerKeyPlaceholder: '例如:Authorization', | |||
| headerValuePlaceholder: '例如:Bearer token123', | |||
| addHeader: '添加请求头', | |||
| noHeaders: '未配置自定义请求头', | |||
| maskedHeadersTip: '为了安全,请求头值已被掩码处理。修改将更新实际值。', | |||
| cancel: '取消', | |||
| save: '保存', | |||
| confirm: '添加并授权', | |||
| timeout: '超时时间', | |||
| sseReadTimeout: 'SSE 读取超时时间', | |||
| timeoutPlaceholder: '30', | |||
| }, | |||
| delete: '删除 MCP 服务', | |||
| deleteConfirmTitle: '你想要删除 {{mcp}} 吗?', | |||
| @@ -87,6 +87,7 @@ export const useCreateMCP = () => { | |||
| icon_background?: string | null | |||
| timeout?: number | |||
| sse_read_timeout?: number | |||
| headers?: Record<string, string> | |||
| }) => { | |||
| return post<ToolWithProvider>('workspaces/current/tool-provider/mcp', { | |||
| body: { | |||
| @@ -113,6 +114,7 @@ export const useUpdateMCP = ({ | |||
| provider_id: string | |||
| timeout?: number | |||
| sse_read_timeout?: number | |||
| headers?: Record<string, string> | |||
| }) => { | |||
| return put('workspaces/current/tool-provider/mcp', { | |||
| body: { | |||