Skip to content

Commit 47e92d7

Browse files
committed
Move input substitution to new server method
It seemed weird doing it in `from_file`, because the caller has to call `from_file` to find out what the required inputs are and then has to call `from_file` against to provide the input values. Now, the caller calls config.get(server, input_values={...}) to provide the input values
1 parent 02fc66f commit 47e92d7

File tree

2 files changed

+99
-135
lines changed

2 files changed

+99
-135
lines changed

src/mcp/client/config/mcp_servers_config.py

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,31 @@ class MCPServersConfig(BaseModel):
138138
servers: dict[str, ServerConfigUnion]
139139
inputs: list[InputDefinition] | None = None
140140

141+
def server(
142+
self,
143+
name: str,
144+
input_values: dict[str, str] | None = None,
145+
) -> ServerConfigUnion:
146+
"""Get a server config by name."""
147+
server = self.servers[name]
148+
149+
# Validate inputs if provided and input definitions exist
150+
if input_values is not None and self.inputs:
151+
missing_inputs = self.validate_inputs(input_values)
152+
if missing_inputs:
153+
descriptions: list[str] = []
154+
for input_id in missing_inputs:
155+
desc = self.get_input_description(input_id)
156+
descriptions.append(f" - {input_id}: {desc or 'No description'}")
157+
158+
raise ValueError("Missing required input values:\n" + "\n".join(descriptions))
159+
160+
# Substitute input placeholders if inputs provided
161+
if input_values:
162+
server.__dict__ = self._substitute_inputs(server.__dict__, input_values)
163+
164+
return server
165+
141166
@model_validator(mode="before")
142167
@classmethod
143168
def handle_field_aliases(cls, data: dict[str, Any]) -> dict[str, Any]:
@@ -275,7 +300,9 @@ def _strip_json_comments(cls, content: str) -> str:
275300

276301
@classmethod
277302
def from_file(
278-
cls, config_path: Path | str, use_pyyaml: bool = False, inputs: dict[str, str] | None = None
303+
cls,
304+
config_path: Path | str,
305+
use_pyyaml: bool = False,
279306
) -> "MCPServersConfig":
280307
"""Load configuration from a JSON or YAML file.
281308
@@ -305,22 +332,4 @@ def from_file(
305332
cleaned_content = cls._strip_json_comments(content)
306333
data = json.loads(cleaned_content)
307334

308-
# Create a preliminary config to validate inputs if they're defined
309-
preliminary_config = cls.model_validate(data)
310-
311-
# Validate inputs if provided and input definitions exist
312-
if inputs is not None and preliminary_config.inputs:
313-
missing_inputs = preliminary_config.validate_inputs(inputs)
314-
if missing_inputs:
315-
descriptions: list[str] = []
316-
for input_id in missing_inputs:
317-
desc = preliminary_config.get_input_description(input_id)
318-
descriptions.append(f" - {input_id}: {desc or 'No description'}")
319-
320-
raise ValueError("Missing required input values:\n" + "\n".join(descriptions))
321-
322-
# Substitute input placeholders if inputs provided
323-
if inputs:
324-
data = cls._substitute_inputs(data, inputs)
325-
326335
return cls.model_validate(data)

tests/client/config/test_mcp_servers_config.py

Lines changed: 71 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -188,108 +188,39 @@ def test_servers_field_takes_precedence():
188188
assert len(config.servers) == 1
189189

190190

191-
def test_input_substitution():
192-
"""Test that ${input:key} placeholders are substituted correctly."""
193-
config_data = {
194-
"servers": {
195-
"azure_server": {
196-
"type": "sse",
197-
"url": "https://${input:app-name}.azurewebsites.net/mcp/sse",
198-
"headers": {"Authorization": "Bearer ${input:api-token}", "X-Custom-Header": "${input:custom-value}"},
199-
},
200-
"stdio_server": {
201-
"type": "stdio",
202-
"command": "python -m ${input:module-name}",
203-
"args": ["--config", "${input:config-file}"],
204-
"env": {"API_KEY": "${input:api-key}", "ENV": "${input:environment}"},
205-
"isActive": True,
206-
},
207-
}
208-
}
209-
210-
inputs = {
211-
"app-name": "my-function-app",
212-
"api-token": "abc123token",
213-
"custom-value": "custom-header-value",
214-
"module-name": "my_server",
215-
"config-file": "/path/to/config.json",
216-
"api-key": "secret-api-key",
217-
"environment": "production",
218-
}
219-
220-
# Test substitution
221-
substituted = MCPServersConfig._substitute_inputs(config_data, inputs)
222-
config = MCPServersConfig.model_validate(substituted)
223-
224-
# Test SSE server substitution
225-
sse_server = config.servers["azure_server"]
226-
assert isinstance(sse_server, SSEServerConfig)
227-
assert sse_server.url == "https://my-function-app.azurewebsites.net/mcp/sse"
228-
assert sse_server.headers == {"Authorization": "Bearer abc123token", "X-Custom-Header": "custom-header-value"}
229-
230-
# Test stdio server substitution
231-
stdio_server = config.servers["stdio_server"]
232-
assert isinstance(stdio_server, StdioServerConfig)
233-
assert stdio_server.command == "python -m my_server"
234-
assert stdio_server.args == ["--config", "/path/to/config.json"]
235-
assert stdio_server.env == {"API_KEY": "secret-api-key", "ENV": "production"}
236-
237-
238-
def test_input_substitution_missing_key():
239-
"""Test that missing input keys raise appropriate errors."""
240-
config_data = {"servers": {"test_server": {"type": "sse", "url": "https://${input:missing-key}.example.com"}}}
241-
242-
inputs = {"other-key": "value"}
243-
244-
with pytest.raises(ValueError, match="Missing input value for key: 'missing-key'"):
245-
MCPServersConfig._substitute_inputs(config_data, inputs)
246-
247-
248-
def test_input_substitution_partial():
249-
"""Test that only specified placeholders are substituted."""
250-
config_data = {
251-
"servers": {
252-
"test_server": {
253-
"type": "sse",
254-
"url": "https://${input:app-name}.example.com/api/${input:version}",
255-
"headers": {"Static-Header": "static-value", "Dynamic-Header": "${input:token}"},
256-
}
257-
}
258-
}
259-
260-
inputs = {
261-
"app-name": "myapp",
262-
"token": "secret123",
263-
# Note: 'version' is intentionally missing
264-
}
265-
266-
with pytest.raises(ValueError, match="Missing input value for key: 'version'"):
267-
MCPServersConfig._substitute_inputs(config_data, inputs)
268-
269-
270191
def test_from_file_with_inputs(tmp_path: Path):
271192
"""Test loading config from file with input substitution."""
272193
# Create test config file
273194
config_content = {
195+
"inputs": [
196+
{"id": "host", "description": "Server hostname"},
197+
{"id": "token", "description": "API token"},
198+
],
274199
"servers": {
275200
"dynamic_server": {
276201
"type": "streamable_http",
277202
"url": "https://${input:host}/mcp/api",
278203
"headers": {"Authorization": "Bearer ${input:token}"},
279204
}
280-
}
205+
},
281206
}
282207

283208
config_file = tmp_path / "test_config.json"
284209
with open(config_file, "w") as f:
285210
json.dump(config_content, f)
286211

287-
inputs = {"host": "api.example.com", "token": "test-token-123"}
212+
config = MCPServersConfig.from_file(config_file)
213+
214+
assert config.get_required_inputs() == ["host", "token"]
215+
assert config.inputs is not None
216+
assert config.inputs[0].id == "host"
217+
assert config.inputs[1].id == "token"
218+
assert config.inputs[0].description == "Server hostname"
219+
assert config.inputs[1].description == "API token"
288220

289-
# Load with input substitution
290-
config = MCPServersConfig.from_file(config_file, inputs=inputs)
221+
input_values = {"host": "api.example.com", "token": "test-token-123"}
222+
server = config.server("dynamic_server", input_values=input_values)
291223

292-
server = config.servers["dynamic_server"]
293224
assert isinstance(server, StreamableHTTPServerConfig)
294225
assert server.url == "https://api.example.com/mcp/api"
295226
assert server.headers == {"Authorization": "Bearer test-token-123"}
@@ -324,6 +255,16 @@ def test_from_file_without_inputs(tmp_path: Path):
324255
def test_input_substitution_yaml_file(tmp_path: Path):
325256
"""Test input substitution with YAML files."""
326257
yaml_content = """
258+
inputs:
259+
- type: promptString
260+
id: module
261+
description: Python module to run
262+
- type: promptString
263+
id: port
264+
description: Port to run the server on
265+
- type: promptString
266+
id: debug
267+
description: Debug mode
327268
servers:
328269
yaml_server:
329270
type: stdio
@@ -336,13 +277,23 @@ def test_input_substitution_yaml_file(tmp_path: Path):
336277
"""
337278

338279
config_file = tmp_path / "test_config.yaml"
339-
config_file.write_text(yaml_content)
280+
assert config_file.write_text(yaml_content)
281+
282+
config = MCPServersConfig.from_file(config_file)
340283

341-
inputs = {"module": "test_server", "port": "8080", "debug": "true"}
284+
assert config.get_required_inputs() == ["module", "port", "debug"]
285+
assert config.inputs is not None
286+
assert len(config.inputs) == 3
287+
assert config.inputs[0].id == "module"
288+
assert config.inputs[0].description == "Python module to run"
289+
assert config.inputs[1].id == "port"
290+
assert config.inputs[1].description == "Port to run the server on"
291+
assert config.inputs[2].id == "debug"
292+
assert config.inputs[2].description == "Debug mode"
342293

343-
config = MCPServersConfig.from_file(config_file, inputs=inputs)
294+
input_values = {"module": "test_server", "port": "8080", "debug": "true"}
295+
server = config.server("yaml_server", input_values=input_values)
344296

345-
server = config.servers["yaml_server"]
346297
assert isinstance(server, StdioServerConfig)
347298
assert server.command == "python -m test_server"
348299
assert server.args == ["--port", "8080"]
@@ -373,15 +324,14 @@ def test_input_definitions_parsing():
373324
config = MCPServersConfig.model_validate(config_data)
374325

375326
# Test input definitions are parsed correctly
327+
assert config.get_required_inputs() == ["functionapp-name", "api-token"]
376328
assert config.inputs is not None
377329
assert len(config.inputs) == 2
378-
379330
app_name_input = config.inputs[0]
380331
assert app_name_input.id == "functionapp-name"
381332
assert app_name_input.description == "Azure Functions App Name"
382333
assert app_name_input.password is False
383334
assert app_name_input.type == "promptString"
384-
385335
api_token_input = config.inputs[1]
386336
assert api_token_input.id == "api-token"
387337
assert api_token_input.description == "API Token for authentication"
@@ -401,19 +351,17 @@ def test_get_required_inputs():
401351
}
402352

403353
config = MCPServersConfig.model_validate(config_data)
404-
required_inputs = config.get_required_inputs()
405354

406-
assert required_inputs == ["input1", "input2", "input3"]
355+
assert config.get_required_inputs() == ["input1", "input2", "input3"]
407356

408357

409358
def test_get_required_inputs_no_inputs_defined():
410359
"""Test getting required inputs when no inputs are defined."""
411360
config_data = {"servers": {"test_server": {"type": "stdio", "command": "python test.py"}}}
412361

413362
config = MCPServersConfig.model_validate(config_data)
414-
required_inputs = config.get_required_inputs()
415363

416-
assert required_inputs == []
364+
assert config.get_required_inputs() == []
417365

418366

419367
def test_get_required_inputs_empty_inputs_list():
@@ -424,10 +372,9 @@ def test_get_required_inputs_empty_inputs_list():
424372
}
425373

426374
config = MCPServersConfig.model_validate(config_data)
427-
required_inputs = config.get_required_inputs()
428-
assert config.validate_inputs({}) == []
429375

430-
assert required_inputs == []
376+
assert config.validate_inputs({}) == []
377+
assert config.get_required_inputs() == []
431378
assert config.inputs == [] # Verify inputs is actually an empty list, not None
432379

433380

@@ -513,12 +460,19 @@ def test_from_file_with_input_validation_success(tmp_path: Path):
513460
with open(config_file, "w") as f:
514461
json.dump(config_content, f)
515462

516-
inputs = {"app-name": "myapp", "env": "prod"}
463+
config = MCPServersConfig.from_file(config_file)
464+
465+
assert config.get_required_inputs() == ["app-name", "env"]
466+
assert config.inputs is not None
467+
assert len(config.inputs) == 2
468+
assert config.inputs[0].id == "app-name"
469+
assert config.inputs[0].description == "Application name"
470+
assert config.inputs[1].id == "env"
471+
assert config.inputs[1].description == "Environment (dev/prod)"
517472

518-
# Should load successfully with all required inputs provided
519-
config = MCPServersConfig.from_file(config_file, inputs=inputs)
473+
input_values = {"app-name": "myapp", "env": "prod"}
474+
server = config.server("app_server", input_values=input_values)
520475

521-
server = config.servers["app_server"]
522476
assert isinstance(server, StreamableHTTPServerConfig)
523477
assert server.url == "https://myapp-prod.example.com/mcp/api"
524478

@@ -537,13 +491,15 @@ def test_from_file_with_input_validation_failure(tmp_path: Path):
537491
with open(config_file, "w") as f:
538492
json.dump(config_content, f)
539493

540-
inputs = {
494+
inputs: dict[str, str] = {
541495
# Missing 'required-key' and 'optional-host'
542496
}
543497

544498
# Should raise ValueError with helpful error message
545499
with pytest.raises(ValueError, match="Missing required input values"):
546-
MCPServersConfig.from_file(config_file, inputs=inputs)
500+
config = MCPServersConfig.from_file(config_file)
501+
server = config.server("test_server", input_values=inputs)
502+
assert server
547503

548504

549505
def test_from_file_without_input_definitions_no_validation(tmp_path: Path):
@@ -556,10 +512,11 @@ def test_from_file_without_input_definitions_no_validation(tmp_path: Path):
556512
with open(config_file, "w") as f:
557513
json.dump(config_content, f)
558514

515+
config = MCPServersConfig.from_file(config_file)
516+
559517
# Even with empty inputs, should load fine since no input definitions exist
560-
config = MCPServersConfig.from_file(config_file, inputs={})
518+
server = config.server("test_server", input_values={})
561519

562-
server = config.servers["test_server"]
563520
assert isinstance(server, StdioServerConfig)
564521
# Placeholder should remain unchanged
565522
assert server.command == "python -m server --token ${input:token}"
@@ -586,20 +543,20 @@ def test_input_definition_with_yaml_file(tmp_path: Path):
586543
"""
587544

588545
config_file = tmp_path / "test_config.yaml"
589-
config_file.write_text(yaml_content)
590-
591-
inputs = {"module-name": "test_module", "config-path": "/etc/config.json"}
546+
assert config_file.write_text(yaml_content)
592547

593-
config = MCPServersConfig.from_file(config_file, inputs=inputs)
548+
config = MCPServersConfig.from_file(config_file)
594549

595550
# Verify input definitions were parsed
551+
assert config.get_required_inputs() == ["module-name", "config-path"]
596552
assert config.inputs is not None
597553
assert len(config.inputs) == 2
598554
assert config.inputs[0].id == "module-name"
599555
assert config.inputs[1].id == "config-path"
600556

601-
# Verify substitution worked
602-
server = config.servers["yaml_server"]
557+
input_values = {"module-name": "test_module", "config-path": "/etc/config.json"}
558+
server = config.server("yaml_server", input_values=input_values)
559+
603560
assert isinstance(server, StdioServerConfig)
604561
assert server.command == "python -m test_module"
605562
assert server.args == ["--config", "/etc/config.json"]
@@ -711,20 +668,18 @@ def test_from_file_with_jsonc_comments(tmp_path: Path):
711668
"""
712669

713670
config_file = tmp_path / "test_config.json"
714-
config_file.write_text(jsonc_content)
715-
716-
inputs = {"api-key": "secret123"}
671+
assert config_file.write_text(jsonc_content)
717672

718673
# Should load successfully despite comments
719-
config = MCPServersConfig.from_file(config_file, inputs=inputs)
674+
config = MCPServersConfig.from_file(config_file)
720675

721676
# Verify input definitions were parsed
722677
assert config.inputs is not None
723678
assert len(config.inputs) == 1
724679
assert config.inputs[0].id == "api-key"
725680

726681
# Verify server configuration and input substitution
727-
server = config.servers["main_server"]
682+
server = config.server("main_server", input_values={"api-key": "secret123"})
728683
assert isinstance(server, SSEServerConfig)
729684
assert server.url == "https://api.example.com/mcp/sse"
730685
assert server.headers == {"Authorization": "Bearer secret123"}

0 commit comments

Comments
 (0)