chatmcp/lib/page/setting/mcp_server.dart

1066 lines
47 KiB
Dart

import 'dart:async';
import 'package:chatmcp/components/widgets/base.dart';
import 'package:chatmcp/page/setting/mcp_info.dart';
import 'package:chatmcp/provider/provider_manager.dart';
import 'package:chatmcp/utils/platform.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart';
import '../../provider/mcp_server_provider.dart';
import 'package:logging/logging.dart';
import 'package:chatmcp/utils/process.dart';
import 'package:chatmcp/generated/app_localizations.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:chatmcp/utils/oauth_discovery.dart';
class McpServer extends StatefulWidget {
const McpServer({super.key});
@override
State<McpServer> createState() => _McpServerState();
}
class _McpServerState extends State<McpServer> {
final TextEditingController _searchController = TextEditingController();
String _selectedTab = 'installed'; // 使用固定的英文标识符
int _refreshCounter = 0;
final Map<String, bool> _serverLoading = {};
// OAuth auto-discovery state
OAuthDiscoveryResult? _oauthDiscovery;
bool _isCheckingOAuth = false;
// 标签映射:内部标识符 -> 显示文本
Map<String, String> _getTabLabels(AppLocalizations l10n) {
return {'all': l10n.all, 'installed': l10n.installed, 'inmemory': 'inmemory'};
}
// 验证URL是否合法
bool isValidUrl(String urlString) {
try {
final uri = Uri.parse(urlString);
return uri.scheme.isNotEmpty && uri.host.isNotEmpty;
} catch (e) {
return false;
}
}
// Check OAuth requirement for URL
Future<void> _checkOAuthRequirement(String url) async {
if (!kIsWeb || !isValidUrl(url)) return;
setState(() {
_isCheckingOAuth = true;
});
try {
final provider = Provider.of<McpServerProvider>(context, listen: false);
final result = await provider.discoverOAuthForServer(url);
setState(() {
_oauthDiscovery = result;
_isCheckingOAuth = false;
});
} catch (e) {
Logger.root.warning('OAuth discovery failed: $e');
setState(() {
_oauthDiscovery = null;
_isCheckingOAuth = false;
});
}
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
body: SafeArea(
child: Consumer<McpServerProvider>(
builder: (context, provider, child) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
const SizedBox(height: 20),
// 搜索框
Card(
elevation: 0,
color: Theme.of(context).colorScheme.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Theme.of(context).colorScheme.outline.withAlpha(26)),
),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: l10n.searchServer,
prefixIcon: Icon(CupertinoIcons.search, color: Theme.of(context).colorScheme.onSurface.withAlpha(153)),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
style: TextStyle(fontSize: 15, color: Theme.of(context).colorScheme.onSurface),
onChanged: (value) => setState(() {}),
),
),
const SizedBox(height: 16),
// 标签选择和操作按钮
LayoutBuilder(
builder: (context, constraints) {
// 在宽屏设备上使用Row实现两边对齐
if (constraints.maxWidth > 500) {
// 设定一个合理的宽度阈值
return Row(
children: [
Wrap(
spacing: 12,
children: [
_buildFilterChip('all', l10n.all),
_buildFilterChip('installed', l10n.installed),
_buildFilterChip('inmemory', l10n.inmemory),
],
),
const Spacer(),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildActionButton(
icon: CupertinoIcons.add,
tooltip: l10n.addProvider,
onPressed: () {
final provider = ProviderManager.mcpServerProvider;
_showEditDialog(context, '', provider, null);
},
),
const SizedBox(width: 8),
_buildActionButton(icon: CupertinoIcons.refresh, tooltip: l10n.refresh, onPressed: () => setState(() {})),
],
),
],
);
} else {
// 在窄屏设备上继续使用Wrap自动换行
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Wrap(
spacing: 12,
children: [
_buildFilterChip('all', l10n.all),
_buildFilterChip('installed', l10n.installed),
_buildFilterChip('inmemory', l10n.inmemory),
],
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
_buildActionButton(
icon: CupertinoIcons.add,
tooltip: l10n.addProvider,
onPressed: () {
final provider = ProviderManager.mcpServerProvider;
_showEditDialog(context, '', provider, null);
},
),
const SizedBox(width: 8),
_buildActionButton(icon: CupertinoIcons.refresh, tooltip: l10n.refresh, onPressed: () => setState(() {})),
],
),
],
);
}
},
),
const SizedBox(height: 16),
// 服务器列表
Expanded(
child: FutureBuilder<Map<String, dynamic>>(
future: (_selectedTab == 'all'
? provider.loadMarketServers()
: _selectedTab == 'inmemory'
? provider.loadInMemoryServers()
: provider.loadServers()),
key: ValueKey('server_list_$_refreshCounter'),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CupertinoActivityIndicator());
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(CupertinoIcons.exclamationmark_circle, size: 48, color: Theme.of(context).colorScheme.error),
const SizedBox(height: 16),
Text('Failed to load: ${snapshot.error}', style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
),
);
}
if (snapshot.hasData) {
final servers = snapshot.data?['mcpServers'] as Map<String, dynamic>? ?? {};
if (servers.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(CupertinoIcons.desktopcomputer, size: 64, color: Theme.of(context).colorScheme.onSurface.withAlpha(153)),
const SizedBox(height: 16),
Text(
l10n.noServerConfigs,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface.withAlpha(153), fontSize: 16),
),
],
),
);
}
return ListView.builder(
itemCount: servers.length,
itemBuilder: (context, index) {
final serverName = servers.keys.elementAt(index);
final serverConfig = servers[serverName];
return _buildServerCard(context, serverName, serverConfig, provider, serverConfig['installed'] ?? false);
},
);
}
return const Center(child: CupertinoActivityIndicator());
},
),
),
],
),
);
},
),
),
);
}
bool isActive(String serverName) {
final provider = ProviderManager.mcpServerProvider.clients;
for (var client in provider.entries) {
if (client.key == serverName) {
return true;
}
}
return false;
}
Widget _buildServerCard(BuildContext context, String serverName, dynamic serverConfig, McpServerProvider provider, bool installed) {
final l10n = AppLocalizations.of(context)!;
bool serverActive = isActive(serverName);
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
elevation: 0,
color: Theme.of(context).colorScheme.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Theme.of(context).colorScheme.outline.withAlpha(26)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(serverActive ? CupertinoIcons.checkmark : CupertinoIcons.desktopcomputer, color: Theme.of(context).colorScheme.primary, size: 20),
const SizedBox(width: 12),
Expanded(
child: Row(
children: [
Text(
serverName,
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15, color: Theme.of(context).colorScheme.onSurface),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
if (serverConfig['type'] != null) ...[
const Gap(size: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withAlpha(80),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Theme.of(context).colorScheme.primary.withAlpha(30), width: 1),
),
child: Text(
(serverConfig['type'] ?? '').toString().toLowerCase(),
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.onSurface.withAlpha(180)),
),
),
],
],
),
),
if (installed) ...[
// OAuth authentication button (web only)
if (kIsWeb) ...[
FutureBuilder<Map<String, dynamic>>(
future: provider.getServerOAuthStatus(serverName),
builder: (context, snapshot) {
final oauthStatus = snapshot.data ?? {};
final oauthEnabled = oauthStatus['enabled'] == true;
final isAuthenticated = oauthStatus['authenticated'] == true;
final needsRefresh = oauthStatus['needs_refresh'] == true;
if (!oauthEnabled) return const SizedBox.shrink();
return _buildActionButton(
icon: isAuthenticated
? (needsRefresh ? CupertinoIcons.refresh : CupertinoIcons.checkmark_shield)
: CupertinoIcons.lock,
tooltip: isAuthenticated
? (needsRefresh ? 'Refresh OAuth Token' : 'OAuth Authenticated')
: 'Authenticate with OAuth',
onPressed: () async {
setState(() {
_serverLoading[serverName] = true;
});
try {
if (needsRefresh) {
await provider.refreshServerToken(serverName);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('OAuth token refreshed successfully')),
);
} else if (!isAuthenticated) {
await provider.authenticateServer(serverName);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('OAuth authentication successful')),
);
}
setState(() {
_refreshCounter++;
});
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('OAuth error: ${e.toString()}')),
);
} finally {
if (mounted) {
setState(() {
_serverLoading[serverName] = false;
});
}
}
},
isLoading: _serverLoading[serverName] == true,
);
},
),
const Gap(size: 10),
],
_buildActionButton(
icon: provider.mcpServerIsRunning(serverName) ? CupertinoIcons.stop : CupertinoIcons.play,
tooltip: provider.mcpServerIsRunning(serverName) ? l10n.stop : l10n.start,
onPressed: () async {
if (provider.mcpServerIsRunning(serverName)) {
await provider.stopMcpServer(serverName);
} else {
setState(() {
_serverLoading[serverName] = true;
});
try {
await provider.startMcpServer(serverName);
} finally {
if (mounted) {
setState(() {
_serverLoading[serverName] = false;
});
}
}
}
},
isLoading: _serverLoading[serverName] == true,
),
const Gap(size: 10),
if (provider.mcpServerIsRunning(serverName))
_buildActionButton(icon: CupertinoIcons.info_circle, tooltip: "info", onPressed: () => _showInfoDialog(context, serverName)),
const Gap(size: 10),
_buildActionButton(
icon: CupertinoIcons.pencil,
tooltip: l10n.edit,
onPressed: () => _showEditDialog(context, serverName, provider, null),
),
const Gap(size: 10),
_buildActionButton(
icon: CupertinoIcons.delete,
tooltip: l10n.delete,
color: Theme.of(context).colorScheme.error,
onPressed: () => _showDeleteConfirmDialog(context, serverName, provider),
),
] else ...[
ElevatedButton.icon(
icon: Icon(CupertinoIcons.cloud_download, color: Theme.of(context).colorScheme.onPrimary),
label: Text(l10n.install),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: () async {
final cmdExists = await isCommandAvailable(serverConfig['command']);
if (!cmdExists) {
showErrorDialog(context, l10n.commandNotExist(serverConfig['command'], getPlatformPath() ?? ''));
} else {
Logger.root.info('Install server configuration: $serverName ${serverConfig['command']} ${serverConfig['args']}');
await _showEditDialog(context, serverName, provider, serverConfig);
}
},
),
],
],
),
),
);
}
void _showInfoDialog(BuildContext context, String serverName) {
if (kIsMobile) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Scaffold(
body: SafeArea(child: McpInfo(serverName: serverName)),
),
),
);
} else {
showDialog(
context: context,
builder: (context) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.8,
height: MediaQuery.of(context).size.height * 0.8,
child: McpInfo(serverName: serverName),
),
),
),
);
}
}
Widget _buildActionButton({
required IconData icon,
required String tooltip,
required VoidCallback onPressed,
Color? color,
bool isLoading = false,
}) {
return Tooltip(
message: tooltip,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: isLoading ? null : onPressed,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color ?? Theme.of(context).colorScheme.primary.withAlpha(51)),
),
child: isLoading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: color ?? Theme.of(context).colorScheme.primary),
)
: Icon(icon, size: 18, color: color ?? Theme.of(context).colorScheme.primary),
),
),
),
);
}
Widget _buildFilterChip(String tabKey, String displayLabel) {
final isSelected = _selectedTab == tabKey;
return FilterChip(
label: Text(
displayLabel,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 14, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal),
),
selected: isSelected,
onSelected: (selected) {
setState(() {
_selectedTab = tabKey;
});
},
backgroundColor: Theme.of(context).colorScheme.surface,
selectedColor: Theme.of(context).colorScheme.primary.withAlpha(31),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.outline.withAlpha(26)),
),
);
}
Future<void> _showEditDialog(BuildContext context, String serverName, McpServerProvider provider, Map<String, dynamic>? newServerConfig) async {
if (!context.mounted) return;
var config = await provider.loadServers();
if (_selectedTab == 'inmemory') {
config = await provider.loadInMemoryServers();
}
var servers = config['mcpServers'] ?? {};
if (!context.mounted) return;
final serverConfig =
newServerConfig ??
servers[serverName] as Map<String, dynamic>? ??
{
'type': 'sse', // streamable, sse, stdio, inmemory
'command': '',
'args': <String>[],
'env': <String, String>{},
'auto_approve': false,
};
String resultServerName = serverName;
String resultType = serverConfig['type']?.toString() ?? '';
String resultCommand = serverConfig['command']?.toString() ?? '';
String resultArgs = (serverConfig['args'] as List<dynamic>?)?.map((e) => e.toString()).join(' ') ?? '';
String resultEnv = (serverConfig['env'] as Map<String, dynamic>?)?.entries.map((e) => '${e.key}=${e.value}').join('\n') ?? '';
bool autoApprove = serverConfig['auto_approve'] as bool? ?? false;
final formKey = GlobalKey<FormBuilderState>();
final isEdit = serverName.isNotEmpty;
try {
if (!context.mounted) {
return;
}
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
title: Text(
'MCP Server - ${serverName.isEmpty ? l10n.addProvider : serverName}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
content: SingleChildScrollView(
child: FormBuilder(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!isEdit)
FormBuilderTextField(
name: 'serverName',
initialValue: resultServerName,
decoration: InputDecoration(
labelText: l10n.serverName,
hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurface.withAlpha(102)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withAlpha(51)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withAlpha(51)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary),
),
prefixIcon: Icon(CupertinoIcons.command, color: Theme.of(context).colorScheme.primary, size: 20),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
style: TextStyle(fontSize: 15, color: Theme.of(context).colorScheme.onSurface),
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.fieldRequired;
}
if (value.length > 50) {
return l10n.serverNameTooLong;
}
return null;
},
),
if (!isEdit) const SizedBox(height: 16),
FormBuilderDropdown<String>(
name: 'type',
initialValue: resultType,
enabled: _selectedTab != 'inmemory',
decoration: InputDecoration(
labelText: l10n.serverType,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withAlpha(51)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withAlpha(51)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary),
),
prefixIcon: Icon(CupertinoIcons.gear, color: Theme.of(context).colorScheme.primary, size: 20),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
items: [
DropdownMenuItem(value: 'sse', child: Text('SSE')),
DropdownMenuItem(value: 'stdio', child: Text('STDIO')),
DropdownMenuItem(value: 'streamable', child: Text('Streamable')),
if (isEdit) DropdownMenuItem(value: 'inmemory', enabled: false, child: Text('InMemory')),
],
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.fieldRequired;
}
return null;
},
),
const SizedBox(height: 16),
FormBuilderTextField(
name: 'command',
enabled: resultType != 'inmemory',
initialValue: resultCommand,
decoration: InputDecoration(
labelText: l10n.command,
hintText: l10n.commandExample,
hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurface.withAlpha(102)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withAlpha(51)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withAlpha(51)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary),
),
prefixIcon: Icon(CupertinoIcons.command, color: Theme.of(context).colorScheme.primary, size: 20),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
style: TextStyle(fontSize: 15, color: Theme.of(context).colorScheme.onSurface),
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.fieldRequired;
}
return null;
},
onChanged: (value) {
if (value != null && value.trim().isNotEmpty && value.startsWith('http')) {
_checkOAuthRequirement(value.trim());
}
},
),
// OAuth discovery status
if (_isCheckingOAuth) ...[
const SizedBox(height: 8),
Row(
children: [
const SizedBox(width: 40), // Align with field content
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 8),
Text(
'Checking authentication requirements...',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withAlpha(150),
fontSize: 13,
),
),
],
),
] else if (_oauthDiscovery?.requiresOAuth == true) ...[
const SizedBox(height: 8),
Container(
margin: const EdgeInsets.only(left: 40),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withAlpha(100),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withAlpha(100),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.security,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'This server requires OAuth authentication. You\'ll be prompted to login after saving.',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontSize: 13,
),
),
),
],
),
),
],
const SizedBox(height: 16),
FormBuilderTextField(
name: 'args',
initialValue: resultArgs,
decoration: InputDecoration(
labelText: l10n.arguments,
hintText: l10n.argumentsExample,
hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurface.withAlpha(102)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withAlpha(51)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withAlpha(51)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary),
),
prefixIcon: Icon(CupertinoIcons.command, color: Theme.of(context).colorScheme.primary, size: 20),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
style: TextStyle(fontSize: 15, color: Theme.of(context).colorScheme.onSurface),
),
const SizedBox(height: 16),
FormBuilderTextField(
name: 'env',
initialValue: resultEnv,
maxLines: 4,
decoration: InputDecoration(
labelText: l10n.environmentVariables,
hintText: l10n.envVarsFormat,
hintStyle: TextStyle(color: Theme.of(context).colorScheme.onSurface.withAlpha(102)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withAlpha(51)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withAlpha(51)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary),
),
prefixIcon: Icon(CupertinoIcons.command, color: Theme.of(context).colorScheme.primary, size: 20),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
style: TextStyle(fontSize: 15, color: Theme.of(context).colorScheme.onSurface),
),
const SizedBox(height: 16),
FormBuilderCheckbox(
name: 'auto_approve',
initialValue: autoApprove,
title: Text(l10n.autoApprove, style: TextStyle(fontSize: 15, color: Theme.of(context).colorScheme.onSurface)),
decoration: const InputDecoration(border: InputBorder.none, contentPadding: EdgeInsets.zero),
),
const SizedBox(height: 16),
// OAuth Auto-Discovery Information
if (kIsWeb) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest.withAlpha(100),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withAlpha(100),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Authentication',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
const SizedBox(height: 8),
Text(
'Authentication is now handled automatically. When you enter a server URL that requires authentication, you\'ll be prompted to login after saving.',
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurface.withAlpha(200),
),
),
],
),
),
],
],
),
),
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(dialogContext, false);
},
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.primary),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () async {
if (!formKey.currentState!.saveAndValidate()) {
return;
}
final values = formKey.currentState!.value;
final saveServerName = serverName.isEmpty ? (values['serverName'] as String).trim() : serverName;
final type = values['type'] as String;
final command = (values['command'] as String).trim();
final argsString = (values['args'] as String).trim();
// 改进的参数解析逻辑,支持引号包围的参数
List<String> args = [];
if (argsString.isNotEmpty) {
// 匹配单引号、双引号包围的参数,或者不包含空格的参数
RegExp regExp = RegExp(r'''"[^"]*"|'[^']*'|\S+''');
Iterable<RegExpMatch> matches = regExp.allMatches(argsString);
for (RegExpMatch match in matches) {
// 保留原始引号
args.add(match.group(0)!);
}
}
final envStr = values['env'] as String;
bool isRemote = type == 'sse' || type == 'streamable';
bool isUrl = isValidUrl(command.trim());
if (kIsMobile && kIsWeb) {
if (type == "stdio") {
showErrorDialog(dialogContext, 'Mobile only supports mcp sse and streamable servers');
return;
}
if (isRemote && !isUrl) {
showErrorDialog(dialogContext, 'Server command must be a valid URL');
return;
}
}
final env = Map<String, String>.fromEntries(
envStr.split('\n').where((line) => line.trim().isNotEmpty).map((line) {
final parts = line.split('=');
if (parts.length < 2) {
return MapEntry(parts[0].trim(), '');
}
return MapEntry(parts[0].trim(), parts.sublist(1).join('=').trim());
}),
);
if (config['mcpServers'] == null) {
config['mcpServers'] = <String, dynamic>{};
}
// Build server configuration
final serverConfigData = {
'name': saveServerName,
'type': type,
'command': command,
'args': args,
'env': env,
'auto_approve': values['auto_approve'] as bool? ?? false,
};
await provider.addMcpServer(serverConfigData);
if (mounted) {
setState(() {
_refreshCounter++;
});
}
if (dialogContext.mounted) {
Navigator.pop(dialogContext, true);
}
// Handle auto-discovered OAuth authentication BEFORE starting server
if (_oauthDiscovery?.requiresOAuth == true && kIsWeb) {
if (mounted) {
// Always try automatic OAuth first
final shouldAuth = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Authentication Required'),
content: Text(
'This server requires OAuth authentication. Would you like to login now?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Later'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Login Now'),
),
],
),
);
if (shouldAuth == true) {
try {
Logger.root.info('Starting OAuth authentication for $saveServerName');
final success = await provider.autoAuthenticateServer(
saveServerName,
_oauthDiscovery!
);
if (success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Authentication successful for $saveServerName'),
backgroundColor: Colors.green,
),
);
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Authentication failed: No success response'),
backgroundColor: Colors.orange,
),
);
}
} catch (e) {
Logger.root.severe('OAuth authentication error: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Authentication failed: ${e.toString()}'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 5),
),
);
}
}
}
}
}
// if server is running, restart it (AFTER OAuth authentication)
if (provider.mcpServerIsRunning(saveServerName)) {
await provider.stopMcpServer(saveServerName);
setState(() {
_serverLoading[serverName] = true;
});
await provider.startMcpServer(saveServerName);
setState(() {
_serverLoading[serverName] = false;
});
}
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.primary,
backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(31),
),
child: Text(l10n.save, style: const TextStyle(fontWeight: FontWeight.w600)),
),
],
backgroundColor: Theme.of(context).colorScheme.surface,
);
},
);
if (confirmed == true && context.mounted) {
setState(() {
_refreshCounter++;
});
}
} finally {
// No controllers to dispose as FormBuilder handles that internally
}
}
Future<void> _showDeleteConfirmDialog(BuildContext context, String serverName, McpServerProvider provider) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
title: Text(l10n.confirmDelete),
content: Text(l10n.confirmDeleteServer(serverName)),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(l10n.cancel)),
TextButton(onPressed: () => Navigator.pop(context, true), child: Text(l10n.delete)),
],
),
);
if (confirmed == true && context.mounted) {
await provider.removeMcpServer(serverName);
setState(() {
_refreshCounter++;
});
}
}
Future<void> showErrorDialog(BuildContext context, String message) async {
final l10n = AppLocalizations.of(context)!;
return showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
title: Text(l10n.error),
content: Text(message),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(l10n.ok))],
),
);
}
}