fix: preserve MCP server ids with slashes

Fixes #2301
This commit is contained in:
RockChinQ
2026-07-03 00:17:29 +08:00
committed by GitHub
parent f85278f98b
commit 04a199a3b2
5 changed files with 130 additions and 17 deletions
@@ -29,11 +29,11 @@ class MCPRouterGroup(group.RouterGroup):
traceback.print_exc()
return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}')
@self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN)
@self.route(
'/servers/<path:server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN
)
async def _(server_name: str) -> str:
"""获取、更新或删除MCP服务器配置"""
from urllib.parse import unquote
server_name = unquote(server_name)
server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name)
@@ -58,17 +58,15 @@ class MCPRouterGroup(group.RouterGroup):
except Exception as e:
return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}')
@self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
@self.route('/servers/<path:server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""测试MCP服务器连接"""
from urllib.parse import unquote
server_name = unquote(server_name)
server_data = await quart.request.json
task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)
return self.success(data={'task_id': task_id})
@self.route('/servers/<server_name>/resources', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
@self.route('/servers/<path:server_name>/resources', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""Get resources from an MCP server"""
server_name = unquote(server_name)
@@ -86,7 +84,9 @@ class MCPRouterGroup(group.RouterGroup):
except Exception as e:
return self.http_status(500, -1, f'Failed to get resources: {str(e)}')
@self.route('/servers/<server_name>/resource-templates', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
@self.route(
'/servers/<path:server_name>/resource-templates', methods=['GET'], auth_type=group.AuthType.USER_TOKEN
)
async def _(server_name: str) -> str:
"""Get resource templates from an MCP server"""
server_name = unquote(server_name)
@@ -96,7 +96,7 @@ class MCPRouterGroup(group.RouterGroup):
except Exception as e:
return self.http_status(500, -1, f'Failed to get resource templates: {str(e)}')
@self.route('/servers/<server_name>/resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
@self.route('/servers/<path:server_name>/resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""Read a resource from an MCP server"""
server_name = unquote(server_name)
+11
View File
@@ -48,6 +48,17 @@ class MCPService:
if total_extensions >= max_extensions:
raise ValueError(f'Maximum number of extensions ({max_extensions}) reached')
server_name = str(server_data.get('name') or '').strip()
if not server_name:
raise ValueError('MCP server name is required')
server_data['name'] = server_name
existing_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name)
)
if existing_result.first() is not None:
raise ValueError(f'MCP server already exists: {server_name}')
server_data['uuid'] = str(uuid.uuid4())
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
@@ -280,6 +280,25 @@ class TestMCPServiceCreateMCPServer:
assert server_uuid is not None
assert len(server_uuid) == 36 # UUID format
async def test_create_mcp_server_duplicate_name_raises(self):
"""Rejects duplicate MCP server names."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'limitation': {'max_extensions': -1}}}
ap.tool_mgr = None
existing_server = _create_mock_mcp_server(name='Existing Server')
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=existing_server))
ap.persistence_mgr.serialize_model = Mock(return_value={})
service = MCPService(ap)
# Execute & Verify
with pytest.raises(ValueError, match='MCP server already exists: Existing Server'):
await service.create_mcp_server({'name': 'Existing Server'})
async def test_create_mcp_server_loads_server(self):
"""Loads server into tool_mgr when enabled."""
# Setup
@@ -301,7 +320,7 @@ class TestMCPServiceCreateMCPServer:
nonlocal call_count
call_count += 1
if call_count == 1:
return _create_mock_result([]) # Empty list for limit check
return _create_mock_result([]) # Empty result for duplicate-name check
elif call_count == 2:
return Mock() # Insert
return _create_mock_result(first_item=server_entity) # Select created
@@ -0,0 +1,76 @@
from __future__ import annotations
import sys
import types
from importlib import import_module
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import quart
core_app_module = types.ModuleType('langbot.pkg.core.app')
core_app_module.Application = object
sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
pytestmark = pytest.mark.asyncio
async def _create_test_client(mcp_service: SimpleNamespace):
app = quart.Quart(__name__)
user_service = SimpleNamespace(
verify_jwt_token=AsyncMock(return_value='test@example.com'),
get_user_by_email=AsyncMock(return_value=SimpleNamespace(user='test@example.com')),
)
ap = SimpleNamespace(mcp_service=mcp_service, user_service=user_service)
MCPRouterGroup = import_module('langbot.pkg.api.http.controller.groups.resources.mcp').MCPRouterGroup
group = MCPRouterGroup(ap, app)
await group.initialize()
return app.test_client()
async def test_mcp_server_route_accepts_encoded_slash_name():
mcp_service = SimpleNamespace(
get_mcp_server_by_name=AsyncMock(
return_value={
'uuid': 'test-uuid',
'name': 'pab1it0/prometheus',
'enable': True,
'mode': 'stdio',
'extra_args': {},
}
)
)
client = await _create_test_client(mcp_service)
response = await client.get(
'/api/v1/mcp/servers/pab1it0%2Fprometheus',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 200
mcp_service.get_mcp_server_by_name.assert_awaited_once_with('pab1it0/prometheus')
payload = await response.get_json()
assert payload['data']['server']['name'] == 'pab1it0/prometheus'
async def test_mcp_resource_route_accepts_encoded_slash_name():
mcp_service = SimpleNamespace(
get_mcp_server_by_name=AsyncMock(),
get_mcp_server_resources=AsyncMock(return_value=[]),
get_mcp_server_resource_templates=AsyncMock(return_value=[]),
get_runtime_info=AsyncMock(return_value={'resource_capabilities': {'subscribe': False}}),
)
client = await _create_test_client(mcp_service)
response = await client.get(
'/api/v1/mcp/servers/pab1it0%2Fprometheus/resources',
headers={'Authorization': 'Bearer test-token'},
)
assert response.status_code == 200
mcp_service.get_mcp_server_by_name.assert_not_awaited()
mcp_service.get_mcp_server_resources.assert_awaited_once_with('pab1it0/prometheus')
payload = await response.get_json()
assert payload['data']['resource_capabilities'] == {'subscribe': False}
@@ -750,6 +750,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
}
try {
let serverConfig: MCPServer;
const serverName =
isEditMode && initServerName ? initServerName : value.name;
if (value.mode === 'remote') {
const headers: Record<string, string> = {};
@@ -758,7 +760,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
});
serverConfig = {
name: value.name,
name: serverName,
mode: 'remote',
enable: true,
extra_args: {
@@ -774,7 +776,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
});
serverConfig = {
name: value.name,
name: serverName,
mode: 'stdio',
enable: true,
extra_args: {
@@ -818,6 +820,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
// `uvx` with no package (exit 2 / "Connection closed", no detail).
// The form values are kept in sync on every edit and on load, so they
// are always current.
const serverName =
isEditMode && initServerName ? initServerName : form.getValues('name');
const formExtraArgs = form.getValues('extra_args') ?? [];
const formStdioArgs = form.getValues('args') ?? [];
let extraArgsData: MCPServerExtraArgsRemote | MCPServerExtraArgsStdio;
@@ -841,7 +845,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
}
const { task_id } = await httpClient.testMCPServer('_', {
name: form.getValues('name'),
name: serverName,
mode,
enable: true,
extra_args: extraArgsData,
@@ -873,7 +877,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
});
} else {
if (isEditMode) {
await loadServerForEdit(form.getValues('name'));
await loadServerForEdit(serverName);
} else {
// Create mode has no persisted server to reload tools from.
// The backend stashes the discovered runtime info (status +
@@ -1163,11 +1167,14 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
</Card>
);
const persistedServerName =
isEditMode && initServerName ? initServerName : form.getValues('name');
const runtimePanel = (
<RuntimePanel
mcpTesting={mcpTesting}
runtimeInfo={runtimeInfo}
serverName={form.getValues('name')}
serverName={persistedServerName}
t={t}
/>
);
@@ -1211,7 +1218,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
<RuntimePanel
mcpTesting={mcpTesting}
runtimeInfo={runtimeInfo}
serverName={form.getValues('name')}
serverName={persistedServerName}
content="tools"
t={t}
/>
@@ -1223,7 +1230,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
<RuntimePanel
mcpTesting={mcpTesting}
runtimeInfo={runtimeInfo}
serverName={form.getValues('name')}
serverName={persistedServerName}
content="resources"
t={t}
/>