1mod migrate;
2mod schema;
3pub mod watcher;
4
5pub use migrate::check_openclaw_detected;
6pub use schema::*;
7pub use watcher::{ConfigWatcher, spawn_sighup_handler};
8
9use anyhow::Result;
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::PathBuf;
13
14use crate::env::LOCALGPT_WORKSPACE;
15use crate::paths::Paths;
16use crate::paths::{DEFAULT_DATA_DIR_STR, DEFAULT_STATE_DIR_STR};
17
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum MemoryBackendKind {
27 #[default]
28 Sqlite,
29 Markdown,
30 None,
31}
32
33impl std::fmt::Display for MemoryBackendKind {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 match self {
36 Self::Sqlite => write!(f, "sqlite"),
37 Self::Markdown => write!(f, "markdown"),
38 Self::None => write!(f, "none"),
39 }
40 }
41}
42
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct Config {
45 #[serde(skip)]
47 pub paths: Paths,
48
49 #[serde(default)]
50 pub agent: AgentConfig,
51
52 #[serde(default)]
53 pub providers: ProvidersConfig,
54
55 #[serde(default)]
56 pub heartbeat: HeartbeatConfig,
57
58 #[serde(default)]
59 pub memory: MemoryConfig,
60
61 #[serde(default)]
62 pub server: ServerConfig,
63
64 #[serde(default)]
65 pub logging: LoggingConfig,
66
67 #[serde(default)]
68 pub tools: ToolsConfig,
69
70 #[serde(default)]
71 pub security: SecurityConfig,
72
73 #[serde(default)]
74 pub sandbox: SandboxConfig,
75
76 #[serde(default)]
77 pub telegram: Option<TelegramConfig>,
78
79 #[serde(default)]
80 pub cron: CronConfig,
81
82 #[serde(default)]
83 pub hooks: HooksConfig,
84
85 #[serde(default)]
86 pub mcp: McpConfig,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct AgentConfig {
91 #[serde(default = "default_model")]
92 pub default_model: String,
93
94 #[serde(default = "default_context_window")]
95 pub context_window: usize,
96
97 #[serde(default = "default_reserve_tokens")]
98 pub reserve_tokens: usize,
99
100 #[serde(default = "default_max_tokens")]
102 pub max_tokens: usize,
103
104 #[serde(default)]
109 pub max_spawn_depth: Option<u8>,
110
111 #[serde(default)]
113 pub subagent_model: Option<String>,
114
115 #[serde(default)]
119 pub fallback_models: Vec<String>,
120
121 #[serde(default = "default_max_tool_repeats")]
124 pub max_tool_repeats: usize,
125
126 #[serde(default = "default_max_tool_errors")]
129 pub max_tool_errors: u32,
130
131 #[serde(default = "default_true")]
134 pub tool_retry_on_malformed: bool,
135
136 #[serde(default = "default_session_max_age")]
139 pub session_max_age: u64,
140
141 #[serde(default = "default_session_max_count")]
144 pub session_max_count: usize,
145
146 #[serde(default = "default_post_compaction_sections")]
149 pub post_compaction_sections: Vec<String>,
150
151 #[serde(default = "default_true")]
154 pub checkpoints_enabled: bool,
155
156 #[serde(default = "default_max_checkpoints")]
158 pub max_checkpoints: usize,
159
160 #[serde(default)]
162 pub active_memory: crate::memory::active_recall::ActiveMemoryConfig,
163
164 #[serde(default = "default_permission_level")]
168 pub permission_level: crate::agent::tools::PermissionLevel,
169
170 #[serde(default = "default_true")]
173 pub auto_approve_loopback: bool,
174}
175
176fn default_max_tool_repeats() -> usize {
177 3
178}
179
180fn default_max_tool_errors() -> u32 {
181 3
182}
183
184fn default_session_max_age() -> u64 {
185 30 * 24 * 60 * 60 }
187
188fn default_session_max_count() -> usize {
189 500
190}
191
192fn default_max_checkpoints() -> usize {
193 5
194}
195
196fn default_permission_level() -> crate::agent::tools::PermissionLevel {
197 crate::agent::tools::PermissionLevel::Elevated
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct ToolsConfig {
203 #[serde(default = "default_bash_timeout")]
205 pub bash_timeout_ms: u64,
206
207 #[serde(default = "default_web_fetch_max_bytes")]
209 pub web_fetch_max_bytes: usize,
210
211 #[serde(default)]
214 pub require_approval: Vec<String>,
215
216 #[serde(default = "default_tool_output_max_chars")]
218 pub tool_output_max_chars: usize,
219
220 #[serde(default = "default_true")]
222 pub log_injection_warnings: bool,
223
224 #[serde(default = "default_true")]
226 pub use_content_delimiters: bool,
227
228 #[serde(default)]
230 pub web_search: Option<WebSearchConfig>,
231
232 #[serde(default)]
234 pub document_loaders: Option<std::collections::HashMap<String, String>>,
235
236 #[serde(default = "default_document_max_bytes")]
238 pub document_max_bytes: usize,
239
240 #[serde(default)]
242 pub stt: Option<crate::media::SttConfig>,
243
244 #[serde(default = "default_image_max_dimension")]
247 pub image_max_dimension: u32,
248
249 #[serde(default = "default_true")]
251 pub media_cache_enabled: bool,
252
253 #[serde(default = "default_media_cache_max_mb")]
255 pub media_cache_max_mb: u64,
256
257 #[serde(default)]
259 pub browser_enabled: bool,
260
261 #[serde(default = "default_browser_port")]
263 pub browser_port: u16,
264
265 #[serde(default)]
268 pub filters: std::collections::HashMap<String, crate::agent::tool_filters::ToolFilter>,
269}
270
271#[derive(Debug, Clone, Default, Serialize, Deserialize)]
272#[serde(rename_all = "lowercase")]
273pub enum SearchProviderType {
274 Searxng,
275 Brave,
276 Tavily,
277 Perplexity,
278 #[default]
279 None,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct WebSearchConfig {
284 #[serde(default)]
285 pub provider: SearchProviderType,
286
287 #[serde(default = "default_true")]
288 pub cache_enabled: bool,
289
290 #[serde(default = "default_cache_ttl")]
292 pub cache_ttl: u64,
293
294 #[serde(default = "default_max_results")]
296 pub max_results: u8,
297
298 #[serde(default = "default_true")]
300 pub prefer_native: bool,
301
302 #[serde(default)]
303 pub searxng: Option<SearxngConfig>,
304
305 #[serde(default)]
306 pub brave: Option<BraveConfig>,
307
308 #[serde(default)]
309 pub tavily: Option<TavilyConfig>,
310
311 #[serde(default)]
312 pub perplexity: Option<PerplexityConfig>,
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct SearxngConfig {
317 pub base_url: String,
318
319 #[serde(default)]
320 pub categories: String,
321
322 #[serde(default)]
323 pub language: String,
324
325 #[serde(default)]
326 pub time_range: String,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct BraveConfig {
331 pub api_key: String,
332
333 #[serde(default)]
334 pub country: String,
335
336 #[serde(default)]
337 pub freshness: String,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct TavilyConfig {
342 pub api_key: String,
343
344 #[serde(default = "default_basic")]
345 pub search_depth: String,
346
347 #[serde(default = "default_true")]
348 pub include_answer: bool,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct PerplexityConfig {
353 pub api_key: String,
354
355 #[serde(default = "default_sonar")]
356 pub model: String,
357}
358
359#[derive(Debug, Clone, Default, Serialize, Deserialize)]
360pub struct SecurityConfig {
361 #[serde(default)]
367 pub strict_policy: bool,
368
369 #[serde(default)]
376 pub disable_policy: bool,
377
378 #[serde(default)]
389 pub disable_suffix: bool,
390
391 #[serde(default)]
394 pub allowed_directories: Vec<String>,
395
396 #[serde(default)]
398 pub encryption: bool,
399
400 #[serde(default)]
403 pub encryption_key_path: Option<String>,
404
405 #[serde(default = "default_sandbox_backend")]
408 pub sandbox_backend: String,
409
410 #[serde(default)]
412 pub sandbox_image: Option<String>,
413
414 #[serde(default)]
416 pub sandbox_memory: Option<String>,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct SandboxConfig {
421 #[serde(default = "default_true")]
423 pub enabled: bool,
424
425 #[serde(default = "default_sandbox_level")]
427 pub level: String,
428
429 #[serde(default = "default_sandbox_timeout")]
431 pub timeout_secs: u64,
432
433 #[serde(default = "default_sandbox_max_output")]
435 pub max_output_bytes: u64,
436
437 #[serde(default = "default_sandbox_max_file_size")]
439 pub max_file_size_bytes: u64,
440
441 #[serde(default = "default_sandbox_max_processes")]
443 pub max_processes: u32,
444
445 #[serde(default)]
447 pub allow_paths: AllowPathsConfig,
448
449 #[serde(default)]
451 pub network: SandboxNetworkConfig,
452}
453
454#[derive(Debug, Clone, Default, Serialize, Deserialize)]
455pub struct AllowPathsConfig {
456 #[serde(default)]
458 pub read: Vec<String>,
459
460 #[serde(default)]
462 pub write: Vec<String>,
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct SandboxNetworkConfig {
467 #[serde(default = "default_sandbox_network_policy")]
469 pub policy: String,
470}
471
472impl Default for SandboxConfig {
473 fn default() -> Self {
474 Self {
475 enabled: default_true(),
476 level: default_sandbox_level(),
477 timeout_secs: default_sandbox_timeout(),
478 max_output_bytes: default_sandbox_max_output(),
479 max_file_size_bytes: default_sandbox_max_file_size(),
480 max_processes: default_sandbox_max_processes(),
481 allow_paths: AllowPathsConfig::default(),
482 network: SandboxNetworkConfig::default(),
483 }
484 }
485}
486
487impl Default for SandboxNetworkConfig {
488 fn default() -> Self {
489 Self {
490 policy: default_sandbox_network_policy(),
491 }
492 }
493}
494
495#[derive(Debug, Clone, Default, Serialize, Deserialize)]
496pub struct ProvidersConfig {
497 #[serde(default)]
498 pub openai: Option<OpenAIConfig>,
499
500 #[serde(default)]
501 pub xai: Option<XaiConfig>,
502
503 #[serde(default)]
504 pub anthropic: Option<AnthropicConfig>,
505
506 #[serde(default)]
507 pub ollama: Option<OllamaConfig>,
508
509 #[serde(default)]
510 pub claude_cli: Option<ClaudeCliConfig>,
511
512 #[serde(default)]
513 pub gemini_cli: Option<GeminiCliConfig>,
514
515 #[serde(default)]
516 pub codex_cli: Option<CodexCliConfig>,
517
518 #[serde(default)]
519 pub glm: Option<GlmConfig>,
520
521 #[serde(default)]
522 pub gemini: Option<GeminiConfig>,
523
524 #[serde(default)]
527 pub openai_compatible: Option<OpenAICompatibleConfig>,
528
529 #[serde(default)]
531 pub vertex: Option<VertexAiConfig>,
532
533 #[serde(default)]
535 pub openrouter: Option<OpenRouterConfig>,
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize)]
540pub struct OpenRouterConfig {
541 pub api_key: String,
542}
543
544#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct OpenAICompatibleConfig {
547 pub base_url: String,
549
550 pub api_key: String,
552
553 #[serde(default)]
555 pub extra_headers: std::collections::HashMap<String, String>,
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct VertexAiConfig {
561 pub service_account_key: String,
563
564 pub project_id: String,
566
567 #[serde(default = "default_vertex_location")]
569 pub location: String,
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize)]
573pub struct OpenAIConfig {
574 pub api_key: String,
575
576 #[serde(default = "default_openai_base_url")]
577 pub base_url: String,
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize)]
581pub struct XaiConfig {
582 pub api_key: String,
583
584 #[serde(default = "default_xai_base_url")]
585 pub base_url: String,
586}
587
588#[derive(Debug, Clone, Serialize, Deserialize)]
589pub struct AnthropicConfig {
590 pub api_key: String,
591
592 #[serde(default = "default_anthropic_base_url")]
593 pub base_url: String,
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize)]
597pub struct OllamaConfig {
598 #[serde(default = "default_ollama_endpoint")]
599 pub endpoint: String,
600
601 #[serde(default = "default_ollama_model")]
602 pub model: String,
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize)]
606pub struct ClaudeCliConfig {
607 #[serde(default = "default_claude_cli_command")]
608 pub command: String,
609
610 #[serde(default = "default_claude_cli_model")]
611 pub model: String,
612
613 #[serde(default = "default_claude_cli_effort")]
616 pub effort: String,
617
618 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub mcp_config_override: Option<String>,
624}
625
626#[derive(Debug, Clone, Serialize, Deserialize)]
627pub struct GeminiCliConfig {
628 #[serde(default = "default_gemini_cli_command")]
629 pub command: String,
630
631 #[serde(default = "default_gemini_cli_model")]
632 pub model: String,
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize)]
636pub struct CodexCliConfig {
637 #[serde(default = "default_codex_cli_command")]
638 pub command: String,
639
640 #[serde(default = "default_codex_cli_model")]
641 pub model: String,
642}
643
644#[derive(Debug, Clone, Serialize, Deserialize)]
645pub struct GlmConfig {
646 pub api_key: String,
647
648 #[serde(default = "default_glm_base_url")]
649 pub base_url: String,
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize)]
653pub struct GeminiConfig {
654 pub api_key: String,
655
656 #[serde(default = "default_gemini_base_url")]
657 pub base_url: String,
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize)]
661pub struct HeartbeatConfig {
662 #[serde(default = "default_true")]
663 pub enabled: bool,
664
665 #[serde(default = "default_interval")]
666 pub interval: String,
667
668 #[serde(default = "default_overdue_delay")]
669 pub overdue_delay: String,
670
671 #[serde(default)]
675 pub timeout: Option<String>,
676
677 #[serde(default)]
678 pub active_hours: Option<ActiveHours>,
679
680 #[serde(default)]
681 pub timezone: Option<String>,
682
683 #[serde(default)]
685 pub dreaming: crate::memory::dreaming::DreamingConfig,
686
687 #[serde(default)]
689 pub mcp_servers: Vec<String>,
690}
691
692#[derive(Debug, Clone, Serialize, Deserialize)]
693pub struct ActiveHours {
694 pub start: String,
695 pub end: String,
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize)]
699pub struct MemoryConfig {
700 #[serde(default)]
702 pub backend: MemoryBackendKind,
703
704 #[serde(default = "default_workspace")]
705 pub workspace: String,
706
707 #[serde(default = "default_embedding_provider")]
709 pub embedding_provider: String,
710
711 #[serde(default = "default_embedding_model")]
712 pub embedding_model: String,
713
714 #[serde(default)]
716 pub gemini_api_key: Option<String>,
717
718 #[serde(default)]
720 pub index_sessions: bool,
721
722 #[serde(default)]
724 pub multimodal_embeddings: bool,
725
726 #[serde(default = "default_embedding_cache_dir")]
730 pub embedding_cache_dir: String,
731
732 #[serde(default = "default_chunk_size")]
733 pub chunk_size: usize,
734
735 #[serde(default = "default_chunk_overlap")]
736 pub chunk_overlap: usize,
737
738 #[serde(default = "default_index_paths")]
741 pub paths: Vec<MemoryIndexPath>,
742
743 #[serde(default = "default_session_max_messages")]
746 pub session_max_messages: usize,
747
748 #[serde(default)]
751 pub session_max_chars: usize,
752
753 #[serde(default)]
759 pub temporal_decay_lambda: f64,
760
761 #[serde(default)]
765 pub llm_query_expansion: bool,
766
767 #[serde(default)]
770 pub wiki_enabled: bool,
771
772 #[serde(default = "default_wiki_fresh_days")]
775 pub wiki_fresh_days: u32,
776
777 #[serde(default = "default_wiki_stale_days")]
780 pub wiki_stale_days: u32,
781}
782
783#[derive(Debug, Clone, Serialize, Deserialize)]
784pub struct MemoryIndexPath {
785 pub path: String,
786 #[serde(default = "default_pattern")]
787 pub pattern: String,
788}
789
790#[derive(Debug, Clone, Serialize, Deserialize)]
791pub struct ServerConfig {
792 #[serde(default = "default_true")]
793 pub enabled: bool,
794
795 #[serde(default = "default_port")]
796 pub port: u16,
797
798 #[serde(default = "default_bind")]
799 pub bind: String,
800
801 #[serde(default)]
806 pub auth_token: Option<String>,
807
808 #[serde(default)]
809 pub rate_limit: RateLimitConfig,
810
811 #[serde(default)]
814 pub cors_origins: Vec<String>,
815
816 #[serde(default = "default_max_request_body")]
820 pub max_request_body: usize,
821
822 #[serde(default)]
826 pub webhook_secret: Option<String>,
827
828 #[serde(default)]
831 pub outbox_enabled: bool,
832
833 #[serde(default = "default_outbox_max_attempts")]
835 pub outbox_max_attempts: i64,
836
837 #[serde(default = "default_outbox_retain_days")]
839 pub outbox_retain_days: u32,
840
841 #[serde(default)]
844 pub tls_enabled: bool,
845
846 #[serde(default = "default_tls_cert_dir")]
848 pub tls_cert_dir: String,
849
850 #[serde(default = "default_tls_renew_threshold")]
852 pub tls_renew_threshold_days: u32,
853}
854
855fn default_max_request_body() -> usize {
856 10 * 1024 * 1024 }
858fn default_outbox_max_attempts() -> i64 {
859 5
860}
861fn default_outbox_retain_days() -> u32 {
862 7
863}
864fn default_tls_cert_dir() -> String {
865 if let Some(proj) = directories::ProjectDirs::from("app", "LocalGPT", "localgpt") {
866 proj.config_dir()
867 .join("certs")
868 .to_string_lossy()
869 .to_string()
870 } else {
871 "~/.config/localgpt/certs".to_string()
872 }
873}
874fn default_tls_renew_threshold() -> u32 {
875 30
876}
877
878#[derive(Debug, Clone, Serialize, Deserialize)]
879pub struct RateLimitConfig {
880 #[serde(default = "default_true")]
881 pub enabled: bool,
882
883 #[serde(default = "default_requests_per_minute")]
885 pub requests_per_minute: u32,
886
887 #[serde(default = "default_burst")]
889 pub burst: u32,
890}
891
892#[derive(Debug, Clone, Serialize, Deserialize)]
893pub struct LoggingConfig {
894 #[serde(default = "default_log_level")]
895 pub level: String,
896
897 #[serde(default = "default_log_path", alias = "file")]
898 pub path: String,
899
900 #[serde(default)]
902 pub retention_days: u32,
903}
904
905#[derive(Debug, Clone, Serialize, Deserialize)]
906pub struct TelegramConfig {
907 #[serde(default)]
908 pub enabled: bool,
909
910 pub api_token: String,
911
912 #[serde(default, skip_serializing_if = "Option::is_none")]
915 pub heartbeat_topic_id: Option<i32>,
916}
917
918#[derive(Debug, Clone, Default, Serialize, Deserialize)]
919pub struct CronConfig {
920 #[serde(default)]
921 pub jobs: Vec<CronJob>,
922}
923
924#[derive(Debug, Clone, Serialize, Deserialize)]
925pub struct CronJob {
926 pub name: String,
927
928 pub schedule: String,
930
931 pub prompt: String,
933
934 #[serde(default)]
936 pub channel: Option<String>,
937
938 #[serde(default = "default_true")]
939 pub enabled: bool,
940
941 #[serde(default = "default_cron_timeout")]
943 pub timeout: String,
944
945 #[serde(default)]
947 pub mcp_servers: Vec<String>,
948}
949
950#[derive(Debug, Clone, Default, Serialize, Deserialize)]
951pub struct HooksConfig {
952 #[serde(default)]
953 pub hooks: Vec<HookConfig>,
954}
955
956#[derive(Debug, Clone, Serialize, Deserialize)]
957pub struct HookConfig {
958 pub name: String,
960
961 pub event: String,
963
964 pub command: String,
966
967 #[serde(default)]
969 pub filter: Option<String>,
970
971 #[serde(default = "default_true")]
973 pub enabled: bool,
974}
975
976#[derive(Debug, Clone, Default, Serialize, Deserialize)]
977pub struct McpConfig {
978 #[serde(default)]
979 pub servers: Vec<McpServerConfig>,
980}
981
982impl McpConfig {
983 pub fn filter_servers(&self, allowlist: &[String]) -> Vec<McpServerConfig> {
986 if allowlist.is_empty() {
987 return self.servers.clone();
988 }
989 self.servers
990 .iter()
991 .filter(|s| {
992 allowlist
993 .iter()
994 .any(|name| name.eq_ignore_ascii_case(&s.name))
995 })
996 .cloned()
997 .collect()
998 }
999}
1000
1001#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1002pub struct McpServerConfig {
1003 #[serde(default)]
1005 pub name: String,
1006
1007 #[serde(default = "default_mcp_transport")]
1009 pub transport: String,
1010
1011 pub command: Option<String>,
1013
1014 #[serde(default)]
1016 pub args: Vec<String>,
1017
1018 #[serde(default)]
1020 pub env: std::collections::HashMap<String, String>,
1021
1022 pub url: Option<String>,
1024
1025 #[serde(default = "default_true")]
1027 pub enabled: bool,
1028}
1029
1030fn default_mcp_transport() -> String {
1031 "stdio".to_string()
1032}
1033
1034fn default_model() -> String {
1036 "claude-cli/opus".to_string()
1038}
1039fn default_context_window() -> usize {
1040 128000
1041}
1042fn default_reserve_tokens() -> usize {
1043 8000
1044}
1045fn default_max_tokens() -> usize {
1046 4096
1047}
1048fn default_bash_timeout() -> u64 {
1049 30000 }
1051fn default_web_fetch_max_bytes() -> usize {
1052 10000
1053}
1054fn default_tool_output_max_chars() -> usize {
1055 50000 }
1057fn default_document_max_bytes() -> usize {
1058 10_485_760 }
1060fn default_media_cache_max_mb() -> u64 {
1061 100
1062}
1063fn default_browser_port() -> u16 {
1064 9222
1065}
1066fn default_sandbox_backend() -> String {
1067 "disabled".to_string()
1068}
1069fn default_image_max_dimension() -> u32 {
1070 1568 }
1072fn default_post_compaction_sections() -> Vec<String> {
1073 vec!["Session Startup".to_string(), "Red Lines".to_string()]
1074}
1075fn default_openai_base_url() -> String {
1076 "https://api.openai.com/v1".to_string()
1077}
1078fn default_xai_base_url() -> String {
1079 "https://api.x.ai/v1".to_string()
1080}
1081fn default_anthropic_base_url() -> String {
1082 "https://api.anthropic.com".to_string()
1083}
1084fn default_ollama_endpoint() -> String {
1085 "http://localhost:11434".to_string()
1086}
1087fn default_ollama_model() -> String {
1088 "llama3".to_string()
1089}
1090fn default_claude_cli_command() -> String {
1091 "claude".to_string()
1092}
1093fn default_claude_cli_model() -> String {
1094 "opus".to_string()
1095}
1096fn default_claude_cli_effort() -> String {
1097 "max".to_string()
1098}
1099fn default_gemini_cli_command() -> String {
1100 "gemini".to_string()
1101}
1102fn default_gemini_cli_model() -> String {
1103 "gemini-3.1-pro-preview".to_string()
1104}
1105fn default_codex_cli_command() -> String {
1106 "codex".to_string()
1107}
1108fn default_codex_cli_model() -> String {
1109 "o4-mini".to_string()
1110}
1111fn default_glm_base_url() -> String {
1112 "https://api.z.ai/api/coding/paas/v4".to_string()
1113}
1114fn default_gemini_base_url() -> String {
1115 "https://generativelanguage.googleapis.com".to_string()
1116}
1117fn default_vertex_location() -> String {
1118 "us-central1".to_string()
1119}
1120fn default_true() -> bool {
1121 true
1122}
1123fn default_interval() -> String {
1124 "30m".to_string()
1125}
1126
1127fn default_overdue_delay() -> String {
1128 "1m".to_string()
1129}
1130fn default_workspace() -> String {
1131 format!("{}/workspace", DEFAULT_DATA_DIR_STR)
1132}
1133fn default_embedding_provider() -> String {
1134 "local".to_string() }
1136fn default_embedding_model() -> String {
1137 "all-MiniLM-L6-v2".to_string() }
1139fn default_embedding_cache_dir() -> String {
1140 crate::paths::DEFAULT_CACHE_DIR_STR.to_string() + "/embeddings"
1141}
1142fn default_chunk_size() -> usize {
1143 400
1144}
1145fn default_chunk_overlap() -> usize {
1146 80
1147}
1148fn default_index_paths() -> Vec<MemoryIndexPath> {
1149 vec![MemoryIndexPath {
1150 path: "knowledge".to_string(),
1151 pattern: "**/*.md".to_string(),
1152 }]
1153}
1154fn default_pattern() -> String {
1155 "**/*.md".to_string()
1156}
1157fn default_session_max_messages() -> usize {
1158 15 }
1160fn default_wiki_fresh_days() -> u32 {
1161 30
1162}
1163fn default_wiki_stale_days() -> u32 {
1164 90
1165}
1166fn default_port() -> u16 {
1167 31327
1168}
1169fn default_cron_timeout() -> String {
1170 "10m".to_string()
1171}
1172fn default_requests_per_minute() -> u32 {
1173 60
1174}
1175fn default_burst() -> u32 {
1176 10
1177}
1178fn default_bind() -> String {
1179 "127.0.0.1".to_string()
1180}
1181fn default_log_level() -> String {
1182 "info".to_string()
1183}
1184fn default_log_path() -> String {
1185 format!("{}/logs", DEFAULT_STATE_DIR_STR)
1186}
1187fn default_sandbox_level() -> String {
1188 "auto".to_string()
1189}
1190fn default_sandbox_timeout() -> u64 {
1191 120
1192}
1193fn default_sandbox_max_output() -> u64 {
1194 1_048_576 }
1196fn default_sandbox_max_file_size() -> u64 {
1197 52_428_800 }
1199fn default_sandbox_max_processes() -> u32 {
1200 64
1201}
1202fn default_sandbox_network_policy() -> String {
1203 "deny".to_string()
1204}
1205fn default_cache_ttl() -> u64 {
1206 900 }
1208fn default_max_results() -> u8 {
1209 5
1210}
1211fn default_basic() -> String {
1212 "basic".to_string()
1213}
1214fn default_sonar() -> String {
1215 "sonar".to_string()
1216}
1217
1218impl Default for AgentConfig {
1219 fn default() -> Self {
1220 Self {
1221 default_model: default_model(),
1222 context_window: default_context_window(),
1223 reserve_tokens: default_reserve_tokens(),
1224 max_tokens: default_max_tokens(),
1225 max_spawn_depth: Some(1), subagent_model: None, fallback_models: Vec::new(), max_tool_repeats: default_max_tool_repeats(), max_tool_errors: default_max_tool_errors(), tool_retry_on_malformed: true,
1231 session_max_age: default_session_max_age(), session_max_count: default_session_max_count(), post_compaction_sections: default_post_compaction_sections(),
1234 checkpoints_enabled: true,
1235 max_checkpoints: default_max_checkpoints(),
1236 active_memory: Default::default(),
1237 permission_level: default_permission_level(),
1238 auto_approve_loopback: true,
1239 }
1240 }
1241}
1242
1243impl Default for ToolsConfig {
1244 fn default() -> Self {
1245 Self {
1246 bash_timeout_ms: default_bash_timeout(),
1247 web_fetch_max_bytes: default_web_fetch_max_bytes(),
1248 require_approval: Vec::new(),
1249 tool_output_max_chars: default_tool_output_max_chars(),
1250 log_injection_warnings: default_true(),
1251 use_content_delimiters: default_true(),
1252 web_search: None,
1253 document_loaders: None,
1254 document_max_bytes: default_document_max_bytes(),
1255 stt: None,
1256 image_max_dimension: default_image_max_dimension(),
1257 media_cache_enabled: true,
1258 media_cache_max_mb: default_media_cache_max_mb(),
1259 browser_enabled: false,
1260 browser_port: default_browser_port(),
1261 filters: std::collections::HashMap::new(),
1262 }
1263 }
1264}
1265
1266impl Default for HeartbeatConfig {
1267 fn default() -> Self {
1268 Self {
1269 enabled: default_true(),
1270 interval: default_interval(),
1271 overdue_delay: default_overdue_delay(),
1272 timeout: None,
1273 active_hours: None,
1274 timezone: None,
1275 dreaming: Default::default(),
1276 mcp_servers: Vec::new(),
1277 }
1278 }
1279}
1280
1281impl Default for MemoryConfig {
1282 fn default() -> Self {
1283 Self {
1284 backend: MemoryBackendKind::default(),
1285 workspace: default_workspace(),
1286 embedding_provider: default_embedding_provider(),
1287 embedding_model: default_embedding_model(),
1288 embedding_cache_dir: default_embedding_cache_dir(),
1289 chunk_size: default_chunk_size(),
1290 chunk_overlap: default_chunk_overlap(),
1291 paths: default_index_paths(),
1292 session_max_messages: default_session_max_messages(),
1293 session_max_chars: 0, temporal_decay_lambda: 0.0, gemini_api_key: None,
1296 index_sessions: false,
1297 multimodal_embeddings: false,
1298 llm_query_expansion: false,
1299 wiki_enabled: false,
1300 wiki_fresh_days: default_wiki_fresh_days(),
1301 wiki_stale_days: default_wiki_stale_days(),
1302 }
1303 }
1304}
1305
1306impl Default for ServerConfig {
1307 fn default() -> Self {
1308 Self {
1309 enabled: default_true(),
1310 port: default_port(),
1311 bind: default_bind(),
1312 auth_token: None,
1313 rate_limit: RateLimitConfig::default(),
1314 cors_origins: Vec::new(),
1315 max_request_body: default_max_request_body(),
1316 webhook_secret: None,
1317 outbox_enabled: false,
1318 outbox_max_attempts: default_outbox_max_attempts(),
1319 outbox_retain_days: default_outbox_retain_days(),
1320 tls_enabled: false,
1321 tls_cert_dir: default_tls_cert_dir(),
1322 tls_renew_threshold_days: default_tls_renew_threshold(),
1323 }
1324 }
1325}
1326
1327impl Default for RateLimitConfig {
1328 fn default() -> Self {
1329 Self {
1330 enabled: default_true(),
1331 requests_per_minute: default_requests_per_minute(),
1332 burst: default_burst(),
1333 }
1334 }
1335}
1336
1337impl Default for LoggingConfig {
1338 fn default() -> Self {
1339 Self {
1340 level: default_log_level(),
1341 path: default_log_path(),
1342 retention_days: 0, }
1344 }
1345}
1346
1347impl Config {
1348 pub fn load() -> Result<Self> {
1349 let paths = Paths::resolve()?;
1350 paths.ensure_dirs()?;
1351 let path = paths.config_file();
1352
1353 if !path.exists() {
1354 let config = Config {
1356 paths,
1357 ..Config::default()
1358 };
1359 config.save_with_template()?;
1360 return Ok(config);
1361 }
1362
1363 let content = fs::read_to_string(&path)?;
1364 let mut config: Config = toml::from_str(&content)?;
1365 config.paths = paths;
1366
1367 config.expand_env_vars();
1369
1370 if config.memory.workspace != default_workspace()
1372 && std::env::var(LOCALGPT_WORKSPACE).is_err()
1373 {
1374 let expanded = shellexpand::tilde(&config.memory.workspace);
1375 let ws_path = PathBuf::from(expanded.to_string());
1376 if ws_path.is_absolute() {
1377 config.paths.workspace = ws_path;
1378 }
1379 }
1380
1381 Ok(config)
1382 }
1383
1384 pub fn load_from_dir(data_dir: &str) -> Result<Self> {
1388 let paths = Paths::from_root(data_dir);
1389 paths.ensure_dirs()?;
1390 let path = paths.config_file();
1391
1392 if !path.exists() {
1393 let config = Config {
1394 paths,
1395 ..Config::default()
1396 };
1397 config.save()?;
1398 return Ok(config);
1399 }
1400
1401 let content = fs::read_to_string(&path)?;
1402 let mut config: Config = toml::from_str(&content)?;
1403 config.paths = paths;
1404 config.expand_env_vars();
1405 Ok(config)
1406 }
1407
1408 pub fn save(&self) -> Result<()> {
1409 let path = self.paths.config_file();
1410
1411 if let Some(parent) = path.parent() {
1413 fs::create_dir_all(parent)?;
1414 }
1415
1416 let content = toml::to_string_pretty(self)?;
1417 fs::write(&path, content)?;
1418
1419 Ok(())
1420 }
1421
1422 pub fn save_with_template(&self) -> Result<()> {
1424 let path = self.paths.config_file();
1425
1426 if let Some(parent) = path.parent() {
1428 fs::create_dir_all(parent)?;
1429 }
1430
1431 fs::write(&path, DEFAULT_CONFIG_TEMPLATE)?;
1432 eprintln!("Created default config at {}", path.display());
1433
1434 Ok(())
1435 }
1436
1437 pub fn config_path() -> Result<PathBuf> {
1438 let paths = Paths::resolve()?;
1439 Ok(paths.config_file())
1440 }
1441
1442 fn expand_env_vars(&mut self) {
1443 if let Some(ref mut openai) = self.providers.openai {
1444 openai.api_key = expand_env(&openai.api_key);
1445 }
1446 if let Some(ref mut xai) = self.providers.xai {
1447 xai.api_key = expand_env(&xai.api_key);
1448 }
1449 if let Some(ref mut anthropic) = self.providers.anthropic {
1450 anthropic.api_key = expand_env(&anthropic.api_key);
1451 }
1452 if let Some(ref mut telegram) = self.telegram {
1453 telegram.api_token = expand_env(&telegram.api_token);
1454 }
1455 if let Some(ref mut ws) = self.tools.web_search
1456 && let Some(ref mut brave) = ws.brave
1457 {
1458 brave.api_key = expand_env(&brave.api_key);
1459 }
1460 if let Some(ref mut ws) = self.tools.web_search
1461 && let Some(ref mut tavily) = ws.tavily
1462 {
1463 tavily.api_key = expand_env(&tavily.api_key);
1464 }
1465 if let Some(ref mut ws) = self.tools.web_search
1466 && let Some(ref mut perplexity) = ws.perplexity
1467 {
1468 perplexity.api_key = expand_env(&perplexity.api_key);
1469 }
1470 if let Some(ref mut gemini) = self.providers.gemini {
1471 gemini.api_key = expand_env(&gemini.api_key);
1472 }
1473 if let Some(ref mut openrouter) = self.providers.openrouter {
1474 openrouter.api_key = expand_env(&openrouter.api_key);
1475 }
1476 if let Some(ref mut openai_compat) = self.providers.openai_compatible {
1477 openai_compat.api_key = expand_env(&openai_compat.api_key);
1478 openai_compat.base_url = expand_env(&openai_compat.base_url);
1479 }
1480 if let Some(ref mut vertex) = self.providers.vertex {
1481 vertex.service_account_key = expand_env(&vertex.service_account_key);
1482 vertex.project_id = expand_env(&vertex.project_id);
1483 }
1484 if let Some(ref mut gemini_key) = self.memory.gemini_api_key {
1485 *gemini_key = expand_env(gemini_key);
1486 }
1487 if let Some(ref mut auth_token) = self.server.auth_token {
1488 *auth_token = expand_env(auth_token);
1489 }
1490 if let Some(ref mut webhook_secret) = self.server.webhook_secret {
1491 *webhook_secret = expand_env(webhook_secret);
1492 }
1493 }
1494
1495 pub fn get_value(&self, key: &str) -> Result<String> {
1496 let parts: Vec<&str> = key.split('.').collect();
1497
1498 match parts.as_slice() {
1499 ["agent", "default_model"] => Ok(self.agent.default_model.clone()),
1500 ["agent", "context_window"] => Ok(self.agent.context_window.to_string()),
1501 ["agent", "reserve_tokens"] => Ok(self.agent.reserve_tokens.to_string()),
1502 ["heartbeat", "enabled"] => Ok(self.heartbeat.enabled.to_string()),
1503 ["heartbeat", "interval"] => Ok(self.heartbeat.interval.clone()),
1504 ["server", "enabled"] => Ok(self.server.enabled.to_string()),
1505 ["server", "port"] => Ok(self.server.port.to_string()),
1506 ["server", "bind"] => Ok(self.server.bind.clone()),
1507 ["memory", "workspace"] => Ok(self.memory.workspace.clone()),
1508 ["logging", "level"] => Ok(self.logging.level.clone()),
1509 _ => anyhow::bail!("Unknown config key: {}", key),
1510 }
1511 }
1512
1513 pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
1514 let parts: Vec<&str> = key.split('.').collect();
1515
1516 match parts.as_slice() {
1517 ["agent", "default_model"] => self.agent.default_model = value.to_string(),
1518 ["agent", "context_window"] => self.agent.context_window = value.parse()?,
1519 ["agent", "reserve_tokens"] => self.agent.reserve_tokens = value.parse()?,
1520 ["heartbeat", "enabled"] => self.heartbeat.enabled = value.parse()?,
1521 ["heartbeat", "interval"] => self.heartbeat.interval = value.to_string(),
1522 ["server", "enabled"] => self.server.enabled = value.parse()?,
1523 ["server", "port"] => self.server.port = value.parse()?,
1524 ["server", "bind"] => self.server.bind = value.to_string(),
1525 ["memory", "workspace"] => self.memory.workspace = value.to_string(),
1526 ["logging", "level"] => self.logging.level = value.to_string(),
1527 _ => anyhow::bail!("Unknown config key: {}", key),
1528 }
1529
1530 Ok(())
1531 }
1532
1533 pub fn workspace_path(&self) -> PathBuf {
1541 self.paths.workspace.clone()
1542 }
1543}
1544
1545fn expand_env(s: &str) -> String {
1546 if let Some(var_name) = s.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
1547 std::env::var(var_name).unwrap_or_else(|_| s.to_string())
1548 } else if let Some(var_name) = s.strip_prefix('$') {
1549 std::env::var(var_name).unwrap_or_else(|_| s.to_string())
1550 } else {
1551 s.to_string()
1552 }
1553}
1554
1555const DEFAULT_CONFIG_TEMPLATE: &str = r#"# LocalGPT Configuration
1557# Auto-created on first run. Edit as needed.
1558
1559[agent]
1560# Default model: claude-cli/opus, anthropic/claude-sonnet-4-5, openai/gpt-4o, xai/grok-3-mini, etc.
1561default_model = "claude-cli/opus"
1562context_window = 128000
1563reserve_tokens = 8000
1564
1565# Spawn agent (subagent) configuration
1566# max_spawn_depth = 1 # 0 = disabled, 1 = single level (default)
1567# subagent_model = "claude-cli/sonnet" # Model for subagents (default: same as default_model)
1568
1569# Failover configuration (optional)
1570# Automatically try fallback models if primary fails with retryable errors
1571# (rate limits, server errors, timeouts). Providers tried in order.
1572# fallback_models = ["openai/gpt-4o", "ollama/llama3"]
1573
1574# Loop detection (optional)
1575# Maximum times the same tool can be called with identical arguments
1576# before detection triggers. Default: 3. Set to 0 to disable.
1577# max_tool_repeats = 3
1578
1579# Anthropic API (for anthropic/* models)
1580# [providers.anthropic]
1581# api_key = "${ANTHROPIC_API_KEY}"
1582
1583# OpenAI API (for openai/* models)
1584# [providers.openai]
1585# api_key = "${OPENAI_API_KEY}"
1586
1587# xAI API (for xai/* models)
1588# [providers.xai]
1589# api_key = "${XAI_API_KEY}"
1590# base_url = "https://api.x.ai/v1"
1591
1592# OpenAI-Compatible provider (OpenRouter, DeepSeek, Groq, vLLM, LiteLLM, etc.)
1593# [providers.openai_compatible]
1594# base_url = "https://openrouter.ai/api/v1"
1595# api_key = "${OPENROUTER_API_KEY}"
1596# # Optional extra headers (e.g., OpenRouter attribution)
1597# extra_headers = { "HTTP-Referer" = "https://localgpt.app", "X-Title" = "LocalGPT" }
1598# # Use with: localgpt chat --model openai-compat/deepseek-chat
1599
1600# Claude CLI (for claude-cli/* models, requires claude CLI installed)
1601[providers.claude_cli]
1602command = "claude"
1603
1604[heartbeat]
1605enabled = true
1606interval = "30m"
1607
1608# Maximum wall-clock time for a single heartbeat run (optional).
1609# If the heartbeat LLM turn exceeds this deadline it is cancelled and
1610# a TimedOut event is recorded so the next interval can run on schedule.
1611# Defaults to half the interval (e.g., "15m" when interval = "30m").
1612# timeout = "15m"
1613
1614# Only run during these hours (optional)
1615# [heartbeat.active_hours]
1616# start = "09:00"
1617# end = "22:00"
1618
1619[memory]
1620# Workspace directory for memory files (MEMORY.md, HEARTBEAT.md, etc.)
1621# Default: XDG data dir (~/.local/share/localgpt/workspace)
1622# Override with environment variables:
1623# LOCALGPT_WORKSPACE=/path/to/workspace - absolute path override
1624# LOCALGPT_PROFILE=work - uses data_dir/workspace-work
1625# workspace = "~/.local/share/localgpt/workspace"
1626
1627# Session memory settings (for /new command)
1628# session_max_messages = 15 # Max messages to save (0 = unlimited)
1629# session_max_chars = 0 # Max chars per message (0 = unlimited, preserves full content)
1630
1631[server]
1632enabled = true
1633port = 31327
1634bind = "127.0.0.1"
1635# Optional bearer token for API authentication
1636# auth_token = "${LOCALGPT_AUTH_TOKEN}"
1637# Allowed CORS origins. If empty (default), allows localhost only (any port).
1638# cors_origins = ["https://myapp.example.com", "http://localhost:5173"]
1639
1640[logging]
1641level = "info"
1642
1643# Shell sandbox (kernel-enforced isolation for LLM-generated commands)
1644# [sandbox]
1645# enabled = true # default: true
1646# level = "auto" # auto | full | standard | minimal | none
1647# timeout_secs = 120 # default: 120
1648# max_output_bytes = 1048576 # default: 1MB
1649#
1650# [sandbox.allow_paths]
1651# read = ["/data/datasets"] # additional read-only paths
1652# write = ["/tmp/builds"] # additional writable paths
1653#
1654# [sandbox.network]
1655# policy = "deny" # deny | proxy
1656
1657# Web search (optional)
1658# [tools.web_search]
1659# provider = "searxng" # searxng | brave | tavily | perplexity | none
1660# cache_enabled = true
1661# cache_ttl = 900 # seconds (default: 15 min)
1662# max_results = 5 # 1-10
1663# prefer_native = true # prefer native provider search when available
1664#
1665# [tools.web_search.searxng]
1666# base_url = "http://localhost:8080"
1667# categories = "general"
1668# language = "en"
1669#
1670# [tools.web_search.brave]
1671# api_key = "${BRAVE_API_KEY}"
1672#
1673# [tools.web_search.tavily]
1674# api_key = "${TAVILY_API_KEY}"
1675# search_depth = "basic" # basic | advanced
1676# include_answer = true
1677#
1678# [tools.web_search.perplexity]
1679# api_key = "${PERPLEXITY_API_KEY}"
1680# model = "sonar"
1681
1682# Telegram bot (optional)
1683# [telegram]
1684# enabled = true
1685# api_token = "${TELEGRAM_BOT_TOKEN}"
1686"#;
1687
1688#[cfg(test)]
1689mod tests {
1690 use super::*;
1691
1692 #[test]
1693 fn test_mcp_server_config_enabled_default() {
1694 let toml_str = r#"
1696 name = "test-server"
1697 transport = "stdio"
1698 command = "echo"
1699 "#;
1700 let config: McpServerConfig = toml::from_str(toml_str).unwrap();
1701 assert_eq!(config.name, "test-server");
1702 assert!(config.enabled);
1703 }
1704
1705 #[test]
1706 fn test_mcp_server_config_enabled_explicit_false() {
1707 let toml_str = r#"
1708 name = "disabled-server"
1709 transport = "stdio"
1710 command = "echo"
1711 enabled = false
1712 "#;
1713 let config: McpServerConfig = toml::from_str(toml_str).unwrap();
1714 assert_eq!(config.name, "disabled-server");
1715 assert!(!config.enabled);
1716 }
1717
1718 #[test]
1719 fn test_mcp_server_config_enabled_explicit_true() {
1720 let toml_str = r#"
1721 name = "enabled-server"
1722 transport = "stdio"
1723 command = "echo"
1724 enabled = true
1725 "#;
1726 let config: McpServerConfig = toml::from_str(toml_str).unwrap();
1727 assert!(config.enabled);
1728 }
1729
1730 #[test]
1731 fn test_mcp_server_config_roundtrip() {
1732 let config = McpServerConfig {
1733 name: "roundtrip".to_string(),
1734 transport: "stdio".to_string(),
1735 command: Some("test-cmd".to_string()),
1736 args: vec!["--flag".to_string()],
1737 env: std::collections::HashMap::new(),
1738 url: None,
1739 enabled: false,
1740 };
1741 let serialized = toml::to_string(&config).unwrap();
1742 assert!(serialized.contains("enabled = false"));
1743
1744 let deserialized: McpServerConfig = toml::from_str(&serialized).unwrap();
1745 assert_eq!(deserialized.name, "roundtrip");
1746 assert!(!deserialized.enabled);
1747 }
1748
1749 #[test]
1750 fn test_mcp_config_with_mixed_enabled() {
1751 let toml_str = r#"
1752 [[servers]]
1753 name = "active"
1754 transport = "stdio"
1755 command = "echo"
1756 enabled = true
1757
1758 [[servers]]
1759 name = "inactive"
1760 transport = "stdio"
1761 command = "echo"
1762 enabled = false
1763
1764 [[servers]]
1765 name = "default"
1766 transport = "sse"
1767 url = "http://localhost:8080"
1768 "#;
1769 let config: McpConfig = toml::from_str(toml_str).unwrap();
1770 assert_eq!(config.servers.len(), 3);
1771 assert!(config.servers[0].enabled);
1772 assert!(!config.servers[1].enabled);
1773 assert!(config.servers[2].enabled); }
1775
1776 #[test]
1777 fn test_mcp_filter_servers_empty_allows_all() {
1778 let config = McpConfig {
1779 servers: vec![
1780 McpServerConfig {
1781 name: "server-a".to_string(),
1782 ..Default::default()
1783 },
1784 McpServerConfig {
1785 name: "server-b".to_string(),
1786 ..Default::default()
1787 },
1788 ],
1789 };
1790 let filtered = config.filter_servers(&[]);
1791 assert_eq!(filtered.len(), 2);
1792 }
1793
1794 #[test]
1795 fn test_mcp_filter_servers_allowlist() {
1796 let config = McpConfig {
1797 servers: vec![
1798 McpServerConfig {
1799 name: "memory-server".to_string(),
1800 ..Default::default()
1801 },
1802 McpServerConfig {
1803 name: "bash-server".to_string(),
1804 ..Default::default()
1805 },
1806 McpServerConfig {
1807 name: "web-server".to_string(),
1808 ..Default::default()
1809 },
1810 ],
1811 };
1812 let filtered = config.filter_servers(&["memory-server".to_string()]);
1813 assert_eq!(filtered.len(), 1);
1814 assert_eq!(filtered[0].name, "memory-server");
1815 }
1816
1817 #[test]
1818 fn test_mcp_filter_servers_case_insensitive() {
1819 let config = McpConfig {
1820 servers: vec![McpServerConfig {
1821 name: "Memory-Server".to_string(),
1822 ..Default::default()
1823 }],
1824 };
1825 let filtered = config.filter_servers(&["memory-server".to_string()]);
1826 assert_eq!(filtered.len(), 1);
1827 }
1828}