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:
parent
11b0ac97a1
commit
9ef32df31e
5 changed files with 471 additions and 8 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue