diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 8b2e31f..58c1ecc 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -339,6 +339,55 @@ class HubService { ); } + /// Snapshot of every configured MCP server. + Future> listMcpClients() async { + final r = await _client.listMcpClients(); + return _mapMcpClients(r); + } + + /// Re-run discovery against every configured MCP server on + /// the running daemon. Returns the same shape as listMcpClients. + Future> refreshMcpClients() async { + final r = await _client.refreshMcpClients(); + return _mapMcpClients(r); + } + + Future> addMcpClient({ + required String name, + required String endpoint, + String apiKeyEnv = '', + String description = '', + }) async { + final r = await _client.addMcpClient( + name: name, + endpoint: endpoint, + apiKeyEnv: apiKeyEnv, + description: description, + ); + return _mapMcpClients(r); + } + + Future> removeMcpClient(String name) async { + final r = await _client.removeMcpClient(name); + return _mapMcpClients(r); + } + + List _mapMcpClients(ListMcpClientsResponse r) { + return r.clients + .map( + (s) => McpClientInfo( + name: s.config.name, + endpoint: s.config.endpoint, + apiKeyEnv: s.config.apiKeyEnv, + description: s.config.description, + toolCount: s.toolCount, + errorKind: s.errorKind, + message: s.message, + ), + ) + .toList(); + } + /// Active channel + per-channel daemon status snapshot. Future channelStatus() async { final r = await _client.channelStatus(); @@ -394,6 +443,8 @@ class HubService { iconUrl: e.iconUrl, screenshotUrls: e.screenshotUrls, docsUrl: e.docsUrl, + kind: e.kind, + provider: e.provider, ), ) .toList(); @@ -924,6 +975,36 @@ class ChannelStatusSnapshot { }); } +/// One configured MCP server plus the last-discovery report. +/// Surfaced by Studio's MCP-clients editor in Settings. +class McpClientInfo { + final String name; + final String endpoint; + final String apiKeyEnv; + final String description; + /// Number of tools the last discovery returned. 0 before + /// discovery has run or on failure. + final int toolCount; + /// Empty on success; otherwise one of: + /// invalid_endpoint / not_implemented / network / http / + /// server / parse / env_missing. + final String errorKind; + /// Human-readable diagnostic. + final String message; + + const McpClientInfo({ + required this.name, + required this.endpoint, + required this.apiKeyEnv, + required this.description, + required this.toolCount, + required this.errorKind, + required this.message, + }); + + bool get healthy => errorKind.isEmpty && toolCount > 0; +} + /// Filesystem paths the daemon owns. Studio's Doctor page /// surfaces these as "Open" buttons so Windows operators who /// never touch a shell can still inspect everything via the OS @@ -1078,6 +1159,15 @@ class StoreItem { /// Explicit docs URL override. When empty, the hub falls back /// to the well-known raw-README paths off [repository]. final String docsUrl; + /// "native" (downloadable bundle) or "federated" (routes + /// through a bridge — MCP, n8n, …). Empty falls back to + /// "native". + final String kind; + /// Provider id for federated entries. Empty for native. + /// E.g. "filesystem" for an `mcp.filesystem.read_file` tool. + final String provider; + + bool get isFederated => kind == 'federated'; const StoreItem({ required this.name, @@ -1098,5 +1188,7 @@ class StoreItem { required this.iconUrl, required this.screenshotUrls, required this.docsUrl, + required this.kind, + required this.provider, }); } diff --git a/lib/main.dart b/lib/main.dart index 18ca721..50d587c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,7 +25,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.21.0'; +const String kStudioVersion = '0.22.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index ddc08cb..a74fcf8 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -37,6 +37,7 @@ class _FaiSettingsDialogState extends State { String? _channelToast; ChannelStatusSnapshot? _channels; SystemAiStatus? _aiStatus; + List? _mcpClients; @override void initState() { @@ -47,6 +48,64 @@ class _FaiSettingsDialogState extends State { _secure = ep.secure; _loadChannels(); _loadAiStatus(); + _loadMcpClients(); + } + + Future _loadMcpClients() async { + try { + final list = await HubService.instance.listMcpClients(); + if (!mounted) return; + setState(() => _mcpClients = list); + } catch (_) { + // Same fail-quiet rule as channels. + } + } + + Future _addMcpClient(McpClientDraft draft) async { + try { + final list = await HubService.instance.addMcpClient( + name: draft.name, + endpoint: draft.endpoint, + apiKeyEnv: draft.apiKeyEnv, + description: draft.description, + ); + if (!mounted) return; + setState(() => _mcpClients = list); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added MCP server "${draft.name}".')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Add failed: $e')), + ); + } + } + + Future _removeMcpClient(String name) async { + try { + final list = await HubService.instance.removeMcpClient(name); + if (!mounted) return; + setState(() => _mcpClients = list); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Remove failed: $e')), + ); + } + } + + Future _refreshMcpClients() async { + try { + final list = await HubService.instance.refreshMcpClients(); + if (!mounted) return; + setState(() => _mcpClients = list); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Refresh failed: $e')), + ); + } } Future _loadChannels() async { @@ -164,11 +223,12 @@ class _FaiSettingsDialogState extends State { vertical: FaiSpace.lg, ), content: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 380), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + constraints: const BoxConstraints(maxWidth: 520, maxHeight: 640), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( 'Where should Studio connect? Default is the local hub at 127.0.0.1:50051.', style: theme.textTheme.bodySmall?.copyWith( @@ -322,8 +382,18 @@ class _FaiSettingsDialogState extends State { }, ), ], + if (_mcpClients != null) ...[ + const SizedBox(height: FaiSpace.lg), + _McpClientsPanel( + clients: _mcpClients!, + onAdd: _addMcpClient, + onRemove: _removeMcpClient, + onRefresh: _refreshMcpClients, + ), + ], ], ), + ), ), actions: [ TextButton( @@ -611,3 +681,304 @@ class _StatRow extends StatelessWidget { ); } } + +/// Draft passed back from the add-server dialog. Kept tiny so +/// the panel can stay stateless. +class McpClientDraft { + final String name; + final String endpoint; + final String apiKeyEnv; + final String description; + const McpClientDraft({ + required this.name, + required this.endpoint, + required this.apiKeyEnv, + required this.description, + }); +} + +/// MCP-clients panel — shows every configured server with its +/// last-discovery health, plus Add / Remove / Refresh actions. +/// This is *the* surface that wires the viral-bridge story to +/// the operator: configure a server here, watch the store +/// fill with synthetic capabilities. Mirrors `fai mcp …` on +/// the CLI. +class _McpClientsPanel extends StatelessWidget { + final List clients; + final ValueChanged onAdd; + final ValueChanged onRemove; + final VoidCallback onRefresh; + + const _McpClientsPanel({ + required this.clients, + required this.onAdd, + required this.onRemove, + required this.onRefresh, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'MCP CLIENTS', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + fontSize: 10, + ), + ), + const SizedBox(width: FaiSpace.sm), + FaiPill( + label: '${clients.length} server${clients.length == 1 ? "" : "s"}', + tone: FaiPillTone.neutral, + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.refresh, size: 16), + tooltip: 'Re-run discovery', + visualDensity: VisualDensity.compact, + onPressed: onRefresh, + ), + IconButton( + icon: const Icon(Icons.add, size: 16), + tooltip: 'Add MCP server', + visualDensity: VisualDensity.compact, + onPressed: () async { + final draft = await _AddMcpClientDialog.show(context); + if (draft != null) onAdd(draft); + }, + ), + ], + ), + const SizedBox(height: 2), + Text( + 'External MCP servers federate their tools as `mcp..` capabilities. ' + 'Configure once, see them in the Store.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.sm), + if (clients.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm), + child: Text( + 'No MCP servers configured. Click + to add one.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + else + for (final c in clients) + _McpClientRow( + client: c, + onRemove: () => onRemove(c.name), + ), + ], + ); + } +} + +class _McpClientRow extends StatelessWidget { + final McpClientInfo client; + final VoidCallback onRemove; + const _McpClientRow({required this.client, required this.onRemove}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final ok = client.healthy; + final dotColor = client.errorKind.isEmpty + ? (ok ? FaiColors.success : FaiColors.muted) + : theme.colorScheme.error; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 6), + child: Icon(Icons.circle, size: 8, color: dotColor), + ), + const SizedBox(width: FaiSpace.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + client.name, + style: FaiTheme.mono( + size: 12, + weight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(width: FaiSpace.sm), + if (client.toolCount > 0) + FaiPill( + label: '${client.toolCount} tools', + tone: FaiPillTone.success, + ) + else if (client.errorKind.isNotEmpty) + FaiPill( + label: client.errorKind, + tone: FaiPillTone.danger, + ), + ], + ), + Text( + client.endpoint, + style: FaiTheme.mono( + size: 10, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (client.message.isNotEmpty) + Text( + client.message, + style: theme.textTheme.bodySmall?.copyWith( + color: client.errorKind.isEmpty + ? theme.colorScheme.onSurfaceVariant + : theme.colorScheme.error, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 14), + tooltip: 'Remove server', + visualDensity: VisualDensity.compact, + onPressed: onRemove, + ), + ], + ), + ); + } +} + +class _AddMcpClientDialog extends StatefulWidget { + const _AddMcpClientDialog(); + + static Future show(BuildContext context) { + return showDialog( + context: context, + builder: (_) => const _AddMcpClientDialog(), + ); + } + + @override + State<_AddMcpClientDialog> createState() => _AddMcpClientDialogState(); +} + +class _AddMcpClientDialogState extends State<_AddMcpClientDialog> { + final _name = TextEditingController(); + final _endpoint = TextEditingController(); + final _apiKey = TextEditingController(); + final _desc = TextEditingController(); + + @override + void dispose() { + _name.dispose(); + _endpoint.dispose(); + _apiKey.dispose(); + _desc.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + title: const Text('Add MCP server'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Tools the server advertises via `tools/list` appear in the Store as ' + '`mcp..` capabilities.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.md), + TextField( + controller: _name, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Name (no dots)', + hintText: 'filesystem', + border: OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: FaiSpace.sm), + TextField( + controller: _endpoint, + decoration: const InputDecoration( + labelText: 'Endpoint', + hintText: 'http://127.0.0.1:3001/mcp', + border: OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: FaiSpace.sm), + TextField( + controller: _apiKey, + decoration: const InputDecoration( + labelText: 'API key env var (optional)', + hintText: 'MCP_FILESYSTEM_TOKEN', + prefixText: '\$', + border: OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: FaiSpace.sm), + TextField( + controller: _desc, + decoration: const InputDecoration( + labelText: 'Notes (optional)', + border: OutlineInputBorder(), + isDense: true, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, null), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + if (_name.text.trim().isEmpty || _endpoint.text.trim().isEmpty) { + return; + } + Navigator.pop( + context, + McpClientDraft( + name: _name.text.trim(), + endpoint: _endpoint.text.trim(), + apiKeyEnv: _apiKey.text.trim(), + description: _desc.text.trim(), + ), + ); + }, + child: const Text('Add + discover'), + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index d3f9c85..ff63fde 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -79,7 +79,7 @@ packages: path: "../fai_dart_sdk" relative: true source: path - version: "0.11.0" + version: "0.12.1" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7b5aaf2..ccf4eab 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.21.0 +version: 0.22.0 environment: sdk: ^3.11.0-200.1.beta