3
3
# stdlib imports
4
4
import json
5
5
import os
6
+ import re
6
7
import shlex
7
8
from pathlib import Path
8
9
from typing import Annotated , Any , Literal
15
16
from pydantic import BaseModel , Field , field_validator , model_validator
16
17
17
18
19
+ class InputDefinition (BaseModel ):
20
+ """Definition of an input parameter."""
21
+
22
+ type : Literal ["promptString" ] = "promptString"
23
+ id : str
24
+ description : str | None = None
25
+ password : bool = False
26
+
27
+
18
28
class MCPServerConfig (BaseModel ):
19
29
"""Base class for MCP server configurations."""
20
30
@@ -83,6 +93,7 @@ class MCPServersConfig(BaseModel):
83
93
"""Configuration for multiple MCP servers."""
84
94
85
95
servers : dict [str , ServerConfigUnion ]
96
+ inputs : list [InputDefinition ] | None = None
86
97
87
98
@model_validator (mode = "before" )
88
99
@classmethod
@@ -115,14 +126,80 @@ def infer_server_types(cls, servers_data: dict[str, Any]) -> dict[str, Any]:
115
126
116
127
return servers_data
117
128
129
+ def get_required_inputs (self ) -> list [str ]:
130
+ """Get list of input IDs that are defined in the inputs section."""
131
+ if not self .inputs :
132
+ return []
133
+ return [input_def .id for input_def in self .inputs ]
134
+
135
+ def validate_inputs (self , provided_inputs : dict [str , str ]) -> list [str ]:
136
+ """Validate provided inputs against input definitions.
137
+
138
+ Returns list of missing required input IDs.
139
+ """
140
+ if not self .inputs :
141
+ return []
142
+
143
+ required_input_ids = self .get_required_inputs ()
144
+ missing_inputs = []
145
+
146
+ for input_id in required_input_ids :
147
+ if input_id not in provided_inputs :
148
+ missing_inputs .append (input_id )
149
+
150
+ return missing_inputs
151
+
152
+ def get_input_description (self , input_id : str ) -> str | None :
153
+ """Get the description for a specific input ID."""
154
+ if not self .inputs :
155
+ return None
156
+
157
+ for input_def in self .inputs :
158
+ if input_def .id == input_id :
159
+ return input_def .description
160
+
161
+ return None
162
+
163
+ @classmethod
164
+ def _substitute_inputs (cls , data : Any , inputs : dict [str , str ]) -> Any :
165
+ """Recursively substitute ${input:key} placeholders with values from inputs dict."""
166
+ if isinstance (data , str ):
167
+ # Replace ${input:key} patterns with values from inputs
168
+ def replace_input (match : re .Match [str ]) -> str :
169
+ key = match .group (1 )
170
+ if key in inputs :
171
+ return inputs [key ]
172
+ else :
173
+ raise ValueError (f"Missing input value for key: '{ key } '" )
174
+
175
+ return re .sub (r"\$\{input:([^}]+)\}" , replace_input , data )
176
+
177
+ elif isinstance (data , dict ):
178
+ result = {} # type: ignore
179
+ for k , v in data .items (): # type: ignore
180
+ result [k ] = cls ._substitute_inputs (v , inputs ) # type: ignore
181
+ return result
182
+
183
+ elif isinstance (data , list ):
184
+ result = [] # type: ignore
185
+ for item in data : # type: ignore
186
+ result .append (cls ._substitute_inputs (item , inputs )) # type: ignore
187
+ return result
188
+
189
+ else :
190
+ return data
191
+
118
192
@classmethod
119
- def from_file (cls , config_path : Path | str , use_pyyaml : bool = False ) -> "MCPServersConfig" :
193
+ def from_file (
194
+ cls , config_path : Path | str , use_pyyaml : bool = False , inputs : dict [str , str ] | None = None
195
+ ) -> "MCPServersConfig" :
120
196
"""Load configuration from a JSON or YAML file.
121
197
122
198
Args:
123
199
config_path: Path to the configuration file
124
200
use_pyyaml: If True, force use of PyYAML parser. Defaults to False.
125
201
Also automatically used for .yaml/.yml files.
202
+ inputs: Dictionary of input values to substitute for ${input:key} placeholders
126
203
"""
127
204
128
205
config_path = os .path .expandvars (config_path ) # Expand environment variables like $HOME
@@ -136,6 +213,26 @@ def from_file(cls, config_path: Path | str, use_pyyaml: bool = False) -> "MCPSer
136
213
if should_use_yaml :
137
214
if not yaml :
138
215
raise ImportError ("PyYAML is required to parse YAML files. " )
139
- return cls . model_validate ( yaml .safe_load (config_file ) )
216
+ data = yaml .safe_load (config_file )
140
217
else :
141
- return cls .model_validate (json .load (config_file ))
218
+ data = json .load (config_file )
219
+
220
+ # Create a preliminary config to validate inputs if they're defined
221
+ preliminary_config = cls .model_validate (data )
222
+
223
+ # Validate inputs if provided and input definitions exist
224
+ if inputs is not None and preliminary_config .inputs :
225
+ missing_inputs = preliminary_config .validate_inputs (inputs )
226
+ if missing_inputs :
227
+ descriptions = []
228
+ for input_id in missing_inputs :
229
+ desc = preliminary_config .get_input_description (input_id )
230
+ descriptions .append (f" - { input_id } : { desc or 'No description' } " )
231
+
232
+ raise ValueError (f"Missing required input values:\n " + "\n " .join (descriptions ))
233
+
234
+ # Substitute input placeholders if inputs provided
235
+ if inputs :
236
+ data = cls ._substitute_inputs (data , inputs )
237
+
238
+ return cls .model_validate (data )
0 commit comments