Repository: jlowin/fastmcp

Issue:
Add efficient API to set model preferences to sampling
### Enhancement Description

Currently ctx.sample(...) does not have a way to alter the modelPreferences field on CreateMessage. I'm using FastMCP in a system that heaviliy relies on sampling from multiple models that this would be a very nice quality of life enhancement.  

### Use Case

If you have more that one model available the syntax just becomes more verbose. I have a custom client that exposes sampling and multiple models.

### Proposed Implementation

I would like to be able to easily set the model hint, so adding `model_preference: Union[str | List[str|ModelPreference]` added to `sample()` would be nice where the `str` are assumed to be the model hint name.

Hints from issue comments:
@davenpi: I opened #542 to address this. Feedback or testing welcome!@jlowin: Closed in #542 

Solution patch:
diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx
index 3a84286..555a0c1 100644
--- a/docs/servers/context.mdx
+++ b/docs/servers/context.mdx
@@ -228,8 +228,8 @@ async def analyze_sentiment(text: str, ctx: Context) -> dict:
     # Create a sampling prompt asking for sentiment analysis
     prompt = f"Analyze the sentiment of the following text as positive, negative, or neutral. Just output a single word - 'positive', 'negative', or 'neutral'. Text to analyze: {text}"
     
-    # Send the sampling request to the client's LLM
-    response = await ctx.sample(prompt)
+    # Send the sampling request to the client's LLM (provide a hint for the model you want to use)
+    response = await ctx.sample(prompt, model_preferences="claude-3-sonnet")
     
     # Process the LLM's response
     sentiment = response.text.strip().lower()
@@ -247,11 +247,12 @@ async def analyze_sentiment(text: str, ctx: Context) -> dict:
 
 **Method signature:**
 
-- **`ctx.sample(messages: str | list[str | SamplingMessage], system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None) -> TextContent | ImageContent`**
+- **`ctx.sample(messages: str | list[str | SamplingMessage], system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None) -> TextContent | ImageContent`**
   - `messages`: A string or list of strings/message objects to send to the LLM
   - `system_prompt`: Optional system prompt to guide the LLM's behavior
   - `temperature`: Optional sampling temperature (controls randomness)
   - `max_tokens`: Optional maximum number of tokens to generate (defaults to 512)
+  - `model_preferences`: Optional model selection preferences (e.g., a model hint string, list of hints, or a ModelPreferences object)
   - Returns the LLM's response as TextContent or ImageContent
 
 When providing a simple string, it's treated as a user message. For more complex scenarios, you can provide a list of messages with different roles.
diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py
index 7aacd5a..f82b238 100644
--- a/src/fastmcp/client/transports.py
+++ b/src/fastmcp/client/transports.py
@@ -535,8 +535,12 @@ class MCPConfigTransport(ClientTransport):
             config = MCPConfig.from_dict(config)
         self.config = config
 
+        # if there are no servers, raise an error
+        if len(self.config.mcpServers) == 0:
+            raise ValueError("No MCP servers defined in the config")
+
         # if there's exactly one server, create a client for that server
-        if len(self.config.mcpServers) == 1:
+        elif len(self.config.mcpServers) == 1:
             self.transport = list(self.config.mcpServers.values())[0].to_transport()
 
         # otherwise create a composite client
diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py
index 4ecd992..7dc77f4 100644
--- a/src/fastmcp/server/context.py
+++ b/src/fastmcp/server/context.py
@@ -12,6 +12,8 @@ from mcp.shared.context import RequestContext
 from mcp.types import (
     CreateMessageResult,
     ImageContent,
+    ModelHint,
+    ModelPreferences,
     Root,
     SamplingMessage,
     TextContent,
@@ -200,6 +202,7 @@ class Context:
         system_prompt: str | None = None,
         temperature: float | None = None,
         max_tokens: int | None = None,
+        model_preferences: ModelPreferences | str | list[str] | None = None,
     ) -> TextContent | ImageContent:
         """
         Send a sampling request to the client and await the response.
@@ -231,6 +234,7 @@ class Context:
             system_prompt=system_prompt,
             temperature=temperature,
             max_tokens=max_tokens,
+            model_preferences=self._parse_model_preferences(model_preferences),
         )
 
         return result.content
@@ -248,3 +252,45 @@ class Context:
         )
 
         return fastmcp.server.dependencies.get_http_request()
+
+    def _parse_model_preferences(
+        self, model_preferences: ModelPreferences | str | list[str] | None
+    ) -> ModelPreferences | None:
+        """
+        Validates and converts user input for model_preferences into a ModelPreferences object.
+
+        Args:
+            model_preferences (ModelPreferences | str | list[str] | None):
+                The model preferences to use. Accepts:
+                - ModelPreferences (returns as-is)
+                - str (single model hint)
+                - list[str] (multiple model hints)
+                - None (no preferences)
+
+        Returns:
+            ModelPreferences | None: The parsed ModelPreferences object, or None if not provided.
+
+        Raises:
+            ValueError: If the input is not a supported type or contains invalid values.
+        """
+        if model_preferences is None:
+            return None
+        elif isinstance(model_preferences, ModelPreferences):
+            return model_preferences
+        elif isinstance(model_preferences, str):
+            # Single model hint
+            return ModelPreferences(hints=[ModelHint(name=model_preferences)])
+        elif isinstance(model_preferences, list):
+            # List of model hints (strings)
+            if not all(isinstance(h, str) for h in model_preferences):
+                raise ValueError(
+                    "All elements of model_preferences list must be"
+                    " strings (model name hints)."
+                )
+            return ModelPreferences(
+                hints=[ModelHint(name=h) for h in model_preferences]
+            )
+        else:
+            raise ValueError(
+                "model_preferences must be one of: ModelPreferences, str, list[str], or None."
+            )
diff --git a/src/fastmcp/utilities/mcp_config.py b/src/fastmcp/utilities/mcp_config.py
index 19905f7..a9f5abe 100644
--- a/src/fastmcp/utilities/mcp_config.py
+++ b/src/fastmcp/utilities/mcp_config.py
@@ -32,11 +32,12 @@ def infer_transport_type_from_url(
         return "streamable-http"
 
 
-class LocalMCPServer(BaseModel):
+class StdioMCPServer(BaseModel):
     command: str
     args: list[str] = Field(default_factory=list)
     env: dict[str, Any] = Field(default_factory=dict)
     cwd: str | None = None
+    transport: Literal["stdio"] = "stdio"
 
     def to_transport(self) -> StdioTransport:
         from fastmcp.client.transports import StdioTransport
@@ -51,8 +52,8 @@ class LocalMCPServer(BaseModel):
 
 class RemoteMCPServer(BaseModel):
     url: str
-    transport: Literal["streamable-http", "sse", "http"] | None = None
     headers: dict[str, str] = Field(default_factory=dict)
+    transport: Literal["streamable-http", "sse", "http"] | None = None
 
     def to_transport(self) -> StreamableHttpTransport | SSETransport:
         from fastmcp.client.transports import SSETransport, StreamableHttpTransport
@@ -69,7 +70,7 @@ class RemoteMCPServer(BaseModel):
 
 
 class MCPConfig(BaseModel):
-    mcpServers: dict[str, LocalMCPServer | RemoteMCPServer]
+    mcpServers: dict[str, StdioMCPServer | RemoteMCPServer]
 
     @classmethod
     def from_dict(cls, config: dict[str, Any]) -> MCPConfig:


Test patch:
diff --git a/tests/client/test_client.py b/tests/client/test_client.py
index 0e97cb8..71625d4 100644
--- a/tests/client/test_client.py
+++ b/tests/client/test_client.py
@@ -673,6 +673,18 @@ class TestInferTransport:
         assert transport.transport.command == "echo"
         assert transport.transport.args == ["hello"]
 
+    def test_config_with_no_servers(self):
+        """Test that an empty MCPConfig raises a ValueError."""
+        config = {"mcpServers": {}}
+        with pytest.raises(ValueError, match="No MCP servers defined in the config"):
+            infer_transport(config)
+
+    def test_mcpconfigtransport_with_no_servers(self):
+        """Test that MCPConfigTransport raises a ValueError when initialized with an empty config."""
+        config = {"mcpServers": {}}
+        with pytest.raises(ValueError, match="No MCP servers defined in the config"):
+            MCPConfigTransport(config=config)
+
     def test_infer_composite_client(self):
         config = {
             "mcpServers": {
diff --git a/tests/server/test_context.py b/tests/server/test_context.py
index 4243ab4..a41b8b6 100644
--- a/tests/server/test_context.py
+++ b/tests/server/test_context.py
@@ -2,9 +2,11 @@ import warnings
 from unittest.mock import MagicMock, patch
 
 import pytest
+from mcp.types import ModelPreferences
 from starlette.requests import Request
 
 from fastmcp.server.context import Context
+from fastmcp.server.server import FastMCP
 
 
 class TestContextDeprecations:
@@ -57,3 +59,30 @@ class TestContextDeprecations:
                 assert "https://gofastmcp.com/patterns/http-requests" in str(
                     warning.message
                 )
+
+
+@pytest.fixture
+def context():
+    return Context(fastmcp=FastMCP())
+
+
+class TestParseModelPreferences:
+    def test_parse_model_preferences_string(self, context):
+        mp = context._parse_model_preferences("claude-3-sonnet")
+        assert isinstance(mp, ModelPreferences)
+        assert mp.hints is not None
+        assert mp.hints[0].name == "claude-3-sonnet"
+
+    def test_parse_model_preferences_list(self, context):
+        mp = context._parse_model_preferences(["claude-3-sonnet", "claude"])
+        assert isinstance(mp, ModelPreferences)
+        assert mp.hints is not None
+        assert [h.name for h in mp.hints] == ["claude-3-sonnet", "claude"]
+
+    def test_parse_model_preferences_object(self, context):
+        obj = ModelPreferences(hints=[])
+        assert context._parse_model_preferences(obj) is obj
+
+    def test_parse_model_preferences_invalid_type(self, context):
+        with pytest.raises(ValueError):
+            context._parse_model_preferences(123)
diff --git a/tests/utilities/test_mcp_config.py b/tests/utilities/test_mcp_config.py
index 627a149..b7737da 100644
--- a/tests/utilities/test_mcp_config.py
+++ b/tests/utilities/test_mcp_config.py
@@ -9,7 +9,7 @@ from fastmcp.client.transports import (
     StdioTransport,
     StreamableHttpTransport,
 )
-from fastmcp.utilities.mcp_config import LocalMCPServer, MCPConfig, RemoteMCPServer
+from fastmcp.utilities.mcp_config import MCPConfig, RemoteMCPServer, StdioMCPServer
 
 
 def test_parse_single_stdio_config():
@@ -89,7 +89,7 @@ def test_parse_multiple_servers():
     assert isinstance(mcp_config.mcpServers["test_server"], RemoteMCPServer)
     assert isinstance(mcp_config.mcpServers["test_server"].to_transport(), SSETransport)
 
-    assert isinstance(mcp_config.mcpServers["test_server_2"], LocalMCPServer)
+    assert isinstance(mcp_config.mcpServers["test_server_2"], StdioMCPServer)
     assert isinstance(
         mcp_config.mcpServers["test_server_2"].to_transport(), StdioTransport
     )