Repository: jlowin/fastmcp

Issue:
Given a Resource uri with an underscore before the :// causes pydantic_core ValidationErrors
### Description

# Overview 
If the URI passed to read_resource(uri) contains an underscore in the scheme portion (e.g., f"s_server2+search://python"), it will raise a pydantic_core._pydantic_core.ValidationError because underscores are not allowed in URL schemes per RFC 3986. To avoid this, it's recommended to restrict server names passed to mcp.import_server(...) from including underscores --e.g., use mcp.import_server("sserver2", server2) instead of mcp.import_server("s_server2", server2).

If possible, could there be some code in place to prevent other developers from falling to my personal programming pit. 

Apologies if this was already documented as a known bug or if it's a well known concept.

Error Snippet: 
```shell
pydantic_core._pydantic_core.ValidationError:
1 validation error for function-wrap[wrap_val()]
Input should be a valid URL, relative URL without a base
[type=url_parsing, input_value='so_files+strelka://myfile.exe', input_type=str]
```

ResourceTemplate from main FastMCP Server:
```shell
[ResourceTemplate(uriTemplate='s_server2+search://{query}', name='search_resources', description='Search for resources matching the query string.', mimeType='text/plain', annotations=None)]
```

Bad URI's are allowed:
```shell
uri = f"s_server2+search://python"
```

### Example Code

```Python
# ____ Client Code ____
import asyncio
from fastmcp import Client
from fastmcp.client.transports import SSETransport

# Client configuration
url="http://localhost:5001/sse"
client = Client(url)

async def main():
    async with client:
        print(await client.list_resources())
        print(await client.list_resource_templates())
        # Construct full URI with default values
        uri = f"s_server2+search://python" # BUG, doesnt allow underscore before the colon
        #uri = f"server2+search://python"
        results = await client.read_resource(uri)
        print(results)

if __name__ == "__main__":
    asyncio.run(main())

# ____ Server2.py ____ 
from fastmcp import FastMCP

server2 = FastMCP(name="MyServer2")

@server2.resource("search://{query}")
def search_resources(query: str) -> bytes:
    """Search for resources matching the query string."""
    # Only 'query' is required in the URI, the other parameters use their defaults
    return {
        "query": query
    }

# ____ server.py ____ 

import asyncio
from fastmcp import FastMCP
from server2 import server2

mcp = FastMCP(name="MyServer1")
async def main():
    # Create a shutdown event
    shutdown_event = asyncio.Event()

    await mcp.import_server("s_server2", server2)
    #await mcp.import_server("server2", server2)
    server_task = asyncio.create_task(mcp.run_async(transport="sse", host="0.0.0.0", port=5001, log_level="debug"))
    await shutdown_event.wait()
if __name__ == "__main__":
    asyncio.run(main())
```

### Version Information

```Text
Version: 2.2.6
```

### Additional Context

I've included a minimal Python example to demonstrate the issue. There are three files involved: client.py, server.py, and server2.py.

Please note that the problematic behavior is triggered by the import_server() function, which currently allows underscores in server names. This leads to pydantic_core._pydantic_core.ValidationError when the client constructs a resource URI using that name, as underscores are not valid in URI schemes per RFC 3986 (from what I believe). 

I've labeled the relevant section of code where this occurs.


Discussion:
Thanks for reporting!



Patch:
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index a92d94f9..b9c28dcb 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -14,6 +14,7 @@
 
 import anyio
 import httpx
+import pydantic
 import uvicorn
 from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
 from mcp.server.auth.middleware.bearer_auth import (
@@ -39,7 +40,7 @@
 from mcp.types import Resource as MCPResource
 from mcp.types import ResourceTemplate as MCPResourceTemplate
 from mcp.types import Tool as MCPTool
-from pydantic.networks import AnyUrl
+from pydantic import AnyUrl
 from starlette.applications import Starlette
 from starlette.middleware import Middleware
 from starlette.middleware.authentication import AuthenticationMiddleware
@@ -88,6 +89,8 @@ def __init__(
         if prompt_separator is None:
             prompt_separator = "_"
 
+        _validate_resource_prefix(f"{prefix}{resource_separator}")
+
         self.server = server
         self.prefix = prefix
         self.tool_separator = tool_separator
@@ -1074,6 +1077,7 @@ async def import_server(
 
         # Import resources and templates from the mounted server
         resource_prefix = f"{prefix}{resource_separator}"
+        _validate_resource_prefix(resource_prefix)
         for key, resource in (await server.get_resources()).items():
             self._resource_manager.add_resource(resource, key=f"{resource_prefix}{key}")
         for key, template in (await server.get_resource_templates()).items():
@@ -1131,3 +1135,13 @@ def from_client(cls, client: Client, **settings: Any) -> FastMCPProxy:
         from fastmcp.server.proxy import FastMCPProxy
 
         return FastMCPProxy(client=client, **settings)
+
+
+def _validate_resource_prefix(prefix: str) -> None:
+    valid_resource = "resource://path/to/resource"
+    try:
+        AnyUrl(f"{prefix}{valid_resource}")
+    except pydantic.ValidationError as e:
+        raise ValueError(
+            f"Resource prefix or separator would result in an invalid resource URI: {e}"
+        )


Test patch:
diff --git a/tests/server/test_import_server.py b/tests/server/test_import_server.py
index a122ee23..8b7cade8 100644
--- a/tests/server/test_import_server.py
+++ b/tests/server/test_import_server.py
@@ -1,6 +1,7 @@
 import json
 from urllib.parse import quote
 
+import pytest
 from mcp.types import TextContent, TextResourceContents
 
 from fastmcp.client.client import Client
@@ -391,3 +392,25 @@ def create_user(name: str, email: str):
         user_data = json.loads(result[0].text)
         assert user_data["name"] == "John Doe"
         assert user_data["email"] == "john@example.com"
+
+
+async def test_import_invalid_resource_prefix():
+    main_app = FastMCP("MainApp")
+    api_app = FastMCP("APIApp")
+
+    with pytest.raises(
+        ValueError,
+        match="Resource prefix or separator would result in an invalid resource URI",
+    ):
+        await main_app.import_server("api_sub", api_app)
+
+
+async def test_import_invalid_resource_separator():
+    main_app = FastMCP("MainApp")
+    api_app = FastMCP("APIApp")
+
+    with pytest.raises(
+        ValueError,
+        match="Resource prefix or separator would result in an invalid resource URI",
+    ):
+        await main_app.import_server("api", api_app, resource_separator="_")
diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py
index 1b495cc3..a07e40e8 100644
--- a/tests/server/test_mount.py
+++ b/tests/server/test_mount.py
@@ -59,6 +59,26 @@ def greet(name: str) -> str:
         assert isinstance(result[0], TextContent)
         assert result[0].text == "Hello, World!"
 
+    async def test_mount_invalid_resource_prefix(self):
+        main_app = FastMCP("MainApp")
+        api_app = FastMCP("APIApp")
+
+        with pytest.raises(
+            ValueError,
+            match="Resource prefix or separator would result in an invalid resource URI",
+        ):
+            main_app.mount("api_sub", api_app)
+
+    async def test_mount_invalid_resource_separator(self):
+        main_app = FastMCP("MainApp")
+        api_app = FastMCP("APIApp")
+
+        with pytest.raises(
+            ValueError,
+            match="Resource prefix or separator would result in an invalid resource URI",
+        ):
+            main_app.mount("api", api_app, resource_separator="_")
+
     async def test_unmount_server(self):
         """Test unmounting a server removes access to its tools."""
         main_app = FastMCP("MainApp")