Skip to content

Commit adaaf83

Browse files
committed
feat: Allow duplicate and empty server prefixes, optimize config loading
Major changes: - Allow duplicate prefixes across servers (including None/empty prefixes) - Remove duplicate prefix validation from config reload - Optimize config loading to use cached version when reload is enabled - Make None the default prefix instead of auto-generating from server name Key improvements: - ServerConfig prefix field changed from str with default "" to str|None with default None - ConfigManager.load_config() now returns cached config from ReloadManager when available - Added cached_config property to ReloadManager for cleaner access - Fixed self_prefix_ property to handle None prefixes properly - Removed _unmount_from_fastmcp workaround implementation for FastMCP servers Test updates: - Updated tests to expect None as default prefix instead of auto-generated values - Changed duplicate prefix validation test to verify duplicates are allowed - Added test cases for duplicate None prefixes Bug fixes: - Fixed MAGG_DEBUG environment variable check to handle missing values - Properly close FastMCP client connections on unmount to prevent pending tasks This change makes server prefixes truly optional and allows multiple servers to share the same prefix or have no prefix at all, providing more flexibility in tool naming. Signed-off-by: Phillip Sitbon <phillip.sitbon@gmail.com>
1 parent 7f31061 commit adaaf83

File tree

9 files changed

+113
-54
lines changed

9 files changed

+113
-54
lines changed

magg/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,7 @@ def main():
554554
sys.exit(130) # Standard exit code for SIGINT
555555
except Exception as e:
556556
print_error(f"Unexpected error: {e}")
557-
if os.getenv('MAGG_DEBUG').lower() in {'1', 'true', 'yes'}:
557+
if os.getenv('MAGG_DEBUG', '').lower() in {'1', 'true', 'yes'}:
558558
import traceback
559559
traceback.print_exc()
560560
sys.exit(1)

magg/reload.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,14 @@ def update_cached_config(self, config: MaggConfig) -> None:
187187
This keeps our internal state in sync when config is saved programmatically.
188188
"""
189189
self._last_config = config
190-
logger.debug("Updated cached config after programmatic save")
190+
191+
def get_cached_config(self) -> MaggConfig | None:
192+
"""Get the cached configuration if available.
193+
194+
Returns:
195+
The cached config or None if no config has been loaded yet.
196+
"""
197+
return self._last_config
191198

192199
async def _watch_loop(self, poll_interval: float) -> None:
193200
"""Main watch loop that handles both watchdog and polling modes."""
@@ -392,17 +399,6 @@ def _server_config_changed(self, old: ServerConfig, new: ServerConfig) -> bool:
392399
def _validate_config(self, config: MaggConfig) -> bool:
393400
"""Validate that the configuration is valid."""
394401
try:
395-
# Check for duplicate prefixes
396-
prefixes = {}
397-
for name, server in config.servers.items():
398-
if server.prefix in prefixes:
399-
logger.error(
400-
"Duplicate prefix '%s' found in servers '%s' and '%s'",
401-
server.prefix, name, prefixes[server.prefix]
402-
)
403-
return False
404-
prefixes[server.prefix] = name
405-
406402
# Validate each server
407403
for name, server in config.servers.items():
408404
if not server.command and not server.uri:
@@ -429,6 +425,13 @@ def __init__(self, config_manager: 'ConfigManager'):
429425
self.config_manager: ConfigManager = config_manager
430426
self._config_reloader: ConfigReloader | None = None
431427
self._reload_callback: ReloadCallback | None = None
428+
429+
@property
430+
def cached_config(self) -> MaggConfig | None:
431+
"""Get the cached configuration if available."""
432+
if self._config_reloader:
433+
return self._config_reloader._last_config
434+
return None
432435

433436
async def setup(self, reload_callback: ReloadCallback) -> None:
434437
"""Setup config file watching with a callback.

magg/server/manager.py

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -112,35 +112,70 @@ async def mount_server(self, server: ServerConfig) -> bool:
112112
logger.error("No command or URI specified for %s", server.name)
113113
return False
114114

115-
# Create proxy and mount
116-
proxy_server = FastMCP.as_proxy(client)
117-
self.mcp.mount(server=proxy_server, prefix=server.prefix, as_proxy=True)
118-
# Store both proxy and client for resource/prompt access
115+
proxy_server = FastMCP.as_proxy(client, name=server.name)
116+
self.mcp.mount(server=proxy_server, prefix=server.prefix)
117+
119118
self.mounted_servers[server.name] = {
120119
'proxy': proxy_server,
121120
'client': client
122121
}
123122

124-
logger.debug("Mounted server %s with prefix %s", server.name, server.prefix)
123+
logger.debug("Mounted server %s with prefix %r", server.name, server.prefix)
125124
return True
126125

127126
except Exception as e:
128127
logger.error("Failed to mount server %s: %s", server.name, e)
129128
return False
130129

131130
# noinspection PyProtectedMember
131+
def _unmount_from_fastmcp(self, server_name: str) -> bool:
132+
"""Remove a mounted server from FastMCP's internal structures.
133+
134+
This is a workaround until FastMCP provides an official unmount method.
135+
Returns True if server was found and removed.
136+
"""
137+
# We need to find and remove the MountedServer object from all managers
138+
found = False
139+
140+
# Check tool manager
141+
if hasattr(self.mcp, '_tool_manager') and hasattr(self.mcp._tool_manager, '_mounted_servers'):
142+
mounted_servers = self.mcp._tool_manager._mounted_servers
143+
for i, ms in enumerate(mounted_servers):
144+
if hasattr(ms, 'server') and hasattr(ms.server, 'name') and ms.server.name == server_name:
145+
mounted_servers.pop(i)
146+
found = True
147+
logger.debug("Removed server %s from tool manager", server_name)
148+
break
149+
150+
# Check resource manager
151+
if hasattr(self.mcp, '_resource_manager') and hasattr(self.mcp._resource_manager, '_mounted_servers'):
152+
mounted_servers = self.mcp._resource_manager._mounted_servers
153+
for i, ms in enumerate(mounted_servers):
154+
if hasattr(ms, 'server') and hasattr(ms.server, 'name') and ms.server.name == server_name:
155+
mounted_servers.pop(i)
156+
logger.debug("Removed server %s from resource manager", server_name)
157+
break
158+
159+
# Check prompt manager
160+
if hasattr(self.mcp, '_prompt_manager') and hasattr(self.mcp._prompt_manager, '_mounted_servers'):
161+
mounted_servers = self.mcp._prompt_manager._mounted_servers
162+
for i, ms in enumerate(mounted_servers):
163+
if hasattr(ms, 'server') and hasattr(ms.server, 'name') and ms.server.name == server_name:
164+
mounted_servers.pop(i)
165+
logger.debug("Removed server %s from prompt manager", server_name)
166+
break
167+
168+
return found
169+
132170
async def unmount_server(self, name: str) -> bool:
133171
"""Unmount a server."""
134172
if name in self.mounted_servers:
135-
# Get the server config to find the prefix
136-
config = self.config
137-
server = config.servers.get(name)
138-
if server and server.prefix in self.mcp._tool_manager._mounted_servers:
139-
# TODO: FastMCP doesn't have an unmount method yet
140-
# For now, we just close the client and remove from our tracking
141-
logger.debug("Would unmount prefix %s (FastMCP unmount not available)", server.prefix)
173+
unmounted = self._unmount_from_fastmcp(name)
174+
if unmounted:
175+
logger.info("Unmounted server %s from FastMCP", name)
176+
else:
177+
logger.debug("Server %s was not found in FastMCP's mounted servers", name)
142178

143-
# Close the client to clean up background tasks
144179
server_info = self.mounted_servers.get(name, {})
145180
client: Client = server_info.get('client')
146181
if client:
@@ -150,7 +185,6 @@ async def unmount_server(self, name: str) -> bool:
150185
except Exception as e:
151186
logger.warning("Error closing client for server %s: %s", name, e)
152187

153-
# Remove from our tracking
154188
del self.mounted_servers[name]
155189
logger.info("Unmounted server %s", name)
156190
return True
@@ -320,9 +354,12 @@ def self_prefix(self) -> str:
320354

321355
@cached_property
322356
def self_prefix_(self) -> str:
323-
"""self_prefix with trailing separator.
357+
"""self_prefix with trailing separator if prefix exists.
324358
"""
325-
return f"{self.self_prefix}{self.server_manager.prefix_separator}"
359+
prefix = self.self_prefix
360+
if prefix:
361+
return f"{prefix}{self.server_manager.prefix_separator}"
362+
return ""
326363

327364
def _register_tools(self):
328365
pass

magg/server/server.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ async def add_server(
289289
server = ServerConfig(
290290
name=name,
291291
source=source,
292-
prefix=prefix or "",
292+
prefix=prefix,
293293
command=actual_command,
294294
args=actual_args,
295295
uri=uri,
@@ -739,7 +739,7 @@ async def status(self) -> MaggResponse:
739739
"tools": {
740740
"total": total_tools,
741741
},
742-
"prefixes": {s.name: s.prefix for s in config.servers.values()}
742+
"prefixes": {s.name: s.prefix for s in config.servers.values() if s.prefix is not None}
743743
}
744744

745745
return MaggResponse.success(status_data)

magg/settings.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,8 @@ class ServerConfig(BaseSettings):
118118

119119
name: str = Field(..., description="Unique server name - can contain any characters")
120120
source: str = Field(..., description="URL/URI/path of the server package, repository, or listing")
121-
prefix: str = Field(
122-
default="",
121+
prefix: str | None = Field(
122+
default=None,
123123
description=f"Tool prefix for this server - must be a valid Python identifier without {PREFIX_SEP!r}."
124124
)
125125
notes: str | None = Field(None, description="Setup notes for LLM and humans")
@@ -136,9 +136,7 @@ class ServerConfig(BaseSettings):
136136

137137
@model_validator(mode='after')
138138
def set_default_prefix(self) -> 'ServerConfig':
139-
"""Set default prefix from name if not provided."""
140-
if not self.prefix:
141-
self.prefix = self.generate_prefix_from_name(self.name)
139+
"""No longer set default prefix - None is allowed."""
142140
return self
143141

144142
@field_validator('prefix')
@@ -286,10 +284,16 @@ def logger(self) -> logging.Logger:
286284
return logging.getLogger(__name__)
287285

288286
def load_config(self) -> MaggConfig:
289-
"""Load configuration from disk.
287+
"""Load configuration from disk or return cached version if reload is enabled.
290288
291289
Note: The only dynamic part of the config is the servers.
292290
"""
291+
# If reload manager is active and has a cached config, return it
292+
if self._reload_manager:
293+
cached = self._reload_manager.cached_config
294+
if cached:
295+
return cached
296+
293297
config = MaggConfig()
294298

295299
if not self.config_path.exists():

magg/test/test_config.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,26 +39,26 @@ def test_server_with_custom_prefix(self):
3939
assert server.prefix == "custom"
4040

4141
def test_server_name_validation(self):
42-
"""Test that server names can be anything and prefix generation works."""
43-
# Any name is now valid - prefix auto-generated from name
42+
"""Test that server names can be anything and prefix defaults to None."""
43+
# Any name is now valid - prefix defaults to None
4444
server1 = ServerConfig(name="valid", source="test")
45-
assert server1.prefix == "valid"
45+
assert server1.prefix is None
4646

4747
server2 = ServerConfig(name="valid123", source="test")
48-
assert server2.prefix == "valid123"
48+
assert server2.prefix is None
4949

5050
server3 = ServerConfig(name="valid-name", source="test")
51-
assert server3.prefix == "validname" # Hyphens removed from prefix
51+
assert server3.prefix is None
5252

5353
# Names that would have been invalid before are now accepted
5454
server4 = ServerConfig(name="123invalid", source="test")
55-
assert server4.prefix == "srv123invalid" # Prefix adjusted to be valid
55+
assert server4.prefix is None
5656

5757
server5 = ServerConfig(name="invalid!", source="test")
58-
assert server5.prefix == "invalid" # Special chars removed
58+
assert server5.prefix is None
5959

6060
server6 = ServerConfig(name="@namespace/package", source="test")
61-
assert server6.prefix == "namespacepackage" # Cleaned up
61+
assert server6.prefix is None
6262

6363
def test_server_prefix_validation(self):
6464
"""Test server prefix validation."""

magg/test/test_config_migration.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def test_new_config_structure(self, temp_config_file):
5757

5858
filesystem = loaded_config.servers["filesystemserver"]
5959
assert filesystem.uri == "http://localhost:8080"
60-
assert filesystem.prefix == "filesystemserver" # Default prefix
60+
assert filesystem.prefix is None # Default is None now
6161

6262
def test_config_serialization_format(self, temp_config_file):
6363
"""Test the actual JSON format of saved config."""
@@ -119,7 +119,7 @@ def test_minimal_server_config(self, temp_config_file):
119119

120120
assert minimal.name == "minimal"
121121
assert minimal.source == "https://example.com"
122-
assert minimal.prefix == "minimal" # Defaults to name
122+
assert minimal.prefix is None # Default is None now
123123
assert minimal.enabled is True # Default enabled
124124
assert minimal.command is None
125125
assert minimal.args is None
@@ -167,6 +167,6 @@ def test_invalid_server_in_config(self, temp_config_file):
167167
assert "valid" in config.servers
168168
assert "123invalid" in config.servers
169169

170-
# Check that the problematic server got a valid prefix
170+
# Check that the problematic server now has None prefix
171171
assert config.servers["123invalid"].name == "123invalid"
172-
assert config.servers["123invalid"].prefix == "srv123invalid" # Auto-generated prefix
172+
assert config.servers["123invalid"].prefix is None # Default is None now

magg/test/test_config_reload.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ async def test_detect_changes_enable_disable(self):
127127

128128
@pytest.mark.asyncio
129129
async def test_config_validation(self):
130-
"""Test config validation catches duplicate prefixes."""
130+
"""Test config validation allows duplicate prefixes."""
131131
config = MaggConfig()
132132
config.servers["server1"] = ServerConfig(
133133
name="server1",
@@ -139,11 +139,26 @@ async def test_config_validation(self):
139139
name="server2",
140140
source="https://example.com/2",
141141
command="echo",
142-
prefix="test" # Duplicate prefix
142+
prefix="test" # Duplicate prefix is now allowed
143143
)
144144

145145
reloader = ConfigReloader(Path("/fake/path"), lambda x: None)
146-
assert not reloader._validate_config(config)
146+
assert reloader._validate_config(config) # Should pass validation
147+
148+
# Test with None/empty prefixes
149+
config.servers["server3"] = ServerConfig(
150+
name="server3",
151+
source="https://example.com/3",
152+
command="echo",
153+
prefix=None
154+
)
155+
config.servers["server4"] = ServerConfig(
156+
name="server4",
157+
source="https://example.com/4",
158+
command="echo",
159+
prefix=None # Duplicate None prefix is also allowed
160+
)
161+
assert reloader._validate_config(config) # Should still pass
147162

148163
@pytest.mark.asyncio
149164
async def test_reload_callback(self, temp_config_file):

magg/test/test_tool_delegation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,13 @@ def test_tool_prefix_handling(self):
5555
# Test prefix validation
5656
assert server.prefix == "custom"
5757

58-
# Test that default prefix uses cleaned name
58+
# Test that default prefix is now None
5959
server2 = ServerConfig(
6060
name="test-server",
6161
source="https://example.com",
6262
command="echo"
6363
)
64-
assert server2.prefix == "testserver" # Hyphens removed
64+
assert server2.prefix is None # Default is None now
6565

6666
def test_tool_name_collision_handling(self):
6767
"""Test handling of tool name collisions."""

0 commit comments

Comments
 (0)