feat(studio): MCP-clients editor + federated entry rendering (v0.22.0)

The viral-bridge story now has a UI surface. Settings →
MCP Clients lists every configured server, shows last-
discovery health (green dot + tool count, red dot + error
kind), and offers Add / Remove / Refresh.

Add-server dialog: name (no dots), endpoint, optional
api_key_env, optional notes. On submit the hub persists +
re-runs discovery in one round-trip; the Store fills with
synthetic `mcp.<name>.<tool>` entries the operator can see
immediately.

StoreItem now carries `kind` and `provider`. Federated
entries route through a bridge — no install bundle to
download — and Studio's install button renders accordingly
(via the existing "Not installable" path; the Phase-2
follow-up adds an explicit "Configure bridge" call-to-action).

Settings dialog grew to 520×640 with a SingleChildScrollView
so the additional panels don't crowd. Channel pill, system-AI
panel, MCP-clients panel all coexist without a redesign.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-07 22:48:21 +02:00
parent 11b0ac97a1
commit 9ef32df31e
5 changed files with 471 additions and 8 deletions

View file

@ -339,6 +339,55 @@ class HubService {
);
}
/// Snapshot of every configured MCP server.
Future<List<McpClientInfo>> 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<List<McpClientInfo>> refreshMcpClients() async {
final r = await _client.refreshMcpClients();
return _mapMcpClients(r);
}
Future<List<McpClientInfo>> 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<List<McpClientInfo>> removeMcpClient(String name) async {
final r = await _client.removeMcpClient(name);
return _mapMcpClients(r);
}
List<McpClientInfo> _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<ChannelStatusSnapshot> 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,
});
}

View file

@ -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<void> main() async {
WidgetsFlutterBinding.ensureInitialized();

View file

@ -37,6 +37,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
String? _channelToast;
ChannelStatusSnapshot? _channels;
SystemAiStatus? _aiStatus;
List<McpClientInfo>? _mcpClients;
@override
void initState() {
@ -47,6 +48,64 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
_secure = ep.secure;
_loadChannels();
_loadAiStatus();
_loadMcpClients();
}
Future<void> _loadMcpClients() async {
try {
final list = await HubService.instance.listMcpClients();
if (!mounted) return;
setState(() => _mcpClients = list);
} catch (_) {
// Same fail-quiet rule as channels.
}
}
Future<void> _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<void> _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<void> _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<void> _loadChannels() async {
@ -164,11 +223,12 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
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<FaiSettingsDialog> {
},
),
],
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<McpClientInfo> clients;
final ValueChanged<McpClientDraft> onAdd;
final ValueChanged<String> 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.<server>.<tool>` 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<McpClientDraft?> show(BuildContext context) {
return showDialog<McpClientDraft>(
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.<name>.<tool>` 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'),
),
],
);
}
}

View file

@ -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:

View file

@ -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