7 files changed
+402
-71
lib/features/chat/data/chat_service.dart
+9
-7
@@ -222,21 +222,23 @@ class LMStudioChatService implements ChatService {
222
body['repeat_penalty'] = params.repeatPenalty;
223
}
224
// LM Studio's native /api/v1/chat expects `reasoning` as a *string*
225
- // ('off' | 'low' | 'medium' | 'high' | 'on'), and does NOT accept the
226
- // OpenAI-compatible `reasoning_effort` / `think` / `enable_thinking`
225
+ // ('off' | 'low' | 'medium' | 'high' | 'xhigh'), and does NOT accept
226
+ // the OpenAI-compatible `reasoning_effort` / `think` / `enable_thinking`
227
// keys that the shared [_applyReasoningControl] writes. Sending the
228
// object form causes HTTP 400 `invalid_request` for every
229
// reasoning-capable model (and ChatReasoningConfig defaults to
230
// enabled). We therefore apply reasoning in the native shape here.
231
//
232
- // 'on' is universally accepted by reasoning-capable models (it means
233
- // "use the model's default effort"); per-model effort strings such as
234
- // 'low'/'medium'/'high' are not guaranteed to be supported (e.g. Gemma
235
- // only allows 'off'/'on'), so we avoid them.
232
+ // We send the user's selected [ReasoningEffort] (already snapped via
233
+ // [resolveEffortForModel] to whatever the active model advertises)
234
+ // rather than the generic 'on', because newer models — e.g.
235
+ // meta/muse-glimmer — reject 'on' with HTTP 400 and only accept the
236
+ // enum effort values. Older models that don't advertise efforts get
237
+ // the classic 'low'/'medium'/'high' fallback, which they accept.
238
if (params.reasoningEnabled == false) {
239
body['reasoning'] = 'off';
240
} else if (params.reasoningEnabled == true) {
239
- body['reasoning'] = 'on';
241
+ body['reasoning'] = params.reasoningEffort.apiValue;
242
}
243
if (params.contextLength > 0) {
244
body['context_length'] = params.contextLength;
lib/features/chat/providers/tooling_providers.dart
+8
@@ -3,6 +3,7 @@ import '../../../core/providers/app_providers.dart';
3
import '../data/tools/tool_registry.dart';
4
import '../data/tools/builtin_tool_provider.dart';
5
import '../data/tools/mcp_tool_provider.dart';
6
+import '../data/tools/tool_definition.dart';
7
import '../data/mcp_server_manager.dart';
8
9
final mcpServerManagerProvider = Provider<McpServerManager>((ref) {
@@ -26,3 +27,10 @@ final toolRegistryProvider = Provider<ToolRegistry>((ref) {
27
],
28
);
29
});
30
+
31
+final availableToolsProvider = FutureProvider<List<ToolDefinition>>((
32
+ ref,
33
+) async {
34
+ final registry = ref.watch(toolRegistryProvider);
35
+ return registry.listTools();
36
+});
lib/features/chat/views/components/notification_permission_banner.dart
+7
-1
@@ -22,7 +22,11 @@ class _NotificationPermissionBannerState
22
@override
23
void initState() {
24
super.initState();
25
- _checkStatus();
25
+ WidgetsBinding.instance.addPostFrameCallback((_) {
26
+ if (mounted) {
27
+ _checkStatus();
28
+ }
29
+ });
30
}
31
32
Future<void> _checkStatus() async {
@@ -32,6 +36,8 @@ class _NotificationPermissionBannerState
36
final service = ref.read(notificationPermissionServiceProvider);
37
final granted = await service.isPermissionGranted();
38
39
+ if (!mounted) return;
40
+
41
if (!granted) {
42
setState(() => _isVisible = true);
43
} else {
lib/features/mcp/views/mcp_tools_screen.dart
+34
-61
@@ -13,49 +13,26 @@ import '../../../core/providers/app_providers.dart';
13
import '../../../core/theme/colors.dart';
14
import '../../../core/utils/system_insets.dart';
15
16
-class McpToolsScreen extends ConsumerStatefulWidget {
16
+class McpToolsScreen extends ConsumerWidget {
17
const McpToolsScreen({super.key});
18
19
- @override
20
- ConsumerState<McpToolsScreen> createState() => _McpToolsScreenState();
21
-}
22
-
23
-class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
24
- Future<List<ToolDefinition>>? _toolsFuture;
25
-
26
- @override
27
- void initState() {
28
- super.initState();
29
- _toolsFuture = _loadTools();
30
- }
31
-
32
- Future<List<ToolDefinition>> _loadTools() {
33
- return ref.read(toolRegistryProvider).listTools();
34
- }
35
-
36
- void _refreshTools() {
37
- ref.invalidate(toolRegistryProvider);
38
- setState(() {
39
- _toolsFuture = _loadTools();
40
- });
41
- }
42
-
43
- void _toggleExampleServer() {
19
+ void _toggleExampleServer(WidgetRef ref) {
20
final manager = ref.read(mcpServerManagerProvider);
21
if (manager.hasExampleServer()) {
22
manager.removeServer(exampleMcpServerLabel);
23
} else {
24
manager.addExampleServer();
25
}
50
- _refreshTools();
26
+ ref.invalidate(availableToolsProvider);
27
}
28
29
@override
54
- Widget build(BuildContext context) {
30
+ Widget build(BuildContext context, WidgetRef ref) {
31
final l10n = AppLocalizations.of(context)!;
32
final settings = ref.watch(settingsProvider);
33
final manager = ref.watch(mcpServerManagerProvider);
34
final hasExampleServer = manager.hasExampleServer();
35
+ final toolsAsync = ref.watch(availableToolsProvider);
36
final isDark = Theme.of(context).brightness == Brightness.dark;
37
final topPadding = MediaQuery.of(context).padding.top;
38
final bottomInset = bottomSystemInset(context);
@@ -117,7 +94,6 @@ class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
94
badges: [_FeatureBadge(label: l10n.experimental_label)],
95
onChanged: (value) {
96
ref.read(settingsProvider.notifier).setMcpEnabled(value);
120
- _refreshTools();
97
},
98
),
99
if (settings.mcpEnabled) ...[
@@ -140,7 +116,6 @@ class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
116
ref
117
.read(settingsProvider.notifier)
118
.setCalendarToolsEnabled(true);
143
- _refreshTools();
119
} else {
120
if (context.mounted) {
121
ScaffoldMessenger.of(context).showSnackBar(
@@ -156,7 +131,6 @@ class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
131
ref
132
.read(settingsProvider.notifier)
133
.setCalendarToolsEnabled(false);
159
- _refreshTools();
134
}
135
},
136
),
@@ -164,7 +138,7 @@ class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
138
label: l10n.enable_example_server,
139
description: l10n.example_mcp_server_desc,
140
value: hasExampleServer,
167
- onChanged: (_) => _toggleExampleServer(),
141
+ onChanged: (_) => _toggleExampleServer(ref),
142
),
143
],
144
],
@@ -174,7 +148,9 @@ class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
148
title: l10n.available_tools,
149
accent: const Color(0xFF22C55E),
150
icon: HugeIcons.strokeRoundedPuzzle,
177
- children: [_buildToolsContent(l10n, settings.mcpEnabled)],
151
+ children: [
152
+ _buildToolsContent(l10n, settings.mcpEnabled, toolsAsync),
153
+ ],
154
);
155
156
return ListView(
@@ -195,7 +171,8 @@ class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
171
if (settings.mcpEnabled) ...[
172
const SizedBox(height: 16),
173
_ConfiguredMcpServersCard(
198
- onServersChanged: _refreshTools,
174
+ onServersChanged: () =>
175
+ ref.invalidate(availableToolsProvider),
176
),
177
],
178
const SizedBox(height: 16),
@@ -213,7 +190,11 @@ class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
190
);
191
}
192
216
- Widget _buildToolsContent(AppLocalizations l10n, bool mcpEnabled) {
193
+ Widget _buildToolsContent(
194
+ AppLocalizations l10n,
195
+ bool mcpEnabled,
196
+ AsyncValue<List<ToolDefinition>> toolsAsync,
197
+ ) {
198
if (!mcpEnabled) {
199
return _StatusPanel(
200
icon: HugeIcons.strokeRoundedAlertCircle,
@@ -222,33 +203,25 @@ class _McpToolsScreenState extends ConsumerState<McpToolsScreen> {
203
);
204
}
205
225
- return FutureBuilder<List<ToolDefinition>>(
226
- future: _toolsFuture,
227
- builder: (context, snapshot) {
228
- if (snapshot.connectionState == ConnectionState.waiting) {
229
- return _McpPanel(
230
- child: Padding(
231
- padding: const EdgeInsets.symmetric(vertical: 24),
232
- child: Center(
233
- child: SizedBox(
234
- width: 22,
235
- height: 22,
236
- child: const CircularProgressIndicator(strokeWidth: 2),
237
- ),
238
- ),
206
+ return toolsAsync.when(
207
+ loading: () => _McpPanel(
208
+ child: const Padding(
209
+ padding: EdgeInsets.symmetric(vertical: 24),
210
+ child: Center(
211
+ child: SizedBox(
212
+ width: 22,
213
+ height: 22,
214
+ child: CircularProgressIndicator(strokeWidth: 2),
215
),
240
- );
241
- }
242
-
243
- if (snapshot.hasError) {
244
- return _StatusPanel(
245
- icon: HugeIcons.strokeRoundedInformationCircle,
246
- title: l10n.unable_load_tools,
247
- body: snapshot.error.toString(),
248
- );
249
- }
250
-
251
- final tools = snapshot.data ?? const <ToolDefinition>[];
216
+ ),
217
+ ),
218
+ ),
219
+ error: (error, _) => _StatusPanel(
220
+ icon: HugeIcons.strokeRoundedInformationCircle,
221
+ title: l10n.unable_load_tools,
222
+ body: error.toString(),
223
+ ),
224
+ data: (tools) {
225
if (tools.isEmpty) {
226
return _StatusPanel(
227
icon: HugeIcons.strokeRoundedPuzzle,
test/features/chat/lm_studio_mcp_integration_test.dart
+59
-2
@@ -8,6 +8,7 @@ import 'package:localmind/features/chat/data/chat_service.dart';
8
import 'package:localmind/features/chat/data/models/message.dart';
9
import 'package:localmind/features/chat/data/models/chat_parameters.dart';
10
import 'package:localmind/features/chat/data/models/mcp_integration.dart';
11
+import 'package:localmind/features/chat/providers/chat_reasoning_providers.dart';
12
import 'package:localmind/features/servers/data/models/server.dart';
13
14
class CapturingStreamInterceptor extends Interceptor {
@@ -173,7 +174,7 @@ void main() {
174
// object and extra OpenAI-compat keys (reasoning_effort/think/
175
// enable_thinking). LM Studio's native /api/v1/chat rejects all of
176
// those with HTTP 400, so LMStudioChatService must emit the native
176
- // string form ('off'|'low'|'medium'|'high'|'on') only.
177
+ // string form ('off'|'low'|'medium'|'high'|'xhigh') only.
178
final interceptor = CapturingStreamInterceptor([
179
'event: chat.start',
180
'data: {"type":"chat.start","model_instance_id":"test-model"}',
@@ -192,6 +193,7 @@ void main() {
193
messages: [userMessage],
194
params: ChatParameters.defaults().copyWith(
195
reasoningEnabled: true,
196
+ reasoningEffort: ReasoningEffort.medium,
197
),
198
)
199
.toList();
@@ -204,7 +206,9 @@ void main() {
206
isA<String>(),
207
reason: 'native endpoint expects reasoning as a string',
208
);
207
- expect(body['reasoning'], equals('on'));
209
+ // Must send the actual effort enum value (e.g. 'medium'), not the
210
+ // generic 'on' which newer models like meta/muse-glimmer reject.
211
+ expect(body['reasoning'], equals('medium'));
212
213
// The OpenAI-compatible reasoning keys must NOT be sent.
214
expect(body.containsKey('reasoning_effort'), isFalse);
@@ -213,6 +217,59 @@ void main() {
217
},
218
);
219
220
+ test(
221
+ 'reasoning enabled sends the requested enum value, not "on" '
222
+ '(muse-glimmer regression)',
223
+ () async {
224
+ // Regression for https://github.com/abdulmominsakib/localmind/issues/75
225
+ // meta/muse-glimmer accepts only 'low'|'medium'|'high'|'xhigh' and
226
+ // rejects 'on'/'off' with HTTP 400. The native LM Studio chat
227
+ // service must forward the user's selected effort instead of the
228
+ // hard-coded 'on'.
229
+ for (final effort in ReasoningEffort.values) {
230
+ // 'max' isn't part of the LM Studio enum, so we only assert the
231
+ // values the server actually accepts. Skip if the enum value
232
+ // exceeds what LM Studio supports.
233
+ if (effort == ReasoningEffort.max) continue;
234
+
235
+ final interceptor = CapturingStreamInterceptor([
236
+ 'event: chat.start',
237
+ 'data: {"type":"chat.start","model_instance_id":"meta/muse-glimmer"}',
238
+ '',
239
+ 'event: chat.end',
240
+ 'data: {"type":"chat.end","result":{"model_instance_id":"meta/muse-glimmer","output":[],"stats":{"input_tokens":1,"total_output_tokens":1}}}',
241
+ '',
242
+ ]);
243
+ final dio = Dio()..interceptors.add(interceptor);
244
+ final service = LMStudioChatService(dio);
245
+
246
+ await service
247
+ .sendMessage(
248
+ server: server,
249
+ modelId: 'meta/muse-glimmer',
250
+ messages: [userMessage],
251
+ params: ChatParameters.defaults().copyWith(
252
+ reasoningEnabled: true,
253
+ reasoningEffort: effort,
254
+ ),
255
+ )
256
+ .toList();
257
+
258
+ final body = interceptor.capturedRequest!.data
259
+ as Map<String, dynamic>;
260
+ expect(
261
+ body['reasoning'],
262
+ equals(effort.apiValue),
263
+ reason:
264
+ 'effort $effort should be forwarded as its enum string',
265
+ );
266
+ // And it must never be the generic 'on' that muse-glimmer
267
+ // rejects.
268
+ expect(body['reasoning'], isNot(equals('on')));
269
+ }
270
+ },
271
+ );
272
+
273
test('reasoning disabled sends native "off" string', () async {
274
final interceptor = CapturingStreamInterceptor([
275
'event: chat.start',
test/features/chat/notification_permission_banner_test.dart
new
+127
@@ -0,0 +1,127 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_localizations/flutter_localizations.dart';
3
+import 'package:flutter_riverpod/flutter_riverpod.dart';
4
+import 'package:flutter_test/flutter_test.dart';
5
+import 'package:localmind/core/providers/app_providers.dart';
6
+import 'package:localmind/core/theme/app_theme.dart';
7
+import 'package:localmind/features/chat/views/components/notification_permission_banner.dart';
8
+import 'package:localmind/features/on_device/data/notification_permission_service.dart';
9
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
10
+import 'package:localmind/features/settings/data/models/app_settings.dart';
11
+import 'package:localmind/l10n/app_localizations.dart';
12
+import 'package:shadcn_ui/shadcn_ui.dart';
13
+import 'package:shared_preferences/shared_preferences.dart';
14
+
15
+class _FakeNotificationPermissionService
16
+ implements NotificationPermissionService {
17
+ _FakeNotificationPermissionService({this.granted = false});
18
+
19
+ final bool granted;
20
+ bool requestCalled = false;
21
+
22
+ @override
23
+ Future<void> init() async {}
24
+
25
+ @override
26
+ Future<bool> isPermissionGranted() async => granted;
27
+
28
+ @override
29
+ Future<bool> requestPermission() async {
30
+ requestCalled = true;
31
+ return true;
32
+ }
33
+}
34
+
35
+class _TestSettingsNotifier extends SettingsNotifier {
36
+ _TestSettingsNotifier(this._initial);
37
+ final AppSettings _initial;
38
+
39
+ @override
40
+ AppSettings build() => _initial;
41
+}
42
+
43
+Widget _wrapWithApp({required Widget child, List<dynamic>? overrides}) {
44
+ return ProviderScope(
45
+ overrides: [...?overrides?.cast()],
46
+ child: ShadTheme(
47
+ data: AppTheme.lightShadTheme,
48
+ child: MaterialApp(
49
+ locale: const Locale('en'),
50
+ localizationsDelegates: const [
51
+ AppLocalizations.delegate,
52
+ GlobalMaterialLocalizations.delegate,
53
+ GlobalWidgetsLocalizations.delegate,
54
+ GlobalCupertinoLocalizations.delegate,
55
+ ],
56
+ supportedLocales: AppLocalizations.supportedLocales,
57
+ home: Scaffold(body: child),
58
+ ),
59
+ ),
60
+ );
61
+}
62
+
63
+void main() {
64
+ TestWidgetsFlutterBinding.ensureInitialized();
65
+
66
+ setUp(() {
67
+ SharedPreferences.setMockInitialValues({});
68
+ });
69
+
70
+ group('NotificationPermissionBanner Widget Tests', () {
71
+ testWidgets(
72
+ 'mounts cleanly and displays banner when permission not granted',
73
+ (tester) async {
74
+ final fakeService = _FakeNotificationPermissionService(granted: false);
75
+
76
+ await tester.pumpWidget(
77
+ _wrapWithApp(
78
+ overrides: [
79
+ notificationPermissionServiceProvider.overrideWithValue(
80
+ fakeService,
81
+ ),
82
+ settingsProvider.overrideWith(
83
+ () => _TestSettingsNotifier(
84
+ AppSettings(hasAskedForNotifications: false),
85
+ ),
86
+ ),
87
+ ],
88
+ child: const NotificationPermissionBanner(),
89
+ ),
90
+ );
91
+
92
+ // Verify no exception on first frame / mount
93
+ expect(tester.takeException(), isNull);
94
+
95
+ await tester.pumpAndSettle();
96
+
97
+ final l10n = lookupAppLocalizations(const Locale('en'));
98
+ expect(find.text(l10n.enable_notifications), findsOneWidget);
99
+ },
100
+ );
101
+
102
+ testWidgets('does not show banner when user already asked', (tester) async {
103
+ final fakeService = _FakeNotificationPermissionService(granted: false);
104
+
105
+ await tester.pumpWidget(
106
+ _wrapWithApp(
107
+ overrides: [
108
+ notificationPermissionServiceProvider.overrideWithValue(
109
+ fakeService,
110
+ ),
111
+ settingsProvider.overrideWith(
112
+ () => _TestSettingsNotifier(
113
+ AppSettings(hasAskedForNotifications: true),
114
+ ),
115
+ ),
116
+ ],
117
+ child: const NotificationPermissionBanner(),
118
+ ),
119
+ );
120
+
121
+ await tester.pumpAndSettle();
122
+
123
+ final l10n = lookupAppLocalizations(const Locale('en'));
124
+ expect(find.text(l10n.enable_notifications), findsNothing);
125
+ });
126
+ });
127
+}
test/features/mcp/mcp_tools_screen_test.dart
new
+158
@@ -0,0 +1,158 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_localizations/flutter_localizations.dart';
3
+import 'package:flutter_riverpod/flutter_riverpod.dart';
4
+import 'package:flutter_test/flutter_test.dart';
5
+import 'package:localmind/core/providers/app_providers.dart';
6
+import 'package:localmind/core/theme/app_theme.dart';
7
+import 'package:localmind/features/chat/data/tools/tool_definition.dart';
8
+import 'package:localmind/features/chat/providers/tooling_providers.dart';
9
+import 'package:localmind/features/mcp/views/mcp_tools_screen.dart';
10
+import 'package:localmind/features/settings/data/models/app_settings.dart';
11
+import 'package:localmind/l10n/app_localizations.dart';
12
+import 'package:shadcn_ui/shadcn_ui.dart';
13
+import 'package:shared_preferences/shared_preferences.dart';
14
+
15
+Widget _wrapWithApp({required Widget child, List<dynamic>? overrides}) {
16
+ return ProviderScope(
17
+ overrides: [...?overrides?.cast()],
18
+ child: ShadTheme(
19
+ data: AppTheme.lightShadTheme,
20
+ child: MaterialApp(
21
+ locale: const Locale('en'),
22
+ localizationsDelegates: const [
23
+ AppLocalizations.delegate,
24
+ GlobalMaterialLocalizations.delegate,
25
+ GlobalWidgetsLocalizations.delegate,
26
+ GlobalCupertinoLocalizations.delegate,
27
+ ],
28
+ supportedLocales: AppLocalizations.supportedLocales,
29
+ home: Scaffold(body: child),
30
+ ),
31
+ ),
32
+ );
33
+}
34
+
35
+void main() {
36
+ TestWidgetsFlutterBinding.ensureInitialized();
37
+
38
+ setUp(() {
39
+ SharedPreferences.setMockInitialValues({});
40
+ });
41
+
42
+ group('McpToolsScreen Widget Tests', () {
43
+ testWidgets('renders without throwing setState during build exception', (
44
+ tester,
45
+ ) async {
46
+ final List<ToolDefinition> tools = [
47
+ const ToolDefinition(
48
+ name: 'get_current_weather',
49
+ description: 'Get weather for location',
50
+ inputSchema: {},
51
+ providerType: ToolProviderType.mcp,
52
+ providerRef: 'weather_server',
53
+ ),
54
+ ];
55
+
56
+ await tester.pumpWidget(
57
+ _wrapWithApp(
58
+ overrides: [
59
+ availableToolsProvider.overrideWith((ref) => Future.value(tools)),
60
+ settingsProvider.overrideWith(
61
+ () => _TestSettingsNotifier(
62
+ AppSettings(mcpEnabled: true, newChatMcpEnabled: true),
63
+ ),
64
+ ),
65
+ ],
66
+ child: const McpToolsScreen(),
67
+ ),
68
+ );
69
+
70
+ // Verify no exceptions were thrown during first build/mount
71
+ expect(tester.takeException(), isNull);
72
+
73
+ await tester.pumpAndSettle();
74
+
75
+ expect(find.text('get_current_weather'), findsOneWidget);
76
+ expect(find.text('Get weather for location'), findsOneWidget);
77
+ expect(find.text('weather_server'), findsOneWidget);
78
+ expect(find.text('MCP'), findsOneWidget);
79
+ });
80
+
81
+ testWidgets('displays disabled warning when mcpEnabled is false', (
82
+ tester,
83
+ ) async {
84
+ await tester.pumpWidget(
85
+ _wrapWithApp(
86
+ overrides: [
87
+ settingsProvider.overrideWith(
88
+ () => _TestSettingsNotifier(AppSettings(mcpEnabled: false)),
89
+ ),
90
+ ],
91
+ child: const McpToolsScreen(),
92
+ ),
93
+ );
94
+
95
+ await tester.pumpAndSettle();
96
+
97
+ final l10n = lookupAppLocalizations(const Locale('en'));
98
+ expect(find.text(l10n.mcp_disabled_warning), findsOneWidget);
99
+ });
100
+
101
+ testWidgets('displays empty state when tools list is empty', (
102
+ tester,
103
+ ) async {
104
+ await tester.pumpWidget(
105
+ _wrapWithApp(
106
+ overrides: [
107
+ availableToolsProvider.overrideWith(
108
+ (ref) => Future.value(const <ToolDefinition>[]),
109
+ ),
110
+ settingsProvider.overrideWith(
111
+ () => _TestSettingsNotifier(
112
+ AppSettings(mcpEnabled: true, newChatMcpEnabled: true),
113
+ ),
114
+ ),
115
+ ],
116
+ child: const McpToolsScreen(),
117
+ ),
118
+ );
119
+
120
+ await tester.pumpAndSettle();
121
+
122
+ final l10n = lookupAppLocalizations(const Locale('en'));
123
+ expect(find.text(l10n.no_tools_registered), findsOneWidget);
124
+ });
125
+
126
+ testWidgets('displays error panel when availableToolsProvider fails', (
127
+ tester,
128
+ ) async {
129
+ await tester.pumpWidget(
130
+ _wrapWithApp(
131
+ overrides: [
132
+ availableToolsProvider.overrideWith(
133
+ (ref) => Future.error('Connection failed to MCP socket'),
134
+ ),
135
+ settingsProvider.overrideWith(
136
+ () => _TestSettingsNotifier(
137
+ AppSettings(mcpEnabled: true, newChatMcpEnabled: true),
138
+ ),
139
+ ),
140
+ ],
141
+ child: const McpToolsScreen(),
142
+ ),
143
+ );
144
+
145
+ await tester.pumpAndSettle();
146
+
147
+ expect(find.text('Connection failed to MCP socket'), findsOneWidget);
148
+ });
149
+ });
150
+}
151
+
152
+class _TestSettingsNotifier extends SettingsNotifier {
153
+ _TestSettingsNotifier(this._initial);
154
+ final AppSettings _initial;
155
+
156
+ @override
157
+ AppSettings build() => _initial;
158
+}