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,
});
}